From 3335915f939ad6d902c26f3d464ea3d9bf8383de Mon Sep 17 00:00:00 2001 From: Geraldo Netto Date: Tue, 2 Jun 2026 11:48:29 +0200 Subject: [PATCH 01/42] docs: add agent guidelines and project rescan findings Add AGENTS.md (canonical agent behavior rules) and CLAUDE.md pointer. Populate TODO.md with project-wide rescan findings, one table per AGENTS.md review category (security, STRIDE, data governance, watchdog, state machine, composition, dependency, extensibility, legacy, configuration, platform, data structure, vectorization, robustness, ui/ux, documentation, plus existing performance/concurrency/SOLID tables). Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 87 +++++++++++++ CLAUDE.md | 10 ++ TODO.md | 358 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 455 insertions(+) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 TODO.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..647dc1d36 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,87 @@ +# Agents Behavior Guide + +This file defines the expected behavior and usage model for AI agents working in this repository. + +## Purpose + +- Provide a standard set of guidelines for agent interactions. +- Ensure consistent behavior when using AI tooling in this workspace. + +## General Agent Behavior + +- Always be polite and concise. +- Prefer short, actionable responses. +- Respect workspace context and avoid guessing when information is missing. +- When making code changes, clearly describe what was changed and why. +- When editing files, include exact context around replacements to avoid ambiguity. + +## Rules + +- Don't assume. Don't hide confusion. Surface tradeoffs and ask the user when unclear. +- Write the minimum code that solves the problem. Avoid speculative or unneeded changes. +- Touch only what you must. Clean up only your own mess and leave the workspace cleaner than you found it. +- Define success criteria before making changes. Verify against those criteria and iterate until satisfied. +- Keep code complexity <= 10 for any new function, class, or method. +- Avoid code duplication and apply SOLID principles where practical. +- Document assumptions, constraints, and design intent in comments or commit notes when they matter. +- Prefer explicit, maintainable solutions over clever shortcuts. +- Propose business/design patterns and DDD only when they improve clarity or structure. +- ALWAYS record review findings in `TODO.md` — never report them only in chat. Any time you + scan, review, audit, or "look for issues" (not just major changes), add each finding to the + matching category table in `TODO.md` before/while reporting it. +- ALWAYS remove completed items from `TODO.md` — once a finding is implemented + tested + merged, + delete its row from the table outright. No "shipped" sub-sections, no struck-through entries. + `git log` is the durable record. Exceptions: the "Open — parked" section keeps open-but-deferred + items with a why-not-now annotation; the "Audit picks deliberately rejected" section keeps the + rationale so future passes don't re-pick the same items. +- When making major changes, rescan the whole project and create or update `TODO.md` with one table per review category. + Each table should use the format: `id | status | effort | description | notes`. + - security + - STRIDE (as in microsoft security framework) + - data governance + - reliability + - observability + - concurrency + - multithreading + - robustiness + - watchdog + - state machine + - composition + - dependency + - adaptability + - extensibility + - legacy + - configuration + - data structure + - vectorization + - platform + - ui / ux + - documentation + - performance + - scalability + - concurrency + - code complexity + - code duplication + - architecture/modularity/SOLID + - decoupling + - business/design patterns/DDD + - reliability/correctness + - observability when the application has it + - wiring gaps — modules/helpers/cfg knobs that exist + pass tests but have no real production call site (orphan exports, cfg flags never read, advertised backends not wired in). A shipped feature is only "shipped" when the dispatcher actually invokes it. + - unused functions/methods — public-shaped callables (no leading `_`) imported by no production code, no tests, no plugins. Different from wiring gaps: these aren't half-wired, they're fully dead. Includes `__init__.py` re-exports that no caller pulls and class methods only ever called from one private site. Each finding: keep / inline / delete decision recorded in `notes`. + +## File Editing + +- Avoid overwriting existing files unless the user explicitly asks or the file is missing. +- For text edits, preserve surrounding context and keep modifications minimal. +- Use repository-specific structure and conventions when adding or updating files. + +## Communications + +- Use headings and bullets for readability. +- Highlight changed files and key points. +- Keep final answers brief and professional. + +## References + +- This workspace currently contains only a small Python utility script, so agent actions should remain lightweight and focused. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..01db940e5 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,10 @@ +# Claude Agent Reference + +This document refers to the repository's agent behavior guidelines. + +For the canonical agent behavior rules, see `AGENTS.md`. + +## Usage + +- When interacting with this workspace, follow the behavior defined in `AGENTS.md`. +- Use `AGENTS.md` as the primary source for agent conduct, editing norms, and response expectations. diff --git a/TODO.md b/TODO.md new file mode 100644 index 000000000..37fb7bc4a --- /dev/null +++ b/TODO.md @@ -0,0 +1,358 @@ +# TODO + +Project-wide rescan findings per AGENTS.md categories. Format: `id | status | effort | description | notes`. + +Status: `open`, `in-progress`, `blocked`. Effort: `S` (≤1h), `M` (half-day), `L` (≥1 day). + +--- + +## security + +| id | status | effort | description | notes | +|----|--------|--------|-------------|-------| +| sec-1 | open | S | `blueman/main/NetConf.py:268` iptables rule split on space without bounds checking — arbitrary args via IP/netmask containing spaces | use `shlex.split()` or pass args as list to subprocess | +| sec-2 | open | S | `blueman/plugins/mechanism/Rfcomm.py:17-21` ps output parsing has no bounds checking; malformed line → `IndexError` | wrap split in try/except or validate line shape | +| sec-3 | open | S | `blueman/plugins/mechanism/Rfcomm.py:19` `int(pid)` without validation; malformed ps output crashes mechanism | validate numeric before `int()` | +| sec-4 | open | S | `blueman/main/PPPConnection.py:74` AT command built via f-string with unvalidated `apn` | validate apn against `[A-Za-z0-9.\-]+` or escape | + +## performance + +| id | status | effort | description | notes | +|----|--------|--------|-------------|-------| +| perf-1 | open | M | `blueman/main/ManagerStats.py:107` polls device stats via `GLib.timeout_add(1000, ...)` every second | switch to event-driven update or `timeout_add_seconds` | +| perf-2 | open | M | `blueman/gui/manager/ManagerDeviceList.py:429` per-device timer for power-level monitoring → O(n) timers | consolidate into single timer batching all devices | +| perf-3 | open | S | `blueman/gui/DeviceList.py:282-285` `clear()` iterates liststore calling `device_remove_event` per item → O(n²) | call `liststore.clear()` once, drop `path_to_row` in bulk | +| perf-4 | open | M | `blueman/bluez/Base.py:100` `device["Prop"]` issues sync `Properties.Get` DBus on UI thread | local prop cache + signal-driven invalidation | +| perf-5 | open | M | `blueman/bluez/Manager.py:115-149` `get_adapter_paths`/`get_devices` iterate `_object_manager.get_objects()` per call | cache, invalidate on object-added/removed | +| perf-6 | open | S | `blueman/gui/manager/ManagerDeviceList.py:384-388,456-497` `row_setup_event`/`row_update_event` re-read same `device[k]` props | read once, reuse | +| perf-7 | open | M | `blueman/gui/manager/ManagerDeviceList.py:353-407` row setup pulls 8+ props via individual `Get` calls | single `GetAll` per row | +| perf-8 | open | S | `blueman/gui/manager/ManagerStats.py:36-37` two `SpeedCalc` instances keep unbounded log lists | cap log size to N samples | +| perf-9 | open | S | `blueman/main/DhcpClient.py:48-50,68` `subprocess.poll()` blocking in 1s `GLib.timeout` | use `Gio.Subprocess` + `wait_check_async` or `GLib.child_watch_add` | +| perf-10 | open | S | `blueman/gui/manager/ManagerMenu.py:53` creates Adapter proxies for all adapters in `__init__` | lazy-instantiate on selection | +| perf-11 | open | S | `blueman/main/Manager.py:161-164` `find_device()` linear scan over all objects | address-indexed dict | +| perf-12 | open | S | `blueman/gui/manager/ManagerProgressbar.py:132-138` reverse-iterate cleanup → O(n²) | track via deque or set | +| perf-13 | open | S | `blueman/main/Applet.py:93-118` plugin broadcast loop runs full plugin set per property change → O(plugins × props × devices) | debounce/batch property events | +| perf-14 | open | S | `blueman/gui/GtkAnimation.py:85` per-animation 41ms timer; multiple animations stack | unify tick clock | + +## scalability + +| id | status | effort | description | notes | +|----|--------|--------|-------------|-------| +| scale-1 | open | M | `blueman/main/Manager.py:149-159` `populate_devices` emits per-device add signal serially | single batch signal | +| scale-2 | open | S | `blueman/main/PulseAudioUtils.py:216-218` PA subscribe callback fires unthrottled on rapid card changes | debounce | +| scale-3 | open | S | `blueman/main/BatteryWatcher.py:18` creates `Battery` per creation signal without dedup | check existence before create | +| scale-4 | open | S | `blueman/gui/manager/ManagerDeviceList.py:658` `device["UUIDs"]` accessed during cell render | cache in row data | + +## concurrency + +| id | status | effort | description | notes | +|----|--------|--------|-------------|-------| +| conc-1 | open | M | `blueman/bluez/Base.py:116-123` async `set()` has no `Gio.Cancellable` plumbing | add cancellable param, store, cancel on teardown | +| conc-2 | open | M | `blueman/bluez/Base.py:44-57` `BaseMeta` caches instances forever; Device/Adapter never released | weakref store or explicit destroy hook | +| conc-3 | open | S | `blueman/main/PulseAudioUtils.py:372-379` `weakref.proxy(self)` in callback silently no-ops if GC'd | hold hard ref or explicit lifecycle | + +## code complexity + +| id | status | effort | description | notes | +|----|--------|--------|-------------|-------| +| cx-1 | open | M | `blueman/gui/manager/ManagerDeviceList.py:453` `row_update_event` 7-elif on property name | dict dispatch `{key: handler}` | +| cx-2 | open | M | `blueman/main/Manager.py:224` `simple_action()` 13-case match mixes routing + business logic | extract `{action: (handler, needs_device)}` table | +| cx-3 | open | L | `blueman/gui/manager/ManagerDeviceList.py:412-540` 4 coupled power-level methods (>100 LOC) | extract `PowerLevelMonitor` class | +| cx-4 | open | M | `blueman/main/PluginManager.py:132-174` `__load_plugin` ~43 LOC, 15+ conditionals (deps/conflicts/priority) | extract `PluginDependencyResolver` | +| cx-5 | open | M | `blueman/gui/manager/ManagerDeviceList.py:553` `tooltip_query` ~102 LOC nested conditions | extract `TooltipBuilder` | +| cx-6 | open | S | `blueman/main/Services.py:58` `on_query_apply_state` returns -1/bool mixed protocol | replace with `ApplyState` enum | + +## code duplication + +| id | status | effort | description | notes | +|----|--------|--------|-------------|-------| +| dup-1 | open | M | `blueman/main/Applet.py:92-118` 8× identical plugin broadcast loops | `_broadcast(event, *args)` helper | +| dup-2 | open | S | `blueman/gui/manager/ManagerDeviceMenu.py:141-188` `connect_service`/`disconnect_service` duplicate nested success/error callbacks | extract async-DBus template | +| dup-3 | open | S | `blueman/gui/manager/ManagerDeviceList.py:463-496` `Trusted`/`Paired` if/else collapsible to single `set(**{key: value})` | inline boolean | +| dup-4 | open | S | `blueman/gui/manager/ManagerDeviceList.py:498-540` `_update_power_levels` + `_disable_power_levels` duplicate bar lookup | extract `BarRenderer` | +| dup-5 | open | S | `blueman/gui/manager/ManagerDeviceList.py:655-677` `_set_cell_data` repeats if/elif for battery/rssi/tpl | polymorphic bar renderers | +| dup-6 | open | S | `blueman/main/Applet.py:78-90` `_on_dbus_name_appeared/_vanished` repeat plugin notify loop | `_notify_manager_state_change(state)` | +| dup-7 | open | S | `blueman/main/Sendto.py:47-55` 6× identical `connect_signal` boilerplate | `_setup_signal_handlers(source, handlers)` | +| dup-8 | open | S | `blueman/main/Services.py:86` bare `except:` with `# noqa: E722` | narrow to expected exceptions | + +## architecture/modularity/SOLID + +| id | status | effort | description | notes | +|----|--------|--------|-------------|-------| +| arch-1 | open | L | `blueman/main/Applet.py:25-148` `BluemanApplet` is God object (init Manager, plugins, broadcasts, state) | extract `PluginBroadcaster`, `ManagerWatcher` | +| arch-2 | open | L | `blueman/main/Manager.py:37-363` `Blueman` mixes lifecycle, UI, device actions, settings | split into `ManagerUI`, `DeviceActionHandler`, `SettingsManager` | +| arch-3 | open | M | `blueman/main/PluginManager.py:176` `__getattr__` magic for plugin lookup breaks IDE/refactor | explicit `get_plugin(name)` accessor | +| arch-4 | open | M | `blueman/main/MechanismApplication.py:42-100` mixes timer, PolicyKit, plugin loading, DBus registration | extract `TimerManager`, `PluginLoader` | +| arch-5 | open | S | `blueman/main/MechanismApplication.py:15-39` Timer reads `BLUEMAN_SOURCE` env var for test mode | subclass `TestTimer` or inject duration | +| arch-6 | open | S | `blueman/main/PluginManager.py:139,200` raise bare `Exception(...)` | introduce `PluginDependencyError`, `PluginError` | +| arch-7 | open | S | `blueman/gui/manager/ManagerDeviceMenu.py:64-65` `__ops__`/`__instances__` class-level globals | DI or event-emitter | +| arch-8 | open | S | `blueman/gui/manager/ManagerDeviceList.py:334-351` UI-formatting `@staticmethod`s placed on liststore class | move to `DeviceDisplayFormatter` | + +## decoupling + +| id | status | effort | description | notes | +|----|--------|--------|-------------|-------| +| dec-1 | open | M | `blueman/plugins/applet/TransferService.py:17` reaches into `parent.Plugins`/`parent.Manager` | DI via plugin interface or signal | +| dec-2 | open | M | `blueman/plugins/manager/Services.py:8` `ManagerPlugin` imports `ManagerDeviceMenu`, `MenuItemsProvider` (GUI layer) | event-based provider interface | +| dec-3 | open | S | `blueman/plugins/applet/AutoConnect.py:62` `self.parent.Manager.find_device()` reach-through | `parent.find_device_by_address(addr)` API | +| dec-4 | open | S | `blueman/plugins/applet/KillSwitch.py:147-149` direct `self.parent.Plugins.StatusIcon/PowerManager` access | optional plugin query w/ fallback | +| dec-5 | open | S | `blueman/plugins/manager/Services.py:82` plugin discovery via `ServicePlugin.__subclasses__()` | registry or `importlib.metadata.entry_points` | +| dec-6 | open | S | `blueman/plugins/AppletPlugin.py:32` hardcoded fallback icon name | constant + GSettings override | + +## business/design patterns/DDD + +| id | status | effort | description | notes | +|----|--------|--------|-------------|-------| +| pat-1 | open | M | `row_update_event`, `simple_action`, `_set_cell_data` all have type/key switch ladders | Strategy or dispatch-table | +| pat-2 | open | S | `on_query_apply_state` magic-return protocol | State enum (DDD value object) | +| pat-3 | open | M | Plugin lifecycle scattered (load/unload/deps/conflicts/state) | introduce `PluginLifecycle` state machine | + +## reliability/correctness + +| id | status | effort | description | notes | +|----|--------|--------|-------------|-------| +| rel-1 | open | S | `blueman/main/NetConf.py:91` `next(...)` without default → `StopIteration` aborts cleanup | `next(..., None)` | +| rel-2 | open | S | `blueman/main/PPPConnection.py:121` `self.file` not initialized in `__init__`; touched in exception paths | init `self.file = None` | +| rel-3 | open | S | `blueman/main/PPPConnection.py:159` `self.pppd` not initialized before `connect_callback`; `poll()` crashes on early error | init `self.pppd = None` | +| rel-4 | open | S | `blueman/main/DhcpClient.py:48` `self._client` undefined until `run()`; `_check_client()` crashes if not called | init `self._client = None` | +| rel-5 | open | S | `blueman/main/PPPConnection.py:76-77` `os.close(self.file)` without fd validity check | guard with try/except `OSError` | +| rel-6 | open | S | `blueman/plugins/mechanism/Network.py:52` `DHCPDHANDLERS[dhcp_handler]` `KeyError` on untrusted input | validate against allowed keys | +| rel-7 | open | S | `blueman/main/PPPConnection.py:222-224` `io_watch`/`timeout` not removed on exception path | try/finally `GLib.source_remove` | +| rel-8 | open | S | `blueman/plugins/mechanism/Rfcomm.py:14` `subprocess.Popen` failure silently swallowed | log + propagate | +| rel-9 | open | S | `blueman/main/Services.py:86` bare `except: pass` hides errors | narrow exception types | +| rel-10 | open | S | `blueman/main/DhcpClient.py:53` `poll()` returns `0` on success but truthy check fails | compare `is not None` or `== 0` explicitly | + +## observability + +| id | status | effort | description | notes | +|----|--------|--------|-------------|-------| +| obs-1 | open | S | `blueman/Functions.py:64,87` `print()` in `check_bluetooth_status()` exception/fallback | `logging.error(..., exc_info=True)` | +| obs-2 | open | S | `blueman/main/NetConf.py:93` `print()` for process termination | `logging.info` with binary/pid context | +| obs-3 | open | S | `blueman/main/Manager.py:62` `print()` in exception handler | `logging.error(..., exc_info=True)` | +| obs-4 | open | S | `blueman/bluez/obex/Manager.py:51,59,68,75` `logging.info(object_path)` lacks event/context | prefix with event name | +| obs-5 | open | S | `blueman/main/NetConf.py:340` silent `pass` on `BridgeException` | `logging.warning(...)` | +| obs-6 | open | S | `blueman/main/PluginManager.py:64,123` `LoadException` swallowed silently | `logging.warning` with plugin name | +| obs-7 | open | S | `blueman/gui/Notification.py:169` silent `ValueError` on notification hints | `logging.debug` unsupported hint | +| obs-8 | open | S | `blueman/main/Sendto.py:286` `logging.debug(e.message)` on `GLib.Error` | use `str(e)` | +| obs-9 | open | S | `blueman/gui/GtkAnimation.py:79` silent `ZeroDivisionError` on duration=0 | `logging.debug("Animation duration zero")` | +| obs-10 | open | S | `blueman/gui/GenericList.py:116` silent `ValueError` from `get_iter` | `logging.debug` invalid path | +| obs-11 | open | S | `blueman/main/DNSServerProvider.py:48` `GLib.Error` swallowed | `logging.debug("DNS lookup failed, using fallback")` | +| obs-12 | open | S | `blueman/plugins/mechanism/Network.py:46` exception only routed to error callback, no local log | add `logging.error` with trace | +| obs-13 | open | S | `blueman/bluez/Base.py:107` `GLib.Error` falls back to cached property silently | `logging.debug` cache fallback | +| obs-14 | open | S | `sendto/blueman_sendto.py.in:14,17,29,33` `print()` for user-facing messages | replace with `logging` where plugin host allows | + +## wiring gaps + +| id | status | effort | description | notes | +|----|--------|--------|-------------|-------| +| wire-1 | open | S | `blueman/services/meta/NetworkService.py:48` calls `AppletService().dchp_client()` but `AppletService` has no such method | route to `AppletDhcpClientService().dchp_client()` | +| wire-2 | open | S | `blueman/main/DBusProxies.py:86` `AppletDhcpClientService` class never instantiated in production | wire into dispatcher or delete | +| wire-3 | open | S | `blueman/main/DBusProxies.py:133` `AppletStatusIconService` has no public methods beyond `__init__` | inline its only call site in `Tray.py` | + +## unused functions/methods + +| id | status | effort | description | notes | +|----|--------|--------|-------------|-------| +| dead-1 | open | S | `blueman/Functions.py:217` `set_proc_title` in `__all__`, no production callers | delete | +| dead-2 | open | S | `blueman/Functions.py:239` `create_logger` in `__all__`, no production callers | delete | +| dead-3 | open | S | `blueman/Functions.py:264` `create_parser` in `__all__`, no production callers | delete | + +## STRIDE + +| id | status | effort | description | notes | +|----|--------|--------|-------------|-------| +| stride-1 | open | M | `blueman/main/DbusService.py:162-170` unhandled exceptions return full traceback in DBus errors, leaking internal paths to any caller | sanitize error messages on the bus; detailed traces to daemon log only | +| stride-2 | open | S | `blueman/plugins/mechanism/Network.py:52` attacker-supplied `dhcp_handler` indexes `DHCPDHANDLERS` dict with no whitelist (DoS / EoP) | validate against allowed keys before lookup (dup of rel-6, security framing) | +| stride-3 | open | M | `blueman/main/NetConf.py:268,276` IPv4 addr/netmask split on space then passed to iptables; malformed CIDR unvalidated (Tampering) | validate IP/netmask format; pass structured args (dup of sec-1) | +| stride-4 | open | M | `blueman/main/MechanismApplication.py:50` if `POLKIT=False` at build, PolicyKit auth skipped silently (Elevation of privilege) | fail-closed; never silently skip authorization; log when disabled | + +## data governance + +| id | status | effort | description | notes | +|----|--------|--------|-------------|-------| +| gov-1 | open | M | `blueman/plugins/applet/RecentConns.py:127-139` device object paths + UUIDs stored unencrypted in GSettings | store only address/UUID; audit schema permissions | +| gov-2 | open | S | `blueman/plugins/applet/RecentConns.py:144` BT addresses (quasi-permanent IDs) logged via `logging.info` | redact/rate-limit address logging in production | +| gov-3 | open | M | `blueman/plugins/applet/NetUsage.py:40,64-65` per-device tx/rx stats persisted at `/org/blueman/plugins/netusages/{Address}/` reveal connection history + volume | document retention; add auto-expire option | +| gov-4 | open | S | `blueman/plugins/applet/RecentConns.py:120` user device aliases (may contain PII) stored plaintext | document plaintext storage; UI warning | + +## multithreading + +| id | status | effort | description | notes | +|----|--------|--------|-------------|-------| +| mt-1 | open | S | `blueman/gui/manager/ManagerMenu.py:96` `GLib.idle_add()` return value/source id ignored; no cleanup if parent destroyed | store source id, remove on teardown | + +## watchdog + +| id | status | effort | description | notes | +|----|--------|--------|-------------|-------| +| wd-1 | open | M | `blueman/main/PPPConnection.py:82-87` pppd spawned with no liveness monitoring; orphan pppd possible on error path | add `GLib.child_watch_add`, kill on cleanup | +| wd-2 | open | M | `blueman/main/PPPConnection.py:76` `cleanup()` only closes fd, leaves io_watch/timeout sources registered | remove all GLib sources in cleanup (overlaps rel-7) | +| wd-3 | open | M | `blueman/main/DhcpClient.py:49-50` two `timeout_add` sources, neither stored; `_check_client` keeps polling dead process after `_on_timeout` | store + `source_remove` both on exit (overlaps rob-3) | +| wd-4 | open | M | `blueman/plugins/mechanism/Rfcomm.py:14` rfcomm watcher Popen fire-and-forget, no PID tracking/liveness; only killed via grepped `ps` | track PID, supervise (overlaps sec-2, rel-8) | +| wd-5 | open | M | `blueman/services/meta/SerialService.py:75` `Popen([RFCOMM_WATCHER_PATH])` no exit/return-code monitoring; crash leaves rfcomm broken | child watch + restart/notify | +| wd-6 | open | S | `blueman/plugins/applet/PPPSupport.py:40` synchronous `Popen(['ps'])` blocks main loop until ps returns | use async `Gio.Subprocess` | +| wd-7 | open | M | `blueman/main/NetConf.py:122,190,235` Dhcpd/Udhcpd/DnsMasq Popen+communicate with no hang supervision | timeout-guard or async | + +## state machine + +| id | status | effort | description | notes | +|----|--------|--------|-------------|-------| +| sm-1 | open | M | `blueman/main/PPPConnection.py:53-75` `__init__` leaves pppd/file/buffer/timeout/io_watch uninitialized; cleanup/check_pppd crash if hit early | init all attrs in `__init__` (overlaps rel-2,rel-3) | +| sm-2 | open | M | `blueman/main/PPPConnection.py:181-210` `on_data_ready` can run cleanup while `on_timeout` still pending → double `error-occurred` emit | explicit connection-state guard, single emit | +| sm-3 | open | L | `blueman/main/PPPConnection.py:213-224` `on_timeout` closure captures stale `command_id` if `send_commands` reused before fire | bind per-command state / cancel prior timeout | +| sm-4 | open | M | `blueman/main/DhcpClient.py:39-51` no state flag; `_check_client` + `_on_timeout` both call `querying.remove()` → possible `ValueError` | guard with done-flag, single removal (overlaps rel-10) | +| sm-5 | open | M | `blueman/main/NetworkManager.py:38,69-70` `_statehandler` asserted not-None but state change can fire before assignment | assign handler before connect / null-guard | +| sm-6 | open | L | `blueman/plugins/applet/PowerManager.py:97,109` Callback timer source id not tracked; orphan timeout fires on GC'd object | store source id, remove in destructor | +| sm-7 | open | M | `blueman/main/NetConf.py:84-101` `DHCPHandler.clean_up()` reads/kills `_pid` with no guard; concurrent calls race / SIGTERM wrong pid | idempotent guard on `_pid` | + +## composition + +| id | status | effort | description | notes | +|----|--------|--------|-------------|-------| +| comp-1 | open | M | `blueman/bluez/Base.py:11-26` `BaseMeta` metaclass couples object identity to DBus path via permanent instance cache | extract caching to registry/factory (overlaps conc-2) | +| comp-2 | open | M | `blueman/bluez/obex/Base.py:5` obex Base subclasses bluez Base, both override metaclass attrs; class-attr duplication | pass bus config to `__init__` instead of subclassing | +| comp-3 | open | S | `blueman/gui/manager/ManagerDeviceList.py:45` 4-level inheritance (Gtk.TreeView→GenericList→DeviceList→ManagerDeviceList) + parent-chain coupling | inject deps via constructor, prefer composition | +| comp-4 | open | M | `blueman/plugins/MechanismPlugin.py:8-12` copies parent methods (timer, confirm_authorization) into `__init__`; tight bind to concrete app | abstract plugin interface + DI | + +## dependency + +| id | status | effort | description | notes | +|----|--------|--------|-------------|-------| +| dep-1 | open | L | `blueman/main/PulseAudioUtils.py:14-18` import-time `CDLL` load raises ImportError if libpulse absent, failing module | lazy loader + optional-support flag | +| dep-2 | open | L | `blueman/main/NetworkManager.py:9-12` import-time `gi.require_version` raises if NM bindings missing | move into lazy init try-block | +| dep-3 | open | L | `blueman/plugins/mechanism/RfKill.py:6-7` import-time `/dev/rfkill` check raises, blocks plugin discovery on systems without it | move check to `on_load()` | +| dep-4 | open | L | `blueman/plugins/applet/GameControllerWakelock.py:14-16,22-23` import-time GdkX11/X11 screen check raises | move platform check to `on_load()` | +| dep-5 | open | S | `blueman/main/Applet.py:10` wildcard `from blueman.Functions import *` obscures deps | explicit imports | +| dep-6 | open | S | `blueman/plugins/applet/NetUsage.py:8` wildcard import from `blueman.Functions` | explicit imports | +| dep-7 | open | M | `blueman/main/DNSServerProvider.py:12` hardcoded `RESOLVER_PATH="/etc/resolv.conf"` | configurable + DNSProvider abstraction (dup cfg-004) | +| dep-8 | open | L | `blueman/Functions.py:210` hardcoded PATH suffix `:/sbin:/usr/sbin` | use `shutil.which()` (dup plat-001) | + +## adaptability + +| id | status | effort | description | notes | +|----|--------|--------|-------------|-------| +| adapt-1 | open | M | `blueman/gui/manager/ManagerDeviceMenu.py:225-242` hardcoded BlueZ error-string mapping with version-specific comments; breaks on newer BlueZ | parse error codes dynamically + version detect | +| adapt-2 | open | M | `blueman/main/Functions.py:104` (`blueman/Functions.py:104`) `time.clock_gettime(CLOCK_MONOTONIC_RAW)` fallback not portable | use `GLib.get_monotonic_time()` consistently | +| adapt-3 | open | M | `blueman/plugins/mechanism/Ppp.py:24` hardcoded `/dev/rfcomm{port}` template | inject device-path factory | +| adapt-4 | open | S | `blueman/main/DbusService.py` bus type hardcoded to SESSION; assumes single-user desktop | make bus_type configurable | + +## extensibility + +| id | status | effort | description | notes | +|----|--------|--------|-------------|-------| +| ext-1 | open | M | `blueman/main/PluginManager.py:132-174` load logic embedded in manager; hard to add plugin types/async loaders | extract LoadStrategy / load pipeline | +| ext-2 | open | M | `blueman/plugins/AppletPlugin.py:35-41` DBus service opt-in via `__dbus_iface_name__` is intricate | `@dbus_service` decorator / ServiceRegistry | +| ext-3 | open | L | `blueman/plugins/ServicePlugin.py:12-62` separate hierarchy from BasePlugin; no depends/conflicts declarations | unify to BasePlugin, add `__depends__`/`__conflicts__` | +| ext-4 | open | M | `blueman/services/meta/NetworkService.py` new service types must implement props; no extension hooks | ServiceRegistry + `@service_provider` | +| ext-5 | open | M | `blueman/main/indicators/IndicatorInterface.py` StatusIcon vs StatusNotifierItem hardcoded; no pluggable indicator backend | IndicatorBackend protocol via PluginManager | +| ext-6 | open | M | `blueman/plugins/applet/Menu.py` menu structure hardcoded; plugins can't extend menus without parent coupling | MenuRegistry / signal-based insertion | +| ext-7 | open | S | `blueman/gui/DeviceList.py:147-162` override hooks only; no registry for third-party extensions | signal-based hooks / extension protocol | + +## legacy + +| id | status | effort | description | notes | +|----|--------|--------|-------------|-------| +| leg-1 | open | M | `blueman/Functions.py:78` deprecated `Gtk.Dialog.run()`/`.destroy()` blocking pattern | non-blocking response-signal pattern | +| leg-2 | open | M | `blueman/Functions.py:189,200` deprecated `Gtk.ImageMenuItem` | migrate to `Gtk.MenuItem` + image | +| leg-3 | open | M | `blueman/main/Sendto.py:178,190,291,461` deprecated dialog `.run()`/`.destroy()` | async response handlers | +| leg-4 | open | M | `blueman/gui/manager/ManagerMenu.py:45,47` `Gtk.ImageMenuItem` in manager UI | migrate to `Gtk.MenuItem` | +| leg-5 | open | S | `blueman/Functions.py:226` raw ctypes `libc.prctl(15,...)` for proc title | document or guard non-Linux (relates dead-1) | +| leg-6 | open | S | `blueman/gui/GtkAnimation.py:200` FIXME `Gtk.render_background()` wrong colors | investigate + fix or document | +| leg-7 | open | S | `blueman/bluez/Device.py:22,29` `# type: ignore` on connect/disconnect masking signature mismatch | resolve override signatures | +| leg-8 | open | S | `blueman/bluez/Network.py:17,26` `# type: ignore` on connect/disconnect | resolve signatures | +| leg-9 | open | S | `blueman/main/indicators/GtkStatusIcon.py:44` `# type: ignore` on submenu enumerate | proper overload/typing | + +## configuration + +| id | status | effort | description | notes | +|----|--------|--------|-------------|-------| +| cfg-1 | open | M | `blueman/Constants.py.in:22` `BLUEMAN_SOURCE` env var checked inline, undocumented feature flag | centralize in config module + document | +| cfg-2 | open | M | `blueman/main/MechanismApplication.py:25` idle timeout hardcoded (30s / 9999 dev) keyed on `BLUEMAN_SOURCE` | make configurable, document dev mode (overlaps arch-5) | +| cfg-3 | open | S | `blueman/main/NetConf.py:62,256` hardcoded `/var/run` PID path | use `XDG_RUNTIME_DIR`/`/run` (overlaps dep-11) | +| cfg-4 | open | M | `blueman/main/DNSServerProvider.py:12` hardcoded `/etc/resolv.conf`, precedence undocumented | document resolved-first precedence | +| cfg-5 | open | S | `blueman/main/DhcpClient.py:17-20` DHCP client search order hardcoded (dhclient/dhcpcd/udhcpc) | configurable list | +| cfg-6 | open | M | `blueman/plugins/services/Network.py` DHCP handler selection (dnsmasq/dhcpd/udhcpd) no user config, undocumented fallback chain | document + expose config | +| cfg-7 | open | S | `blueman/config/AutoConnectConfig.py:10` GSettings schema id hardcoded, duplicated across plugins | module constant | + +## platform + +| id | status | effort | description | notes | +|----|--------|--------|-------------|-------| +| plat-1 | open | M | `blueman/Functions.py:210` hardcoded `:/sbin:/usr/sbin` fallback (dup dep-8) | `shutil.which()` | +| plat-2 | open | M | `blueman/main/PPPConnection.py:83` hardcoded `/usr/sbin/pppd` | dynamic `have()` lookup | +| plat-3 | open | M | `blueman/main/NetConf.py:268,276` hardcoded `/sbin/iptables` | dynamic lookup | +| plat-4 | open | L | `blueman/main/NetConf.py:255` hardcoded `/proc/sys/net/ipv4` IP-forward, Linux-only | abstract, no non-Linux fallback | +| plat-5 | open | M | `blueman/plugins/applet/KillSwitch.py:59,87` hardcoded `/dev/rfkill`, silent fail without it | feature-detect + graceful degrade | +| plat-6 | open | S | `blueman/plugins/mechanism/RfKill.py:6` module-level `/dev/rfkill` check raises at import (dup dep-3) | move to `on_load()` | +| plat-7 | open | M | `blueman/plugins/applet/NetUsage.py:84,87` hardcoded `/sys/class/net` sysfs paths, Linux-only | abstraction + degrade | +| plat-8 | open | S | `blueman/Functions.py:256` hardcoded `/dev/log` syslog address | platform detect / fallback to stderr | +| plat-9 | open | M | `blueman/main/NetConf.py:24` `/proc/{pid}` cmdline check, Linux-only | abstract proc access | +| plat-10 | open | S | `blueman/services/meta/SerialService.py` hardcoded `/dev/rfcomm{port}` naming | abstract device node | + +## data structure + +| id | status | effort | description | notes | +|----|--------|--------|-------------|-------| +| ds-1 | open | M | `blueman/main/SpeedCalc.py:24` `del self.log[0]` O(n) list prune | `collections.deque(maxlen=N)` (overlaps perf-8) | +| ds-2 | open | M | `blueman/plugins/applet/NetUsage.py:261-268` linear liststore scan by address in `monitor_added` | address→iter dict | +| ds-3 | open | M | `blueman/plugins/applet/NetUsage.py:276-283` linear liststore scan by address in `monitor_removed` | address→iter dict | +| ds-4 | open | M | `blueman/plugins/applet/RecentConns.py:129-137` linear scan of `stored_items` by (adapter,address,uuid) | tuple-keyed dict | +| ds-5 | open | M | `blueman/bluez/Manager.py:160-164` `find_device()` scans all DBus objects, repeated Address lookups | cached device index (dup perf-11, scale) | + +## vectorization + +| id | status | effort | description | notes | +|----|--------|--------|-------------|-------| +| vec-1 | open | M | `blueman/main/Sendto.py:140-143` per-property-change loop over UUIDs for OBEX_OBJPUSH | set membership / `any()` | +| vec-2 | open | L | `blueman/bluez/Manager.py:138-149` `get_devices()` rescans all objects per `find_device()` | cache indexed by adapter, batch GetAll (dup perf-5) | +| vec-3 | open | L | `blueman/gui/DeviceList.py:281-289` `clear()` per-row `device_remove_event` + dict lookups | bulk clear, defer path_to_row cleanup (dup perf-3) | + +## robustiness + +| id | status | effort | description | notes | +|----|--------|--------|-------------|-------| +| rob-1 | open | M | `blueman/gui/manager/ManagerProgressbar.py:178` `timeout_add(41,pulse)` source id not captured; pulses after `stop()` | store + remove source id (overlaps perf-14) | +| rob-2 | open | M | `blueman/gui/manager/ManagerProgressbar.py:117` `timeout_add(timeout,finalize)` id discarded; double-finalize | capture + remove before re-call | +| rob-3 | open | M | `blueman/main/DhcpClient.py:49-50` two timeout sources never stored/removed (dup wd-3) | store ids, remove on exit | +| rob-4 | open | M | `blueman/gui/DeviceList.py:256` discovery progress timeout source not stored/removed | capture id, remove in `stop_discovery()` | +| rob-5 | open | S | `blueman/main/PPPConnection.py:182-197` OSError path may skip `source_remove(io_watch)` before cleanup → leaked source | remove source in except (overlaps rel-7) | +| rob-6 | open | S | `blueman/plugins/applet/NetUsage.py:79-80` Monitor `__del__` doesn't remove timeout source | guard + `source_remove(poller)` | +| rob-7 | open | M | `blueman/main/Sendto.py:351-378` `on_transfer_progress` divides by `spd` without re-guard after ZeroDivisionError | `if spd>0` guard + log | + +## ui / ux + +| id | status | effort | description | notes | +|----|--------|--------|-------------|-------| +| ux-1 | open | M | `blueman/main/Sendto.py:310` blocking `time.sleep(1)` on UI thread during discovery stop | `GLib.timeout_add_seconds` | +| ux-2 | open | S | `blueman/gui/Notification.py:51` hardcoded notification size 350x50 | responsive sizing | +| ux-3 | open | S | `blueman/gui/manager/ManagerProgressbar.py:50` hardcoded progressbar 100x15 | flexible sizing | +| ux-4 | open | M | `blueman/gui/Notification.py:168-169` bare `except ValueError: pass` on hint set (dup obs-7) | log when fallback occurs | +| ux-5 | open | S | `blueman/gui/Notification.py:107-108` empty `add_action()` stub logs warning | implement or remove stub | +| ux-6 | open | M | `blueman/main/Sendto.py:178,188,291,461` blocking `dialog.run()` freeze UI (overlaps leg-3) | non-blocking response signals | +| ux-7 | open | S | `blueman/gui/manager/ManagerDeviceList.py:508` FIXME "horrible workaround" inadequate feedback | proper user feedback | +| ux-8 | open | S | `blueman/main/Manager.py:183` FIXME BlueZ stop/start not surfaced to user | notification/infobar on daemon loss | + +## documentation + +| id | status | effort | description | notes | +|----|--------|--------|-------------|-------| +| doc-1 | open | S | `blueman/Functions.py:217,239,264` dead `set_proc_title`/`create_logger`/`create_parser` in `__all__` (see dead-1..3) | document or remove | +| doc-2 | open | M | `blueman/gui/GenericList.py` no module/class docstring | document TreeView wrapper + signals | +| doc-3 | open | M | `blueman/gui/DeviceList.py` class docstring missing; signals only in `__gsignals__` | document model + key signals | +| doc-4 | open | S | `blueman/main/Builder.py` class lacks docstring | document Gtk.Builder wrapper behavior | +| doc-5 | open | M | `blueman/gui/Notification.py` `Notification()` factory + bubble/dialog undocumented | document return-type selection | +| doc-6 | open | M | `blueman/gui/DeviceSelectorDialog.py` `DeviceRow`/`DeviceSelector` lack docstrings | document selector pattern | +| doc-7 | open | M | `blueman/gui/CommonUi.py` `ErrorDialog` lacks docstring; `excp` param undocumented | document exception UI | +| doc-8 | open | S | `blueman/gui/manager/ManagerProgressbar.py` class undocumented (cancellable/text params) | document progress lifecycle | +| doc-9 | open | M | `blueman/gui/GsmSettings.py` class lacks docstring | document GSM settings binding | +| doc-10 | open | M | `README` lacks plugin/dev API docs | document plugin loading + extension points | + +--- + +## Open — parked + +_(none yet)_ + +## Audit picks deliberately rejected + +_(none yet)_ From b008ae55f2c12bbdff605ff81994f829b29889f4 Mon Sep 17 00:00:00 2001 From: Geraldo Netto Date: Tue, 2 Jun 2026 11:57:49 +0200 Subject: [PATCH 02/42] docs: close wire-1/wire-2, reject wire-3 in TODO wire-1 (DHCP renew routed to wrong proxy) and wire-2 (AppletDhcpClientService never instantiated) fixed on branch fix/wire-dhcp-proxy. wire-3 recorded as a deliberately-rejected false positive: AppletStatusIconService is a required signal-only proxy in Tray.py. Co-Authored-By: Claude Opus 4.8 (1M context) --- TODO.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/TODO.md b/TODO.md index 37fb7bc4a..f846ada9d 100644 --- a/TODO.md +++ b/TODO.md @@ -145,9 +145,8 @@ Status: `open`, `in-progress`, `blocked`. Effort: `S` (≤1h), `M` (half-day), ` | id | status | effort | description | notes | |----|--------|--------|-------------|-------| -| wire-1 | open | S | `blueman/services/meta/NetworkService.py:48` calls `AppletService().dchp_client()` but `AppletService` has no such method | route to `AppletDhcpClientService().dchp_client()` | -| wire-2 | open | S | `blueman/main/DBusProxies.py:86` `AppletDhcpClientService` class never instantiated in production | wire into dispatcher or delete | -| wire-3 | open | S | `blueman/main/DBusProxies.py:133` `AppletStatusIconService` has no public methods beyond `__init__` | inline its only call site in `Tray.py` | + +_(none open)_ ## unused functions/methods @@ -355,4 +354,9 @@ _(none yet)_ ## Audit picks deliberately rejected -_(none yet)_ +- **wire-3** (`AppletStatusIconService` "has no public methods") — false positive. It is a + signal-only `Gio.DBusProxy` for the `org.blueman.Applet.StatusIcon` interface. A proxy emits + `g-signal` only for its own interface, so `Tray.py` needs this distinct proxy to receive + `IconNameChanged`/`VisibilityChanged`/`ToolTipTitleChanged`/`ToolTipTextChanged` + (`AppletMenuService` only delivers `MenuChanged` on the Menu interface). Deleting/inlining it + would silence tray-icon updates. Kept; guarded by a test in `test/main/test_dbus_proxies.py`. From 231d225e131c56cdc10a35b2e7173fe9d11b8bb5 Mon Sep 17 00:00:00 2001 From: Geraldo Netto Date: Tue, 2 Jun 2026 12:15:38 +0200 Subject: [PATCH 03/42] docs: close SpeedCalc perf item, park GTK-bound perf items SpeedCalc log-bounding + zero-elapsed guard shipped on branch perf/small-optimizations. Park the ManagerProgressbar cleanup and GtkAnimation timer items: both are GTK-app-bound / architectural, not low-risk and not unit-testable headless. Co-Authored-By: Claude Opus 4.8 (1M context) --- TODO.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/TODO.md b/TODO.md index f846ada9d..300bda744 100644 --- a/TODO.md +++ b/TODO.md @@ -26,13 +26,10 @@ Status: `open`, `in-progress`, `blocked`. Effort: `S` (≤1h), `M` (half-day), ` | perf-5 | open | M | `blueman/bluez/Manager.py:115-149` `get_adapter_paths`/`get_devices` iterate `_object_manager.get_objects()` per call | cache, invalidate on object-added/removed | | perf-6 | open | S | `blueman/gui/manager/ManagerDeviceList.py:384-388,456-497` `row_setup_event`/`row_update_event` re-read same `device[k]` props | read once, reuse | | perf-7 | open | M | `blueman/gui/manager/ManagerDeviceList.py:353-407` row setup pulls 8+ props via individual `Get` calls | single `GetAll` per row | -| perf-8 | open | S | `blueman/gui/manager/ManagerStats.py:36-37` two `SpeedCalc` instances keep unbounded log lists | cap log size to N samples | | perf-9 | open | S | `blueman/main/DhcpClient.py:48-50,68` `subprocess.poll()` blocking in 1s `GLib.timeout` | use `Gio.Subprocess` + `wait_check_async` or `GLib.child_watch_add` | | perf-10 | open | S | `blueman/gui/manager/ManagerMenu.py:53` creates Adapter proxies for all adapters in `__init__` | lazy-instantiate on selection | | perf-11 | open | S | `blueman/main/Manager.py:161-164` `find_device()` linear scan over all objects | address-indexed dict | -| perf-12 | open | S | `blueman/gui/manager/ManagerProgressbar.py:132-138` reverse-iterate cleanup → O(n²) | track via deque or set | | perf-13 | open | S | `blueman/main/Applet.py:93-118` plugin broadcast loop runs full plugin set per property change → O(plugins × props × devices) | debounce/batch property events | -| perf-14 | open | S | `blueman/gui/GtkAnimation.py:85` per-animation 41ms timer; multiple animations stack | unify tick clock | ## scalability @@ -350,7 +347,13 @@ _(none open)_ ## Open — parked -_(none yet)_ +- **perf-12** (`ManagerProgressbar` instance cleanup) — the loop is GTK-widget-bound + (`finalize()` touches builder/window/hbox/Stats) and is actually O(n), not O(n²); no real + perf win and not unit-testable without a full Manager app. Park until reworked alongside the + rob-1/rob-2 source-id fixes in the same file. +- **perf-14** (`GtkAnimation` per-animation timer) — the fix ("unify tick clock") is a + shared-timer architecture change, not a low-risk edit, and can't reach genuine coverage + headless. Park for a dedicated animation-scheduler change. ## Audit picks deliberately rejected From bb81c4f7dc97ed28f4768f5989a32df3050a901a Mon Sep 17 00:00:00 2001 From: Geraldo Netto Date: Tue, 2 Jun 2026 12:29:58 +0200 Subject: [PATCH 04/42] docs: close security input-validation items NetConf iptables arg handling + IPv4 validation, Rfcomm ps-output parsing, and PPPConnection APN validation shipped on branch security/input-validation. Removes sec-1..sec-4 and the duplicate stride-3 row. Co-Authored-By: Claude Opus 4.8 (1M context) --- TODO.md | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/TODO.md b/TODO.md index 300bda744..344193939 100644 --- a/TODO.md +++ b/TODO.md @@ -10,10 +10,8 @@ Status: `open`, `in-progress`, `blocked`. Effort: `S` (≤1h), `M` (half-day), ` | id | status | effort | description | notes | |----|--------|--------|-------------|-------| -| sec-1 | open | S | `blueman/main/NetConf.py:268` iptables rule split on space without bounds checking — arbitrary args via IP/netmask containing spaces | use `shlex.split()` or pass args as list to subprocess | -| sec-2 | open | S | `blueman/plugins/mechanism/Rfcomm.py:17-21` ps output parsing has no bounds checking; malformed line → `IndexError` | wrap split in try/except or validate line shape | -| sec-3 | open | S | `blueman/plugins/mechanism/Rfcomm.py:19` `int(pid)` without validation; malformed ps output crashes mechanism | validate numeric before `int()` | -| sec-4 | open | S | `blueman/main/PPPConnection.py:74` AT command built via f-string with unvalidated `apn` | validate apn against `[A-Za-z0-9.\-]+` or escape | + +_(none open)_ ## performance @@ -159,7 +157,6 @@ _(none open)_ |----|--------|--------|-------------|-------| | stride-1 | open | M | `blueman/main/DbusService.py:162-170` unhandled exceptions return full traceback in DBus errors, leaking internal paths to any caller | sanitize error messages on the bus; detailed traces to daemon log only | | stride-2 | open | S | `blueman/plugins/mechanism/Network.py:52` attacker-supplied `dhcp_handler` indexes `DHCPDHANDLERS` dict with no whitelist (DoS / EoP) | validate against allowed keys before lookup (dup of rel-6, security framing) | -| stride-3 | open | M | `blueman/main/NetConf.py:268,276` IPv4 addr/netmask split on space then passed to iptables; malformed CIDR unvalidated (Tampering) | validate IP/netmask format; pass structured args (dup of sec-1) | | stride-4 | open | M | `blueman/main/MechanismApplication.py:50` if `POLKIT=False` at build, PolicyKit auth skipped silently (Elevation of privilege) | fail-closed; never silently skip authorization; log when disabled | ## data governance From a337e6ffed541931b0e6dd18753d7917893e0d89 Mon Sep 17 00:00:00 2001 From: Geraldo Netto Date: Tue, 2 Jun 2026 12:38:25 +0200 Subject: [PATCH 05/42] docs: close reliability crash-guard items DhcpClient client-state guards + poll fix, mechanism Network handler-key validation, NetConf cleanup next() default, and Rfcomm open error handling shipped on branch reliability/crash-guards. Removes rel-1, rel-4, rel-6, rel-8, rel-10 and the duplicate stride-2 row. Remaining rel items are the PPPConnection/Services paths that need a live GLib loop. Co-Authored-By: Claude Opus 4.8 (1M context) --- TODO.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/TODO.md b/TODO.md index 344193939..179510721 100644 --- a/TODO.md +++ b/TODO.md @@ -106,16 +106,11 @@ _(none open)_ | id | status | effort | description | notes | |----|--------|--------|-------------|-------| -| rel-1 | open | S | `blueman/main/NetConf.py:91` `next(...)` without default → `StopIteration` aborts cleanup | `next(..., None)` | | rel-2 | open | S | `blueman/main/PPPConnection.py:121` `self.file` not initialized in `__init__`; touched in exception paths | init `self.file = None` | | rel-3 | open | S | `blueman/main/PPPConnection.py:159` `self.pppd` not initialized before `connect_callback`; `poll()` crashes on early error | init `self.pppd = None` | -| rel-4 | open | S | `blueman/main/DhcpClient.py:48` `self._client` undefined until `run()`; `_check_client()` crashes if not called | init `self._client = None` | | rel-5 | open | S | `blueman/main/PPPConnection.py:76-77` `os.close(self.file)` without fd validity check | guard with try/except `OSError` | -| rel-6 | open | S | `blueman/plugins/mechanism/Network.py:52` `DHCPDHANDLERS[dhcp_handler]` `KeyError` on untrusted input | validate against allowed keys | | rel-7 | open | S | `blueman/main/PPPConnection.py:222-224` `io_watch`/`timeout` not removed on exception path | try/finally `GLib.source_remove` | -| rel-8 | open | S | `blueman/plugins/mechanism/Rfcomm.py:14` `subprocess.Popen` failure silently swallowed | log + propagate | | rel-9 | open | S | `blueman/main/Services.py:86` bare `except: pass` hides errors | narrow exception types | -| rel-10 | open | S | `blueman/main/DhcpClient.py:53` `poll()` returns `0` on success but truthy check fails | compare `is not None` or `== 0` explicitly | ## observability @@ -156,7 +151,6 @@ _(none open)_ | id | status | effort | description | notes | |----|--------|--------|-------------|-------| | stride-1 | open | M | `blueman/main/DbusService.py:162-170` unhandled exceptions return full traceback in DBus errors, leaking internal paths to any caller | sanitize error messages on the bus; detailed traces to daemon log only | -| stride-2 | open | S | `blueman/plugins/mechanism/Network.py:52` attacker-supplied `dhcp_handler` indexes `DHCPDHANDLERS` dict with no whitelist (DoS / EoP) | validate against allowed keys before lookup (dup of rel-6, security framing) | | stride-4 | open | M | `blueman/main/MechanismApplication.py:50` if `POLKIT=False` at build, PolicyKit auth skipped silently (Elevation of privilege) | fail-closed; never silently skip authorization; log when disabled | ## data governance From 4466eec0533b629cef9e3f7dd15e2965abd15dac Mon Sep 17 00:00:00 2001 From: Geraldo Netto Date: Sun, 14 Jun 2026 18:30:50 +0200 Subject: [PATCH 06/42] docs: add review categories from rescan notes --- AGENTS.md | 8 ++++++++ TODO.md | 56 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 647dc1d36..ee8565a9c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,6 +38,8 @@ This file defines the expected behavior and usage model for AI agents working in Each table should use the format: `id | status | effort | description | notes`. - security - STRIDE (as in microsoft security framework) + - input validation / command safety + - data integrity - data governance - reliability - observability @@ -52,13 +54,18 @@ This file defines the expected behavior and usage model for AI agents working in - extensibility - legacy - configuration + - API contract & compatibility - data structure - vectorization - platform - ui / ux + - accessibility + - i18n - documentation + - test coverage - performance - scalability + - caching strategy - concurrency - code complexity - code duplication @@ -67,6 +74,7 @@ This file defines the expected behavior and usage model for AI agents working in - business/design patterns/DDD - reliability/correctness - observability when the application has it + - release & deploy engineering - wiring gaps — modules/helpers/cfg knobs that exist + pass tests but have no real production call site (orphan exports, cfg flags never read, advertised backends not wired in). A shipped feature is only "shipped" when the dispatcher actually invokes it. - unused functions/methods — public-shaped callables (no leading `_`) imported by no production code, no tests, no plugins. Different from wiring gaps: these aren't half-wired, they're fully dead. Includes `__init__.py` re-exports that no caller pulls and class methods only ever called from one private site. Each finding: keep / inline / delete decision recorded in `notes`. diff --git a/TODO.md b/TODO.md index 179510721..791aad325 100644 --- a/TODO.md +++ b/TODO.md @@ -13,6 +13,20 @@ Status: `open`, `in-progress`, `blocked`. Effort: `S` (≤1h), `M` (half-day), ` _(none open)_ +## input validation / command safety + +| id | status | effort | description | notes | +|----|--------|--------|-------------|-------| + +_(none open)_ + +## data integrity + +| id | status | effort | description | notes | +|----|--------|--------|-------------|-------| + +_(none open)_ + ## performance | id | status | effort | description | notes | @@ -38,6 +52,13 @@ _(none open)_ | scale-3 | open | S | `blueman/main/BatteryWatcher.py:18` creates `Battery` per creation signal without dedup | check existence before create | | scale-4 | open | S | `blueman/gui/manager/ManagerDeviceList.py:658` `device["UUIDs"]` accessed during cell render | cache in row data | +## caching strategy + +| id | status | effort | description | notes | +|----|--------|--------|-------------|-------| + +_(none open)_ + ## concurrency | id | status | effort | description | notes | @@ -70,6 +91,13 @@ _(none open)_ | dup-7 | open | S | `blueman/main/Sendto.py:47-55` 6× identical `connect_signal` boilerplate | `_setup_signal_handlers(source, handlers)` | | dup-8 | open | S | `blueman/main/Services.py:86` bare `except:` with `# noqa: E722` | narrow to expected exceptions | +## API contract & compatibility + +| id | status | effort | description | notes | +|----|--------|--------|-------------|-------| + +_(none open)_ + ## architecture/modularity/SOLID | id | status | effort | description | notes | @@ -319,6 +347,20 @@ _(none open)_ | ux-7 | open | S | `blueman/gui/manager/ManagerDeviceList.py:508` FIXME "horrible workaround" inadequate feedback | proper user feedback | | ux-8 | open | S | `blueman/main/Manager.py:183` FIXME BlueZ stop/start not surfaced to user | notification/infobar on daemon loss | +## accessibility + +| id | status | effort | description | notes | +|----|--------|--------|-------------|-------| + +_(none open)_ + +## i18n + +| id | status | effort | description | notes | +|----|--------|--------|-------------|-------| + +_(none open)_ + ## documentation | id | status | effort | description | notes | @@ -334,6 +376,20 @@ _(none open)_ | doc-9 | open | M | `blueman/gui/GsmSettings.py` class lacks docstring | document GSM settings binding | | doc-10 | open | M | `README` lacks plugin/dev API docs | document plugin loading + extension points | +## test coverage + +| id | status | effort | description | notes | +|----|--------|--------|-------------|-------| + +_(none open)_ + +## release & deploy engineering + +| id | status | effort | description | notes | +|----|--------|--------|-------------|-------| + +_(none open)_ + --- ## Open — parked From 339d9cad316391cb4b7c8f0c818a964cba845fb4 Mon Sep 17 00:00:00 2001 From: Geraldo Netto Date: Sun, 14 Jun 2026 18:34:34 +0200 Subject: [PATCH 07/42] docs: record expanded rescan findings --- TODO.md | 27 +++++++++++---------------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/TODO.md b/TODO.md index 791aad325..4afd30951 100644 --- a/TODO.md +++ b/TODO.md @@ -17,15 +17,13 @@ _(none open)_ | id | status | effort | description | notes | |----|--------|--------|-------------|-------| - -_(none open)_ +| cmd-1 | open | S | `sendto/blueman_sendto.py.in:20-28` builds a shell-style command line by wrapping file paths in double quotes and passing the joined string to `Gio.AppInfo.create_from_commandline`. A filename containing quotes or command separators can break argument boundaries when launched through the desktop shell parser. | Build a `Gio.AppInfo`/`Gio.Subprocess` invocation from an argv vector, or escape with GLib shell-quoting for every path. Add a regression test with spaces, quotes, and semicolons in filenames. Cross-ref test-1. | ## data integrity | id | status | effort | description | notes | |----|--------|--------|-------------|-------| - -_(none open)_ +| data-1 | open | S | `blueman/plugins/applet/TransferService.py:296-303` resolves incoming-file name collisions by prefixing only second-resolution time, then moves without rechecking the timestamped destination. Two same-named transfers completing in the same second can collide and overwrite/fail depending on platform semantics. | Generate a unique destination with an exclusive create/rename loop (`name`, `timestamp_name`, `timestamp_1_name`, ...), and test repeated same-second completions. | ## performance @@ -56,8 +54,7 @@ _(none open)_ | id | status | effort | description | notes | |----|--------|--------|-------------|-------| - -_(none open)_ +| cache-1 | open | M | `blueman/bluez/Base.py:100-107` caches DBus properties only after a synchronous `Get`, and falls back to the cached value on later `GLib.Error` without freshness metadata. Callers cannot tell whether they received live state or stale state, and no cache invalidation policy is documented per property. | Make cache state explicit: update from `PropertiesChanged`, mark stale on bus errors, and expose/handle stale reads at call sites that need fresh state. Cross-ref perf-4, obs-13. | ## concurrency @@ -95,8 +92,7 @@ _(none open)_ | id | status | effort | description | notes | |----|--------|--------|-------------|-------| - -_(none open)_ +| api-1 | open | S | `blueman/main/DBusProxies.py:91` exposes the Python proxy method as `dchp_client`, while the DBus method and interface are `DhcpClient`. The typo is now part of the local Python call surface and makes future refactors/API docs error-prone. | Add correctly spelled `dhcp_client()` as the public method, keep `dchp_client()` as a deprecated alias until callers/tests migrate, then remove the alias in a later cleanup. | ## architecture/modularity/SOLID @@ -351,15 +347,14 @@ _(none open)_ | id | status | effort | description | notes | |----|--------|--------|-------------|-------| - -_(none open)_ +| a11y-1 | open | S | `blueman/gui/applet/PluginDialog.py:87-112` dynamically creates labels next to `Gtk.SpinButton`/`Gtk.Entry` controls but does not set mnemonic widgets or accessible label relationships. Screen readers and keyboard users get weaker context for plugin preference fields. | Use mnemonic labels (`use_underline`) where possible and set label/accessibility relationships for generated controls; add an accessibility smoke test for generated preference widgets. | ## i18n | id | status | effort | description | notes | |----|--------|--------|-------------|-------| - -_(none open)_ +| i18n-1 | open | S | `sendto/blueman_sendto.py.in:46-50` hardcodes Nautilus/Caja/Nemo menu labels and tips in English, and `sendto/blueman_sendto.py.in` is not listed in `po/POTFILES.in`, so translators never see them. | Wrap file-manager extension labels/tips in gettext and add the generated/template source to extraction. | +| i18n-2 | open | S | `blueman/main/applet/BluezAgent.py:201-229` builds authentication notification sentences by concatenating translated fragments with device names, PINs, and markup. Translators cannot reorder the whole sentence or place punctuation naturally. | Use one format string per complete sentence/message with named placeholders, e.g. `%(device)s` and `%(passkey)s`, preserving markup escaping. | ## documentation @@ -380,15 +375,15 @@ _(none open)_ | id | status | effort | description | notes | |----|--------|--------|-------------|-------| - -_(none open)_ +| test-1 | open | S | No tests cover `sendto/blueman_sendto.py.in` command construction for selected file paths. The quoting bug in cmd-1 would pass unnoticed for paths with quotes, semicolons, or leading dashes. | Add a small unit test around the file-list-to-launch-command path after extracting it into a pure helper. Cross-ref cmd-1. | +| test-2 | open | S | No tests cover `BluezAgent._on_display_passkey` boundary values for `entered`. `blueman/main/applet/BluezAgent.py:201-203` indexes `key[entered]`, so an out-of-range or fully-entered value can crash the agent notification path. | Add focused tests for `entered` values 0, 5, 6, and invalid values; clamp or render without bolding when all digits are entered. | ## release & deploy engineering | id | status | effort | description | notes | |----|--------|--------|-------------|-------| - -_(none open)_ +| releng-1 | open | S | `make_release.sh:3-7` archives `HEAD` using the latest tag name from `git describe --tags --abbrev=0`, without verifying that `HEAD` is exactly that tag or that the working tree is clean. A release tarball can be mislabeled with the previous tag or include unintended worktree attributes. | Require `git describe --tags --exact-match`, fail on dirty status, and print the commit/tag being archived. | +| releng-2 | open | S | `make_release.sh:9-16` produces `.tar.xz` and `.tar.gz` but no checksums or signatures. Downstream packagers/users have no release-integrity artifact from the script. | Generate SHA256 sums and optionally detached signatures as part of the release script, documenting the expected verification flow. | --- From 42292dc6668358ae1d14c5b6fca5e8d768a81c71 Mon Sep 17 00:00:00 2001 From: Geraldo Netto Date: Mon, 15 Jun 2026 05:16:02 +0200 Subject: [PATCH 08/42] docs: add follow-up rescan findings --- TODO.md | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/TODO.md b/TODO.md index 4afd30951..d6a3ee984 100644 --- a/TODO.md +++ b/TODO.md @@ -10,20 +10,21 @@ Status: `open`, `in-progress`, `blocked`. Effort: `S` (≤1h), `M` (half-day), ` | id | status | effort | description | notes | |----|--------|--------|-------------|-------| - -_(none open)_ +| sec-1 | open | S | `blueman/plugins/applet/TransferService.py:181-186` interpolates the user-configured shared path into notification markup without escaping it. A path containing Pango markup can alter the fallback notification body and may be interpreted by notification daemons that support body markup. | Escape `shared-path` and the fallback path before formatting, or use a plain-text notification path. Add a regression test with ``, `&`, and quote characters. | ## input validation / command safety | id | status | effort | description | notes | |----|--------|--------|-------------|-------| | cmd-1 | open | S | `sendto/blueman_sendto.py.in:20-28` builds a shell-style command line by wrapping file paths in double quotes and passing the joined string to `Gio.AppInfo.create_from_commandline`. A filename containing quotes or command separators can break argument boundaries when launched through the desktop shell parser. | Build a `Gio.AppInfo`/`Gio.Subprocess` invocation from an argv vector, or escape with GLib shell-quoting for every path. Add a regression test with spaces, quotes, and semicolons in filenames. Cross-ref test-1. | +| cmd-2 | open | M | `blueman/Functions.py:120-134` exposes `launch(cmd: str, ...)` as a command-line string API and sends it to `Gio.AppInfo.create_from_commandline`. Callers such as `blueman/plugins/manager/Notes.py:35` embed options in `cmd`, so argument boundaries depend on string parsing instead of an argv contract. | Replace or supplement `launch` with an argv-based helper (`program`, `args`, `files`) and migrate command-building call sites. Keep `system=True` uses explicit and reviewed. | ## data integrity | id | status | effort | description | notes | |----|--------|--------|-------------|-------| | data-1 | open | S | `blueman/plugins/applet/TransferService.py:296-303` resolves incoming-file name collisions by prefixing only second-resolution time, then moves without rechecking the timestamped destination. Two same-named transfers completing in the same second can collide and overwrite/fail depending on platform semantics. | Generate a unique destination with an exclusive create/rename loop (`name`, `timestamp_name`, `timestamp_1_name`, ...), and test repeated same-second completions. | +| data-2 | open | S | `blueman/plugins/manager/Notes.py:32-35` creates a `.vnt` temporary file with `delete=False` and relies on the launched sendto process to delete it. If launch fails or the process never starts, the note body remains in `/tmp` indefinitely. | Delete the temp file when `launch()` returns false or raises; consider creating it in an app-owned temp directory with cleanup on startup. Cross-ref gov-5. | ## performance @@ -93,6 +94,7 @@ _(none open)_ | id | status | effort | description | notes | |----|--------|--------|-------------|-------| | api-1 | open | S | `blueman/main/DBusProxies.py:91` exposes the Python proxy method as `dchp_client`, while the DBus method and interface are `DhcpClient`. The typo is now part of the local Python call surface and makes future refactors/API docs error-prone. | Add correctly spelled `dhcp_client()` as the public method, keep `dchp_client()` as a deprecated alias until callers/tests migrate, then remove the alias in a later cleanup. | +| api-2 | open | M | `blueman/Functions.py:133` uses `Gio.AppInfo.create_from_commandline` in a shared helper, but its API accepts one opaque command string plus separate `paths`. This makes it hard for callers to express portable argv semantics or safely pass non-file options without depending on GLib command-line parsing. | Define a stable internal process-launch contract around argv and file arguments; deprecate the string form after migrating users. Cross-ref cmd-2. | ## architecture/modularity/SOLID @@ -135,6 +137,8 @@ _(none open)_ | rel-5 | open | S | `blueman/main/PPPConnection.py:76-77` `os.close(self.file)` without fd validity check | guard with try/except `OSError` | | rel-7 | open | S | `blueman/main/PPPConnection.py:222-224` `io_watch`/`timeout` not removed on exception path | try/finally `GLib.source_remove` | | rel-9 | open | S | `blueman/main/Services.py:86` bare `except: pass` hides errors | narrow exception types | +| rel-10 | open | S | `blueman/plugins/applet/TransferService.py:95-100` schedules removal of an allowed device but the timeout closure reads `self._pending_transfer` later instead of capturing the accepted address. A second pending transfer or cleared state can remove the wrong address or hit the assertion. | Capture `address` in the closure and remove it idempotently (`discard`-style) from the allowed list. Cross-ref sm-8. | +| rel-11 | open | S | `blueman/main/applet/BluezAgent.py:201-203` indexes `key[entered]` when displaying a passkey. If BlueZ reports `entered == 6` after all digits are typed, or an invalid value, the notification path raises `IndexError`. | Clamp `entered` to the valid range and render the fully-entered passkey without bolding a missing digit. Cross-ref test-2. | ## observability @@ -185,6 +189,7 @@ _(none open)_ | gov-2 | open | S | `blueman/plugins/applet/RecentConns.py:144` BT addresses (quasi-permanent IDs) logged via `logging.info` | redact/rate-limit address logging in production | | gov-3 | open | M | `blueman/plugins/applet/NetUsage.py:40,64-65` per-device tx/rx stats persisted at `/org/blueman/plugins/netusages/{Address}/` reveal connection history + volume | document retention; add auto-expire option | | gov-4 | open | S | `blueman/plugins/applet/RecentConns.py:120` user device aliases (may contain PII) stored plaintext | document plaintext storage; UI warning | +| gov-5 | open | S | `blueman/plugins/manager/Notes.py:32-35` can leave plaintext note bodies in temporary `.vnt` files when send launch fails. These notes are user-authored content and can include sensitive data. | Ensure temp-note lifecycle is owned by Blueman until a child process has definitely taken responsibility; clean stale `note*.vnt` files where safe. Cross-ref data-2. | ## multithreading @@ -211,10 +216,11 @@ _(none open)_ | sm-1 | open | M | `blueman/main/PPPConnection.py:53-75` `__init__` leaves pppd/file/buffer/timeout/io_watch uninitialized; cleanup/check_pppd crash if hit early | init all attrs in `__init__` (overlaps rel-2,rel-3) | | sm-2 | open | M | `blueman/main/PPPConnection.py:181-210` `on_data_ready` can run cleanup while `on_timeout` still pending → double `error-occurred` emit | explicit connection-state guard, single emit | | sm-3 | open | L | `blueman/main/PPPConnection.py:213-224` `on_timeout` closure captures stale `command_id` if `send_commands` reused before fire | bind per-command state / cancel prior timeout | -| sm-4 | open | M | `blueman/main/DhcpClient.py:39-51` no state flag; `_check_client` + `_on_timeout` both call `querying.remove()` → possible `ValueError` | guard with done-flag, single removal (overlaps rel-10) | +| sm-4 | open | M | `blueman/main/DhcpClient.py:39-51` no state flag; `_check_client` + `_on_timeout` both call `querying.remove()` → possible `ValueError` | guard with done-flag, single removal (overlaps wd-3, rob-3) | | sm-5 | open | M | `blueman/main/NetworkManager.py:38,69-70` `_statehandler` asserted not-None but state change can fire before assignment | assign handler before connect / null-guard | | sm-6 | open | L | `blueman/plugins/applet/PowerManager.py:97,109` Callback timer source id not tracked; orphan timeout fires on GC'd object | store source id, remove in destructor | | sm-7 | open | M | `blueman/main/NetConf.py:84-101` `DHCPHandler.clean_up()` reads/kills `_pid` with no guard; concurrent calls race / SIGTERM wrong pid | idempotent guard on `_pid` | +| sm-8 | open | M | `blueman/plugins/applet/TransferService.py:78-123` tracks only one `_pending_transfer` for authorization, but multiple incoming pushes can overlap before the user answers. A later request overwrites the pending state used by the first notification action. | Track pending transfers by `transfer_path`; bind notification callbacks to an immutable pending-transfer record. Cross-ref rel-10. | ## composition @@ -355,6 +361,7 @@ _(none open)_ |----|--------|--------|-------------|-------| | i18n-1 | open | S | `sendto/blueman_sendto.py.in:46-50` hardcodes Nautilus/Caja/Nemo menu labels and tips in English, and `sendto/blueman_sendto.py.in` is not listed in `po/POTFILES.in`, so translators never see them. | Wrap file-manager extension labels/tips in gettext and add the generated/template source to extraction. | | i18n-2 | open | S | `blueman/main/applet/BluezAgent.py:201-229` builds authentication notification sentences by concatenating translated fragments with device names, PINs, and markup. Translators cannot reorder the whole sentence or place punctuation naturally. | Use one format string per complete sentence/message with named placeholders, e.g. `%(device)s` and `%(passkey)s`, preserving markup escaping. | +| i18n-3 | open | S | `blueman/plugins/applet/TransferService.py:186` uses the action label `"Reset to default"` without gettext, so the fallback notification action is always English. | Wrap the action label in `_()` and ensure it appears in `po/POTFILES.in`. | ## documentation @@ -377,6 +384,7 @@ _(none open)_ |----|--------|--------|-------------|-------| | test-1 | open | S | No tests cover `sendto/blueman_sendto.py.in` command construction for selected file paths. The quoting bug in cmd-1 would pass unnoticed for paths with quotes, semicolons, or leading dashes. | Add a small unit test around the file-list-to-launch-command path after extracting it into a pure helper. Cross-ref cmd-1. | | test-2 | open | S | No tests cover `BluezAgent._on_display_passkey` boundary values for `entered`. `blueman/main/applet/BluezAgent.py:201-203` indexes `key[entered]`, so an out-of-range or fully-entered value can crash the agent notification path. | Add focused tests for `entered` values 0, 5, 6, and invalid values; clamp or render without bolding when all digits are entered. | +| test-3 | open | M | Incoming OBEX transfer authorization and completion paths in `blueman/plugins/applet/TransferService.py:78-123,286-329` have no focused tests for overlapping requests, allowed-device expiry, filename collisions, or failed final moves. Current coverage would miss data-1, rel-10, and sm-8. | Extract testable helpers for pending-transfer records and destination selection; add unit tests with mocked `Transfer`, `Session`, and notifications. | ## release & deploy engineering From f2a6b0f72df4869f4bc50f4f909a8a17967dabb3 Mon Sep 17 00:00:00 2001 From: Geraldo Netto Date: Mon, 15 Jun 2026 07:39:26 +0200 Subject: [PATCH 09/42] docs: rescan project todo findings --- TODO.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/TODO.md b/TODO.md index d6a3ee984..86b0411a9 100644 --- a/TODO.md +++ b/TODO.md @@ -25,6 +25,7 @@ Status: `open`, `in-progress`, `blocked`. Effort: `S` (≤1h), `M` (half-day), ` |----|--------|--------|-------------|-------| | data-1 | open | S | `blueman/plugins/applet/TransferService.py:296-303` resolves incoming-file name collisions by prefixing only second-resolution time, then moves without rechecking the timestamped destination. Two same-named transfers completing in the same second can collide and overwrite/fail depending on platform semantics. | Generate a unique destination with an exclusive create/rename loop (`name`, `timestamp_name`, `timestamp_1_name`, ...), and test repeated same-second completions. | | data-2 | open | S | `blueman/plugins/manager/Notes.py:32-35` creates a `.vnt` temporary file with `delete=False` and relies on the launched sendto process to delete it. If launch fails or the process never starts, the note body remains in `/tmp` indefinitely. | Delete the temp file when `launch()` returns false or raises; consider creating it in an app-owned temp directory with cleanup on startup. Cross-ref gov-5. | +| data-3 | open | S | `blueman/Functions.py:349-353` parses `/etc/os-release` lines with `line.split("=")`, so valid quoted values containing `=` are rejected and omitted from logged system info. | Use `split("=", 1)` and add a regression test with `PRETTY_NAME="Name=Variant"`. | ## performance @@ -139,6 +140,7 @@ Status: `open`, `in-progress`, `blocked`. Effort: `S` (≤1h), `M` (half-day), ` | rel-9 | open | S | `blueman/main/Services.py:86` bare `except: pass` hides errors | narrow exception types | | rel-10 | open | S | `blueman/plugins/applet/TransferService.py:95-100` schedules removal of an allowed device but the timeout closure reads `self._pending_transfer` later instead of capturing the accepted address. A second pending transfer or cleared state can remove the wrong address or hit the assertion. | Capture `address` in the closure and remove it idempotently (`discard`-style) from the allowed list. Cross-ref sm-8. | | rel-11 | open | S | `blueman/main/applet/BluezAgent.py:201-203` indexes `key[entered]` when displaying a passkey. If BlueZ reports `entered == 6` after all digits are typed, or an invalid value, the notification path raises `IndexError`. | Clamp `entered` to the valid range and render the fully-entered passkey without bolding a missing digit. Cross-ref test-2. | +| rel-12 | open | S | `blueman/plugins/BasePlugin.py:50` registers `weakref.finalize(self, self._on_plugin_delete)`. Passing a bound method keeps `self` strongly referenced by the finalizer, so plugin instances may not be collected and the delete hook is unreliable. | Register a module-level/static cleanup callback with weak state, or rely on explicit plugin unload and remove the finalizer. | ## observability @@ -158,6 +160,7 @@ Status: `open`, `in-progress`, `blocked`. Effort: `S` (≤1h), `M` (half-day), ` | obs-12 | open | S | `blueman/plugins/mechanism/Network.py:46` exception only routed to error callback, no local log | add `logging.error` with trace | | obs-13 | open | S | `blueman/bluez/Base.py:107` `GLib.Error` falls back to cached property silently | `logging.debug` cache fallback | | obs-14 | open | S | `sendto/blueman_sendto.py.in:14,17,29,33` `print()` for user-facing messages | replace with `logging` where plugin host allows | +| obs-15 | open | S | `blueman/plugins/applet/AutoConnect.py:116-117` ignores automatic connection failures with `pass`, so failed auto-connect attempts leave no log trail and are hard to diagnose. | Log the target service/device and failure reason at debug or warning level, with rate limiting if needed. | ## wiring gaps @@ -208,6 +211,7 @@ _(none open)_ | wd-5 | open | M | `blueman/services/meta/SerialService.py:75` `Popen([RFCOMM_WATCHER_PATH])` no exit/return-code monitoring; crash leaves rfcomm broken | child watch + restart/notify | | wd-6 | open | S | `blueman/plugins/applet/PPPSupport.py:40` synchronous `Popen(['ps'])` blocks main loop until ps returns | use async `Gio.Subprocess` | | wd-7 | open | M | `blueman/main/NetConf.py:122,190,235` Dhcpd/Udhcpd/DnsMasq Popen+communicate with no hang supervision | timeout-guard or async | +| wd-8 | open | S | `blueman/main/indicators/StatusNotifierItem.py:32-42` starts a repeating revision-advertisement timeout and discards the source id. The menu service cannot remove the source on unregister/teardown, so it can keep emitting after the tray path is gone. | Store the source id and remove it in an explicit `unregister`/delete path; add a test that teardown removes the source. | ## state machine @@ -221,6 +225,7 @@ _(none open)_ | sm-6 | open | L | `blueman/plugins/applet/PowerManager.py:97,109` Callback timer source id not tracked; orphan timeout fires on GC'd object | store source id, remove in destructor | | sm-7 | open | M | `blueman/main/NetConf.py:84-101` `DHCPHandler.clean_up()` reads/kills `_pid` with no guard; concurrent calls race / SIGTERM wrong pid | idempotent guard on `_pid` | | sm-8 | open | M | `blueman/plugins/applet/TransferService.py:78-123` tracks only one `_pending_transfer` for authorization, but multiple incoming pushes can overlap before the user answers. A later request overwrites the pending state used by the first notification action. | Track pending transfers by `transfer_path`; bind notification callbacks to an immutable pending-transfer record. Cross-ref rel-10. | +| sm-9 | open | S | `blueman/plugins/applet/ShowConnected.py:86-92` schedules delayed `enumerate_connections()` calls on every manager-state-enabled event without storing/canceling the source. A fast state flap can let a stale enumeration update the icon after the manager is disabled. | Store the pending source id, cancel it on manager disable/unload, and ignore callbacks if manager state changed. | ## composition @@ -335,6 +340,7 @@ _(none open)_ | rob-5 | open | S | `blueman/main/PPPConnection.py:182-197` OSError path may skip `source_remove(io_watch)` before cleanup → leaked source | remove source in except (overlaps rel-7) | | rob-6 | open | S | `blueman/plugins/applet/NetUsage.py:79-80` Monitor `__del__` doesn't remove timeout source | guard + `source_remove(poller)` | | rob-7 | open | M | `blueman/main/Sendto.py:351-378` `on_transfer_progress` divides by `spd` without re-guard after ZeroDivisionError | `if spd>0` guard + log | +| rob-8 | open | S | `blueman/gui/Animation.py:28-35` `start()` is not idempotent: calling it twice overwrites `self.timer` and leaks the first `GLib.timeout_add` source, so `stop()` can remove only the newest timer. | Return early if already started, or stop the existing source before starting a new one; add a start/stop source-id test. Cross-ref test-4. | ## ui / ux @@ -385,6 +391,7 @@ _(none open)_ | test-1 | open | S | No tests cover `sendto/blueman_sendto.py.in` command construction for selected file paths. The quoting bug in cmd-1 would pass unnoticed for paths with quotes, semicolons, or leading dashes. | Add a small unit test around the file-list-to-launch-command path after extracting it into a pure helper. Cross-ref cmd-1. | | test-2 | open | S | No tests cover `BluezAgent._on_display_passkey` boundary values for `entered`. `blueman/main/applet/BluezAgent.py:201-203` indexes `key[entered]`, so an out-of-range or fully-entered value can crash the agent notification path. | Add focused tests for `entered` values 0, 5, 6, and invalid values; clamp or render without bolding when all digits are entered. | | test-3 | open | M | Incoming OBEX transfer authorization and completion paths in `blueman/plugins/applet/TransferService.py:78-123,286-329` have no focused tests for overlapping requests, allowed-device expiry, filename collisions, or failed final moves. Current coverage would miss data-1, rel-10, and sm-8. | Extract testable helpers for pending-transfer records and destination selection; add unit tests with mocked `Transfer`, `Session`, and notifications. | +| test-4 | open | S | No tests cover `blueman/gui/Animation.py` timer source lifecycle. The `start()`/`stop()` path can leak sources if `start()` is called repeatedly, and current tests would not detect it. | Add a focused test with mocked `GLib.timeout_add`/`source_remove` for idempotent start and complete cleanup. Cross-ref rob-8. | ## release & deploy engineering From 9361c8b036d2ee1c6c5491e9305e24291f8a3ce1 Mon Sep 17 00:00:00 2001 From: Geraldo Netto Date: Fri, 19 Jun 2026 11:46:13 +0200 Subject: [PATCH 10/42] docs: expand AGENTS.md review categories with definitions Replace the flat category-name list with a deduplicated Category definitions subsection: each review category carries a concise definition and, where relevant, the framework to cite per finding (STRIDE, OWASP ASVS, Laws of UX). Scoped to this project's domain (D-Bus, polkit, network plugins, GTK, gettext, meson/autotools CI). Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 187 +++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 144 insertions(+), 43 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ee8565a9c..8a30ad908 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,49 +34,150 @@ This file defines the expected behavior and usage model for AI agents working in `git log` is the durable record. Exceptions: the "Open — parked" section keeps open-but-deferred items with a why-not-now annotation; the "Audit picks deliberately rejected" section keeps the rationale so future passes don't re-pick the same items. -- When making major changes, rescan the whole project and create or update `TODO.md` with one table per review category. - Each table should use the format: `id | status | effort | description | notes`. - - security - - STRIDE (as in microsoft security framework) - - input validation / command safety - - data integrity - - data governance - - reliability - - observability - - concurrency - - multithreading - - robustiness - - watchdog - - state machine - - composition - - dependency - - adaptability - - extensibility - - legacy - - configuration - - API contract & compatibility - - data structure - - vectorization - - platform - - ui / ux - - accessibility - - i18n - - documentation - - test coverage - - performance - - scalability - - caching strategy - - concurrency - - code complexity - - code duplication - - architecture/modularity/SOLID - - decoupling - - business/design patterns/DDD - - reliability/correctness - - observability when the application has it - - release & deploy engineering - - wiring gaps — modules/helpers/cfg knobs that exist + pass tests but have no real production call site (orphan exports, cfg flags never read, advertised backends not wired in). A shipped feature is only "shipped" when the dispatcher actually invokes it. - - unused functions/methods — public-shaped callables (no leading `_`) imported by no production code, no tests, no plugins. Different from wiring gaps: these aren't half-wired, they're fully dead. Includes `__init__.py` re-exports that no caller pulls and class methods only ever called from one private site. Each finding: keep / inline / delete decision recorded in `notes`. +- When making major changes, rescan the whole project and create or update `TODO.md` with one + table per review category defined below. Each table uses the format: + `id | status | effort | description | notes`. + +### Category definitions + +Use the lens that fits the finding; when a category names a framework, cite the specific +framework/law in the finding's `notes`. + +- **security** — injection boundaries (command, path traversal, D-Bus/IPC input) and + privilege boundaries (the polkit mechanism, setuid/root helpers). Every code path that + acts on untrusted input validates it first. Threat-model through complementary lenses + and name the one used: **STRIDE** (Spoofing, Tampering, Repudiation, Information + disclosure, Denial of service, Elevation of privilege) per data-flow boundary; the + **OWASP ASVS** checklist where it maps; and **attack trees** to decompose a high-value + target (gain root via the mechanism, spoof a device, intercept a connection) into + concrete leaf attacks. +- **input validation / command safety** — network and device inputs are validated before + they are used to build shell, `iptables`, AT, or D-Bus commands; no argument injection + via embedded spaces/newlines; command arguments are passed as argv lists, never a + split string. +- **data integrity** — in-memory/UI state (e.g. the device liststore) stays consistent + with the underlying bluez/system state: no stale row points at a removed device, signal + handlers keep derived state in sync on add/remove/rename. +- **data governance** — no private absolute paths (`/home/…`), secrets, or API keys are + committed, with a guard (CI grep / pre-commit) enforcing it; logs minimize sensitive + identifiers (BT addresses, object paths) to what is needed and never leak them beyond + the local session. +- **reliability / correctness** — logic bugs under normal flow. +- **robustness / recovery** — kill-safety, atomic writes (write-temp-then-rename), + partial-state recovery (a dying process or interrupted operation can't corrupt state or + orphan a resource), and cleanup of orphaned resources. +- **dependability** — stays useful when a dependency, provider, or optional subsystem + fails: graceful degradation, retry/backoff with timeout coverage, fallback chains that + stop before they amplify damage or hide partial failure. +- **observability / operability** — failures are *surfaced*, not merely logged; every + background mechanism exposes liveness + last result; D-Bus timeouts are sensible and + logging is actionable. Assess via the three pillars (logs / metrics / health) scaled to + a desktop app, and a silent-failure audit — enumerate every way the system can degrade + with no user-visible symptom. +- **concurrency** — concurrency-correctness on shared state and coordination; guards + against interleaved updates and double-submit. +- **multithreading** — thread-safety and thread-resource issues beyond concurrency: + background-thread lifecycle, swallowed futures whose exceptions are never checked, lock + granularity, and GLib main-loop vs worker-thread boundaries. +- **distributed systems** — multi-process coordination across the applet / mechanism / + services split (even on one box): lock correctness, idempotent re-runs, shared-resource + contention, and partial-write durability across processes. +- **watchdog** — liveness/stall detection for long-running operations (DHCP, PPP, + transfers, scans): timeouts, heartbeats, progress-stall detection, and automatic + abort/recovery semantics. +- **state machine integrity** — every lifecycle transition (connect/disconnect, adapter + power, agent pairing, lock/unlock, transfer/download) guards illegal transitions, + prevents terminal-state re-entry, and cleans up on every error path — not just the + cancel path. +- **time & scheduling correctness** — elapsed-time math uses a monotonic clock; timeouts + and intervals (D-Bus timeouts, autoconnect interval, speed sampling) are keyed so replay + or clock skew never double-fires or stalls; guard zero/negative elapsed time. +- **platform** — cross-distro/runtime portability: POSIX-only primitives, signal + handling, and version assumptions on GLib/GTK/PyGObject, bluez, and D-Bus availability; + production-vs-local divergence. +- **performance** — bottlenecks on hot paths (device-list render, signal handling, repeated + property reads). +- **scalability** — behavior as adapters, devices, batteries, and signal traffic grow. +- **N+1 / call efficiency** — avoid per-row repeated D-Bus property `Get` calls where one + `GetAll`/cached read suffices; batch lookups; UI refreshes don't fan out one IPC round-trip + per item. +- **caching strategy** — every cache declares key shape + size cap + invalidation trigger + + a public reset hook; derived UI state invalidates on the source signal. +- **data structure** — right structures on hot paths: sets/maps for membership, no O(N²) + dedup, no per-item re-parse where a cache belongs. +- **memory and cpu management** — peak memory, streaming vs materialization, and CPU-heavy + work kept off the GLib main loop. +- **code complexity** — cognitive complexity ≤ 10; fat methods split into helpers. +- **code duplication** — shared logic (input validation, D-Bus read/write, command + building) lives in one place, not copy-pasted across modules. +- **architecture / modularity / SOLID** — proper boundaries: GUI thin, business logic in + services, D-Bus/system access behind the bluez layer, no logic buried in widget code. +- **system design** — end-to-end subsystem boundaries and feedback loops: whether the + architecture preserves isolation, operability, and extension seams across module + boundaries. +- **decoupling** — separation of concerns across module seams; the bluez layer, GUI, and + plugins are independently testable. +- **composition** — prefer small collaborators and explicit composition over god + objects, inheritance-heavy shapes, and copy-pasted registries when that reduces + coupling. +- **dependency** — third-party and optional imports are justified, pinned sensibly, and + degrade gracefully when absent; a vendor dependency sits behind an app-owned adapter + rather than being imported/`new`-ed across the codebase. +- **configuration discoverability** — every runtime knob (GSettings / config) has a + default, a typed accessor, documented deployment coverage, validation where needed, and + tests for security-sensitive defaults. +- **API contract & compatibility** — D-Bus and other IPC surfaces are reviewed as + compatibility artifacts, not just docs: introspection ⇄ implementation parity in both + directions, the full error/signal surface declared, stable signatures, and breaking + changes that are deliberate, named, and versioned. +- **CLI / option integrity** — command options, help text, and defaults match actual + behavior across the `blueman-*` entry points; ignored or misleading flags are findings. +- **wiring gaps** — shipped classes, services, plugins, commands, or signal handlers that + exist and pass tests but are not connected to the runtime path expected by docs or + tests. A feature is "shipped" only when the dispatcher actually invokes it. +- **unused code** — public-shaped methods/handlers with no caller, no test, no view; each + finding records keep / inline / delete. +- **unused functions/methods** — narrower grep-proven dead or test-only callable symbols + (no leading `_`, imported by no production code), including `__init__` re-exports no + caller pulls; each finding records delete / wire / intentionally keep in `notes`. +- **legacy / deprecation** — back-compat shims whose constituency is grep-proven gone are + flagged to remove; still-live shims are recorded as "do not remove" with the live caller + so a future pass doesn't re-pick them. +- **plugin extensibility** — advertised extension points (applet / manager / mechanism + plugins) stay open through registries and documented contracts rather than closed + `if`/`switch` dispatch or private-only hooks. +- **adaptability** — hardcoded assumptions that block change without a code edit: magic + numbers, locale/timeout/path constants, and lookup maps that should be config or a + documented invariant. +- **business / design patterns / DDD** — apply patterns only when they remove a concrete + pain; a missing pattern is a finding only when a named pattern would clarify a real + boundary or lifecycle. +- **release & deploy engineering** — the path from green CI to a healthy installed build + is engineered, not improvised: CI gates fail closed and mirror reality (job ordering, + smoke tests against the real build, pinned actions, reproducible builds from committed + lockfiles), with a documented upgrade/rollback story for the meson + autotools + packaging. +- **UI / UX** — GTK surfaces render without dead controls; empty/loading/error states are + handled; keyboard-first flows work. Assess through the **Laws of UX** + ([lawsofux.com](https://lawsofux.com/)) and cite the relevant law per finding (Fitts's + Law, Hick's Law, Jakob's Law, Doherty Threshold, Miller's Law, etc.). +- **accessibility** — semantic widgets, ATK/ARIA on interactive controls, keyboard + navigation, focus management, and sufficient contrast. +- **product engineering** — shipped-default sanity, setup/onboarding friction, actionable + runtime failures, and docs-vs-behavior drift from an end-user perspective. +- **design thinking** — user-centered empty/loading/error states, recovery paths, and + decisions grounded in observed user needs rather than internal convenience. +- **documentation** — `README`, man pages, and setup docs stay truthful: documented + commands work as written and advertised features/flags match the code. +- **i18n** — UI strings route through the gettext catalog; no hard-coded English on user + surfaces. +- **purpose** — mission alignment to a Bluetooth manager; scope-creep subsystems flagged. +- **test coverage** — new functions carry focused unit/feature coverage before merge + (≥80% target in CI); critical paths — input validation, command building, D-Bus signal + handling, device-list updates — carry focused tests. +- **test / fuzz coverage** — property/fuzz/adversarial coverage exists for parsers and + command builders (`ps` output, AT/`iptables`/shell argument construction, network + inputs), concurrency, and IPC contracts; counts toward the same coverage gate. ## File Editing From 3b072d467b21868ab2df914b43e81cae3c790994 Mon Sep 17 00:00:00 2001 From: Geraldo Netto Date: Fri, 19 Jun 2026 11:56:15 +0200 Subject: [PATCH 11/42] docs: rescan project, add findings for uncovered AGENTS.md categories Add 33 findings across previously-empty review categories: dependability, distributed systems, time & scheduling correctness, memory and cpu management, system design, CLI / option integrity, product engineering, design thinking, and test / fuzz coverage; plus rel-13 (NetConf clean_up StopIteration). Deduped against existing findings; dropped false/already- fixed candidates (DhcpClient timeout no-op, blueman-report nonexistent, overlaps with perf-/arch-). Co-Authored-By: Claude Opus 4.8 (1M context) --- TODO.md | 78 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/TODO.md b/TODO.md index 86b0411a9..408a75d72 100644 --- a/TODO.md +++ b/TODO.md @@ -141,6 +141,7 @@ Status: `open`, `in-progress`, `blocked`. Effort: `S` (≤1h), `M` (half-day), ` | rel-10 | open | S | `blueman/plugins/applet/TransferService.py:95-100` schedules removal of an allowed device but the timeout closure reads `self._pending_transfer` later instead of capturing the accepted address. A second pending transfer or cleared state can remove the wrong address or hit the assertion. | Capture `address` in the closure and remove it idempotently (`discard`-style) from the allowed list. Cross-ref sm-8. | | rel-11 | open | S | `blueman/main/applet/BluezAgent.py:201-203` indexes `key[entered]` when displaying a passkey. If BlueZ reports `entered == 6` after all digits are typed, or an invalid value, the notification path raises `IndexError`. | Clamp `entered` to the valid range and render the fully-entered passkey without bolding a missing digit. Cross-ref test-2. | | rel-12 | open | S | `blueman/plugins/BasePlugin.py:50` registers `weakref.finalize(self, self._on_plugin_delete)`. Passing a bound method keeps `self` strongly referenced by the finalizer, so plugin instances may not be collected and the delete hook is unreliable. | Register a module-level/static cleanup callback with weak state, or rely on explicit plugin unload and remove the finalizer. | +| rel-13 | open | S | `blueman/main/NetConf.py:91` `DHCPHandler.clean_up()` uses `next(b for b in self._BINARIES if _is_running(b, pid))` with no default; if `pid` is live but its cmdline matches no known binary (recycled PID), this raises `StopIteration`, aborting cleanup so the `dhcp` lock is never released and future apply/disable wedge. | Use `next((...), None)` so the existing `running_binary is None` branch runs and `unlock("dhcp")` always executes. Cross-ref sm-7. | ## observability @@ -400,6 +401,83 @@ _(none open)_ | releng-1 | open | S | `make_release.sh:3-7` archives `HEAD` using the latest tag name from `git describe --tags --abbrev=0`, without verifying that `HEAD` is exactly that tag or that the working tree is clean. A release tarball can be mislabeled with the previous tag or include unintended worktree attributes. | Require `git describe --tags --exact-match`, fail on dirty status, and print the commit/tag being archived. | | releng-2 | open | S | `make_release.sh:9-16` produces `.tar.xz` and `.tar.gz` but no checksums or signatures. Downstream packagers/users have no release-integrity artifact from the script. | Generate SHA256 sums and optionally detached signatures as part of the release script, documenting the expected verification flow. | +## dependability + +| id | status | effort | description | notes | +|----|--------|--------|-------------|-------| +| depend-1 | open | M | `blueman/main/NetConf.py:64-72` `DHCPHandler.apply` locks `dhcp` after a successful `_start` even when `_read_pid_file` returns `None` (daemon slow to write its pidfile; `DnsMasqHandler`/`UdhcpdHandler` don't reliably yield a pid in time). Later `clean_up` reads a now-absent pidfile, logs "Stale dhcp lockfile" and never kills the orphaned daemon — leaking a DHCP server bound to pan1. | Poll the pidfile with a bounded retry before locking; if no pid is obtained, treat the start as failed and tear down instead of locking. Cross-ref sm-7. | +| depend-2 | open | S | `blueman/main/NetConf.py:117-119` `DnsMasqHandler._start` appends `--dhcp-option=option:dns-server,{join(dns_servers)}` whenever `localhost:53` is reachable; if `DNSServerProvider.get_servers()` returned empty the option becomes a trailing-comma empty value, which dnsmasq rejects — the start fails entirely instead of degrading to "address but no DNS option". | Only append the `dns-server` option when `dns_servers` is non-empty. | +| depend-3 | open | S | `blueman/main/DNSServerProvider.py:29,102` `_get_servers_from_systemd_resolved`/`_subscribe_systemd_resolved` call `Gio.bus_get_sync(SYSTEM)` and `DBusProxy.new_for_bus_sync` with no error handling around bus/proxy acquisition (only the later `Get` at :48 is guarded). A briefly-unavailable system bus makes `__init__` raise and the whole provider fail rather than falling back to resolv.conf. | Wrap bus/proxy acquisition in try/except `GLib.Error` and degrade to the resolv.conf path. Cross-ref mem-2. | + +## distributed systems + +| id | status | effort | description | notes | +|----|--------|--------|-------------|-------| +| dist-1 | open | M | `blueman/main/NetConf.py:366-374` `lock`/`unlock`/`locked` are plain `touch`/`unlink(missing_ok)`/`exists` on `/var/run/blueman-*` with no `flock` or atomic check-and-set. The mechanism is a system D-Bus service serving concurrent `EnableNetwork`/`DisableNetwork`/`DhcpClient` calls; two near-simultaneous `apply_settings` both see `locked()==False`, both enable forwarding, both append iptables MASQUERADE/FORWARD rules, and both start DHCP daemons on pan1 — duplicate rules accumulate and the shared `_dhcp_handler`/`_ipt_rules` class state corrupts. | Hold a real exclusive lock (`fcntl.flock` on the lockfile) across the whole apply/clean_up, or process mechanism requests strictly serially; make rule application idempotent (flush blueman rules before re-adding). | +| dist-2 | open | M | `blueman/main/NetConf.py:253,270,280` `_ipt_rules` is in-memory class state but the iptables rules it tracks live in the kernel and survive a mechanism restart (idle-exit after 30s, `MechanismApplication.py:25`). After re-activation `_ipt_rules` is empty while old MASQUERADE/FORWARD rules and the `iptables` lockfile persist; a later `clean_up`/`_del_ipt_rules` deletes nothing yet `unlock("iptables")`, and a new apply sees the stale lock and skips re-adding — leaving stale rules for the previous address. | Tag blueman rules with an iptables comment and flush-by-comment on apply; reconcile lockfile state against actual kernel rules at startup instead of trusting in-memory state. | +| dist-3 | open | S | `blueman/plugins/mechanism/Rfcomm.py:13-14` `_open_rfcomm` spawns a watcher per call with no dedup; two `OpenRFCOMM` calls for the same `port_id` start two `blueman-rfcomm-watcher /dev/rfcommN` processes, and `_close_rfcomm` kills only by matching the `ps` cmdline (can leave orphans or signal a recycled/foreign PID). | Before launching, scan for an existing watcher on that port and skip if present; track watcher PIDs in the mechanism rather than re-deriving from `ps`. Cross-ref wd-4, mem-1. | +| dist-4 | open | M | `blueman/main/NetConf.py:347-348` In `apply_settings` the dhcp branch runs `clean_up()` (unlocks `dhcp`) then `apply()` (re-locks). If `apply`'s `_start` raises `NetworkSetupError`, earlier locks/forwarding/iptables from the same call are already applied — leaving a partially-applied state (bridge up, forwarding on, rules present) with no DHCP and no rollback; the caller just propagates a generic error. | Wrap `apply_settings` in try/except that runs full `NetConf.clean_up()` on any failure so the system is left all-or-nothing. Cross-ref depend-1. | + +## time & scheduling correctness + +| id | status | effort | description | notes | +|----|--------|--------|-------------|-------| +| time-1 | open | M | `blueman/main/SpeedCalc.py:21` `calc()` keys elapsed-time/speed math on wall clock `time.time()`; an NTP step or manual clock change can skew the divisor across retained samples and produce erratic speeds (the zero-elapsed guard only catches exact ties/backsteps within the window). | Sample with `time.monotonic()` / `GLib.get_monotonic_time()`; a monotonic clock never steps. Distinct from ds-1 (log prune) and adapt-2 (clock_gettime portability). | +| time-2 | open | S | `blueman/main/Sendto.py:360` transfer-progress throttle `tm - self._last_update > 0.5` uses `time.time()`; a backward clock step stalls all speed/ETA UI updates until wall time catches up, a forward step fires every call. | Use `time.monotonic()` for `tm`/`self._last_update`. | +| time-3 | open | S | `blueman/plugins/applet/NetUsage.py:201` session duration `datetime.now() - fromtimestamp(config["time"])` is pure wall-clock; if the clock moved backward since the stored start, the delta is negative and renders nonsense durations. | Clamp negative deltas to 0 (or store a monotonic anchor) before formatting. | +| time-4 | open | M | `blueman/main/MechanismApplication.py:20-29` the idle-exit timer counts 1s `timeout_add` ticks (`self.time += 1` to 30) instead of comparing a monotonic deadline; GLib coalesces/delays timeouts under load or suspend, so the "30s idle" auto-exit drifts and can fire much later than intended. | Record `GLib.get_monotonic_time()` on activity and exit once `now - last >= 30s`, independent of tick count. Cross-ref cfg-2. | + +## memory and cpu management + +| id | status | effort | description | notes | +|----|--------|--------|-------------|-------| +| mem-1 | open | S | `blueman/plugins/mechanism/Rfcomm.py:17` `_close_rfcomm` shells out `ps -e o pid,args` and `communicate()` synchronously inside the privileged mechanism D-Bus method, blocking the mechanism main loop while it scans every process to find one watcher PID. | Track watcher PIDs (from `Popen` in `_open_rfcomm`) keyed by port and kill by stored PID instead of scanning `ps`. Cross-ref dist-3. | +| mem-2 | open | S | `blueman/main/DNSServerProvider.py:29-79` `_get_servers_from_systemd_resolved` issues a chain of synchronous `call_sync` D-Bus calls (Get DNS, then per-interface GetLink + DefaultRoute Get) with `-1` (infinite) timeout on the main loop whenever DHCP servers are resolved, scaling with interface count and able to hang indefinitely. | Use finite timeouts and/or move resolution off the main loop; cache across the `changed` signal instead of re-walking all links each call. Cross-ref depend-3. | +| mem-3 | open | S | `blueman/main/NetConf.py:239` `UdhcpdHandler._start` calls a blocking `sleep(0.1)` after spawning udhcpd to wait for the pid file, inside the mechanism process. Distinct from ux-1 (Sendto UI sleep). | Poll the pid file with a short non-blocking `GLib.timeout_add` loop instead of a fixed blocking sleep. | + +## system design + +| id | status | effort | description | notes | +|----|--------|--------|-------------|-------| +| sysd-1 | open | M | `blueman/bluez/Base.py:147-149` `destroy()` only `del self.__proxy` and never removes the instance from `BaseMeta.__instances__` (no API to). With the permanent singleton cache, a later `Device(obj_path=...)` returns the destroyed instance whose `__proxy` is gone → `AttributeError` on next access; object lifecycle and the identity cache are not isolated. | Have `destroy()` pop `self` from the instance cache; key the cache per `(class, path)` and invalidate on `InterfacesRemoved`. Cross-ref conc-2, comp-1. | +| sysd-2 | open | M | `blueman/main/PluginManager.py:92,117` plugin discovery uses `plugin_class.__subclasses__()` (import side effects) and mutates shared class attributes (`cls.__unloadable__ = False`); two PluginManager instances (applet vs mechanism) or a reload mutate shared class state, so load order/conflict resolution is global, not per-manager. | Register plugins explicitly into a per-manager registry and keep per-instance load flags off the class object. Cross-ref dec-5, ext-1. | + +## CLI / option integrity + +| id | status | effort | description | notes | +|----|--------|--------|-------------|-------| +| cli-1 | open | S | `blueman/Functions.py:273` `--loglevel` has no `help`, no `choices`; an unrecognized value (e.g. `--loglevel verbose`) silently coerces to WARNING with no error, so users get quieter logs than expected. Applies to all 7 entry points using `create_parser`. | Add `choices=[debug,info,warning,error,critical]` (case-insensitive) and `help=`; argparse then rejects bad values clearly. | +| cli-2 | open | S | `apps/blueman-mechanism.in:38,57-58` `-d/--debug` only logs "Enabled verbose output" and does nothing else; the level is driven by `--loglevel`, so `--debug` does NOT enable debug logging — a dead/misleading flag. | Make `--debug` set `log_level = logging.DEBUG`, or remove it and document `--loglevel debug`. | +| cli-3 | open | S | `apps/blueman-adapters.in:24` `--socket-id` (XEmbed) is undocumented — no `help=`, absent from `data/man/blueman-adapters.1` — yet plumbed into `BluemanAdapters(... socket_id)`. | Add `help=` text and document, or mark intentionally internal. | +| cli-4 | open | S | `data/man/blueman-sendto.1` documents only `--device=ADDRESS`, but `apps/blueman-sendto.in:32-38` also ships `-d/--dest`, `-s/--source`, `-u/--delete` and a positional `FILE`. Man page is out of date vs `--help`. | Update the man page to list all options and the `FILE` positional. | +| cli-5 | open | S | `data/man/blueman-applet.1`, `blueman-manager.1`, `blueman-services.1` state "There are no options.", but each accepts `--loglevel`/`--syslog` via `create_parser`. Docs contradict behavior. | Replace "no options" with the actual flags. | +| cli-6 | open | S | `data/man/blueman-adapters.1:1` `.TH` header is `BLUEMAN-SENDTO` (copy-paste), so `man blueman-adapters` shows the wrong title/section. | Fix `.TH` to `BLUEMAN-ADAPTERS`. | +| cli-7 | open | S | `data/man/blueman-adapters.1` says the `adapter` arg selects the initial tab in `hci0` form, but `blueman/main/Adapter.py:74-76` matches tab keys and derives the page via `int(name[3:])`; any non-`hciN` value is silently dropped, and the positional has no CLI `help=` (`apps/blueman-adapters.in:25`). | Add `help=` to the positional stating the `hciN` format/behavior; align the man page. | + +## product engineering + +| id | status | effort | description | notes | +|----|--------|--------|-------------|-------| +| prodeng-1 | open | S | `blueman/main/Sendto.py:61-63` aborts with a bare log "Error: No Adapters present" (no GUI dialog, no remedy) when no adapter is present; a user who launched sendto from a file manager's "Send To" sees nothing actionable. | Show a GTK error dialog telling the user to enable/plug in a Bluetooth adapter, mirroring `check_bluetooth_status`. | +| prodeng-2 | open | S | `blueman/main/Sendto.py:69` `--source` with an unknown adapter logs "Unknown adapter, trying first available" only to console and silently falls back; a CLI user who mistyped `-s` never learns their choice was ignored. | Print the fallback notice to stderr (or error out) instead of silently switching adapters. | + +## design thinking + +| id | status | effort | description | notes | +|----|--------|--------|-------------|-------| +| dsgn-1 | open | S | `blueman/main/Adapter.py:73-78` when `blueman-adapters ` names a nonexistent adapter, it logs "the selected adapter does not exist" to console but still opens the window on the default tab — a GUI-launched user gets no on-screen feedback that their argument was ignored (silent dead-end). | Show an in-window/infobar message (or a toast) and fall through to the first tab. | + +## test / fuzz coverage + +| id | status | effort | description | notes | +|----|--------|--------|-------------|-------| +| fuzz-1 | open | M | `blueman/main/DhcpClient.py:25-77` has NO test file. `__init__` builds the client argv from `have()` across dhclient/dhcpcd/udhcpc; `_check_client` parses `poll()` status and reads `netifs[self._interface][0]`. Untested: client selection when several/none exist, argv assembly, poll-status branching, and the `KeyError`/`IndexError` when the bound interface is absent from `get_local_interfaces()`. | Add `test/main/test_dhcpclient.py` mocking `have`/`Popen`/`get_local_interfaces`; cover argv per client, run() raising when none found, double-run, poll 0/1/None, and hostile interface maps (missing key, empty tuple). | +| fuzz-2 | open | S | `blueman/Sdp.py:358-385` `ServiceUUID` is untested. `UUID(uuid)` raises `ValueError` on malformed input; `name`/`short_uuid`/`reserved` decode the 128-bit int and index `uuid_names[short_uuid]`. Untested: short vs full UUIDs, the all-zero case, Proprietary (non-reserved) UUIDs, unknown reserved short ids (KeyError→"Unknown"), and malformed/empty/garbage strings from the BlueZ wire. | Add `test/test_sdp.py` covering reserved short UUIDs, `int==0`, a non-Bluetooth-base UUID, an unknown reserved id, and a fuzz set of malformed strings asserting only `ValueError` escapes construction. | +| fuzz-3 | open | S | `blueman/DeviceClass.py:473-555` `get_major_class`/`get_minor_class`/`gatt_appearance_to_name` decode raw class-of-device and GATT appearance bitfields, untested. Hostile/boundary inputs: negative ints, values exceeding 16 bits, out-of-range minor indices, and appearance category boundaries around the reserved/invalid guards (:541-547). | Add `test/test_deviceclass.py` parametrized over major indices + overflow, each minor family in/out of range, and `gatt_appearance_to_name` at category edges plus a sweep asserting no `KeyError`/`IndexError` escapes. | +| fuzz-4 | open | S | `blueman/Functions.py:166-181` `format_bytes` has a confirmed boundary bug and no test: strict `<` on both band edges means exact powers of 1024 fall through to GB — `format_bytes(1024)` returns `(9.5e-07, "GB")` instead of `(1.0, "KB")`; 1048576/1073741824 likewise mislabel. | Fix comparisons to `<=`/`>=` and add tests asserting exact boundaries 1024→KB, 1048576→MB, 1073741824→GB plus 0, sub-1024, and a huge value. | +| fuzz-5 | open | S | `blueman/Functions.py:340-356` `parse_os_release` (nested in `log_system_info`) splits with `line.split("=")` and is untested: a valid `PRETTY_NAME="Name=Variant"` raises `ValueError` and is dropped; comment/blank/`=`-less lines also untested. | Extract `parse_os_release` to module scope, use `split("=", 1)`, and test `KEY="a=b"`, comment/blank, and missing-`=` lines asserting graceful skip. Cross-ref data-3. | +| fuzz-6 | open | S | `blueman/Functions.py:147-154` `adapter_path_to_name` parses a D-Bus object path with greedy `re.search(r".*(hci[0-9]*)", path)` (zero digits allowed) and is untested; hostile/edge inputs (`/org/bluez/hci`, trailing `dev_..` segments, `prefix-hci99-suffix`, empty/None, no-`hci`) can yield surprising captures or `None`. | Add tests for normal `/org/bluez/hci0`, None/empty→None, no-`hci`→None, trailing segments, and multiple `hci` occurrences to pin the greedy behavior. | + --- ## Open — parked From c0ea89054bf02d1de7e91d72e4e58e210fece567 Mon Sep 17 00:00:00 2001 From: Geraldo Netto Date: Fri, 19 Jun 2026 12:09:28 +0200 Subject: [PATCH 12/42] docs: drop TODO items already implemented on other branches Remove findings whose fix exists on a dedicated branch or already landed on main, to stop double-tracking: - perf-2/6/7, scale-4, dup-3 -> fix/manager-device-list-perf (GetAll, single power-level timer, cached uuids, batched set) - conc-1/2, sysd-1 -> fix/bluez-base-concurrency-guards (Cancellable plumbing, WeakValueDictionary instance cache + destroy cleanup) - rel-2/3/5/7, sm-1 -> fix/ppp-crash-guards (init attrs, guarded cleanup, source removal) - rel-13 -> reliability/crash-guards (next(...,None)) - ds-1 -> already on main (SpeedCalc deque, #3289) Co-Authored-By: Claude Opus 4.8 (1M context) --- TODO.md | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/TODO.md b/TODO.md index 408a75d72..134c42ed4 100644 --- a/TODO.md +++ b/TODO.md @@ -32,12 +32,9 @@ Status: `open`, `in-progress`, `blocked`. Effort: `S` (≤1h), `M` (half-day), ` | id | status | effort | description | notes | |----|--------|--------|-------------|-------| | perf-1 | open | M | `blueman/main/ManagerStats.py:107` polls device stats via `GLib.timeout_add(1000, ...)` every second | switch to event-driven update or `timeout_add_seconds` | -| perf-2 | open | M | `blueman/gui/manager/ManagerDeviceList.py:429` per-device timer for power-level monitoring → O(n) timers | consolidate into single timer batching all devices | | perf-3 | open | S | `blueman/gui/DeviceList.py:282-285` `clear()` iterates liststore calling `device_remove_event` per item → O(n²) | call `liststore.clear()` once, drop `path_to_row` in bulk | | perf-4 | open | M | `blueman/bluez/Base.py:100` `device["Prop"]` issues sync `Properties.Get` DBus on UI thread | local prop cache + signal-driven invalidation | | perf-5 | open | M | `blueman/bluez/Manager.py:115-149` `get_adapter_paths`/`get_devices` iterate `_object_manager.get_objects()` per call | cache, invalidate on object-added/removed | -| perf-6 | open | S | `blueman/gui/manager/ManagerDeviceList.py:384-388,456-497` `row_setup_event`/`row_update_event` re-read same `device[k]` props | read once, reuse | -| perf-7 | open | M | `blueman/gui/manager/ManagerDeviceList.py:353-407` row setup pulls 8+ props via individual `Get` calls | single `GetAll` per row | | perf-9 | open | S | `blueman/main/DhcpClient.py:48-50,68` `subprocess.poll()` blocking in 1s `GLib.timeout` | use `Gio.Subprocess` + `wait_check_async` or `GLib.child_watch_add` | | perf-10 | open | S | `blueman/gui/manager/ManagerMenu.py:53` creates Adapter proxies for all adapters in `__init__` | lazy-instantiate on selection | | perf-11 | open | S | `blueman/main/Manager.py:161-164` `find_device()` linear scan over all objects | address-indexed dict | @@ -50,7 +47,6 @@ Status: `open`, `in-progress`, `blocked`. Effort: `S` (≤1h), `M` (half-day), ` | scale-1 | open | M | `blueman/main/Manager.py:149-159` `populate_devices` emits per-device add signal serially | single batch signal | | scale-2 | open | S | `blueman/main/PulseAudioUtils.py:216-218` PA subscribe callback fires unthrottled on rapid card changes | debounce | | scale-3 | open | S | `blueman/main/BatteryWatcher.py:18` creates `Battery` per creation signal without dedup | check existence before create | -| scale-4 | open | S | `blueman/gui/manager/ManagerDeviceList.py:658` `device["UUIDs"]` accessed during cell render | cache in row data | ## caching strategy @@ -62,8 +58,6 @@ Status: `open`, `in-progress`, `blocked`. Effort: `S` (≤1h), `M` (half-day), ` | id | status | effort | description | notes | |----|--------|--------|-------------|-------| -| conc-1 | open | M | `blueman/bluez/Base.py:116-123` async `set()` has no `Gio.Cancellable` plumbing | add cancellable param, store, cancel on teardown | -| conc-2 | open | M | `blueman/bluez/Base.py:44-57` `BaseMeta` caches instances forever; Device/Adapter never released | weakref store or explicit destroy hook | | conc-3 | open | S | `blueman/main/PulseAudioUtils.py:372-379` `weakref.proxy(self)` in callback silently no-ops if GC'd | hold hard ref or explicit lifecycle | ## code complexity @@ -83,7 +77,6 @@ Status: `open`, `in-progress`, `blocked`. Effort: `S` (≤1h), `M` (half-day), ` |----|--------|--------|-------------|-------| | dup-1 | open | M | `blueman/main/Applet.py:92-118` 8× identical plugin broadcast loops | `_broadcast(event, *args)` helper | | dup-2 | open | S | `blueman/gui/manager/ManagerDeviceMenu.py:141-188` `connect_service`/`disconnect_service` duplicate nested success/error callbacks | extract async-DBus template | -| dup-3 | open | S | `blueman/gui/manager/ManagerDeviceList.py:463-496` `Trusted`/`Paired` if/else collapsible to single `set(**{key: value})` | inline boolean | | dup-4 | open | S | `blueman/gui/manager/ManagerDeviceList.py:498-540` `_update_power_levels` + `_disable_power_levels` duplicate bar lookup | extract `BarRenderer` | | dup-5 | open | S | `blueman/gui/manager/ManagerDeviceList.py:655-677` `_set_cell_data` repeats if/elif for battery/rssi/tpl | polymorphic bar renderers | | dup-6 | open | S | `blueman/main/Applet.py:78-90` `_on_dbus_name_appeared/_vanished` repeat plugin notify loop | `_notify_manager_state_change(state)` | @@ -133,15 +126,10 @@ Status: `open`, `in-progress`, `blocked`. Effort: `S` (≤1h), `M` (half-day), ` | id | status | effort | description | notes | |----|--------|--------|-------------|-------| -| rel-2 | open | S | `blueman/main/PPPConnection.py:121` `self.file` not initialized in `__init__`; touched in exception paths | init `self.file = None` | -| rel-3 | open | S | `blueman/main/PPPConnection.py:159` `self.pppd` not initialized before `connect_callback`; `poll()` crashes on early error | init `self.pppd = None` | -| rel-5 | open | S | `blueman/main/PPPConnection.py:76-77` `os.close(self.file)` without fd validity check | guard with try/except `OSError` | -| rel-7 | open | S | `blueman/main/PPPConnection.py:222-224` `io_watch`/`timeout` not removed on exception path | try/finally `GLib.source_remove` | | rel-9 | open | S | `blueman/main/Services.py:86` bare `except: pass` hides errors | narrow exception types | | rel-10 | open | S | `blueman/plugins/applet/TransferService.py:95-100` schedules removal of an allowed device but the timeout closure reads `self._pending_transfer` later instead of capturing the accepted address. A second pending transfer or cleared state can remove the wrong address or hit the assertion. | Capture `address` in the closure and remove it idempotently (`discard`-style) from the allowed list. Cross-ref sm-8. | | rel-11 | open | S | `blueman/main/applet/BluezAgent.py:201-203` indexes `key[entered]` when displaying a passkey. If BlueZ reports `entered == 6` after all digits are typed, or an invalid value, the notification path raises `IndexError`. | Clamp `entered` to the valid range and render the fully-entered passkey without bolding a missing digit. Cross-ref test-2. | | rel-12 | open | S | `blueman/plugins/BasePlugin.py:50` registers `weakref.finalize(self, self._on_plugin_delete)`. Passing a bound method keeps `self` strongly referenced by the finalizer, so plugin instances may not be collected and the delete hook is unreliable. | Register a module-level/static cleanup callback with weak state, or rely on explicit plugin unload and remove the finalizer. | -| rel-13 | open | S | `blueman/main/NetConf.py:91` `DHCPHandler.clean_up()` uses `next(b for b in self._BINARIES if _is_running(b, pid))` with no default; if `pid` is live but its cmdline matches no known binary (recycled PID), this raises `StopIteration`, aborting cleanup so the `dhcp` lock is never released and future apply/disable wedge. | Use `next((...), None)` so the existing `running_binary is None` branch runs and `unlock("dhcp")` always executes. Cross-ref sm-7. | ## observability @@ -218,7 +206,6 @@ _(none open)_ | id | status | effort | description | notes | |----|--------|--------|-------------|-------| -| sm-1 | open | M | `blueman/main/PPPConnection.py:53-75` `__init__` leaves pppd/file/buffer/timeout/io_watch uninitialized; cleanup/check_pppd crash if hit early | init all attrs in `__init__` (overlaps rel-2,rel-3) | | sm-2 | open | M | `blueman/main/PPPConnection.py:181-210` `on_data_ready` can run cleanup while `on_timeout` still pending → double `error-occurred` emit | explicit connection-state guard, single emit | | sm-3 | open | L | `blueman/main/PPPConnection.py:213-224` `on_timeout` closure captures stale `command_id` if `send_commands` reused before fire | bind per-command state / cancel prior timeout | | sm-4 | open | M | `blueman/main/DhcpClient.py:39-51` no state flag; `_check_client` + `_on_timeout` both call `querying.remove()` → possible `ValueError` | guard with done-flag, single removal (overlaps wd-3, rob-3) | @@ -316,7 +303,6 @@ _(none open)_ | id | status | effort | description | notes | |----|--------|--------|-------------|-------| -| ds-1 | open | M | `blueman/main/SpeedCalc.py:24` `del self.log[0]` O(n) list prune | `collections.deque(maxlen=N)` (overlaps perf-8) | | ds-2 | open | M | `blueman/plugins/applet/NetUsage.py:261-268` linear liststore scan by address in `monitor_added` | address→iter dict | | ds-3 | open | M | `blueman/plugins/applet/NetUsage.py:276-283` linear liststore scan by address in `monitor_removed` | address→iter dict | | ds-4 | open | M | `blueman/plugins/applet/RecentConns.py:129-137` linear scan of `stored_items` by (adapter,address,uuid) | tuple-keyed dict | @@ -439,7 +425,6 @@ _(none open)_ | id | status | effort | description | notes | |----|--------|--------|-------------|-------| -| sysd-1 | open | M | `blueman/bluez/Base.py:147-149` `destroy()` only `del self.__proxy` and never removes the instance from `BaseMeta.__instances__` (no API to). With the permanent singleton cache, a later `Device(obj_path=...)` returns the destroyed instance whose `__proxy` is gone → `AttributeError` on next access; object lifecycle and the identity cache are not isolated. | Have `destroy()` pop `self` from the instance cache; key the cache per `(class, path)` and invalidate on `InterfacesRemoved`. Cross-ref conc-2, comp-1. | | sysd-2 | open | M | `blueman/main/PluginManager.py:92,117` plugin discovery uses `plugin_class.__subclasses__()` (import side effects) and mutates shared class attributes (`cls.__unloadable__ = False`); two PluginManager instances (applet vs mechanism) or a reload mutate shared class state, so load order/conflict resolution is global, not per-manager. | Register plugins explicitly into a per-manager registry and keep per-instance load flags off the class object. Cross-ref dec-5, ext-1. | ## CLI / option integrity From 9e257489724a36a6d0da93981f9a5129820168bd Mon Sep 17 00:00:00 2001 From: Geraldo Netto Date: Fri, 19 Jun 2026 12:13:37 +0200 Subject: [PATCH 13/42] docs: sort TODO tables by description; document the convention Each finding's description starts with file:line, so sorting every TODO.md table by description clusters same-file items for batch fixing. Add the rule to AGENTS.md and sort all existing tables accordingly. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 3 + TODO.md | 190 +++++++++++++++++++++++++++--------------------------- 2 files changed, 98 insertions(+), 95 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8a30ad908..d4c597848 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,6 +37,9 @@ This file defines the expected behavior and usage model for AI agents working in - When making major changes, rescan the whole project and create or update `TODO.md` with one table per review category defined below. Each table uses the format: `id | status | effort | description | notes`. +- Keep every `TODO.md` table sorted by the `description` column. Each description starts with the + affected `file:line`, so sorting clusters findings in the same file together — letting related + items be fixed in one batch. Re-sort a table whenever you add or edit its rows. ### Category definitions diff --git a/TODO.md b/TODO.md index 134c42ed4..d661d3bcc 100644 --- a/TODO.md +++ b/TODO.md @@ -16,37 +16,37 @@ Status: `open`, `in-progress`, `blocked`. Effort: `S` (≤1h), `M` (half-day), ` | id | status | effort | description | notes | |----|--------|--------|-------------|-------| -| cmd-1 | open | S | `sendto/blueman_sendto.py.in:20-28` builds a shell-style command line by wrapping file paths in double quotes and passing the joined string to `Gio.AppInfo.create_from_commandline`. A filename containing quotes or command separators can break argument boundaries when launched through the desktop shell parser. | Build a `Gio.AppInfo`/`Gio.Subprocess` invocation from an argv vector, or escape with GLib shell-quoting for every path. Add a regression test with spaces, quotes, and semicolons in filenames. Cross-ref test-1. | | cmd-2 | open | M | `blueman/Functions.py:120-134` exposes `launch(cmd: str, ...)` as a command-line string API and sends it to `Gio.AppInfo.create_from_commandline`. Callers such as `blueman/plugins/manager/Notes.py:35` embed options in `cmd`, so argument boundaries depend on string parsing instead of an argv contract. | Replace or supplement `launch` with an argv-based helper (`program`, `args`, `files`) and migrate command-building call sites. Keep `system=True` uses explicit and reviewed. | +| cmd-1 | open | S | `sendto/blueman_sendto.py.in:20-28` builds a shell-style command line by wrapping file paths in double quotes and passing the joined string to `Gio.AppInfo.create_from_commandline`. A filename containing quotes or command separators can break argument boundaries when launched through the desktop shell parser. | Build a `Gio.AppInfo`/`Gio.Subprocess` invocation from an argv vector, or escape with GLib shell-quoting for every path. Add a regression test with spaces, quotes, and semicolons in filenames. Cross-ref test-1. | ## data integrity | id | status | effort | description | notes | |----|--------|--------|-------------|-------| +| data-3 | open | S | `blueman/Functions.py:349-353` parses `/etc/os-release` lines with `line.split("=")`, so valid quoted values containing `=` are rejected and omitted from logged system info. | Use `split("=", 1)` and add a regression test with `PRETTY_NAME="Name=Variant"`. | | data-1 | open | S | `blueman/plugins/applet/TransferService.py:296-303` resolves incoming-file name collisions by prefixing only second-resolution time, then moves without rechecking the timestamped destination. Two same-named transfers completing in the same second can collide and overwrite/fail depending on platform semantics. | Generate a unique destination with an exclusive create/rename loop (`name`, `timestamp_name`, `timestamp_1_name`, ...), and test repeated same-second completions. | | data-2 | open | S | `blueman/plugins/manager/Notes.py:32-35` creates a `.vnt` temporary file with `delete=False` and relies on the launched sendto process to delete it. If launch fails or the process never starts, the note body remains in `/tmp` indefinitely. | Delete the temp file when `launch()` returns false or raises; consider creating it in an app-owned temp directory with cleanup on startup. Cross-ref gov-5. | -| data-3 | open | S | `blueman/Functions.py:349-353` parses `/etc/os-release` lines with `line.split("=")`, so valid quoted values containing `=` are rejected and omitted from logged system info. | Use `split("=", 1)` and add a regression test with `PRETTY_NAME="Name=Variant"`. | ## performance | id | status | effort | description | notes | |----|--------|--------|-------------|-------| -| perf-1 | open | M | `blueman/main/ManagerStats.py:107` polls device stats via `GLib.timeout_add(1000, ...)` every second | switch to event-driven update or `timeout_add_seconds` | -| perf-3 | open | S | `blueman/gui/DeviceList.py:282-285` `clear()` iterates liststore calling `device_remove_event` per item → O(n²) | call `liststore.clear()` once, drop `path_to_row` in bulk | | perf-4 | open | M | `blueman/bluez/Base.py:100` `device["Prop"]` issues sync `Properties.Get` DBus on UI thread | local prop cache + signal-driven invalidation | | perf-5 | open | M | `blueman/bluez/Manager.py:115-149` `get_adapter_paths`/`get_devices` iterate `_object_manager.get_objects()` per call | cache, invalidate on object-added/removed | -| perf-9 | open | S | `blueman/main/DhcpClient.py:48-50,68` `subprocess.poll()` blocking in 1s `GLib.timeout` | use `Gio.Subprocess` + `wait_check_async` or `GLib.child_watch_add` | +| perf-3 | open | S | `blueman/gui/DeviceList.py:282-285` `clear()` iterates liststore calling `device_remove_event` per item → O(n²) | call `liststore.clear()` once, drop `path_to_row` in bulk | | perf-10 | open | S | `blueman/gui/manager/ManagerMenu.py:53` creates Adapter proxies for all adapters in `__init__` | lazy-instantiate on selection | -| perf-11 | open | S | `blueman/main/Manager.py:161-164` `find_device()` linear scan over all objects | address-indexed dict | | perf-13 | open | S | `blueman/main/Applet.py:93-118` plugin broadcast loop runs full plugin set per property change → O(plugins × props × devices) | debounce/batch property events | +| perf-9 | open | S | `blueman/main/DhcpClient.py:48-50,68` `subprocess.poll()` blocking in 1s `GLib.timeout` | use `Gio.Subprocess` + `wait_check_async` or `GLib.child_watch_add` | +| perf-11 | open | S | `blueman/main/Manager.py:161-164` `find_device()` linear scan over all objects | address-indexed dict | +| perf-1 | open | M | `blueman/main/ManagerStats.py:107` polls device stats via `GLib.timeout_add(1000, ...)` every second | switch to event-driven update or `timeout_add_seconds` | ## scalability | id | status | effort | description | notes | |----|--------|--------|-------------|-------| +| scale-3 | open | S | `blueman/main/BatteryWatcher.py:18` creates `Battery` per creation signal without dedup | check existence before create | | scale-1 | open | M | `blueman/main/Manager.py:149-159` `populate_devices` emits per-device add signal serially | single batch signal | | scale-2 | open | S | `blueman/main/PulseAudioUtils.py:216-218` PA subscribe callback fires unthrottled on rapid card changes | debounce | -| scale-3 | open | S | `blueman/main/BatteryWatcher.py:18` creates `Battery` per creation signal without dedup | check existence before create | ## caching strategy @@ -64,22 +64,22 @@ Status: `open`, `in-progress`, `blocked`. Effort: `S` (≤1h), `M` (half-day), ` | id | status | effort | description | notes | |----|--------|--------|-------------|-------| +| cx-3 | open | L | `blueman/gui/manager/ManagerDeviceList.py:412-540` 4 coupled power-level methods (>100 LOC) | extract `PowerLevelMonitor` class | | cx-1 | open | M | `blueman/gui/manager/ManagerDeviceList.py:453` `row_update_event` 7-elif on property name | dict dispatch `{key: handler}` | +| cx-5 | open | M | `blueman/gui/manager/ManagerDeviceList.py:553` `tooltip_query` ~102 LOC nested conditions | extract `TooltipBuilder` | | cx-2 | open | M | `blueman/main/Manager.py:224` `simple_action()` 13-case match mixes routing + business logic | extract `{action: (handler, needs_device)}` table | -| cx-3 | open | L | `blueman/gui/manager/ManagerDeviceList.py:412-540` 4 coupled power-level methods (>100 LOC) | extract `PowerLevelMonitor` class | | cx-4 | open | M | `blueman/main/PluginManager.py:132-174` `__load_plugin` ~43 LOC, 15+ conditionals (deps/conflicts/priority) | extract `PluginDependencyResolver` | -| cx-5 | open | M | `blueman/gui/manager/ManagerDeviceList.py:553` `tooltip_query` ~102 LOC nested conditions | extract `TooltipBuilder` | | cx-6 | open | S | `blueman/main/Services.py:58` `on_query_apply_state` returns -1/bool mixed protocol | replace with `ApplyState` enum | ## code duplication | id | status | effort | description | notes | |----|--------|--------|-------------|-------| -| dup-1 | open | M | `blueman/main/Applet.py:92-118` 8× identical plugin broadcast loops | `_broadcast(event, *args)` helper | -| dup-2 | open | S | `blueman/gui/manager/ManagerDeviceMenu.py:141-188` `connect_service`/`disconnect_service` duplicate nested success/error callbacks | extract async-DBus template | | dup-4 | open | S | `blueman/gui/manager/ManagerDeviceList.py:498-540` `_update_power_levels` + `_disable_power_levels` duplicate bar lookup | extract `BarRenderer` | | dup-5 | open | S | `blueman/gui/manager/ManagerDeviceList.py:655-677` `_set_cell_data` repeats if/elif for battery/rssi/tpl | polymorphic bar renderers | +| dup-2 | open | S | `blueman/gui/manager/ManagerDeviceMenu.py:141-188` `connect_service`/`disconnect_service` duplicate nested success/error callbacks | extract async-DBus template | | dup-6 | open | S | `blueman/main/Applet.py:78-90` `_on_dbus_name_appeared/_vanished` repeat plugin notify loop | `_notify_manager_state_change(state)` | +| dup-1 | open | M | `blueman/main/Applet.py:92-118` 8× identical plugin broadcast loops | `_broadcast(event, *args)` helper | | dup-7 | open | S | `blueman/main/Sendto.py:47-55` 6× identical `connect_signal` boilerplate | `_setup_signal_handlers(source, handlers)` | | dup-8 | open | S | `blueman/main/Services.py:86` bare `except:` with `# noqa: E722` | narrow to expected exceptions | @@ -87,69 +87,69 @@ Status: `open`, `in-progress`, `blocked`. Effort: `S` (≤1h), `M` (half-day), ` | id | status | effort | description | notes | |----|--------|--------|-------------|-------| -| api-1 | open | S | `blueman/main/DBusProxies.py:91` exposes the Python proxy method as `dchp_client`, while the DBus method and interface are `DhcpClient`. The typo is now part of the local Python call surface and makes future refactors/API docs error-prone. | Add correctly spelled `dhcp_client()` as the public method, keep `dchp_client()` as a deprecated alias until callers/tests migrate, then remove the alias in a later cleanup. | | api-2 | open | M | `blueman/Functions.py:133` uses `Gio.AppInfo.create_from_commandline` in a shared helper, but its API accepts one opaque command string plus separate `paths`. This makes it hard for callers to express portable argv semantics or safely pass non-file options without depending on GLib command-line parsing. | Define a stable internal process-launch contract around argv and file arguments; deprecate the string form after migrating users. Cross-ref cmd-2. | +| api-1 | open | S | `blueman/main/DBusProxies.py:91` exposes the Python proxy method as `dchp_client`, while the DBus method and interface are `DhcpClient`. The typo is now part of the local Python call surface and makes future refactors/API docs error-prone. | Add correctly spelled `dhcp_client()` as the public method, keep `dchp_client()` as a deprecated alias until callers/tests migrate, then remove the alias in a later cleanup. | ## architecture/modularity/SOLID | id | status | effort | description | notes | |----|--------|--------|-------------|-------| +| arch-8 | open | S | `blueman/gui/manager/ManagerDeviceList.py:334-351` UI-formatting `@staticmethod`s placed on liststore class | move to `DeviceDisplayFormatter` | +| arch-7 | open | S | `blueman/gui/manager/ManagerDeviceMenu.py:64-65` `__ops__`/`__instances__` class-level globals | DI or event-emitter | | arch-1 | open | L | `blueman/main/Applet.py:25-148` `BluemanApplet` is God object (init Manager, plugins, broadcasts, state) | extract `PluginBroadcaster`, `ManagerWatcher` | | arch-2 | open | L | `blueman/main/Manager.py:37-363` `Blueman` mixes lifecycle, UI, device actions, settings | split into `ManagerUI`, `DeviceActionHandler`, `SettingsManager` | -| arch-3 | open | M | `blueman/main/PluginManager.py:176` `__getattr__` magic for plugin lookup breaks IDE/refactor | explicit `get_plugin(name)` accessor | -| arch-4 | open | M | `blueman/main/MechanismApplication.py:42-100` mixes timer, PolicyKit, plugin loading, DBus registration | extract `TimerManager`, `PluginLoader` | | arch-5 | open | S | `blueman/main/MechanismApplication.py:15-39` Timer reads `BLUEMAN_SOURCE` env var for test mode | subclass `TestTimer` or inject duration | +| arch-4 | open | M | `blueman/main/MechanismApplication.py:42-100` mixes timer, PolicyKit, plugin loading, DBus registration | extract `TimerManager`, `PluginLoader` | | arch-6 | open | S | `blueman/main/PluginManager.py:139,200` raise bare `Exception(...)` | introduce `PluginDependencyError`, `PluginError` | -| arch-7 | open | S | `blueman/gui/manager/ManagerDeviceMenu.py:64-65` `__ops__`/`__instances__` class-level globals | DI or event-emitter | -| arch-8 | open | S | `blueman/gui/manager/ManagerDeviceList.py:334-351` UI-formatting `@staticmethod`s placed on liststore class | move to `DeviceDisplayFormatter` | +| arch-3 | open | M | `blueman/main/PluginManager.py:176` `__getattr__` magic for plugin lookup breaks IDE/refactor | explicit `get_plugin(name)` accessor | ## decoupling | id | status | effort | description | notes | |----|--------|--------|-------------|-------| -| dec-1 | open | M | `blueman/plugins/applet/TransferService.py:17` reaches into `parent.Plugins`/`parent.Manager` | DI via plugin interface or signal | -| dec-2 | open | M | `blueman/plugins/manager/Services.py:8` `ManagerPlugin` imports `ManagerDeviceMenu`, `MenuItemsProvider` (GUI layer) | event-based provider interface | | dec-3 | open | S | `blueman/plugins/applet/AutoConnect.py:62` `self.parent.Manager.find_device()` reach-through | `parent.find_device_by_address(addr)` API | | dec-4 | open | S | `blueman/plugins/applet/KillSwitch.py:147-149` direct `self.parent.Plugins.StatusIcon/PowerManager` access | optional plugin query w/ fallback | -| dec-5 | open | S | `blueman/plugins/manager/Services.py:82` plugin discovery via `ServicePlugin.__subclasses__()` | registry or `importlib.metadata.entry_points` | +| dec-1 | open | M | `blueman/plugins/applet/TransferService.py:17` reaches into `parent.Plugins`/`parent.Manager` | DI via plugin interface or signal | | dec-6 | open | S | `blueman/plugins/AppletPlugin.py:32` hardcoded fallback icon name | constant + GSettings override | +| dec-5 | open | S | `blueman/plugins/manager/Services.py:82` plugin discovery via `ServicePlugin.__subclasses__()` | registry or `importlib.metadata.entry_points` | +| dec-2 | open | M | `blueman/plugins/manager/Services.py:8` `ManagerPlugin` imports `ManagerDeviceMenu`, `MenuItemsProvider` (GUI layer) | event-based provider interface | ## business/design patterns/DDD | id | status | effort | description | notes | |----|--------|--------|-------------|-------| -| pat-1 | open | M | `row_update_event`, `simple_action`, `_set_cell_data` all have type/key switch ladders | Strategy or dispatch-table | | pat-2 | open | S | `on_query_apply_state` magic-return protocol | State enum (DDD value object) | +| pat-1 | open | M | `row_update_event`, `simple_action`, `_set_cell_data` all have type/key switch ladders | Strategy or dispatch-table | | pat-3 | open | M | Plugin lifecycle scattered (load/unload/deps/conflicts/state) | introduce `PluginLifecycle` state machine | ## reliability/correctness | id | status | effort | description | notes | |----|--------|--------|-------------|-------| +| rel-11 | open | S | `blueman/main/applet/BluezAgent.py:201-203` indexes `key[entered]` when displaying a passkey. If BlueZ reports `entered == 6` after all digits are typed, or an invalid value, the notification path raises `IndexError`. | Clamp `entered` to the valid range and render the fully-entered passkey without bolding a missing digit. Cross-ref test-2. | | rel-9 | open | S | `blueman/main/Services.py:86` bare `except: pass` hides errors | narrow exception types | | rel-10 | open | S | `blueman/plugins/applet/TransferService.py:95-100` schedules removal of an allowed device but the timeout closure reads `self._pending_transfer` later instead of capturing the accepted address. A second pending transfer or cleared state can remove the wrong address or hit the assertion. | Capture `address` in the closure and remove it idempotently (`discard`-style) from the allowed list. Cross-ref sm-8. | -| rel-11 | open | S | `blueman/main/applet/BluezAgent.py:201-203` indexes `key[entered]` when displaying a passkey. If BlueZ reports `entered == 6` after all digits are typed, or an invalid value, the notification path raises `IndexError`. | Clamp `entered` to the valid range and render the fully-entered passkey without bolding a missing digit. Cross-ref test-2. | | rel-12 | open | S | `blueman/plugins/BasePlugin.py:50` registers `weakref.finalize(self, self._on_plugin_delete)`. Passing a bound method keeps `self` strongly referenced by the finalizer, so plugin instances may not be collected and the delete hook is unreliable. | Register a module-level/static cleanup callback with weak state, or rely on explicit plugin unload and remove the finalizer. | ## observability | id | status | effort | description | notes | |----|--------|--------|-------------|-------| +| obs-13 | open | S | `blueman/bluez/Base.py:107` `GLib.Error` falls back to cached property silently | `logging.debug` cache fallback | +| obs-4 | open | S | `blueman/bluez/obex/Manager.py:51,59,68,75` `logging.info(object_path)` lacks event/context | prefix with event name | | obs-1 | open | S | `blueman/Functions.py:64,87` `print()` in `check_bluetooth_status()` exception/fallback | `logging.error(..., exc_info=True)` | -| obs-2 | open | S | `blueman/main/NetConf.py:93` `print()` for process termination | `logging.info` with binary/pid context | +| obs-10 | open | S | `blueman/gui/GenericList.py:116` silent `ValueError` from `get_iter` | `logging.debug` invalid path | +| obs-9 | open | S | `blueman/gui/GtkAnimation.py:79` silent `ZeroDivisionError` on duration=0 | `logging.debug("Animation duration zero")` | +| obs-7 | open | S | `blueman/gui/Notification.py:169` silent `ValueError` on notification hints | `logging.debug` unsupported hint | +| obs-11 | open | S | `blueman/main/DNSServerProvider.py:48` `GLib.Error` swallowed | `logging.debug("DNS lookup failed, using fallback")` | | obs-3 | open | S | `blueman/main/Manager.py:62` `print()` in exception handler | `logging.error(..., exc_info=True)` | -| obs-4 | open | S | `blueman/bluez/obex/Manager.py:51,59,68,75` `logging.info(object_path)` lacks event/context | prefix with event name | | obs-5 | open | S | `blueman/main/NetConf.py:340` silent `pass` on `BridgeException` | `logging.warning(...)` | +| obs-2 | open | S | `blueman/main/NetConf.py:93` `print()` for process termination | `logging.info` with binary/pid context | | obs-6 | open | S | `blueman/main/PluginManager.py:64,123` `LoadException` swallowed silently | `logging.warning` with plugin name | -| obs-7 | open | S | `blueman/gui/Notification.py:169` silent `ValueError` on notification hints | `logging.debug` unsupported hint | | obs-8 | open | S | `blueman/main/Sendto.py:286` `logging.debug(e.message)` on `GLib.Error` | use `str(e)` | -| obs-9 | open | S | `blueman/gui/GtkAnimation.py:79` silent `ZeroDivisionError` on duration=0 | `logging.debug("Animation duration zero")` | -| obs-10 | open | S | `blueman/gui/GenericList.py:116` silent `ValueError` from `get_iter` | `logging.debug` invalid path | -| obs-11 | open | S | `blueman/main/DNSServerProvider.py:48` `GLib.Error` swallowed | `logging.debug("DNS lookup failed, using fallback")` | +| obs-15 | open | S | `blueman/plugins/applet/AutoConnect.py:116-117` ignores automatic connection failures with `pass`, so failed auto-connect attempts leave no log trail and are hard to diagnose. | Log the target service/device and failure reason at debug or warning level, with rate limiting if needed. | | obs-12 | open | S | `blueman/plugins/mechanism/Network.py:46` exception only routed to error callback, no local log | add `logging.error` with trace | -| obs-13 | open | S | `blueman/bluez/Base.py:107` `GLib.Error` falls back to cached property silently | `logging.debug` cache fallback | | obs-14 | open | S | `sendto/blueman_sendto.py.in:14,17,29,33` `print()` for user-facing messages | replace with `logging` where plugin host allows | -| obs-15 | open | S | `blueman/plugins/applet/AutoConnect.py:116-117` ignores automatic connection failures with `pass`, so failed auto-connect attempts leave no log trail and are hard to diagnose. | Log the target service/device and failure reason at debug or warning level, with rate limiting if needed. | ## wiring gaps @@ -177,10 +177,10 @@ _(none open)_ | id | status | effort | description | notes | |----|--------|--------|-------------|-------| -| gov-1 | open | M | `blueman/plugins/applet/RecentConns.py:127-139` device object paths + UUIDs stored unencrypted in GSettings | store only address/UUID; audit schema permissions | -| gov-2 | open | S | `blueman/plugins/applet/RecentConns.py:144` BT addresses (quasi-permanent IDs) logged via `logging.info` | redact/rate-limit address logging in production | | gov-3 | open | M | `blueman/plugins/applet/NetUsage.py:40,64-65` per-device tx/rx stats persisted at `/org/blueman/plugins/netusages/{Address}/` reveal connection history + volume | document retention; add auto-expire option | | gov-4 | open | S | `blueman/plugins/applet/RecentConns.py:120` user device aliases (may contain PII) stored plaintext | document plaintext storage; UI warning | +| gov-1 | open | M | `blueman/plugins/applet/RecentConns.py:127-139` device object paths + UUIDs stored unencrypted in GSettings | store only address/UUID; audit schema permissions | +| gov-2 | open | S | `blueman/plugins/applet/RecentConns.py:144` BT addresses (quasi-permanent IDs) logged via `logging.info` | redact/rate-limit address logging in production | | gov-5 | open | S | `blueman/plugins/manager/Notes.py:32-35` can leave plaintext note bodies in temporary `.vnt` files when send launch fails. These notes are user-authored content and can include sensitive data. | Ensure temp-note lifecycle is owned by Blueman until a child process has definitely taken responsibility; clean stale `note*.vnt` files where safe. Cross-ref data-2. | ## multithreading @@ -193,27 +193,27 @@ _(none open)_ | id | status | effort | description | notes | |----|--------|--------|-------------|-------| -| wd-1 | open | M | `blueman/main/PPPConnection.py:82-87` pppd spawned with no liveness monitoring; orphan pppd possible on error path | add `GLib.child_watch_add`, kill on cleanup | -| wd-2 | open | M | `blueman/main/PPPConnection.py:76` `cleanup()` only closes fd, leaves io_watch/timeout sources registered | remove all GLib sources in cleanup (overlaps rel-7) | | wd-3 | open | M | `blueman/main/DhcpClient.py:49-50` two `timeout_add` sources, neither stored; `_check_client` keeps polling dead process after `_on_timeout` | store + `source_remove` both on exit (overlaps rob-3) | +| wd-8 | open | S | `blueman/main/indicators/StatusNotifierItem.py:32-42` starts a repeating revision-advertisement timeout and discards the source id. The menu service cannot remove the source on unregister/teardown, so it can keep emitting after the tray path is gone. | Store the source id and remove it in an explicit `unregister`/delete path; add a test that teardown removes the source. | +| wd-7 | open | M | `blueman/main/NetConf.py:122,190,235` Dhcpd/Udhcpd/DnsMasq Popen+communicate with no hang supervision | timeout-guard or async | +| wd-2 | open | M | `blueman/main/PPPConnection.py:76` `cleanup()` only closes fd, leaves io_watch/timeout sources registered | remove all GLib sources in cleanup (overlaps rel-7) | +| wd-1 | open | M | `blueman/main/PPPConnection.py:82-87` pppd spawned with no liveness monitoring; orphan pppd possible on error path | add `GLib.child_watch_add`, kill on cleanup | +| wd-6 | open | S | `blueman/plugins/applet/PPPSupport.py:40` synchronous `Popen(['ps'])` blocks main loop until ps returns | use async `Gio.Subprocess` | | wd-4 | open | M | `blueman/plugins/mechanism/Rfcomm.py:14` rfcomm watcher Popen fire-and-forget, no PID tracking/liveness; only killed via grepped `ps` | track PID, supervise (overlaps sec-2, rel-8) | | wd-5 | open | M | `blueman/services/meta/SerialService.py:75` `Popen([RFCOMM_WATCHER_PATH])` no exit/return-code monitoring; crash leaves rfcomm broken | child watch + restart/notify | -| wd-6 | open | S | `blueman/plugins/applet/PPPSupport.py:40` synchronous `Popen(['ps'])` blocks main loop until ps returns | use async `Gio.Subprocess` | -| wd-7 | open | M | `blueman/main/NetConf.py:122,190,235` Dhcpd/Udhcpd/DnsMasq Popen+communicate with no hang supervision | timeout-guard or async | -| wd-8 | open | S | `blueman/main/indicators/StatusNotifierItem.py:32-42` starts a repeating revision-advertisement timeout and discards the source id. The menu service cannot remove the source on unregister/teardown, so it can keep emitting after the tray path is gone. | Store the source id and remove it in an explicit `unregister`/delete path; add a test that teardown removes the source. | ## state machine | id | status | effort | description | notes | |----|--------|--------|-------------|-------| -| sm-2 | open | M | `blueman/main/PPPConnection.py:181-210` `on_data_ready` can run cleanup while `on_timeout` still pending → double `error-occurred` emit | explicit connection-state guard, single emit | -| sm-3 | open | L | `blueman/main/PPPConnection.py:213-224` `on_timeout` closure captures stale `command_id` if `send_commands` reused before fire | bind per-command state / cancel prior timeout | | sm-4 | open | M | `blueman/main/DhcpClient.py:39-51` no state flag; `_check_client` + `_on_timeout` both call `querying.remove()` → possible `ValueError` | guard with done-flag, single removal (overlaps wd-3, rob-3) | +| sm-7 | open | M | `blueman/main/NetConf.py:84-101` `DHCPHandler.clean_up()` reads/kills `_pid` with no guard; concurrent calls race / SIGTERM wrong pid | idempotent guard on `_pid` | | sm-5 | open | M | `blueman/main/NetworkManager.py:38,69-70` `_statehandler` asserted not-None but state change can fire before assignment | assign handler before connect / null-guard | +| sm-2 | open | M | `blueman/main/PPPConnection.py:181-210` `on_data_ready` can run cleanup while `on_timeout` still pending → double `error-occurred` emit | explicit connection-state guard, single emit | +| sm-3 | open | L | `blueman/main/PPPConnection.py:213-224` `on_timeout` closure captures stale `command_id` if `send_commands` reused before fire | bind per-command state / cancel prior timeout | | sm-6 | open | L | `blueman/plugins/applet/PowerManager.py:97,109` Callback timer source id not tracked; orphan timeout fires on GC'd object | store source id, remove in destructor | -| sm-7 | open | M | `blueman/main/NetConf.py:84-101` `DHCPHandler.clean_up()` reads/kills `_pid` with no guard; concurrent calls race / SIGTERM wrong pid | idempotent guard on `_pid` | -| sm-8 | open | M | `blueman/plugins/applet/TransferService.py:78-123` tracks only one `_pending_transfer` for authorization, but multiple incoming pushes can overlap before the user answers. A later request overwrites the pending state used by the first notification action. | Track pending transfers by `transfer_path`; bind notification callbacks to an immutable pending-transfer record. Cross-ref rel-10. | | sm-9 | open | S | `blueman/plugins/applet/ShowConnected.py:86-92` schedules delayed `enumerate_connections()` calls on every manager-state-enabled event without storing/canceling the source. A fast state flap can let a stale enumeration update the icon after the manager is disabled. | Store the pending source id, cancel it on manager disable/unload, and ignore callbacks if manager state changed. | +| sm-8 | open | M | `blueman/plugins/applet/TransferService.py:78-123` tracks only one `_pending_transfer` for authorization, but multiple incoming pushes can overlap before the user answers. A later request overwrites the pending state used by the first notification action. | Track pending transfers by `transfer_path`; bind notification callbacks to an immutable pending-transfer record. Cross-ref rel-10. | ## composition @@ -228,119 +228,119 @@ _(none open)_ | id | status | effort | description | notes | |----|--------|--------|-------------|-------| -| dep-1 | open | L | `blueman/main/PulseAudioUtils.py:14-18` import-time `CDLL` load raises ImportError if libpulse absent, failing module | lazy loader + optional-support flag | +| dep-8 | open | L | `blueman/Functions.py:210` hardcoded PATH suffix `:/sbin:/usr/sbin` | use `shutil.which()` (dup plat-001) | +| dep-5 | open | S | `blueman/main/Applet.py:10` wildcard `from blueman.Functions import *` obscures deps | explicit imports | +| dep-7 | open | M | `blueman/main/DNSServerProvider.py:12` hardcoded `RESOLVER_PATH="/etc/resolv.conf"` | configurable + DNSProvider abstraction (dup cfg-004) | | dep-2 | open | L | `blueman/main/NetworkManager.py:9-12` import-time `gi.require_version` raises if NM bindings missing | move into lazy init try-block | -| dep-3 | open | L | `blueman/plugins/mechanism/RfKill.py:6-7` import-time `/dev/rfkill` check raises, blocks plugin discovery on systems without it | move check to `on_load()` | +| dep-1 | open | L | `blueman/main/PulseAudioUtils.py:14-18` import-time `CDLL` load raises ImportError if libpulse absent, failing module | lazy loader + optional-support flag | | dep-4 | open | L | `blueman/plugins/applet/GameControllerWakelock.py:14-16,22-23` import-time GdkX11/X11 screen check raises | move platform check to `on_load()` | -| dep-5 | open | S | `blueman/main/Applet.py:10` wildcard `from blueman.Functions import *` obscures deps | explicit imports | | dep-6 | open | S | `blueman/plugins/applet/NetUsage.py:8` wildcard import from `blueman.Functions` | explicit imports | -| dep-7 | open | M | `blueman/main/DNSServerProvider.py:12` hardcoded `RESOLVER_PATH="/etc/resolv.conf"` | configurable + DNSProvider abstraction (dup cfg-004) | -| dep-8 | open | L | `blueman/Functions.py:210` hardcoded PATH suffix `:/sbin:/usr/sbin` | use `shutil.which()` (dup plat-001) | +| dep-3 | open | L | `blueman/plugins/mechanism/RfKill.py:6-7` import-time `/dev/rfkill` check raises, blocks plugin discovery on systems without it | move check to `on_load()` | ## adaptability | id | status | effort | description | notes | |----|--------|--------|-------------|-------| | adapt-1 | open | M | `blueman/gui/manager/ManagerDeviceMenu.py:225-242` hardcoded BlueZ error-string mapping with version-specific comments; breaks on newer BlueZ | parse error codes dynamically + version detect | +| adapt-4 | open | S | `blueman/main/DbusService.py` bus type hardcoded to SESSION; assumes single-user desktop | make bus_type configurable | | adapt-2 | open | M | `blueman/main/Functions.py:104` (`blueman/Functions.py:104`) `time.clock_gettime(CLOCK_MONOTONIC_RAW)` fallback not portable | use `GLib.get_monotonic_time()` consistently | | adapt-3 | open | M | `blueman/plugins/mechanism/Ppp.py:24` hardcoded `/dev/rfcomm{port}` template | inject device-path factory | -| adapt-4 | open | S | `blueman/main/DbusService.py` bus type hardcoded to SESSION; assumes single-user desktop | make bus_type configurable | ## extensibility | id | status | effort | description | notes | |----|--------|--------|-------------|-------| +| ext-7 | open | S | `blueman/gui/DeviceList.py:147-162` override hooks only; no registry for third-party extensions | signal-based hooks / extension protocol | +| ext-5 | open | M | `blueman/main/indicators/IndicatorInterface.py` StatusIcon vs StatusNotifierItem hardcoded; no pluggable indicator backend | IndicatorBackend protocol via PluginManager | | ext-1 | open | M | `blueman/main/PluginManager.py:132-174` load logic embedded in manager; hard to add plugin types/async loaders | extract LoadStrategy / load pipeline | +| ext-6 | open | M | `blueman/plugins/applet/Menu.py` menu structure hardcoded; plugins can't extend menus without parent coupling | MenuRegistry / signal-based insertion | | ext-2 | open | M | `blueman/plugins/AppletPlugin.py:35-41` DBus service opt-in via `__dbus_iface_name__` is intricate | `@dbus_service` decorator / ServiceRegistry | | ext-3 | open | L | `blueman/plugins/ServicePlugin.py:12-62` separate hierarchy from BasePlugin; no depends/conflicts declarations | unify to BasePlugin, add `__depends__`/`__conflicts__` | | ext-4 | open | M | `blueman/services/meta/NetworkService.py` new service types must implement props; no extension hooks | ServiceRegistry + `@service_provider` | -| ext-5 | open | M | `blueman/main/indicators/IndicatorInterface.py` StatusIcon vs StatusNotifierItem hardcoded; no pluggable indicator backend | IndicatorBackend protocol via PluginManager | -| ext-6 | open | M | `blueman/plugins/applet/Menu.py` menu structure hardcoded; plugins can't extend menus without parent coupling | MenuRegistry / signal-based insertion | -| ext-7 | open | S | `blueman/gui/DeviceList.py:147-162` override hooks only; no registry for third-party extensions | signal-based hooks / extension protocol | ## legacy | id | status | effort | description | notes | |----|--------|--------|-------------|-------| -| leg-1 | open | M | `blueman/Functions.py:78` deprecated `Gtk.Dialog.run()`/`.destroy()` blocking pattern | non-blocking response-signal pattern | +| leg-7 | open | S | `blueman/bluez/Device.py:22,29` `# type: ignore` on connect/disconnect masking signature mismatch | resolve override signatures | +| leg-8 | open | S | `blueman/bluez/Network.py:17,26` `# type: ignore` on connect/disconnect | resolve signatures | | leg-2 | open | M | `blueman/Functions.py:189,200` deprecated `Gtk.ImageMenuItem` | migrate to `Gtk.MenuItem` + image | -| leg-3 | open | M | `blueman/main/Sendto.py:178,190,291,461` deprecated dialog `.run()`/`.destroy()` | async response handlers | -| leg-4 | open | M | `blueman/gui/manager/ManagerMenu.py:45,47` `Gtk.ImageMenuItem` in manager UI | migrate to `Gtk.MenuItem` | | leg-5 | open | S | `blueman/Functions.py:226` raw ctypes `libc.prctl(15,...)` for proc title | document or guard non-Linux (relates dead-1) | +| leg-1 | open | M | `blueman/Functions.py:78` deprecated `Gtk.Dialog.run()`/`.destroy()` blocking pattern | non-blocking response-signal pattern | | leg-6 | open | S | `blueman/gui/GtkAnimation.py:200` FIXME `Gtk.render_background()` wrong colors | investigate + fix or document | -| leg-7 | open | S | `blueman/bluez/Device.py:22,29` `# type: ignore` on connect/disconnect masking signature mismatch | resolve override signatures | -| leg-8 | open | S | `blueman/bluez/Network.py:17,26` `# type: ignore` on connect/disconnect | resolve signatures | +| leg-4 | open | M | `blueman/gui/manager/ManagerMenu.py:45,47` `Gtk.ImageMenuItem` in manager UI | migrate to `Gtk.MenuItem` | | leg-9 | open | S | `blueman/main/indicators/GtkStatusIcon.py:44` `# type: ignore` on submenu enumerate | proper overload/typing | +| leg-3 | open | M | `blueman/main/Sendto.py:178,190,291,461` deprecated dialog `.run()`/`.destroy()` | async response handlers | ## configuration | id | status | effort | description | notes | |----|--------|--------|-------------|-------| +| cfg-7 | open | S | `blueman/config/AutoConnectConfig.py:10` GSettings schema id hardcoded, duplicated across plugins | module constant | | cfg-1 | open | M | `blueman/Constants.py.in:22` `BLUEMAN_SOURCE` env var checked inline, undocumented feature flag | centralize in config module + document | +| cfg-5 | open | S | `blueman/main/DhcpClient.py:17-20` DHCP client search order hardcoded (dhclient/dhcpcd/udhcpc) | configurable list | +| cfg-4 | open | M | `blueman/main/DNSServerProvider.py:12` hardcoded `/etc/resolv.conf`, precedence undocumented | document resolved-first precedence | | cfg-2 | open | M | `blueman/main/MechanismApplication.py:25` idle timeout hardcoded (30s / 9999 dev) keyed on `BLUEMAN_SOURCE` | make configurable, document dev mode (overlaps arch-5) | | cfg-3 | open | S | `blueman/main/NetConf.py:62,256` hardcoded `/var/run` PID path | use `XDG_RUNTIME_DIR`/`/run` (overlaps dep-11) | -| cfg-4 | open | M | `blueman/main/DNSServerProvider.py:12` hardcoded `/etc/resolv.conf`, precedence undocumented | document resolved-first precedence | -| cfg-5 | open | S | `blueman/main/DhcpClient.py:17-20` DHCP client search order hardcoded (dhclient/dhcpcd/udhcpc) | configurable list | | cfg-6 | open | M | `blueman/plugins/services/Network.py` DHCP handler selection (dnsmasq/dhcpd/udhcpd) no user config, undocumented fallback chain | document + expose config | -| cfg-7 | open | S | `blueman/config/AutoConnectConfig.py:10` GSettings schema id hardcoded, duplicated across plugins | module constant | ## platform | id | status | effort | description | notes | |----|--------|--------|-------------|-------| | plat-1 | open | M | `blueman/Functions.py:210` hardcoded `:/sbin:/usr/sbin` fallback (dup dep-8) | `shutil.which()` | -| plat-2 | open | M | `blueman/main/PPPConnection.py:83` hardcoded `/usr/sbin/pppd` | dynamic `have()` lookup | -| plat-3 | open | M | `blueman/main/NetConf.py:268,276` hardcoded `/sbin/iptables` | dynamic lookup | +| plat-8 | open | S | `blueman/Functions.py:256` hardcoded `/dev/log` syslog address | platform detect / fallback to stderr | +| plat-9 | open | M | `blueman/main/NetConf.py:24` `/proc/{pid}` cmdline check, Linux-only | abstract proc access | | plat-4 | open | L | `blueman/main/NetConf.py:255` hardcoded `/proc/sys/net/ipv4` IP-forward, Linux-only | abstract, no non-Linux fallback | +| plat-3 | open | M | `blueman/main/NetConf.py:268,276` hardcoded `/sbin/iptables` | dynamic lookup | +| plat-2 | open | M | `blueman/main/PPPConnection.py:83` hardcoded `/usr/sbin/pppd` | dynamic `have()` lookup | | plat-5 | open | M | `blueman/plugins/applet/KillSwitch.py:59,87` hardcoded `/dev/rfkill`, silent fail without it | feature-detect + graceful degrade | -| plat-6 | open | S | `blueman/plugins/mechanism/RfKill.py:6` module-level `/dev/rfkill` check raises at import (dup dep-3) | move to `on_load()` | | plat-7 | open | M | `blueman/plugins/applet/NetUsage.py:84,87` hardcoded `/sys/class/net` sysfs paths, Linux-only | abstraction + degrade | -| plat-8 | open | S | `blueman/Functions.py:256` hardcoded `/dev/log` syslog address | platform detect / fallback to stderr | -| plat-9 | open | M | `blueman/main/NetConf.py:24` `/proc/{pid}` cmdline check, Linux-only | abstract proc access | +| plat-6 | open | S | `blueman/plugins/mechanism/RfKill.py:6` module-level `/dev/rfkill` check raises at import (dup dep-3) | move to `on_load()` | | plat-10 | open | S | `blueman/services/meta/SerialService.py` hardcoded `/dev/rfcomm{port}` naming | abstract device node | ## data structure | id | status | effort | description | notes | |----|--------|--------|-------------|-------| +| ds-5 | open | M | `blueman/bluez/Manager.py:160-164` `find_device()` scans all DBus objects, repeated Address lookups | cached device index (dup perf-11, scale) | | ds-2 | open | M | `blueman/plugins/applet/NetUsage.py:261-268` linear liststore scan by address in `monitor_added` | address→iter dict | | ds-3 | open | M | `blueman/plugins/applet/NetUsage.py:276-283` linear liststore scan by address in `monitor_removed` | address→iter dict | | ds-4 | open | M | `blueman/plugins/applet/RecentConns.py:129-137` linear scan of `stored_items` by (adapter,address,uuid) | tuple-keyed dict | -| ds-5 | open | M | `blueman/bluez/Manager.py:160-164` `find_device()` scans all DBus objects, repeated Address lookups | cached device index (dup perf-11, scale) | ## vectorization | id | status | effort | description | notes | |----|--------|--------|-------------|-------| -| vec-1 | open | M | `blueman/main/Sendto.py:140-143` per-property-change loop over UUIDs for OBEX_OBJPUSH | set membership / `any()` | | vec-2 | open | L | `blueman/bluez/Manager.py:138-149` `get_devices()` rescans all objects per `find_device()` | cache indexed by adapter, batch GetAll (dup perf-5) | | vec-3 | open | L | `blueman/gui/DeviceList.py:281-289` `clear()` per-row `device_remove_event` + dict lookups | bulk clear, defer path_to_row cleanup (dup perf-3) | +| vec-1 | open | M | `blueman/main/Sendto.py:140-143` per-property-change loop over UUIDs for OBEX_OBJPUSH | set membership / `any()` | ## robustiness | id | status | effort | description | notes | |----|--------|--------|-------------|-------| -| rob-1 | open | M | `blueman/gui/manager/ManagerProgressbar.py:178` `timeout_add(41,pulse)` source id not captured; pulses after `stop()` | store + remove source id (overlaps perf-14) | +| rob-8 | open | S | `blueman/gui/Animation.py:28-35` `start()` is not idempotent: calling it twice overwrites `self.timer` and leaks the first `GLib.timeout_add` source, so `stop()` can remove only the newest timer. | Return early if already started, or stop the existing source before starting a new one; add a start/stop source-id test. Cross-ref test-4. | +| rob-4 | open | M | `blueman/gui/DeviceList.py:256` discovery progress timeout source not stored/removed | capture id, remove in `stop_discovery()` | | rob-2 | open | M | `blueman/gui/manager/ManagerProgressbar.py:117` `timeout_add(timeout,finalize)` id discarded; double-finalize | capture + remove before re-call | +| rob-1 | open | M | `blueman/gui/manager/ManagerProgressbar.py:178` `timeout_add(41,pulse)` source id not captured; pulses after `stop()` | store + remove source id (overlaps perf-14) | | rob-3 | open | M | `blueman/main/DhcpClient.py:49-50` two timeout sources never stored/removed (dup wd-3) | store ids, remove on exit | -| rob-4 | open | M | `blueman/gui/DeviceList.py:256` discovery progress timeout source not stored/removed | capture id, remove in `stop_discovery()` | | rob-5 | open | S | `blueman/main/PPPConnection.py:182-197` OSError path may skip `source_remove(io_watch)` before cleanup → leaked source | remove source in except (overlaps rel-7) | -| rob-6 | open | S | `blueman/plugins/applet/NetUsage.py:79-80` Monitor `__del__` doesn't remove timeout source | guard + `source_remove(poller)` | | rob-7 | open | M | `blueman/main/Sendto.py:351-378` `on_transfer_progress` divides by `spd` without re-guard after ZeroDivisionError | `if spd>0` guard + log | -| rob-8 | open | S | `blueman/gui/Animation.py:28-35` `start()` is not idempotent: calling it twice overwrites `self.timer` and leaks the first `GLib.timeout_add` source, so `stop()` can remove only the newest timer. | Return early if already started, or stop the existing source before starting a new one; add a start/stop source-id test. Cross-ref test-4. | +| rob-6 | open | S | `blueman/plugins/applet/NetUsage.py:79-80` Monitor `__del__` doesn't remove timeout source | guard + `source_remove(poller)` | ## ui / ux | id | status | effort | description | notes | |----|--------|--------|-------------|-------| -| ux-1 | open | M | `blueman/main/Sendto.py:310` blocking `time.sleep(1)` on UI thread during discovery stop | `GLib.timeout_add_seconds` | -| ux-2 | open | S | `blueman/gui/Notification.py:51` hardcoded notification size 350x50 | responsive sizing | +| ux-7 | open | S | `blueman/gui/manager/ManagerDeviceList.py:508` FIXME "horrible workaround" inadequate feedback | proper user feedback | | ux-3 | open | S | `blueman/gui/manager/ManagerProgressbar.py:50` hardcoded progressbar 100x15 | flexible sizing | -| ux-4 | open | M | `blueman/gui/Notification.py:168-169` bare `except ValueError: pass` on hint set (dup obs-7) | log when fallback occurs | | ux-5 | open | S | `blueman/gui/Notification.py:107-108` empty `add_action()` stub logs warning | implement or remove stub | -| ux-6 | open | M | `blueman/main/Sendto.py:178,188,291,461` blocking `dialog.run()` freeze UI (overlaps leg-3) | non-blocking response signals | -| ux-7 | open | S | `blueman/gui/manager/ManagerDeviceList.py:508` FIXME "horrible workaround" inadequate feedback | proper user feedback | +| ux-4 | open | M | `blueman/gui/Notification.py:168-169` bare `except ValueError: pass` on hint set (dup obs-7) | log when fallback occurs | +| ux-2 | open | S | `blueman/gui/Notification.py:51` hardcoded notification size 350x50 | responsive sizing | | ux-8 | open | S | `blueman/main/Manager.py:183` FIXME BlueZ stop/start not surfaced to user | notification/infobar on daemon loss | +| ux-6 | open | M | `blueman/main/Sendto.py:178,188,291,461` blocking `dialog.run()` freeze UI (overlaps leg-3) | non-blocking response signals | +| ux-1 | open | M | `blueman/main/Sendto.py:310` blocking `time.sleep(1)` on UI thread during discovery stop | `GLib.timeout_add_seconds` | ## accessibility @@ -352,33 +352,33 @@ _(none open)_ | id | status | effort | description | notes | |----|--------|--------|-------------|-------| -| i18n-1 | open | S | `sendto/blueman_sendto.py.in:46-50` hardcodes Nautilus/Caja/Nemo menu labels and tips in English, and `sendto/blueman_sendto.py.in` is not listed in `po/POTFILES.in`, so translators never see them. | Wrap file-manager extension labels/tips in gettext and add the generated/template source to extraction. | | i18n-2 | open | S | `blueman/main/applet/BluezAgent.py:201-229` builds authentication notification sentences by concatenating translated fragments with device names, PINs, and markup. Translators cannot reorder the whole sentence or place punctuation naturally. | Use one format string per complete sentence/message with named placeholders, e.g. `%(device)s` and `%(passkey)s`, preserving markup escaping. | | i18n-3 | open | S | `blueman/plugins/applet/TransferService.py:186` uses the action label `"Reset to default"` without gettext, so the fallback notification action is always English. | Wrap the action label in `_()` and ensure it appears in `po/POTFILES.in`. | +| i18n-1 | open | S | `sendto/blueman_sendto.py.in:46-50` hardcodes Nautilus/Caja/Nemo menu labels and tips in English, and `sendto/blueman_sendto.py.in` is not listed in `po/POTFILES.in`, so translators never see them. | Wrap file-manager extension labels/tips in gettext and add the generated/template source to extraction. | ## documentation | id | status | effort | description | notes | |----|--------|--------|-------------|-------| | doc-1 | open | S | `blueman/Functions.py:217,239,264` dead `set_proc_title`/`create_logger`/`create_parser` in `__all__` (see dead-1..3) | document or remove | -| doc-2 | open | M | `blueman/gui/GenericList.py` no module/class docstring | document TreeView wrapper + signals | +| doc-7 | open | M | `blueman/gui/CommonUi.py` `ErrorDialog` lacks docstring; `excp` param undocumented | document exception UI | | doc-3 | open | M | `blueman/gui/DeviceList.py` class docstring missing; signals only in `__gsignals__` | document model + key signals | -| doc-4 | open | S | `blueman/main/Builder.py` class lacks docstring | document Gtk.Builder wrapper behavior | -| doc-5 | open | M | `blueman/gui/Notification.py` `Notification()` factory + bubble/dialog undocumented | document return-type selection | | doc-6 | open | M | `blueman/gui/DeviceSelectorDialog.py` `DeviceRow`/`DeviceSelector` lack docstrings | document selector pattern | -| doc-7 | open | M | `blueman/gui/CommonUi.py` `ErrorDialog` lacks docstring; `excp` param undocumented | document exception UI | -| doc-8 | open | S | `blueman/gui/manager/ManagerProgressbar.py` class undocumented (cancellable/text params) | document progress lifecycle | +| doc-2 | open | M | `blueman/gui/GenericList.py` no module/class docstring | document TreeView wrapper + signals | | doc-9 | open | M | `blueman/gui/GsmSettings.py` class lacks docstring | document GSM settings binding | +| doc-8 | open | S | `blueman/gui/manager/ManagerProgressbar.py` class undocumented (cancellable/text params) | document progress lifecycle | +| doc-5 | open | M | `blueman/gui/Notification.py` `Notification()` factory + bubble/dialog undocumented | document return-type selection | +| doc-4 | open | S | `blueman/main/Builder.py` class lacks docstring | document Gtk.Builder wrapper behavior | | doc-10 | open | M | `README` lacks plugin/dev API docs | document plugin loading + extension points | ## test coverage | id | status | effort | description | notes | |----|--------|--------|-------------|-------| -| test-1 | open | S | No tests cover `sendto/blueman_sendto.py.in` command construction for selected file paths. The quoting bug in cmd-1 would pass unnoticed for paths with quotes, semicolons, or leading dashes. | Add a small unit test around the file-list-to-launch-command path after extracting it into a pure helper. Cross-ref cmd-1. | -| test-2 | open | S | No tests cover `BluezAgent._on_display_passkey` boundary values for `entered`. `blueman/main/applet/BluezAgent.py:201-203` indexes `key[entered]`, so an out-of-range or fully-entered value can crash the agent notification path. | Add focused tests for `entered` values 0, 5, 6, and invalid values; clamp or render without bolding when all digits are entered. | | test-3 | open | M | Incoming OBEX transfer authorization and completion paths in `blueman/plugins/applet/TransferService.py:78-123,286-329` have no focused tests for overlapping requests, allowed-device expiry, filename collisions, or failed final moves. Current coverage would miss data-1, rel-10, and sm-8. | Extract testable helpers for pending-transfer records and destination selection; add unit tests with mocked `Transfer`, `Session`, and notifications. | | test-4 | open | S | No tests cover `blueman/gui/Animation.py` timer source lifecycle. The `start()`/`stop()` path can leak sources if `start()` is called repeatedly, and current tests would not detect it. | Add a focused test with mocked `GLib.timeout_add`/`source_remove` for idempotent start and complete cleanup. Cross-ref rob-8. | +| test-2 | open | S | No tests cover `BluezAgent._on_display_passkey` boundary values for `entered`. `blueman/main/applet/BluezAgent.py:201-203` indexes `key[entered]`, so an out-of-range or fully-entered value can crash the agent notification path. | Add focused tests for `entered` values 0, 5, 6, and invalid values; clamp or render without bolding when all digits are entered. | +| test-1 | open | S | No tests cover `sendto/blueman_sendto.py.in` command construction for selected file paths. The quoting bug in cmd-1 would pass unnoticed for paths with quotes, semicolons, or leading dashes. | Add a small unit test around the file-list-to-launch-command path after extracting it into a pure helper. Cross-ref cmd-1. | ## release & deploy engineering @@ -391,35 +391,35 @@ _(none open)_ | id | status | effort | description | notes | |----|--------|--------|-------------|-------| -| depend-1 | open | M | `blueman/main/NetConf.py:64-72` `DHCPHandler.apply` locks `dhcp` after a successful `_start` even when `_read_pid_file` returns `None` (daemon slow to write its pidfile; `DnsMasqHandler`/`UdhcpdHandler` don't reliably yield a pid in time). Later `clean_up` reads a now-absent pidfile, logs "Stale dhcp lockfile" and never kills the orphaned daemon — leaking a DHCP server bound to pan1. | Poll the pidfile with a bounded retry before locking; if no pid is obtained, treat the start as failed and tear down instead of locking. Cross-ref sm-7. | -| depend-2 | open | S | `blueman/main/NetConf.py:117-119` `DnsMasqHandler._start` appends `--dhcp-option=option:dns-server,{join(dns_servers)}` whenever `localhost:53` is reachable; if `DNSServerProvider.get_servers()` returned empty the option becomes a trailing-comma empty value, which dnsmasq rejects — the start fails entirely instead of degrading to "address but no DNS option". | Only append the `dns-server` option when `dns_servers` is non-empty. | | depend-3 | open | S | `blueman/main/DNSServerProvider.py:29,102` `_get_servers_from_systemd_resolved`/`_subscribe_systemd_resolved` call `Gio.bus_get_sync(SYSTEM)` and `DBusProxy.new_for_bus_sync` with no error handling around bus/proxy acquisition (only the later `Get` at :48 is guarded). A briefly-unavailable system bus makes `__init__` raise and the whole provider fail rather than falling back to resolv.conf. | Wrap bus/proxy acquisition in try/except `GLib.Error` and degrade to the resolv.conf path. Cross-ref mem-2. | +| depend-2 | open | S | `blueman/main/NetConf.py:117-119` `DnsMasqHandler._start` appends `--dhcp-option=option:dns-server,{join(dns_servers)}` whenever `localhost:53` is reachable; if `DNSServerProvider.get_servers()` returned empty the option becomes a trailing-comma empty value, which dnsmasq rejects — the start fails entirely instead of degrading to "address but no DNS option". | Only append the `dns-server` option when `dns_servers` is non-empty. | +| depend-1 | open | M | `blueman/main/NetConf.py:64-72` `DHCPHandler.apply` locks `dhcp` after a successful `_start` even when `_read_pid_file` returns `None` (daemon slow to write its pidfile; `DnsMasqHandler`/`UdhcpdHandler` don't reliably yield a pid in time). Later `clean_up` reads a now-absent pidfile, logs "Stale dhcp lockfile" and never kills the orphaned daemon — leaking a DHCP server bound to pan1. | Poll the pidfile with a bounded retry before locking; if no pid is obtained, treat the start as failed and tear down instead of locking. Cross-ref sm-7. | ## distributed systems | id | status | effort | description | notes | |----|--------|--------|-------------|-------| -| dist-1 | open | M | `blueman/main/NetConf.py:366-374` `lock`/`unlock`/`locked` are plain `touch`/`unlink(missing_ok)`/`exists` on `/var/run/blueman-*` with no `flock` or atomic check-and-set. The mechanism is a system D-Bus service serving concurrent `EnableNetwork`/`DisableNetwork`/`DhcpClient` calls; two near-simultaneous `apply_settings` both see `locked()==False`, both enable forwarding, both append iptables MASQUERADE/FORWARD rules, and both start DHCP daemons on pan1 — duplicate rules accumulate and the shared `_dhcp_handler`/`_ipt_rules` class state corrupts. | Hold a real exclusive lock (`fcntl.flock` on the lockfile) across the whole apply/clean_up, or process mechanism requests strictly serially; make rule application idempotent (flush blueman rules before re-adding). | | dist-2 | open | M | `blueman/main/NetConf.py:253,270,280` `_ipt_rules` is in-memory class state but the iptables rules it tracks live in the kernel and survive a mechanism restart (idle-exit after 30s, `MechanismApplication.py:25`). After re-activation `_ipt_rules` is empty while old MASQUERADE/FORWARD rules and the `iptables` lockfile persist; a later `clean_up`/`_del_ipt_rules` deletes nothing yet `unlock("iptables")`, and a new apply sees the stale lock and skips re-adding — leaving stale rules for the previous address. | Tag blueman rules with an iptables comment and flush-by-comment on apply; reconcile lockfile state against actual kernel rules at startup instead of trusting in-memory state. | -| dist-3 | open | S | `blueman/plugins/mechanism/Rfcomm.py:13-14` `_open_rfcomm` spawns a watcher per call with no dedup; two `OpenRFCOMM` calls for the same `port_id` start two `blueman-rfcomm-watcher /dev/rfcommN` processes, and `_close_rfcomm` kills only by matching the `ps` cmdline (can leave orphans or signal a recycled/foreign PID). | Before launching, scan for an existing watcher on that port and skip if present; track watcher PIDs in the mechanism rather than re-deriving from `ps`. Cross-ref wd-4, mem-1. | | dist-4 | open | M | `blueman/main/NetConf.py:347-348` In `apply_settings` the dhcp branch runs `clean_up()` (unlocks `dhcp`) then `apply()` (re-locks). If `apply`'s `_start` raises `NetworkSetupError`, earlier locks/forwarding/iptables from the same call are already applied — leaving a partially-applied state (bridge up, forwarding on, rules present) with no DHCP and no rollback; the caller just propagates a generic error. | Wrap `apply_settings` in try/except that runs full `NetConf.clean_up()` on any failure so the system is left all-or-nothing. Cross-ref depend-1. | +| dist-1 | open | M | `blueman/main/NetConf.py:366-374` `lock`/`unlock`/`locked` are plain `touch`/`unlink(missing_ok)`/`exists` on `/var/run/blueman-*` with no `flock` or atomic check-and-set. The mechanism is a system D-Bus service serving concurrent `EnableNetwork`/`DisableNetwork`/`DhcpClient` calls; two near-simultaneous `apply_settings` both see `locked()==False`, both enable forwarding, both append iptables MASQUERADE/FORWARD rules, and both start DHCP daemons on pan1 — duplicate rules accumulate and the shared `_dhcp_handler`/`_ipt_rules` class state corrupts. | Hold a real exclusive lock (`fcntl.flock` on the lockfile) across the whole apply/clean_up, or process mechanism requests strictly serially; make rule application idempotent (flush blueman rules before re-adding). | +| dist-3 | open | S | `blueman/plugins/mechanism/Rfcomm.py:13-14` `_open_rfcomm` spawns a watcher per call with no dedup; two `OpenRFCOMM` calls for the same `port_id` start two `blueman-rfcomm-watcher /dev/rfcommN` processes, and `_close_rfcomm` kills only by matching the `ps` cmdline (can leave orphans or signal a recycled/foreign PID). | Before launching, scan for an existing watcher on that port and skip if present; track watcher PIDs in the mechanism rather than re-deriving from `ps`. Cross-ref wd-4, mem-1. | ## time & scheduling correctness | id | status | effort | description | notes | |----|--------|--------|-------------|-------| -| time-1 | open | M | `blueman/main/SpeedCalc.py:21` `calc()` keys elapsed-time/speed math on wall clock `time.time()`; an NTP step or manual clock change can skew the divisor across retained samples and produce erratic speeds (the zero-elapsed guard only catches exact ties/backsteps within the window). | Sample with `time.monotonic()` / `GLib.get_monotonic_time()`; a monotonic clock never steps. Distinct from ds-1 (log prune) and adapt-2 (clock_gettime portability). | +| time-4 | open | M | `blueman/main/MechanismApplication.py:20-29` the idle-exit timer counts 1s `timeout_add` ticks (`self.time += 1` to 30) instead of comparing a monotonic deadline; GLib coalesces/delays timeouts under load or suspend, so the "30s idle" auto-exit drifts and can fire much later than intended. | Record `GLib.get_monotonic_time()` on activity and exit once `now - last >= 30s`, independent of tick count. Cross-ref cfg-2. | | time-2 | open | S | `blueman/main/Sendto.py:360` transfer-progress throttle `tm - self._last_update > 0.5` uses `time.time()`; a backward clock step stalls all speed/ETA UI updates until wall time catches up, a forward step fires every call. | Use `time.monotonic()` for `tm`/`self._last_update`. | +| time-1 | open | M | `blueman/main/SpeedCalc.py:21` `calc()` keys elapsed-time/speed math on wall clock `time.time()`; an NTP step or manual clock change can skew the divisor across retained samples and produce erratic speeds (the zero-elapsed guard only catches exact ties/backsteps within the window). | Sample with `time.monotonic()` / `GLib.get_monotonic_time()`; a monotonic clock never steps. Distinct from ds-1 (log prune) and adapt-2 (clock_gettime portability). | | time-3 | open | S | `blueman/plugins/applet/NetUsage.py:201` session duration `datetime.now() - fromtimestamp(config["time"])` is pure wall-clock; if the clock moved backward since the stored start, the delta is negative and renders nonsense durations. | Clamp negative deltas to 0 (or store a monotonic anchor) before formatting. | -| time-4 | open | M | `blueman/main/MechanismApplication.py:20-29` the idle-exit timer counts 1s `timeout_add` ticks (`self.time += 1` to 30) instead of comparing a monotonic deadline; GLib coalesces/delays timeouts under load or suspend, so the "30s idle" auto-exit drifts and can fire much later than intended. | Record `GLib.get_monotonic_time()` on activity and exit once `now - last >= 30s`, independent of tick count. Cross-ref cfg-2. | ## memory and cpu management | id | status | effort | description | notes | |----|--------|--------|-------------|-------| -| mem-1 | open | S | `blueman/plugins/mechanism/Rfcomm.py:17` `_close_rfcomm` shells out `ps -e o pid,args` and `communicate()` synchronously inside the privileged mechanism D-Bus method, blocking the mechanism main loop while it scans every process to find one watcher PID. | Track watcher PIDs (from `Popen` in `_open_rfcomm`) keyed by port and kill by stored PID instead of scanning `ps`. Cross-ref dist-3. | | mem-2 | open | S | `blueman/main/DNSServerProvider.py:29-79` `_get_servers_from_systemd_resolved` issues a chain of synchronous `call_sync` D-Bus calls (Get DNS, then per-interface GetLink + DefaultRoute Get) with `-1` (infinite) timeout on the main loop whenever DHCP servers are resolved, scaling with interface count and able to hang indefinitely. | Use finite timeouts and/or move resolution off the main loop; cache across the `changed` signal instead of re-walking all links each call. Cross-ref depend-3. | | mem-3 | open | S | `blueman/main/NetConf.py:239` `UdhcpdHandler._start` calls a blocking `sleep(0.1)` after spawning udhcpd to wait for the pid file, inside the mechanism process. Distinct from ux-1 (Sendto UI sleep). | Poll the pid file with a short non-blocking `GLib.timeout_add` loop instead of a fixed blocking sleep. | +| mem-1 | open | S | `blueman/plugins/mechanism/Rfcomm.py:17` `_close_rfcomm` shells out `ps -e o pid,args` and `communicate()` synchronously inside the privileged mechanism D-Bus method, blocking the mechanism main loop while it scans every process to find one watcher PID. | Track watcher PIDs (from `Popen` in `_open_rfcomm`) keyed by port and kill by stored PID instead of scanning `ps`. Cross-ref dist-3. | ## system design @@ -431,13 +431,13 @@ _(none open)_ | id | status | effort | description | notes | |----|--------|--------|-------------|-------| -| cli-1 | open | S | `blueman/Functions.py:273` `--loglevel` has no `help`, no `choices`; an unrecognized value (e.g. `--loglevel verbose`) silently coerces to WARNING with no error, so users get quieter logs than expected. Applies to all 7 entry points using `create_parser`. | Add `choices=[debug,info,warning,error,critical]` (case-insensitive) and `help=`; argparse then rejects bad values clearly. | -| cli-2 | open | S | `apps/blueman-mechanism.in:38,57-58` `-d/--debug` only logs "Enabled verbose output" and does nothing else; the level is driven by `--loglevel`, so `--debug` does NOT enable debug logging — a dead/misleading flag. | Make `--debug` set `log_level = logging.DEBUG`, or remove it and document `--loglevel debug`. | | cli-3 | open | S | `apps/blueman-adapters.in:24` `--socket-id` (XEmbed) is undocumented — no `help=`, absent from `data/man/blueman-adapters.1` — yet plumbed into `BluemanAdapters(... socket_id)`. | Add `help=` text and document, or mark intentionally internal. | -| cli-4 | open | S | `data/man/blueman-sendto.1` documents only `--device=ADDRESS`, but `apps/blueman-sendto.in:32-38` also ships `-d/--dest`, `-s/--source`, `-u/--delete` and a positional `FILE`. Man page is out of date vs `--help`. | Update the man page to list all options and the `FILE` positional. | -| cli-5 | open | S | `data/man/blueman-applet.1`, `blueman-manager.1`, `blueman-services.1` state "There are no options.", but each accepts `--loglevel`/`--syslog` via `create_parser`. Docs contradict behavior. | Replace "no options" with the actual flags. | +| cli-2 | open | S | `apps/blueman-mechanism.in:38,57-58` `-d/--debug` only logs "Enabled verbose output" and does nothing else; the level is driven by `--loglevel`, so `--debug` does NOT enable debug logging — a dead/misleading flag. | Make `--debug` set `log_level = logging.DEBUG`, or remove it and document `--loglevel debug`. | +| cli-1 | open | S | `blueman/Functions.py:273` `--loglevel` has no `help`, no `choices`; an unrecognized value (e.g. `--loglevel verbose`) silently coerces to WARNING with no error, so users get quieter logs than expected. Applies to all 7 entry points using `create_parser`. | Add `choices=[debug,info,warning,error,critical]` (case-insensitive) and `help=`; argparse then rejects bad values clearly. | | cli-6 | open | S | `data/man/blueman-adapters.1:1` `.TH` header is `BLUEMAN-SENDTO` (copy-paste), so `man blueman-adapters` shows the wrong title/section. | Fix `.TH` to `BLUEMAN-ADAPTERS`. | | cli-7 | open | S | `data/man/blueman-adapters.1` says the `adapter` arg selects the initial tab in `hci0` form, but `blueman/main/Adapter.py:74-76` matches tab keys and derives the page via `int(name[3:])`; any non-`hciN` value is silently dropped, and the positional has no CLI `help=` (`apps/blueman-adapters.in:25`). | Add `help=` to the positional stating the `hciN` format/behavior; align the man page. | +| cli-5 | open | S | `data/man/blueman-applet.1`, `blueman-manager.1`, `blueman-services.1` state "There are no options.", but each accepts `--loglevel`/`--syslog` via `create_parser`. Docs contradict behavior. | Replace "no options" with the actual flags. | +| cli-4 | open | S | `data/man/blueman-sendto.1` documents only `--device=ADDRESS`, but `apps/blueman-sendto.in:32-38` also ships `-d/--dest`, `-s/--source`, `-u/--delete` and a positional `FILE`. Man page is out of date vs `--help`. | Update the man page to list all options and the `FILE` positional. | ## product engineering @@ -456,12 +456,12 @@ _(none open)_ | id | status | effort | description | notes | |----|--------|--------|-------------|-------| -| fuzz-1 | open | M | `blueman/main/DhcpClient.py:25-77` has NO test file. `__init__` builds the client argv from `have()` across dhclient/dhcpcd/udhcpc; `_check_client` parses `poll()` status and reads `netifs[self._interface][0]`. Untested: client selection when several/none exist, argv assembly, poll-status branching, and the `KeyError`/`IndexError` when the bound interface is absent from `get_local_interfaces()`. | Add `test/main/test_dhcpclient.py` mocking `have`/`Popen`/`get_local_interfaces`; cover argv per client, run() raising when none found, double-run, poll 0/1/None, and hostile interface maps (missing key, empty tuple). | -| fuzz-2 | open | S | `blueman/Sdp.py:358-385` `ServiceUUID` is untested. `UUID(uuid)` raises `ValueError` on malformed input; `name`/`short_uuid`/`reserved` decode the 128-bit int and index `uuid_names[short_uuid]`. Untested: short vs full UUIDs, the all-zero case, Proprietary (non-reserved) UUIDs, unknown reserved short ids (KeyError→"Unknown"), and malformed/empty/garbage strings from the BlueZ wire. | Add `test/test_sdp.py` covering reserved short UUIDs, `int==0`, a non-Bluetooth-base UUID, an unknown reserved id, and a fuzz set of malformed strings asserting only `ValueError` escapes construction. | | fuzz-3 | open | S | `blueman/DeviceClass.py:473-555` `get_major_class`/`get_minor_class`/`gatt_appearance_to_name` decode raw class-of-device and GATT appearance bitfields, untested. Hostile/boundary inputs: negative ints, values exceeding 16 bits, out-of-range minor indices, and appearance category boundaries around the reserved/invalid guards (:541-547). | Add `test/test_deviceclass.py` parametrized over major indices + overflow, each minor family in/out of range, and `gatt_appearance_to_name` at category edges plus a sweep asserting no `KeyError`/`IndexError` escapes. | +| fuzz-6 | open | S | `blueman/Functions.py:147-154` `adapter_path_to_name` parses a D-Bus object path with greedy `re.search(r".*(hci[0-9]*)", path)` (zero digits allowed) and is untested; hostile/edge inputs (`/org/bluez/hci`, trailing `dev_..` segments, `prefix-hci99-suffix`, empty/None, no-`hci`) can yield surprising captures or `None`. | Add tests for normal `/org/bluez/hci0`, None/empty→None, no-`hci`→None, trailing segments, and multiple `hci` occurrences to pin the greedy behavior. | | fuzz-4 | open | S | `blueman/Functions.py:166-181` `format_bytes` has a confirmed boundary bug and no test: strict `<` on both band edges means exact powers of 1024 fall through to GB — `format_bytes(1024)` returns `(9.5e-07, "GB")` instead of `(1.0, "KB")`; 1048576/1073741824 likewise mislabel. | Fix comparisons to `<=`/`>=` and add tests asserting exact boundaries 1024→KB, 1048576→MB, 1073741824→GB plus 0, sub-1024, and a huge value. | | fuzz-5 | open | S | `blueman/Functions.py:340-356` `parse_os_release` (nested in `log_system_info`) splits with `line.split("=")` and is untested: a valid `PRETTY_NAME="Name=Variant"` raises `ValueError` and is dropped; comment/blank/`=`-less lines also untested. | Extract `parse_os_release` to module scope, use `split("=", 1)`, and test `KEY="a=b"`, comment/blank, and missing-`=` lines asserting graceful skip. Cross-ref data-3. | -| fuzz-6 | open | S | `blueman/Functions.py:147-154` `adapter_path_to_name` parses a D-Bus object path with greedy `re.search(r".*(hci[0-9]*)", path)` (zero digits allowed) and is untested; hostile/edge inputs (`/org/bluez/hci`, trailing `dev_..` segments, `prefix-hci99-suffix`, empty/None, no-`hci`) can yield surprising captures or `None`. | Add tests for normal `/org/bluez/hci0`, None/empty→None, no-`hci`→None, trailing segments, and multiple `hci` occurrences to pin the greedy behavior. | +| fuzz-1 | open | M | `blueman/main/DhcpClient.py:25-77` has NO test file. `__init__` builds the client argv from `have()` across dhclient/dhcpcd/udhcpc; `_check_client` parses `poll()` status and reads `netifs[self._interface][0]`. Untested: client selection when several/none exist, argv assembly, poll-status branching, and the `KeyError`/`IndexError` when the bound interface is absent from `get_local_interfaces()`. | Add `test/main/test_dhcpclient.py` mocking `have`/`Popen`/`get_local_interfaces`; cover argv per client, run() raising when none found, double-run, poll 0/1/None, and hostile interface maps (missing key, empty tuple). | +| fuzz-2 | open | S | `blueman/Sdp.py:358-385` `ServiceUUID` is untested. `UUID(uuid)` raises `ValueError` on malformed input; `name`/`short_uuid`/`reserved` decode the 128-bit int and index `uuid_names[short_uuid]`. Untested: short vs full UUIDs, the all-zero case, Proprietary (non-reserved) UUIDs, unknown reserved short ids (KeyError→"Unknown"), and malformed/empty/garbage strings from the BlueZ wire. | Add `test/test_sdp.py` covering reserved short UUIDs, `int==0`, a non-Bluetooth-base UUID, an unknown reserved id, and a fuzz set of malformed strings asserting only `ValueError` escapes construction. | --- From 38cd4c6e54e04a13a9682c6ff20eeb58ee94c32b Mon Sep 17 00:00:00 2001 From: Geraldo Netto Date: Fri, 19 Jun 2026 12:58:55 +0200 Subject: [PATCH 14/42] docs: drop TODO items implemented in perf/bluez-base-property-cache comp-1 (InstanceRegistry extraction), perf-4 (cache-first Base.get), cache-1 (explicit freshness/stale), obs-13 (log cached fallback) are implemented on the perf/bluez-base-property-cache branch. Co-Authored-By: Claude Opus 4.8 (1M context) --- TODO.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/TODO.md b/TODO.md index d661d3bcc..8b5fb08d6 100644 --- a/TODO.md +++ b/TODO.md @@ -31,7 +31,6 @@ Status: `open`, `in-progress`, `blocked`. Effort: `S` (≤1h), `M` (half-day), ` | id | status | effort | description | notes | |----|--------|--------|-------------|-------| -| perf-4 | open | M | `blueman/bluez/Base.py:100` `device["Prop"]` issues sync `Properties.Get` DBus on UI thread | local prop cache + signal-driven invalidation | | perf-5 | open | M | `blueman/bluez/Manager.py:115-149` `get_adapter_paths`/`get_devices` iterate `_object_manager.get_objects()` per call | cache, invalidate on object-added/removed | | perf-3 | open | S | `blueman/gui/DeviceList.py:282-285` `clear()` iterates liststore calling `device_remove_event` per item → O(n²) | call `liststore.clear()` once, drop `path_to_row` in bulk | | perf-10 | open | S | `blueman/gui/manager/ManagerMenu.py:53` creates Adapter proxies for all adapters in `__init__` | lazy-instantiate on selection | @@ -52,7 +51,6 @@ Status: `open`, `in-progress`, `blocked`. Effort: `S` (≤1h), `M` (half-day), ` | id | status | effort | description | notes | |----|--------|--------|-------------|-------| -| cache-1 | open | M | `blueman/bluez/Base.py:100-107` caches DBus properties only after a synchronous `Get`, and falls back to the cached value on later `GLib.Error` without freshness metadata. Callers cannot tell whether they received live state or stale state, and no cache invalidation policy is documented per property. | Make cache state explicit: update from `PropertiesChanged`, mark stale on bus errors, and expose/handle stale reads at call sites that need fresh state. Cross-ref perf-4, obs-13. | ## concurrency @@ -135,7 +133,6 @@ Status: `open`, `in-progress`, `blocked`. Effort: `S` (≤1h), `M` (half-day), ` | id | status | effort | description | notes | |----|--------|--------|-------------|-------| -| obs-13 | open | S | `blueman/bluez/Base.py:107` `GLib.Error` falls back to cached property silently | `logging.debug` cache fallback | | obs-4 | open | S | `blueman/bluez/obex/Manager.py:51,59,68,75` `logging.info(object_path)` lacks event/context | prefix with event name | | obs-1 | open | S | `blueman/Functions.py:64,87` `print()` in `check_bluetooth_status()` exception/fallback | `logging.error(..., exc_info=True)` | | obs-10 | open | S | `blueman/gui/GenericList.py:116` silent `ValueError` from `get_iter` | `logging.debug` invalid path | @@ -219,7 +216,6 @@ _(none open)_ | id | status | effort | description | notes | |----|--------|--------|-------------|-------| -| comp-1 | open | M | `blueman/bluez/Base.py:11-26` `BaseMeta` metaclass couples object identity to DBus path via permanent instance cache | extract caching to registry/factory (overlaps conc-2) | | comp-2 | open | M | `blueman/bluez/obex/Base.py:5` obex Base subclasses bluez Base, both override metaclass attrs; class-attr duplication | pass bus config to `__init__` instead of subclassing | | comp-3 | open | S | `blueman/gui/manager/ManagerDeviceList.py:45` 4-level inheritance (Gtk.TreeView→GenericList→DeviceList→ManagerDeviceList) + parent-chain coupling | inject deps via constructor, prefer composition | | comp-4 | open | M | `blueman/plugins/MechanismPlugin.py:8-12` copies parent methods (timer, confirm_authorization) into `__init__`; tight bind to concrete app | abstract plugin interface + DI | From e117a4eeaa28aded130bd8bc61d7a4d804b49ce1 Mon Sep 17 00:00:00 2001 From: Geraldo Netto Date: Fri, 19 Jun 2026 13:16:17 +0200 Subject: [PATCH 15/42] docs: drop TODO items implemented in perf/devicelist-clear-discovery rob-4 (track/cancel discovery progress timer), perf-3 (single-pass clear), and vec-3 (release row references before clearing) are implemented on the perf/devicelist-clear-discovery branch. Co-Authored-By: Claude Opus 4.8 (1M context) --- TODO.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/TODO.md b/TODO.md index 8b5fb08d6..ce5a5f34d 100644 --- a/TODO.md +++ b/TODO.md @@ -32,7 +32,6 @@ Status: `open`, `in-progress`, `blocked`. Effort: `S` (≤1h), `M` (half-day), ` | id | status | effort | description | notes | |----|--------|--------|-------------|-------| | perf-5 | open | M | `blueman/bluez/Manager.py:115-149` `get_adapter_paths`/`get_devices` iterate `_object_manager.get_objects()` per call | cache, invalidate on object-added/removed | -| perf-3 | open | S | `blueman/gui/DeviceList.py:282-285` `clear()` iterates liststore calling `device_remove_event` per item → O(n²) | call `liststore.clear()` once, drop `path_to_row` in bulk | | perf-10 | open | S | `blueman/gui/manager/ManagerMenu.py:53` creates Adapter proxies for all adapters in `__init__` | lazy-instantiate on selection | | perf-13 | open | S | `blueman/main/Applet.py:93-118` plugin broadcast loop runs full plugin set per property change → O(plugins × props × devices) | debounce/batch property events | | perf-9 | open | S | `blueman/main/DhcpClient.py:48-50,68` `subprocess.poll()` blocking in 1s `GLib.timeout` | use `Gio.Subprocess` + `wait_check_async` or `GLib.child_watch_add` | @@ -309,7 +308,6 @@ _(none open)_ | id | status | effort | description | notes | |----|--------|--------|-------------|-------| | vec-2 | open | L | `blueman/bluez/Manager.py:138-149` `get_devices()` rescans all objects per `find_device()` | cache indexed by adapter, batch GetAll (dup perf-5) | -| vec-3 | open | L | `blueman/gui/DeviceList.py:281-289` `clear()` per-row `device_remove_event` + dict lookups | bulk clear, defer path_to_row cleanup (dup perf-3) | | vec-1 | open | M | `blueman/main/Sendto.py:140-143` per-property-change loop over UUIDs for OBEX_OBJPUSH | set membership / `any()` | ## robustiness @@ -317,7 +315,6 @@ _(none open)_ | id | status | effort | description | notes | |----|--------|--------|-------------|-------| | rob-8 | open | S | `blueman/gui/Animation.py:28-35` `start()` is not idempotent: calling it twice overwrites `self.timer` and leaks the first `GLib.timeout_add` source, so `stop()` can remove only the newest timer. | Return early if already started, or stop the existing source before starting a new one; add a start/stop source-id test. Cross-ref test-4. | -| rob-4 | open | M | `blueman/gui/DeviceList.py:256` discovery progress timeout source not stored/removed | capture id, remove in `stop_discovery()` | | rob-2 | open | M | `blueman/gui/manager/ManagerProgressbar.py:117` `timeout_add(timeout,finalize)` id discarded; double-finalize | capture + remove before re-call | | rob-1 | open | M | `blueman/gui/manager/ManagerProgressbar.py:178` `timeout_add(41,pulse)` source id not captured; pulses after `stop()` | store + remove source id (overlaps perf-14) | | rob-3 | open | M | `blueman/main/DhcpClient.py:49-50` two timeout sources never stored/removed (dup wd-3) | store ids, remove on exit | From 0196eea078226fe327d5572309f7273e227273de Mon Sep 17 00:00:00 2001 From: Geraldo Netto Date: Fri, 19 Jun 2026 13:18:02 +0200 Subject: [PATCH 16/42] docs: add rule against documenting obvious code Comment only the non-obvious (why/constraints/edge cases); delete redundant comments rather than write them. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index d4c597848..ae3df6e9c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,7 @@ This file defines the expected behavior and usage model for AI agents working in - Keep code complexity <= 10 for any new function, class, or method. - Avoid code duplication and apply SOLID principles where practical. - Document assumptions, constraints, and design intent in comments or commit notes when they matter. +- Do not add comments that restate what the code plainly says. Comment only the non-obvious: why a choice was made, a constraint, or a subtle edge case. Delete redundant comments rather than write them. - Prefer explicit, maintainable solutions over clever shortcuts. - Propose business/design patterns and DDD only when they improve clarity or structure. - ALWAYS record review findings in `TODO.md` — never report them only in chat. Any time you From c9be5ba72fa464d815a46edd028d072854dc8104 Mon Sep 17 00:00:00 2001 From: Geraldo Netto Date: Fri, 19 Jun 2026 19:27:32 +0200 Subject: [PATCH 17/42] fix(functions): correct format_bytes unit boundaries (fuzz-4) Exact powers of 1024 fell through to the GB branch because both band edges used strict `<`, so format_bytes(1024) returned (9.5e-07, "GB") instead of (1.0, "KB"); 1 MiB and 1 GiB were mislabelled the same way. Drop the lower-bound comparison and rely on the cascading upper bounds so each boundary lands in its own unit. Add test/test_functions.py covering the 1024/1048576/1073741824 boundaries plus zero, sub-KB, mid-band, and a huge value. Co-Authored-By: Claude Opus 4.8 (1M context) --- TODO.md | 1 - blueman/Functions.py | 4 ++-- test/Makefile.am | 3 ++- test/test_functions.py | 35 +++++++++++++++++++++++++++++++++++ 4 files changed, 39 insertions(+), 4 deletions(-) create mode 100644 test/test_functions.py diff --git a/TODO.md b/TODO.md index ce5a5f34d..d2d4960df 100644 --- a/TODO.md +++ b/TODO.md @@ -451,7 +451,6 @@ _(none open)_ |----|--------|--------|-------------|-------| | fuzz-3 | open | S | `blueman/DeviceClass.py:473-555` `get_major_class`/`get_minor_class`/`gatt_appearance_to_name` decode raw class-of-device and GATT appearance bitfields, untested. Hostile/boundary inputs: negative ints, values exceeding 16 bits, out-of-range minor indices, and appearance category boundaries around the reserved/invalid guards (:541-547). | Add `test/test_deviceclass.py` parametrized over major indices + overflow, each minor family in/out of range, and `gatt_appearance_to_name` at category edges plus a sweep asserting no `KeyError`/`IndexError` escapes. | | fuzz-6 | open | S | `blueman/Functions.py:147-154` `adapter_path_to_name` parses a D-Bus object path with greedy `re.search(r".*(hci[0-9]*)", path)` (zero digits allowed) and is untested; hostile/edge inputs (`/org/bluez/hci`, trailing `dev_..` segments, `prefix-hci99-suffix`, empty/None, no-`hci`) can yield surprising captures or `None`. | Add tests for normal `/org/bluez/hci0`, None/empty→None, no-`hci`→None, trailing segments, and multiple `hci` occurrences to pin the greedy behavior. | -| fuzz-4 | open | S | `blueman/Functions.py:166-181` `format_bytes` has a confirmed boundary bug and no test: strict `<` on both band edges means exact powers of 1024 fall through to GB — `format_bytes(1024)` returns `(9.5e-07, "GB")` instead of `(1.0, "KB")`; 1048576/1073741824 likewise mislabel. | Fix comparisons to `<=`/`>=` and add tests asserting exact boundaries 1024→KB, 1048576→MB, 1073741824→GB plus 0, sub-1024, and a huge value. | | fuzz-5 | open | S | `blueman/Functions.py:340-356` `parse_os_release` (nested in `log_system_info`) splits with `line.split("=")` and is untested: a valid `PRETTY_NAME="Name=Variant"` raises `ValueError` and is dropped; comment/blank/`=`-less lines also untested. | Extract `parse_os_release` to module scope, use `split("=", 1)`, and test `KEY="a=b"`, comment/blank, and missing-`=` lines asserting graceful skip. Cross-ref data-3. | | fuzz-1 | open | M | `blueman/main/DhcpClient.py:25-77` has NO test file. `__init__` builds the client argv from `have()` across dhclient/dhcpcd/udhcpc; `_check_client` parses `poll()` status and reads `netifs[self._interface][0]`. Untested: client selection when several/none exist, argv assembly, poll-status branching, and the `KeyError`/`IndexError` when the bound interface is absent from `get_local_interfaces()`. | Add `test/main/test_dhcpclient.py` mocking `have`/`Popen`/`get_local_interfaces`; cover argv per client, run() raising when none found, double-run, poll 0/1/None, and hostile interface maps (missing key, empty tuple). | | fuzz-2 | open | S | `blueman/Sdp.py:358-385` `ServiceUUID` is untested. `UUID(uuid)` raises `ValueError` on malformed input; `name`/`short_uuid`/`reserved` decode the 128-bit int and index `uuid_names[short_uuid]`. Untested: short vs full UUIDs, the all-zero case, Proprietary (non-reserved) UUIDs, unknown reserved short ids (KeyError→"Unknown"), and malformed/empty/garbage strings from the BlueZ wire. | Add `test/test_sdp.py` covering reserved short UUIDs, `int==0`, a non-Bluetooth-base UUID, an unknown reserved id, and a fuzz set of malformed strings asserting only `ValueError` escapes construction. | diff --git a/blueman/Functions.py b/blueman/Functions.py index 2e48e8aab..d0efbf673 100644 --- a/blueman/Functions.py +++ b/blueman/Functions.py @@ -168,10 +168,10 @@ def format_bytes(size: float) -> tuple[float, str]: if size < 1024: ret = size suffix = _("B") - elif 1024 < size < (1024 * 1024): + elif size < (1024 * 1024): ret = size / 1024 suffix = _("KB") - elif (1024 * 1024) < size < (1024 * 1024 * 1024): + elif size < (1024 * 1024 * 1024): ret = size / (1024 * 1024) suffix = _("MB") else: diff --git a/test/Makefile.am b/test/Makefile.am index 0333b925e..7bea7ffc0 100644 --- a/test/Makefile.am +++ b/test/Makefile.am @@ -11,4 +11,5 @@ SUBDIRS = \ EXTRA_DIST = \ __init__.py \ test_imports.py \ - test_gobject.py + test_gobject.py \ + test_functions.py diff --git a/test/test_functions.py b/test/test_functions.py new file mode 100644 index 000000000..ea4a73d7c --- /dev/null +++ b/test/test_functions.py @@ -0,0 +1,35 @@ +from unittest import TestCase + +from blueman.Functions import format_bytes + + +class TestFormatBytes(TestCase): + def test_zero(self) -> None: + self.assertEqual(format_bytes(0), (0.0, "B")) + + def test_sub_kilobyte(self) -> None: + self.assertEqual(format_bytes(512), (512.0, "B")) + self.assertEqual(format_bytes(1023), (1023.0, "B")) + + def test_kilobyte_boundary(self) -> None: + # Regression: exact 1024 must be 1.0 KB, not a fraction of a GB. + self.assertEqual(format_bytes(1024), (1.0, "KB")) + + def test_megabyte_boundary(self) -> None: + self.assertEqual(format_bytes(1024 * 1024), (1.0, "MB")) + + def test_gigabyte_boundary(self) -> None: + self.assertEqual(format_bytes(1024 * 1024 * 1024), (1.0, "GB")) + + def test_mid_band_values(self) -> None: + self.assertEqual(format_bytes(1536), (1.5, "KB")) + self.assertEqual(format_bytes(1024 * 1024 * 3), (3.0, "MB")) + + def test_huge_value(self) -> None: + ret, suffix = format_bytes(5 * 1024 ** 4) + self.assertEqual(suffix, "GB") + self.assertEqual(ret, 5 * 1024) + + def test_accepts_float_and_int(self) -> None: + self.assertEqual(format_bytes(2048.0), (2.0, "KB")) + self.assertEqual(format_bytes(2048), (2.0, "KB")) From 08805e8a6768644e68c62b5246908cee1d0d7393 Mon Sep 17 00:00:00 2001 From: Geraldo Netto Date: Fri, 19 Jun 2026 19:28:17 +0200 Subject: [PATCH 18/42] test(functions): pin adapter_path_to_name behavior (fuzz-6) adapter_path_to_name parses a D-Bus object path with a greedy `re.search(r".*(hci[0-9]*)", path)` and had no tests. Add cases pinning the current contract: normal paths, None/empty -> None, no-hci -> None, case sensitivity, trailing device segments, zero-digit "hci", greedy last-occurrence selection, and embedded matches. Co-Authored-By: Claude Opus 4.8 (1M context) --- TODO.md | 1 - test/test_functions.py | 35 ++++++++++++++++++++++++++++++++++- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/TODO.md b/TODO.md index d2d4960df..4f911cc53 100644 --- a/TODO.md +++ b/TODO.md @@ -450,7 +450,6 @@ _(none open)_ | id | status | effort | description | notes | |----|--------|--------|-------------|-------| | fuzz-3 | open | S | `blueman/DeviceClass.py:473-555` `get_major_class`/`get_minor_class`/`gatt_appearance_to_name` decode raw class-of-device and GATT appearance bitfields, untested. Hostile/boundary inputs: negative ints, values exceeding 16 bits, out-of-range minor indices, and appearance category boundaries around the reserved/invalid guards (:541-547). | Add `test/test_deviceclass.py` parametrized over major indices + overflow, each minor family in/out of range, and `gatt_appearance_to_name` at category edges plus a sweep asserting no `KeyError`/`IndexError` escapes. | -| fuzz-6 | open | S | `blueman/Functions.py:147-154` `adapter_path_to_name` parses a D-Bus object path with greedy `re.search(r".*(hci[0-9]*)", path)` (zero digits allowed) and is untested; hostile/edge inputs (`/org/bluez/hci`, trailing `dev_..` segments, `prefix-hci99-suffix`, empty/None, no-`hci`) can yield surprising captures or `None`. | Add tests for normal `/org/bluez/hci0`, None/empty→None, no-`hci`→None, trailing segments, and multiple `hci` occurrences to pin the greedy behavior. | | fuzz-5 | open | S | `blueman/Functions.py:340-356` `parse_os_release` (nested in `log_system_info`) splits with `line.split("=")` and is untested: a valid `PRETTY_NAME="Name=Variant"` raises `ValueError` and is dropped; comment/blank/`=`-less lines also untested. | Extract `parse_os_release` to module scope, use `split("=", 1)`, and test `KEY="a=b"`, comment/blank, and missing-`=` lines asserting graceful skip. Cross-ref data-3. | | fuzz-1 | open | M | `blueman/main/DhcpClient.py:25-77` has NO test file. `__init__` builds the client argv from `have()` across dhclient/dhcpcd/udhcpc; `_check_client` parses `poll()` status and reads `netifs[self._interface][0]`. Untested: client selection when several/none exist, argv assembly, poll-status branching, and the `KeyError`/`IndexError` when the bound interface is absent from `get_local_interfaces()`. | Add `test/main/test_dhcpclient.py` mocking `have`/`Popen`/`get_local_interfaces`; cover argv per client, run() raising when none found, double-run, poll 0/1/None, and hostile interface maps (missing key, empty tuple). | | fuzz-2 | open | S | `blueman/Sdp.py:358-385` `ServiceUUID` is untested. `UUID(uuid)` raises `ValueError` on malformed input; `name`/`short_uuid`/`reserved` decode the 128-bit int and index `uuid_names[short_uuid]`. Untested: short vs full UUIDs, the all-zero case, Proprietary (non-reserved) UUIDs, unknown reserved short ids (KeyError→"Unknown"), and malformed/empty/garbage strings from the BlueZ wire. | Add `test/test_sdp.py` covering reserved short UUIDs, `int==0`, a non-Bluetooth-base UUID, an unknown reserved id, and a fuzz set of malformed strings asserting only `ValueError` escapes construction. | diff --git a/test/test_functions.py b/test/test_functions.py index ea4a73d7c..b21130590 100644 --- a/test/test_functions.py +++ b/test/test_functions.py @@ -1,6 +1,6 @@ from unittest import TestCase -from blueman.Functions import format_bytes +from blueman.Functions import adapter_path_to_name, format_bytes class TestFormatBytes(TestCase): @@ -33,3 +33,36 @@ def test_huge_value(self) -> None: def test_accepts_float_and_int(self) -> None: self.assertEqual(format_bytes(2048.0), (2.0, "KB")) self.assertEqual(format_bytes(2048), (2.0, "KB")) + + +class TestAdapterPathToName(TestCase): + def test_normal_path(self) -> None: + self.assertEqual(adapter_path_to_name("/org/bluez/hci0"), "hci0") + + def test_none_and_empty(self) -> None: + self.assertIsNone(adapter_path_to_name(None)) + self.assertIsNone(adapter_path_to_name("")) + + def test_no_hci(self) -> None: + self.assertIsNone(adapter_path_to_name("/org/bluez")) + self.assertIsNone(adapter_path_to_name("no-match")) + + def test_case_sensitive(self) -> None: + # The pattern matches lowercase "hci" only. + self.assertIsNone(adapter_path_to_name("HCI0")) + + def test_trailing_segments(self) -> None: + # Device sub-paths still resolve to the adapter name. + self.assertEqual(adapter_path_to_name("/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF"), "hci0") + + def test_zero_digits_allowed(self) -> None: + # The `[0-9]*` quantifier permits an "hci" with no index. + self.assertEqual(adapter_path_to_name("/org/bluez/hci"), "hci") + self.assertEqual(adapter_path_to_name("hci"), "hci") + + def test_greedy_picks_last_occurrence(self) -> None: + # Greedy `.*` consumes up to the final "hci" match. + self.assertEqual(adapter_path_to_name("/org/bluez/hci0hci1"), "hci1") + + def test_embedded_in_other_text(self) -> None: + self.assertEqual(adapter_path_to_name("prefix-hci99-suffix"), "hci99") From a2d8de3dcf2450a90f2e1b61d109c2dfe4986d81 Mon Sep 17 00:00:00 2001 From: Geraldo Netto Date: Fri, 19 Jun 2026 19:29:21 +0200 Subject: [PATCH 19/42] fix(functions): extract parse_os_release and keep values with "=" (fuzz-5, data-3) parse_os_release was nested inside log_system_info and split each line with `line.split("=")`, so a valid quoted value containing "=" (e.g. PRETTY_NAME="Name=Variant") raised ValueError and was dropped from the logged system info. Promote it to module scope, parse with str.partition("=") so only the first "=" separates key from value, and skip blank lines explicitly. Add tests for basic keys, a value containing "=", unquoted values, comment/blank lines, lines without "=", and a missing file. Co-Authored-By: Claude Opus 4.8 (1M context) --- TODO.md | 2 -- blueman/Functions.py | 35 ++++++++++++++++++----------------- test/test_functions.py | 38 +++++++++++++++++++++++++++++++++++++- 3 files changed, 55 insertions(+), 20 deletions(-) diff --git a/TODO.md b/TODO.md index 4f911cc53..c36ec8444 100644 --- a/TODO.md +++ b/TODO.md @@ -23,7 +23,6 @@ Status: `open`, `in-progress`, `blocked`. Effort: `S` (≤1h), `M` (half-day), ` | id | status | effort | description | notes | |----|--------|--------|-------------|-------| -| data-3 | open | S | `blueman/Functions.py:349-353` parses `/etc/os-release` lines with `line.split("=")`, so valid quoted values containing `=` are rejected and omitted from logged system info. | Use `split("=", 1)` and add a regression test with `PRETTY_NAME="Name=Variant"`. | | data-1 | open | S | `blueman/plugins/applet/TransferService.py:296-303` resolves incoming-file name collisions by prefixing only second-resolution time, then moves without rechecking the timestamped destination. Two same-named transfers completing in the same second can collide and overwrite/fail depending on platform semantics. | Generate a unique destination with an exclusive create/rename loop (`name`, `timestamp_name`, `timestamp_1_name`, ...), and test repeated same-second completions. | | data-2 | open | S | `blueman/plugins/manager/Notes.py:32-35` creates a `.vnt` temporary file with `delete=False` and relies on the launched sendto process to delete it. If launch fails or the process never starts, the note body remains in `/tmp` indefinitely. | Delete the temp file when `launch()` returns false or raises; consider creating it in an app-owned temp directory with cleanup on startup. Cross-ref gov-5. | @@ -450,7 +449,6 @@ _(none open)_ | id | status | effort | description | notes | |----|--------|--------|-------------|-------| | fuzz-3 | open | S | `blueman/DeviceClass.py:473-555` `get_major_class`/`get_minor_class`/`gatt_appearance_to_name` decode raw class-of-device and GATT appearance bitfields, untested. Hostile/boundary inputs: negative ints, values exceeding 16 bits, out-of-range minor indices, and appearance category boundaries around the reserved/invalid guards (:541-547). | Add `test/test_deviceclass.py` parametrized over major indices + overflow, each minor family in/out of range, and `gatt_appearance_to_name` at category edges plus a sweep asserting no `KeyError`/`IndexError` escapes. | -| fuzz-5 | open | S | `blueman/Functions.py:340-356` `parse_os_release` (nested in `log_system_info`) splits with `line.split("=")` and is untested: a valid `PRETTY_NAME="Name=Variant"` raises `ValueError` and is dropped; comment/blank/`=`-less lines also untested. | Extract `parse_os_release` to module scope, use `split("=", 1)`, and test `KEY="a=b"`, comment/blank, and missing-`=` lines asserting graceful skip. Cross-ref data-3. | | fuzz-1 | open | M | `blueman/main/DhcpClient.py:25-77` has NO test file. `__init__` builds the client argv from `have()` across dhclient/dhcpcd/udhcpc; `_check_client` parses `poll()` status and reads `netifs[self._interface][0]`. Untested: client selection when several/none exist, argv assembly, poll-status branching, and the `KeyError`/`IndexError` when the bound interface is absent from `get_local_interfaces()`. | Add `test/main/test_dhcpclient.py` mocking `have`/`Popen`/`get_local_interfaces`; cover argv per client, run() raising when none found, double-run, poll 0/1/None, and hostile interface maps (missing key, empty tuple). | | fuzz-2 | open | S | `blueman/Sdp.py:358-385` `ServiceUUID` is untested. `UUID(uuid)` raises `ValueError` on malformed input; `name`/`short_uuid`/`reserved` decode the 128-bit int and index `uuid_names[short_uuid]`. Untested: short vs full UUIDs, the all-zero case, Proprietary (non-reserved) UUIDs, unknown reserved short ids (KeyError→"Unknown"), and malformed/empty/garbage strings from the BlueZ wire. | Add `test/test_sdp.py` covering reserved short UUIDs, `int==0`, a non-Bluetooth-base UUID, an unknown reserved id, and a fuzz set of malformed strings asserting only `ValueError` escapes construction. | diff --git a/blueman/Functions.py b/blueman/Functions.py index d0efbf673..7e2588b1c 100644 --- a/blueman/Functions.py +++ b/blueman/Functions.py @@ -337,24 +337,25 @@ def bmexit(msg: str | int | None = None) -> None: raise SystemExit(msg) -def log_system_info() -> None: - def parse_os_release(path: Path) -> dict[str, str]: - release_dict = {} - try: - with path.open() as f: - for line in f: - line = line.strip() - if line.startswith("#"): - continue - try: - key, val = line.split("=") - release_dict[key] = val.strip("\"") - except ValueError: - logging.error(f"Unable to parse line: {line}") - except OSError: - logging.error(f"Could not read {path.as_uri()}") - return release_dict +def parse_os_release(path: Path) -> dict[str, str]: + release_dict = {} + try: + with path.open() as f: + for line in f: + line = line.strip() + if not line or line.startswith("#"): + continue + key, sep, val = line.partition("=") + if not sep: + logging.error(f"Unable to parse line: {line}") + continue + release_dict[key] = val.strip("\"") + except OSError: + logging.error(f"Could not read {path.as_uri()}") + return release_dict + +def log_system_info() -> None: try: complete = subprocess.run( [BLUETOOTHD_PATH, "-v"], diff --git a/test/test_functions.py b/test/test_functions.py index b21130590..4b53008c9 100644 --- a/test/test_functions.py +++ b/test/test_functions.py @@ -1,6 +1,8 @@ +import tempfile +from pathlib import Path from unittest import TestCase -from blueman.Functions import adapter_path_to_name, format_bytes +from blueman.Functions import adapter_path_to_name, format_bytes, parse_os_release class TestFormatBytes(TestCase): @@ -66,3 +68,37 @@ def test_greedy_picks_last_occurrence(self) -> None: def test_embedded_in_other_text(self) -> None: self.assertEqual(adapter_path_to_name("prefix-hci99-suffix"), "hci99") + + +class TestParseOsRelease(TestCase): + def _parse(self, content: str) -> dict[str, str]: + with tempfile.NamedTemporaryFile("w", suffix="os-release", delete=False) as f: + f.write(content) + path = Path(f.name) + try: + return parse_os_release(path) + finally: + path.unlink() + + def test_basic_keys(self) -> None: + result = self._parse('NAME="Foo"\nVERSION="1.0"\n') + self.assertEqual(result, {"NAME": "Foo", "VERSION": "1.0"}) + + def test_value_with_equals_sign(self) -> None: + # Regression (data-3): a quoted value containing "=" must survive. + result = self._parse('PRETTY_NAME="Name=Variant"\n') + self.assertEqual(result["PRETTY_NAME"], "Name=Variant") + + def test_unquoted_value(self) -> None: + self.assertEqual(self._parse("ID=arch\n"), {"ID": "arch"}) + + def test_comments_and_blank_lines_skipped(self) -> None: + result = self._parse("# comment\n\n \nID=foo\n") + self.assertEqual(result, {"ID": "foo"}) + + def test_line_without_equals_skipped(self) -> None: + result = self._parse("garbage line\nID=foo\n") + self.assertEqual(result, {"ID": "foo"}) + + def test_missing_file_returns_empty(self) -> None: + self.assertEqual(parse_os_release(Path("/nonexistent/os-release")), {}) From 23a0c68a7d82318f64252034553620a3993d9477 Mon Sep 17 00:00:00 2001 From: Geraldo Netto Date: Fri, 19 Jun 2026 19:30:30 +0200 Subject: [PATCH 20/42] refactor(functions): resolve have() via shutil.which (dep-8, plat-1) have() hand-rolled a PATH scan with a hardcoded ":/sbin:/usr/sbin" suffix and checked os.access(path, os.EX_OK) -- os.EX_OK is 0, i.e. F_OK, so it only confirmed existence, not executability. Delegate the lookup to shutil.which, which honours the executable bit, and append the sbin directories to the search path only when they are not already present. Add tests covering found/not-found, sbin-dir augmentation, and de-duplication. Co-Authored-By: Claude Opus 4.8 (1M context) --- TODO.md | 2 -- blueman/Functions.py | 15 ++++++++++----- test/test_functions.py | 33 ++++++++++++++++++++++++++++++++- 3 files changed, 42 insertions(+), 8 deletions(-) diff --git a/TODO.md b/TODO.md index c36ec8444..f1ece925b 100644 --- a/TODO.md +++ b/TODO.md @@ -222,7 +222,6 @@ _(none open)_ | id | status | effort | description | notes | |----|--------|--------|-------------|-------| -| dep-8 | open | L | `blueman/Functions.py:210` hardcoded PATH suffix `:/sbin:/usr/sbin` | use `shutil.which()` (dup plat-001) | | dep-5 | open | S | `blueman/main/Applet.py:10` wildcard `from blueman.Functions import *` obscures deps | explicit imports | | dep-7 | open | M | `blueman/main/DNSServerProvider.py:12` hardcoded `RESOLVER_PATH="/etc/resolv.conf"` | configurable + DNSProvider abstraction (dup cfg-004) | | dep-2 | open | L | `blueman/main/NetworkManager.py:9-12` import-time `gi.require_version` raises if NM bindings missing | move into lazy init try-block | @@ -282,7 +281,6 @@ _(none open)_ | id | status | effort | description | notes | |----|--------|--------|-------------|-------| -| plat-1 | open | M | `blueman/Functions.py:210` hardcoded `:/sbin:/usr/sbin` fallback (dup dep-8) | `shutil.which()` | | plat-8 | open | S | `blueman/Functions.py:256` hardcoded `/dev/log` syslog address | platform detect / fallback to stderr | | plat-9 | open | M | `blueman/main/NetConf.py:24` `/proc/{pid}` cmdline check, Linux-only | abstract proc access | | plat-4 | open | L | `blueman/main/NetConf.py:255` hardcoded `/proc/sys/net/ipv4` IP-forward, Linux-only | abstract, no non-Linux fallback | diff --git a/blueman/Functions.py b/blueman/Functions.py index 7e2588b1c..eabf931e6 100644 --- a/blueman/Functions.py +++ b/blueman/Functions.py @@ -23,6 +23,7 @@ import re import os import pathlib +import shutil import sys import errno from gettext import gettext as _ @@ -207,11 +208,15 @@ def create_menuitem( def have(t: str) -> pathlib.Path | None: - pathstr = os.environ['PATH'] + ':/sbin:/usr/sbin' - for path in [pathlib.Path(p, t) for p in pathstr.split(":")]: - if path.exists() and os.access(path, os.EX_OK): - return path - return None + search_path = os.environ.get("PATH", os.defpath) + # System binaries such as dhcp clients commonly live in sbin dirs that are + # not on a desktop session's PATH; append them if missing. + for sbin in ("/sbin", "/usr/sbin"): + if sbin not in search_path.split(os.pathsep): + search_path += os.pathsep + sbin + + found = shutil.which(t, path=search_path) + return pathlib.Path(found) if found else None def set_proc_title(name: str | None = None) -> int: diff --git a/test/test_functions.py b/test/test_functions.py index 4b53008c9..c3750c91a 100644 --- a/test/test_functions.py +++ b/test/test_functions.py @@ -1,8 +1,10 @@ +import os import tempfile from pathlib import Path from unittest import TestCase +from unittest.mock import patch -from blueman.Functions import adapter_path_to_name, format_bytes, parse_os_release +from blueman.Functions import adapter_path_to_name, format_bytes, have, parse_os_release class TestFormatBytes(TestCase): @@ -102,3 +104,32 @@ def test_line_without_equals_skipped(self) -> None: def test_missing_file_returns_empty(self) -> None: self.assertEqual(parse_os_release(Path("/nonexistent/os-release")), {}) + + +class TestHave(TestCase): + @patch("blueman.Functions.shutil.which", return_value="/usr/bin/dhclient") + def test_found_returns_path(self, which: object) -> None: + result = have("dhclient") + self.assertEqual(result, Path("/usr/bin/dhclient")) + + @patch("blueman.Functions.shutil.which", return_value=None) + def test_not_found_returns_none(self, which: object) -> None: + self.assertIsNone(have("nonexistent-binary")) + + @patch.dict(os.environ, {"PATH": "/usr/bin"}, clear=True) + @patch("blueman.Functions.shutil.which", return_value=None) + def test_appends_sbin_dirs(self, which: object) -> None: + have("dhcpcd") + used_path = which.call_args.kwargs["path"] + parts = used_path.split(os.pathsep) + self.assertIn("/usr/bin", parts) + self.assertIn("/sbin", parts) + self.assertIn("/usr/sbin", parts) + + @patch.dict(os.environ, {"PATH": "/sbin:/usr/sbin:/usr/bin"}, clear=True) + @patch("blueman.Functions.shutil.which", return_value=None) + def test_does_not_duplicate_existing_sbin(self, which: object) -> None: + have("udhcpc") + used_path = which.call_args.kwargs["path"] + self.assertEqual(used_path.split(os.pathsep).count("/sbin"), 1) + self.assertEqual(used_path.split(os.pathsep).count("/usr/sbin"), 1) From 7c8bef666a67e9754b410c53c1c204b8d4fbd3aa Mon Sep 17 00:00:00 2001 From: Geraldo Netto Date: Fri, 19 Jun 2026 19:31:55 +0200 Subject: [PATCH 21/42] fix(functions): degrade gracefully when /dev/log is missing (plat-8) create_logger unconditionally constructed SysLogHandler(address="/dev/log"), which raises on platforms and minimal containers without that socket, taking down the whole process at logger setup. Guard the handler construction and, on OSError, log a warning and keep the basicConfig stderr handler instead. Add tests for the available, unavailable, and syslog-disabled paths. Co-Authored-By: Claude Opus 4.8 (1M context) --- TODO.md | 1 - blueman/Functions.py | 14 ++++++++++---- test/test_functions.py | 38 ++++++++++++++++++++++++++++++++++++-- 3 files changed, 46 insertions(+), 7 deletions(-) diff --git a/TODO.md b/TODO.md index f1ece925b..40ff6b439 100644 --- a/TODO.md +++ b/TODO.md @@ -281,7 +281,6 @@ _(none open)_ | id | status | effort | description | notes | |----|--------|--------|-------------|-------| -| plat-8 | open | S | `blueman/Functions.py:256` hardcoded `/dev/log` syslog address | platform detect / fallback to stderr | | plat-9 | open | M | `blueman/main/NetConf.py:24` `/proc/{pid}` cmdline check, Linux-only | abstract proc access | | plat-4 | open | L | `blueman/main/NetConf.py:255` hardcoded `/proc/sys/net/ipv4` IP-forward, Linux-only | abstract, no non-Linux fallback | | plat-3 | open | M | `blueman/main/NetConf.py:268,276` hardcoded `/sbin/iptables` | dynamic lookup | diff --git a/blueman/Functions.py b/blueman/Functions.py index eabf931e6..c6a4a6cf1 100644 --- a/blueman/Functions.py +++ b/blueman/Functions.py @@ -258,10 +258,16 @@ def create_logger( logger.name = name if syslog: - syslog_handler = logging.handlers.SysLogHandler(address="/dev/log") - syslog_formatter = logging.Formatter(syslog_logger_format) - syslog_handler.setFormatter(syslog_formatter) - logger.addHandler(syslog_handler) + try: + syslog_handler = logging.handlers.SysLogHandler(address="/dev/log") + except OSError: + # /dev/log is absent on non-Linux platforms and minimal containers; + # the basicConfig stderr handler still provides logging. + logging.warning("Syslog socket /dev/log unavailable, logging to stderr only") + else: + syslog_formatter = logging.Formatter(syslog_logger_format) + syslog_handler.setFormatter(syslog_formatter) + logger.addHandler(syslog_handler) return logger diff --git a/test/test_functions.py b/test/test_functions.py index c3750c91a..7ac1ba437 100644 --- a/test/test_functions.py +++ b/test/test_functions.py @@ -1,10 +1,11 @@ +import logging import os import tempfile from pathlib import Path from unittest import TestCase -from unittest.mock import patch +from unittest.mock import MagicMock, patch -from blueman.Functions import adapter_path_to_name, format_bytes, have, parse_os_release +from blueman.Functions import adapter_path_to_name, create_logger, format_bytes, have, parse_os_release class TestFormatBytes(TestCase): @@ -133,3 +134,36 @@ def test_does_not_duplicate_existing_sbin(self, which: object) -> None: used_path = which.call_args.kwargs["path"] self.assertEqual(used_path.split(os.pathsep).count("/sbin"), 1) self.assertEqual(used_path.split(os.pathsep).count("/usr/sbin"), 1) + + +class TestCreateLogger(TestCase): + def setUp(self) -> None: + root = logging.getLogger(None) + self._saved_handlers = root.handlers[:] + self._saved_name = root.name + self._saved_level = root.level + + def tearDown(self) -> None: + root = logging.getLogger(None) + root.handlers[:] = self._saved_handlers + root.name = self._saved_name + root.level = self._saved_level + + @patch("blueman.Functions.logging.handlers.SysLogHandler") + def test_syslog_handler_added_when_available(self, handler_cls: MagicMock) -> None: + handler_cls.return_value = logging.NullHandler() + create_logger(logging.INFO, "blueman-test", syslog=True) + handler_cls.assert_called_once_with(address="/dev/log") + + @patch("blueman.Functions.logging.handlers.SysLogHandler", side_effect=OSError) + def test_falls_back_to_stderr_when_dev_log_missing(self, handler_cls: MagicMock) -> None: + # Must not raise when /dev/log is unavailable, and add no syslog handler. + before = len(logging.getLogger(None).handlers) + logger = create_logger(logging.INFO, "blueman-test", syslog=True) + handler_cls.assert_called_once_with(address="/dev/log") + self.assertLessEqual(len(logger.handlers), before + 1) # only basicConfig's stderr handler, if any + + @patch("blueman.Functions.logging.handlers.SysLogHandler") + def test_no_syslog_handler_when_disabled(self, handler_cls: MagicMock) -> None: + create_logger(logging.INFO, "blueman-test", syslog=False) + handler_cls.assert_not_called() From c27650dfaf9149dafe526170e3533ddb37750891 Mon Sep 17 00:00:00 2001 From: Geraldo Netto Date: Fri, 19 Jun 2026 19:32:51 +0200 Subject: [PATCH 22/42] feat(functions): validate --loglevel with choices and help (cli-1) --loglevel had no choices and no help, so a typo like `--loglevel verbose` silently coerced to WARNING across all entry points, leaving users with quieter logs than intended and no error. Add case-insensitive choices (debug/info/warning/error/critical) via type=str.lower plus help text, so argparse rejects unknown values clearly. Existing consumers compare args.LEVEL.upper(), which is unaffected. Add tests for default, lowercasing, rejection, help/choices metadata, the syslog flag, and disabling loglevel. Co-Authored-By: Claude Opus 4.8 (1M context) --- TODO.md | 1 - blueman/Functions.py | 5 ++++- test/test_functions.py | 42 +++++++++++++++++++++++++++++++++++++++++- 3 files changed, 45 insertions(+), 3 deletions(-) diff --git a/TODO.md b/TODO.md index 40ff6b439..ff16c0b3d 100644 --- a/TODO.md +++ b/TODO.md @@ -422,7 +422,6 @@ _(none open)_ |----|--------|--------|-------------|-------| | cli-3 | open | S | `apps/blueman-adapters.in:24` `--socket-id` (XEmbed) is undocumented — no `help=`, absent from `data/man/blueman-adapters.1` — yet plumbed into `BluemanAdapters(... socket_id)`. | Add `help=` text and document, or mark intentionally internal. | | cli-2 | open | S | `apps/blueman-mechanism.in:38,57-58` `-d/--debug` only logs "Enabled verbose output" and does nothing else; the level is driven by `--loglevel`, so `--debug` does NOT enable debug logging — a dead/misleading flag. | Make `--debug` set `log_level = logging.DEBUG`, or remove it and document `--loglevel debug`. | -| cli-1 | open | S | `blueman/Functions.py:273` `--loglevel` has no `help`, no `choices`; an unrecognized value (e.g. `--loglevel verbose`) silently coerces to WARNING with no error, so users get quieter logs than expected. Applies to all 7 entry points using `create_parser`. | Add `choices=[debug,info,warning,error,critical]` (case-insensitive) and `help=`; argparse then rejects bad values clearly. | | cli-6 | open | S | `data/man/blueman-adapters.1:1` `.TH` header is `BLUEMAN-SENDTO` (copy-paste), so `man blueman-adapters` shows the wrong title/section. | Fix `.TH` to `BLUEMAN-ADAPTERS`. | | cli-7 | open | S | `data/man/blueman-adapters.1` says the `adapter` arg selects the initial tab in `hci0` form, but `blueman/main/Adapter.py:74-76` matches tab keys and derives the page via `int(name[3:])`; any non-`hciN` value is silently dropped, and the positional has no CLI `help=` (`apps/blueman-adapters.in:25`). | Add `help=` to the positional stating the `hciN` format/behavior; align the man page. | | cli-5 | open | S | `data/man/blueman-applet.1`, `blueman-manager.1`, `blueman-services.1` state "There are no options.", but each accepts `--loglevel`/`--syslog` via `create_parser`. Docs contradict behavior. | Replace "no options" with the actual flags. | diff --git a/blueman/Functions.py b/blueman/Functions.py index c6a4a6cf1..28e56ce20 100644 --- a/blueman/Functions.py +++ b/blueman/Functions.py @@ -281,7 +281,10 @@ def create_parser( parser = argparse.ArgumentParser() if loglevel: - parser.add_argument("--loglevel", dest="LEVEL", default="warning") + parser.add_argument( + "--loglevel", dest="LEVEL", default="warning", type=str.lower, + choices=["debug", "info", "warning", "error", "critical"], + help="Logging verbosity (case-insensitive); defaults to warning.") if syslog: parser.add_argument("--syslog", dest="syslog", action="store_true") diff --git a/test/test_functions.py b/test/test_functions.py index 7ac1ba437..a450d17f1 100644 --- a/test/test_functions.py +++ b/test/test_functions.py @@ -5,7 +5,14 @@ from unittest import TestCase from unittest.mock import MagicMock, patch -from blueman.Functions import adapter_path_to_name, create_logger, format_bytes, have, parse_os_release +from blueman.Functions import ( + adapter_path_to_name, + create_logger, + create_parser, + format_bytes, + have, + parse_os_release, +) class TestFormatBytes(TestCase): @@ -167,3 +174,36 @@ def test_falls_back_to_stderr_when_dev_log_missing(self, handler_cls: MagicMock) def test_no_syslog_handler_when_disabled(self, handler_cls: MagicMock) -> None: create_logger(logging.INFO, "blueman-test", syslog=False) handler_cls.assert_not_called() + + +class TestCreateParser(TestCase): + def test_default_loglevel(self) -> None: + args = create_parser().parse_args([]) + self.assertEqual(args.LEVEL, "warning") + + def test_valid_loglevel_lowercased(self) -> None: + args = create_parser().parse_args(["--loglevel", "DEBUG"]) + self.assertEqual(args.LEVEL, "debug") + + def test_invalid_loglevel_rejected(self) -> None: + # argparse exits with status 2 on an out-of-choices value. + with self.assertRaises(SystemExit): + create_parser().parse_args(["--loglevel", "verbose"]) + + def test_loglevel_has_help(self) -> None: + for action in create_parser()._actions: + if action.dest == "LEVEL": + self.assertTrue(action.help) + self.assertEqual( + set(action.choices), {"debug", "info", "warning", "error", "critical"}) + break + else: + self.fail("--loglevel argument not registered") + + def test_syslog_flag(self) -> None: + self.assertTrue(create_parser().parse_args(["--syslog"]).syslog) + self.assertFalse(create_parser().parse_args([]).syslog) + + def test_loglevel_can_be_disabled(self) -> None: + parser = create_parser(loglevel=False) + self.assertFalse(any(a.dest == "LEVEL" for a in parser._actions)) From 66c0417bfccf5e2bec8315a994a963861a64f3ad Mon Sep 17 00:00:00 2001 From: Geraldo Netto Date: Fri, 19 Jun 2026 19:33:42 +0200 Subject: [PATCH 23/42] fix(functions): log instead of print in check_bluetooth_status (obs-1) The "applet needs to be running" and "Failed to enable bluetooth" messages went to stdout via print(), bypassing the logging configuration and leaving no record in syslog/journald. Route both through logging.error (with exc_info on the DBusProxyFailed path, replacing the redundant logging.exception). Add tests for the missing-applet exit path and the no-PowerManager early return. Co-Authored-By: Claude Opus 4.8 (1M context) --- TODO.md | 1 - blueman/Functions.py | 7 +++---- test/test_functions.py | 22 ++++++++++++++++++++++ 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/TODO.md b/TODO.md index ff16c0b3d..53b7e7467 100644 --- a/TODO.md +++ b/TODO.md @@ -132,7 +132,6 @@ Status: `open`, `in-progress`, `blocked`. Effort: `S` (≤1h), `M` (half-day), ` | id | status | effort | description | notes | |----|--------|--------|-------------|-------| | obs-4 | open | S | `blueman/bluez/obex/Manager.py:51,59,68,75` `logging.info(object_path)` lacks event/context | prefix with event name | -| obs-1 | open | S | `blueman/Functions.py:64,87` `print()` in `check_bluetooth_status()` exception/fallback | `logging.error(..., exc_info=True)` | | obs-10 | open | S | `blueman/gui/GenericList.py:116` silent `ValueError` from `get_iter` | `logging.debug` invalid path | | obs-9 | open | S | `blueman/gui/GtkAnimation.py:79` silent `ZeroDivisionError` on duration=0 | `logging.debug("Animation duration zero")` | | obs-7 | open | S | `blueman/gui/Notification.py:169` silent `ValueError` on notification hints | `logging.debug` unsupported hint | diff --git a/blueman/Functions.py b/blueman/Functions.py index 28e56ce20..38904afee 100644 --- a/blueman/Functions.py +++ b/blueman/Functions.py @@ -60,9 +60,8 @@ def check_bluetooth_status(message: str, exitfunc: Callable[[], Any]) -> None: try: applet = AppletService() powermanager = AppletPowerManagerService() - except DBusProxyFailed as e: - logging.exception(e) - print("Blueman applet needs to be running") + except DBusProxyFailed: + logging.error("Blueman applet needs to be running", exc_info=True) exitfunc() return @@ -85,7 +84,7 @@ def check_bluetooth_status(message: str, exitfunc: Callable[[], Any]) -> None: powermanager.set_bluetooth_status(True) if not powermanager.get_bluetooth_status(): - print('Failed to enable bluetooth') + logging.error("Failed to enable bluetooth") exitfunc() diff --git a/test/test_functions.py b/test/test_functions.py index a450d17f1..ee8be0718 100644 --- a/test/test_functions.py +++ b/test/test_functions.py @@ -7,12 +7,14 @@ from blueman.Functions import ( adapter_path_to_name, + check_bluetooth_status, create_logger, create_parser, format_bytes, have, parse_os_release, ) +from blueman.main.DBusProxies import DBusProxyFailed class TestFormatBytes(TestCase): @@ -207,3 +209,23 @@ def test_syslog_flag(self) -> None: def test_loglevel_can_be_disabled(self) -> None: parser = create_parser(loglevel=False) self.assertFalse(any(a.dest == "LEVEL" for a in parser._actions)) + + +class TestCheckBluetoothStatus(TestCase): + @patch("blueman.Functions.AppletService", side_effect=DBusProxyFailed) + def test_logs_and_exits_when_applet_missing(self, applet: MagicMock) -> None: + exitfunc = MagicMock() + with self.assertLogs(level=logging.ERROR) as logs: + check_bluetooth_status("msg", exitfunc) + exitfunc.assert_called_once_with() + self.assertTrue(any("applet needs to be running" in m for m in logs.output)) + + @patch("blueman.Functions.AppletPowerManagerService") + @patch("blueman.Functions.AppletService") + def test_returns_when_powermanager_plugin_absent( + self, applet: MagicMock, power: MagicMock + ) -> None: + applet.return_value.QueryPlugins.return_value = [] + exitfunc = MagicMock() + check_bluetooth_status("msg", exitfunc) + exitfunc.assert_not_called() From 85ceebd698399258ed5bdb77dbe0dec874395ed3 Mon Sep 17 00:00:00 2001 From: Geraldo Netto Date: Fri, 19 Jun 2026 19:34:56 +0200 Subject: [PATCH 24/42] fix(functions): guard set_proc_title for non-Linux platforms (leg-5) set_proc_title unconditionally loaded libc.so.6 and called prctl(PR_SET_NAME), both Linux/glibc specific. On other platforms the LoadLibrary or prctl lookup raises and crashes process startup. Return early as a no-op on non-Linux, wrap the libc/prctl access in try/except returning -1 on failure, and document the behaviour in the docstring. Add tests for the non-Linux no-op, the Linux prctl path, and the libc-unavailable failure. Co-Authored-By: Claude Opus 4.8 (1M context) --- TODO.md | 1 - blueman/Functions.py | 22 +++++++++++++++++----- test/test_functions.py | 23 +++++++++++++++++++++++ 3 files changed, 40 insertions(+), 6 deletions(-) diff --git a/TODO.md b/TODO.md index 53b7e7467..173cbbf09 100644 --- a/TODO.md +++ b/TODO.md @@ -257,7 +257,6 @@ _(none open)_ | leg-7 | open | S | `blueman/bluez/Device.py:22,29` `# type: ignore` on connect/disconnect masking signature mismatch | resolve override signatures | | leg-8 | open | S | `blueman/bluez/Network.py:17,26` `# type: ignore` on connect/disconnect | resolve signatures | | leg-2 | open | M | `blueman/Functions.py:189,200` deprecated `Gtk.ImageMenuItem` | migrate to `Gtk.MenuItem` + image | -| leg-5 | open | S | `blueman/Functions.py:226` raw ctypes `libc.prctl(15,...)` for proc title | document or guard non-Linux (relates dead-1) | | leg-1 | open | M | `blueman/Functions.py:78` deprecated `Gtk.Dialog.run()`/`.destroy()` blocking pattern | non-blocking response-signal pattern | | leg-6 | open | S | `blueman/gui/GtkAnimation.py:200` FIXME `Gtk.render_background()` wrong colors | investigate + fix or document | | leg-4 | open | M | `blueman/gui/manager/ManagerMenu.py:45,47` `Gtk.ImageMenuItem` in manager UI | migrate to `Gtk.MenuItem` | diff --git a/blueman/Functions.py b/blueman/Functions.py index 38904afee..dbb026cb9 100644 --- a/blueman/Functions.py +++ b/blueman/Functions.py @@ -219,15 +219,27 @@ def have(t: str) -> pathlib.Path | None: def set_proc_title(name: str | None = None) -> int: - """Set the process title""" + """Set the process title via ``prctl(PR_SET_NAME)``. + + Only Linux exposes ``prctl`` through glibc; on other platforms this is a + no-op returning 0. Returns -1 if libc/prctl cannot be reached. + """ if not name: name = pathlib.Path(sys.argv[0]).name - libc = cdll.LoadLibrary('libc.so.6') - buff = create_string_buffer(len(name) + 1) - buff.value = name.encode("UTF-8") - ret: int = libc.prctl(15, byref(buff), 0, 0, 0) + if not sys.platform.startswith("linux"): + logging.debug("set_proc_title is only supported on Linux") + return 0 + + try: + libc = cdll.LoadLibrary('libc.so.6') + buff = create_string_buffer(len(name) + 1) + buff.value = name.encode("UTF-8") + ret: int = libc.prctl(15, byref(buff), 0, 0, 0) + except (OSError, AttributeError): + logging.error("Failed to set process title", exc_info=True) + return -1 if ret != 0: logging.error("Failed to set process title") diff --git a/test/test_functions.py b/test/test_functions.py index ee8be0718..904452f27 100644 --- a/test/test_functions.py +++ b/test/test_functions.py @@ -13,6 +13,7 @@ format_bytes, have, parse_os_release, + set_proc_title, ) from blueman.main.DBusProxies import DBusProxyFailed @@ -229,3 +230,25 @@ def test_returns_when_powermanager_plugin_absent( exitfunc = MagicMock() check_bluetooth_status("msg", exitfunc) exitfunc.assert_not_called() + + +class TestSetProcTitle(TestCase): + @patch("blueman.Functions.sys.platform", "darwin") + @patch("blueman.Functions.cdll") + def test_noop_on_non_linux(self, cdll: MagicMock) -> None: + self.assertEqual(set_proc_title("blueman"), 0) + cdll.LoadLibrary.assert_not_called() + + @patch("blueman.Functions.sys.platform", "linux") + @patch("blueman.Functions.cdll") + def test_calls_prctl_on_linux(self, cdll: MagicMock) -> None: + cdll.LoadLibrary.return_value.prctl.return_value = 0 + self.assertEqual(set_proc_title("blueman"), 0) + cdll.LoadLibrary.return_value.prctl.assert_called_once() + + @patch("blueman.Functions.sys.platform", "linux") + @patch("blueman.Functions.cdll") + def test_returns_minus_one_when_libc_unavailable(self, cdll: MagicMock) -> None: + cdll.LoadLibrary.side_effect = OSError + with self.assertLogs(level=logging.ERROR): + self.assertEqual(set_proc_title("blueman"), -1) From dc91cba13caccba6e853884364c345544efe770c Mon Sep 17 00:00:00 2001 From: Geraldo Netto Date: Fri, 19 Jun 2026 19:37:00 +0200 Subject: [PATCH 25/42] feat(functions): add argv contract to launch() (cmd-2, api-2) launch() accepted only a full command-line string, so callers embedded options directly in cmd (e.g. Notes.py built "blueman-sendto --delete --device={addr}"), making argument boundaries depend on GLib command-line parsing rather than an argv contract. Add an optional args iterable: when provided, the program token and each argument are shell-quoted individually via GLib.shell_quote, so spaces, quotes, and shell metacharacters can never cross argument boundaries. The legacy string form still works when args is omitted (deprecated). Migrate the Notes.py send-note call site to the argv form. Add tests for the legacy form, argv quoting, metacharacter neutralization, the launch result, and path-to-GFile conversion. Co-Authored-By: Claude Opus 4.8 (1M context) --- TODO.md | 2 -- blueman/Functions.py | 21 ++++++++++-- blueman/plugins/manager/Notes.py | 2 +- test/test_functions.py | 57 ++++++++++++++++++++++++++++++++ 4 files changed, 77 insertions(+), 5 deletions(-) diff --git a/TODO.md b/TODO.md index 173cbbf09..2e41c5265 100644 --- a/TODO.md +++ b/TODO.md @@ -16,7 +16,6 @@ Status: `open`, `in-progress`, `blocked`. Effort: `S` (≤1h), `M` (half-day), ` | id | status | effort | description | notes | |----|--------|--------|-------------|-------| -| cmd-2 | open | M | `blueman/Functions.py:120-134` exposes `launch(cmd: str, ...)` as a command-line string API and sends it to `Gio.AppInfo.create_from_commandline`. Callers such as `blueman/plugins/manager/Notes.py:35` embed options in `cmd`, so argument boundaries depend on string parsing instead of an argv contract. | Replace or supplement `launch` with an argv-based helper (`program`, `args`, `files`) and migrate command-building call sites. Keep `system=True` uses explicit and reviewed. | | cmd-1 | open | S | `sendto/blueman_sendto.py.in:20-28` builds a shell-style command line by wrapping file paths in double quotes and passing the joined string to `Gio.AppInfo.create_from_commandline`. A filename containing quotes or command separators can break argument boundaries when launched through the desktop shell parser. | Build a `Gio.AppInfo`/`Gio.Subprocess` invocation from an argv vector, or escape with GLib shell-quoting for every path. Add a regression test with spaces, quotes, and semicolons in filenames. Cross-ref test-1. | ## data integrity @@ -83,7 +82,6 @@ Status: `open`, `in-progress`, `blocked`. Effort: `S` (≤1h), `M` (half-day), ` | id | status | effort | description | notes | |----|--------|--------|-------------|-------| -| api-2 | open | M | `blueman/Functions.py:133` uses `Gio.AppInfo.create_from_commandline` in a shared helper, but its API accepts one opaque command string plus separate `paths`. This makes it hard for callers to express portable argv semantics or safely pass non-file options without depending on GLib command-line parsing. | Define a stable internal process-launch contract around argv and file arguments; deprecate the string form after migrating users. Cross-ref cmd-2. | | api-1 | open | S | `blueman/main/DBusProxies.py:91` exposes the Python proxy method as `dchp_client`, while the DBus method and interface are `DhcpClient`. The typo is now part of the local Python call surface and makes future refactors/API docs error-prone. | Add correctly spelled `dhcp_client()` as the public method, keep `dchp_client()` as a deprecated alias until callers/tests migrate, then remove the alias in a later cleanup. | ## architecture/modularity/SOLID diff --git a/blueman/Functions.py b/blueman/Functions.py index dbb026cb9..1263ecda8 100644 --- a/blueman/Functions.py +++ b/blueman/Functions.py @@ -50,6 +50,7 @@ from gi.repository import Gdk from gi.repository import GdkPixbuf from gi.repository import Gio +from gi.repository import GLib __all__ = ["check_bluetooth_status", "launch", "setup_icon_path", "adapter_path_to_name", "e_", "bmexit", "format_bytes", "create_menuitem", "have", "set_proc_title", "create_logger", "create_parser", "open_rfcomm", @@ -95,8 +96,17 @@ def launch( icon_name: str | None = None, name: str = "blueman", sn: bool = True, + args: Iterable[str] | None = None, ) -> bool: - """Launch a gui app with startup notification""" + """Launch a gui app with startup notification. + + ``cmd`` is the program to run. Pass options as an ``args`` iterable rather + than embedding them in ``cmd``: each program token and argument is then + shell-quoted individually so argument boundaries follow an argv contract + instead of GLib command-line parsing. The legacy form, where ``cmd`` itself + is a full command line, is still accepted when ``args`` is omitted but is + deprecated. + """ context = None gtktimestamp = Gtk.get_current_event_time() if gtktimestamp == 0: @@ -122,6 +132,13 @@ def launch( else: command = pathlib.Path(cmd).expanduser() + if args is not None: + # argv contract: quote each token so spaces/quotes/separators in + # arguments cannot cross argument boundaries. + command_line = " ".join(GLib.shell_quote(arg) for arg in (command.as_posix(), *args)) + else: + command_line = command.as_posix() + if paths: files: list[Gio.File] | None = [Gio.File.new_for_commandline_arg(p) for p in paths] else: @@ -130,7 +147,7 @@ def launch( if icon_name and context is not None: context.set_icon_name(icon_name) - appinfo = Gio.AppInfo.create_from_commandline(command.as_posix(), name, flags) + appinfo = Gio.AppInfo.create_from_commandline(command_line, name, flags) launched: bool = appinfo.launch(files, context) if not launched: diff --git a/blueman/plugins/manager/Notes.py b/blueman/plugins/manager/Notes.py index e37149c69..3deb3fc9d 100644 --- a/blueman/plugins/manager/Notes.py +++ b/blueman/plugins/manager/Notes.py @@ -32,7 +32,7 @@ def send_note_cb(dialog: Gtk.Dialog, response_id: int, device_address: str, text tempfile = NamedTemporaryFile(suffix='.vnt', prefix='note', delete=False) tempfile.write(data.encode('utf-8')) tempfile.close() - launch(f"blueman-sendto --delete --device={device_address}", paths=[tempfile.name]) + launch("blueman-sendto", args=["--delete", f"--device={device_address}"], paths=[tempfile.name]) def send_note(device: Device, parent: Gtk.ApplicationWindow) -> None: diff --git a/test/test_functions.py b/test/test_functions.py index 904452f27..a5e47c8c5 100644 --- a/test/test_functions.py +++ b/test/test_functions.py @@ -5,6 +5,9 @@ from unittest import TestCase from unittest.mock import MagicMock, patch +from gi.repository import GLib + +from blueman.Constants import BIN_DIR from blueman.Functions import ( adapter_path_to_name, check_bluetooth_status, @@ -12,6 +15,7 @@ create_parser, format_bytes, have, + launch, parse_os_release, set_proc_title, ) @@ -252,3 +256,56 @@ def test_returns_minus_one_when_libc_unavailable(self, cdll: MagicMock) -> None: cdll.LoadLibrary.side_effect = OSError with self.assertLogs(level=logging.ERROR): self.assertEqual(set_proc_title("blueman"), -1) + + +@patch("blueman.Functions.Gtk.get_current_event_time", return_value=0) +@patch("blueman.Functions.Gio.File.new_for_commandline_arg") +@patch("blueman.Functions.Gio.AppInfo.create_from_commandline") +class TestLaunch(TestCase): + @staticmethod + def _command_line(create: MagicMock) -> str: + return create.call_args.args[0] + + def test_legacy_string_form_unquoted( + self, create: MagicMock, _new_file: MagicMock, _evt: MagicMock + ) -> None: + create.return_value.launch.return_value = True + self.assertTrue(launch("blueman-services")) + self.assertEqual(self._command_line(create), (BIN_DIR / "blueman-services").as_posix()) + + def test_argv_form_quotes_each_token( + self, create: MagicMock, _new_file: MagicMock, _evt: MagicMock + ) -> None: + create.return_value.launch.return_value = True + launch("blueman-sendto", args=["--delete", "--device=AA:BB:CC:DD:EE:FF"]) + expected = " ".join( + GLib.shell_quote(t) for t in ( + (BIN_DIR / "blueman-sendto").as_posix(), + "--delete", + "--device=AA:BB:CC:DD:EE:FF", + ) + ) + self.assertEqual(self._command_line(create), expected) + + def test_argv_form_neutralizes_shell_metacharacters( + self, create: MagicMock, _new_file: MagicMock, _evt: MagicMock + ) -> None: + create.return_value.launch.return_value = True + hostile = "foo; rm -rf ~" + launch("blueman-sendto", args=[hostile]) + command_line = self._command_line(create) + # The hostile argument must survive as a single shell-quoted token. + self.assertIn(GLib.shell_quote(hostile), command_line) + + def test_returns_launch_result( + self, create: MagicMock, _new_file: MagicMock, _evt: MagicMock + ) -> None: + create.return_value.launch.return_value = False + self.assertFalse(launch("blueman-services")) + + def test_paths_become_gio_files( + self, create: MagicMock, new_file: MagicMock, _evt: MagicMock + ) -> None: + create.return_value.launch.return_value = True + launch("xdg-open", paths=["/tmp/a", "/tmp/b"], system=True) + self.assertEqual(new_file.call_count, 2) From 87d047254636d34343799a7aa96a876bf39b43b9 Mon Sep 17 00:00:00 2001 From: Geraldo Netto Date: Fri, 19 Jun 2026 19:37:43 +0200 Subject: [PATCH 26/42] docs(functions): document create_logger and create_parser (doc-1) doc-1 framed these as dead helpers to "document or remove", but they are live: every entry point in apps/*.in imports and calls them (the audit missed the .in sources). Document them instead of removing, noting their role and the syslog fallback / shared CLI surface. set_proc_title was already documented alongside the leg-5 platform guard. Co-Authored-By: Claude Opus 4.8 (1M context) --- TODO.md | 1 - blueman/Functions.py | 11 +++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/TODO.md b/TODO.md index 2e41c5265..6a961929d 100644 --- a/TODO.md +++ b/TODO.md @@ -345,7 +345,6 @@ _(none open)_ | id | status | effort | description | notes | |----|--------|--------|-------------|-------| -| doc-1 | open | S | `blueman/Functions.py:217,239,264` dead `set_proc_title`/`create_logger`/`create_parser` in `__all__` (see dead-1..3) | document or remove | | doc-7 | open | M | `blueman/gui/CommonUi.py` `ErrorDialog` lacks docstring; `excp` param undocumented | document exception UI | | doc-3 | open | M | `blueman/gui/DeviceList.py` class docstring missing; signals only in `__gsignals__` | document model + key signals | | doc-6 | open | M | `blueman/gui/DeviceSelectorDialog.py` `DeviceRow`/`DeviceSelector` lack docstrings | document selector pattern | diff --git a/blueman/Functions.py b/blueman/Functions.py index 1263ecda8..687fe05a6 100644 --- a/blueman/Functions.py +++ b/blueman/Functions.py @@ -276,6 +276,12 @@ def create_logger( date_fmt: str | None = None, syslog: bool = False, ) -> logging.Logger: + """Configure and return the root logger for an entry point. + + Used by every blueman binary (see ``apps/*.in``) to set the process-wide + log level, name, and format. With ``syslog`` enabled a SysLogHandler is + added when ``/dev/log`` is available, otherwise logging stays on stderr. + """ if log_format is None: log_format = logger_format if date_fmt is None: @@ -305,6 +311,11 @@ def create_parser( syslog: bool = True, loglevel: bool = True, ) -> argparse.ArgumentParser: + """Build the shared argument parser used by every blueman entry point. + + Adds the common ``--loglevel`` and ``--syslog`` options (each toggleable) + so all binaries in ``apps/*.in`` expose a consistent CLI surface. + """ if parser is None: parser = argparse.ArgumentParser() From 1ad3f688a38cfdd36d7d9922b2a3371c077218d3 Mon Sep 17 00:00:00 2001 From: Geraldo Netto Date: Fri, 19 Jun 2026 19:38:32 +0200 Subject: [PATCH 27/42] docs(todo): reject dead-1/2/3 as false positives set_proc_title, create_logger, and create_parser were flagged as unused with "no production callers", but the audit only scanned *.py and missed apps/*.in: all three are imported and called by every blueman binary. Deleting them would break startup of every executable. Move them from "unused functions" to "Audit picks deliberately rejected" with the evidence and pointers to the real fixes made instead (cli-1, plat-8, leg-5, doc-1). Co-Authored-By: Claude Opus 4.8 (1M context) --- TODO.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/TODO.md b/TODO.md index 6a961929d..055b9504b 100644 --- a/TODO.md +++ b/TODO.md @@ -154,9 +154,8 @@ _(none open)_ | id | status | effort | description | notes | |----|--------|--------|-------------|-------| -| dead-1 | open | S | `blueman/Functions.py:217` `set_proc_title` in `__all__`, no production callers | delete | -| dead-2 | open | S | `blueman/Functions.py:239` `create_logger` in `__all__`, no production callers | delete | -| dead-3 | open | S | `blueman/Functions.py:264` `create_parser` in `__all__`, no production callers | delete | + +_(none open)_ ## STRIDE @@ -463,3 +462,11 @@ _(none open)_ `IconNameChanged`/`VisibilityChanged`/`ToolTipTitleChanged`/`ToolTipTextChanged` (`AppletMenuService` only delivers `MenuChanged` on the Menu interface). Deleting/inlining it would silence tray-icon updates. Kept; guarded by a test in `test/main/test_dbus_proxies.py`. +- **dead-1 / dead-2 / dead-3** (`set_proc_title`/`create_logger`/`create_parser` "no production + callers") — false positives. The audit scanned `*.py` only and missed the entry-point + templates: all three are imported and called by every binary in `apps/*.in` + (`blueman-applet`, `blueman-manager`, `blueman-sendto`, `blueman-adapters`, `blueman-services`, + `blueman-tray`, `blueman-mechanism`, plus `set_proc_title` in `blueman-rfcomm-watcher`). + Deleting them would break startup of every executable. Kept and documented (doc-1); the + related real issues were fixed instead: `create_parser` loglevel validation (cli-1), + `create_logger` syslog fallback (plat-8), and `set_proc_title` non-Linux guard (leg-5). From 5fe6e5b8425e2db703cba7a0b35013a996fc4c8a Mon Sep 17 00:00:00 2001 From: Geraldo Netto Date: Fri, 19 Jun 2026 19:39:35 +0200 Subject: [PATCH 28/42] docs(todo): park leg-1 and leg-2 GTK3 deprecation refactors Both deprecated APIs (Gtk.Dialog.run and Gtk.ImageMenuItem) still function under GTK3. leg-1 is a synchronous startup gate whose async rewrite ripples into every entry point and needs a live main loop; leg-2's create_menuitem is a 20-call-site chokepoint whose replacement changes menu-item child structure. Neither can reach genuine coverage headless or be validated without running the GUI, matching the existing parked-item rationale (perf-12/perf-14). Park for the GTK4 migration. Co-Authored-By: Claude Opus 4.8 (1M context) --- TODO.md | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/TODO.md b/TODO.md index 055b9504b..69ccfeeba 100644 --- a/TODO.md +++ b/TODO.md @@ -253,8 +253,6 @@ _(none open)_ |----|--------|--------|-------------|-------| | leg-7 | open | S | `blueman/bluez/Device.py:22,29` `# type: ignore` on connect/disconnect masking signature mismatch | resolve override signatures | | leg-8 | open | S | `blueman/bluez/Network.py:17,26` `# type: ignore` on connect/disconnect | resolve signatures | -| leg-2 | open | M | `blueman/Functions.py:189,200` deprecated `Gtk.ImageMenuItem` | migrate to `Gtk.MenuItem` + image | -| leg-1 | open | M | `blueman/Functions.py:78` deprecated `Gtk.Dialog.run()`/`.destroy()` blocking pattern | non-blocking response-signal pattern | | leg-6 | open | S | `blueman/gui/GtkAnimation.py:200` FIXME `Gtk.render_background()` wrong colors | investigate + fix or document | | leg-4 | open | M | `blueman/gui/manager/ManagerMenu.py:45,47` `Gtk.ImageMenuItem` in manager UI | migrate to `Gtk.MenuItem` | | leg-9 | open | S | `blueman/main/indicators/GtkStatusIcon.py:44` `# type: ignore` on submenu enumerate | proper overload/typing | @@ -453,6 +451,20 @@ _(none open)_ - **perf-14** (`GtkAnimation` per-animation timer) — the fix ("unify tick clock") is a shared-timer architecture change, not a low-risk edit, and can't reach genuine coverage headless. Park for a dedicated animation-scheduler change. +- **leg-1** (`check_bluetooth_status` `Gtk.Dialog.run()`/`.destroy()`) — `run()` is deprecated + but still supported in GTK3. The function is a synchronous startup gate called by every entry + point before the GLib main loop, and its result (via `exitfunc`) decides whether the app + proceeds. A non-blocking response-signal rewrite must run a main loop and turn every caller + into a continuation — a cross-file behavioural change on the bluetooth-enable path that cannot + reach genuine coverage headless (needs a live dialog + main loop). Park for the GTK4 migration + or a dedicated async-dialog change (overlaps leg-3, ux-6). +- **leg-2** (`create_menuitem` `Gtk.ImageMenuItem`) — deprecated but functional in GTK3. + `create_menuitem` is the single chokepoint returning a `Gtk.ImageMenuItem`, consumed by ~20 + call sites (manager menu, status icon, plugins) that rely on the returned item's child being + an `AccelLabel` with markup. The GTK3-supported replacement (`Gtk.MenuItem` + manual + image/label box) changes the child structure and image handling — a visible UI change only + validatable by running the GUI, so it can't meet the headless coverage bar. Park for the + GTK4 migration alongside leg-4 (`ManagerMenu` `ImageMenuItem`). ## Audit picks deliberately rejected From 844e94c58d364ae66ed7f7597ae88a097c88825a Mon Sep 17 00:00:00 2001 From: Geraldo Netto Date: Fri, 19 Jun 2026 20:29:20 +0200 Subject: [PATCH 29/42] docs(todo): close NetConf.py items implemented in fix/netconf-hardening Remove the 14 NetConf.py findings addressed on the fix/netconf-hardening branch (PR to upstream): obs-2, obs-5, plat-3, plat-9, plat-4, cfg-3, depend-2, depend-1, mem-3, sm-7, wd-7, dist-2, dist-1, dist-4. Co-Authored-By: Claude Opus 4.8 (1M context) --- TODO.md | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/TODO.md b/TODO.md index 69ccfeeba..9c5b9e422 100644 --- a/TODO.md +++ b/TODO.md @@ -135,8 +135,6 @@ Status: `open`, `in-progress`, `blocked`. Effort: `S` (≤1h), `M` (half-day), ` | obs-7 | open | S | `blueman/gui/Notification.py:169` silent `ValueError` on notification hints | `logging.debug` unsupported hint | | obs-11 | open | S | `blueman/main/DNSServerProvider.py:48` `GLib.Error` swallowed | `logging.debug("DNS lookup failed, using fallback")` | | obs-3 | open | S | `blueman/main/Manager.py:62` `print()` in exception handler | `logging.error(..., exc_info=True)` | -| obs-5 | open | S | `blueman/main/NetConf.py:340` silent `pass` on `BridgeException` | `logging.warning(...)` | -| obs-2 | open | S | `blueman/main/NetConf.py:93` `print()` for process termination | `logging.info` with binary/pid context | | obs-6 | open | S | `blueman/main/PluginManager.py:64,123` `LoadException` swallowed silently | `logging.warning` with plugin name | | obs-8 | open | S | `blueman/main/Sendto.py:286` `logging.debug(e.message)` on `GLib.Error` | use `str(e)` | | obs-15 | open | S | `blueman/plugins/applet/AutoConnect.py:116-117` ignores automatic connection failures with `pass`, so failed auto-connect attempts leave no log trail and are hard to diagnose. | Log the target service/device and failure reason at debug or warning level, with rate limiting if needed. | @@ -186,7 +184,6 @@ _(none open)_ |----|--------|--------|-------------|-------| | wd-3 | open | M | `blueman/main/DhcpClient.py:49-50` two `timeout_add` sources, neither stored; `_check_client` keeps polling dead process after `_on_timeout` | store + `source_remove` both on exit (overlaps rob-3) | | wd-8 | open | S | `blueman/main/indicators/StatusNotifierItem.py:32-42` starts a repeating revision-advertisement timeout and discards the source id. The menu service cannot remove the source on unregister/teardown, so it can keep emitting after the tray path is gone. | Store the source id and remove it in an explicit `unregister`/delete path; add a test that teardown removes the source. | -| wd-7 | open | M | `blueman/main/NetConf.py:122,190,235` Dhcpd/Udhcpd/DnsMasq Popen+communicate with no hang supervision | timeout-guard or async | | wd-2 | open | M | `blueman/main/PPPConnection.py:76` `cleanup()` only closes fd, leaves io_watch/timeout sources registered | remove all GLib sources in cleanup (overlaps rel-7) | | wd-1 | open | M | `blueman/main/PPPConnection.py:82-87` pppd spawned with no liveness monitoring; orphan pppd possible on error path | add `GLib.child_watch_add`, kill on cleanup | | wd-6 | open | S | `blueman/plugins/applet/PPPSupport.py:40` synchronous `Popen(['ps'])` blocks main loop until ps returns | use async `Gio.Subprocess` | @@ -198,7 +195,6 @@ _(none open)_ | id | status | effort | description | notes | |----|--------|--------|-------------|-------| | sm-4 | open | M | `blueman/main/DhcpClient.py:39-51` no state flag; `_check_client` + `_on_timeout` both call `querying.remove()` → possible `ValueError` | guard with done-flag, single removal (overlaps wd-3, rob-3) | -| sm-7 | open | M | `blueman/main/NetConf.py:84-101` `DHCPHandler.clean_up()` reads/kills `_pid` with no guard; concurrent calls race / SIGTERM wrong pid | idempotent guard on `_pid` | | sm-5 | open | M | `blueman/main/NetworkManager.py:38,69-70` `_statehandler` asserted not-None but state change can fire before assignment | assign handler before connect / null-guard | | sm-2 | open | M | `blueman/main/PPPConnection.py:181-210` `on_data_ready` can run cleanup while `on_timeout` still pending → double `error-occurred` emit | explicit connection-state guard, single emit | | sm-3 | open | L | `blueman/main/PPPConnection.py:213-224` `on_timeout` closure captures stale `command_id` if `send_commands` reused before fire | bind per-command state / cancel prior timeout | @@ -267,16 +263,12 @@ _(none open)_ | cfg-5 | open | S | `blueman/main/DhcpClient.py:17-20` DHCP client search order hardcoded (dhclient/dhcpcd/udhcpc) | configurable list | | cfg-4 | open | M | `blueman/main/DNSServerProvider.py:12` hardcoded `/etc/resolv.conf`, precedence undocumented | document resolved-first precedence | | cfg-2 | open | M | `blueman/main/MechanismApplication.py:25` idle timeout hardcoded (30s / 9999 dev) keyed on `BLUEMAN_SOURCE` | make configurable, document dev mode (overlaps arch-5) | -| cfg-3 | open | S | `blueman/main/NetConf.py:62,256` hardcoded `/var/run` PID path | use `XDG_RUNTIME_DIR`/`/run` (overlaps dep-11) | | cfg-6 | open | M | `blueman/plugins/services/Network.py` DHCP handler selection (dnsmasq/dhcpd/udhcpd) no user config, undocumented fallback chain | document + expose config | ## platform | id | status | effort | description | notes | |----|--------|--------|-------------|-------| -| plat-9 | open | M | `blueman/main/NetConf.py:24` `/proc/{pid}` cmdline check, Linux-only | abstract proc access | -| plat-4 | open | L | `blueman/main/NetConf.py:255` hardcoded `/proc/sys/net/ipv4` IP-forward, Linux-only | abstract, no non-Linux fallback | -| plat-3 | open | M | `blueman/main/NetConf.py:268,276` hardcoded `/sbin/iptables` | dynamic lookup | | plat-2 | open | M | `blueman/main/PPPConnection.py:83` hardcoded `/usr/sbin/pppd` | dynamic `have()` lookup | | plat-5 | open | M | `blueman/plugins/applet/KillSwitch.py:59,87` hardcoded `/dev/rfkill`, silent fail without it | feature-detect + graceful degrade | | plat-7 | open | M | `blueman/plugins/applet/NetUsage.py:84,87` hardcoded `/sys/class/net` sysfs paths, Linux-only | abstraction + degrade | @@ -373,16 +365,11 @@ _(none open)_ | id | status | effort | description | notes | |----|--------|--------|-------------|-------| | depend-3 | open | S | `blueman/main/DNSServerProvider.py:29,102` `_get_servers_from_systemd_resolved`/`_subscribe_systemd_resolved` call `Gio.bus_get_sync(SYSTEM)` and `DBusProxy.new_for_bus_sync` with no error handling around bus/proxy acquisition (only the later `Get` at :48 is guarded). A briefly-unavailable system bus makes `__init__` raise and the whole provider fail rather than falling back to resolv.conf. | Wrap bus/proxy acquisition in try/except `GLib.Error` and degrade to the resolv.conf path. Cross-ref mem-2. | -| depend-2 | open | S | `blueman/main/NetConf.py:117-119` `DnsMasqHandler._start` appends `--dhcp-option=option:dns-server,{join(dns_servers)}` whenever `localhost:53` is reachable; if `DNSServerProvider.get_servers()` returned empty the option becomes a trailing-comma empty value, which dnsmasq rejects — the start fails entirely instead of degrading to "address but no DNS option". | Only append the `dns-server` option when `dns_servers` is non-empty. | -| depend-1 | open | M | `blueman/main/NetConf.py:64-72` `DHCPHandler.apply` locks `dhcp` after a successful `_start` even when `_read_pid_file` returns `None` (daemon slow to write its pidfile; `DnsMasqHandler`/`UdhcpdHandler` don't reliably yield a pid in time). Later `clean_up` reads a now-absent pidfile, logs "Stale dhcp lockfile" and never kills the orphaned daemon — leaking a DHCP server bound to pan1. | Poll the pidfile with a bounded retry before locking; if no pid is obtained, treat the start as failed and tear down instead of locking. Cross-ref sm-7. | ## distributed systems | id | status | effort | description | notes | |----|--------|--------|-------------|-------| -| dist-2 | open | M | `blueman/main/NetConf.py:253,270,280` `_ipt_rules` is in-memory class state but the iptables rules it tracks live in the kernel and survive a mechanism restart (idle-exit after 30s, `MechanismApplication.py:25`). After re-activation `_ipt_rules` is empty while old MASQUERADE/FORWARD rules and the `iptables` lockfile persist; a later `clean_up`/`_del_ipt_rules` deletes nothing yet `unlock("iptables")`, and a new apply sees the stale lock and skips re-adding — leaving stale rules for the previous address. | Tag blueman rules with an iptables comment and flush-by-comment on apply; reconcile lockfile state against actual kernel rules at startup instead of trusting in-memory state. | -| dist-4 | open | M | `blueman/main/NetConf.py:347-348` In `apply_settings` the dhcp branch runs `clean_up()` (unlocks `dhcp`) then `apply()` (re-locks). If `apply`'s `_start` raises `NetworkSetupError`, earlier locks/forwarding/iptables from the same call are already applied — leaving a partially-applied state (bridge up, forwarding on, rules present) with no DHCP and no rollback; the caller just propagates a generic error. | Wrap `apply_settings` in try/except that runs full `NetConf.clean_up()` on any failure so the system is left all-or-nothing. Cross-ref depend-1. | -| dist-1 | open | M | `blueman/main/NetConf.py:366-374` `lock`/`unlock`/`locked` are plain `touch`/`unlink(missing_ok)`/`exists` on `/var/run/blueman-*` with no `flock` or atomic check-and-set. The mechanism is a system D-Bus service serving concurrent `EnableNetwork`/`DisableNetwork`/`DhcpClient` calls; two near-simultaneous `apply_settings` both see `locked()==False`, both enable forwarding, both append iptables MASQUERADE/FORWARD rules, and both start DHCP daemons on pan1 — duplicate rules accumulate and the shared `_dhcp_handler`/`_ipt_rules` class state corrupts. | Hold a real exclusive lock (`fcntl.flock` on the lockfile) across the whole apply/clean_up, or process mechanism requests strictly serially; make rule application idempotent (flush blueman rules before re-adding). | | dist-3 | open | S | `blueman/plugins/mechanism/Rfcomm.py:13-14` `_open_rfcomm` spawns a watcher per call with no dedup; two `OpenRFCOMM` calls for the same `port_id` start two `blueman-rfcomm-watcher /dev/rfcommN` processes, and `_close_rfcomm` kills only by matching the `ps` cmdline (can leave orphans or signal a recycled/foreign PID). | Before launching, scan for an existing watcher on that port and skip if present; track watcher PIDs in the mechanism rather than re-deriving from `ps`. Cross-ref wd-4, mem-1. | ## time & scheduling correctness @@ -399,7 +386,6 @@ _(none open)_ | id | status | effort | description | notes | |----|--------|--------|-------------|-------| | mem-2 | open | S | `blueman/main/DNSServerProvider.py:29-79` `_get_servers_from_systemd_resolved` issues a chain of synchronous `call_sync` D-Bus calls (Get DNS, then per-interface GetLink + DefaultRoute Get) with `-1` (infinite) timeout on the main loop whenever DHCP servers are resolved, scaling with interface count and able to hang indefinitely. | Use finite timeouts and/or move resolution off the main loop; cache across the `changed` signal instead of re-walking all links each call. Cross-ref depend-3. | -| mem-3 | open | S | `blueman/main/NetConf.py:239` `UdhcpdHandler._start` calls a blocking `sleep(0.1)` after spawning udhcpd to wait for the pid file, inside the mechanism process. Distinct from ux-1 (Sendto UI sleep). | Poll the pid file with a short non-blocking `GLib.timeout_add` loop instead of a fixed blocking sleep. | | mem-1 | open | S | `blueman/plugins/mechanism/Rfcomm.py:17` `_close_rfcomm` shells out `ps -e o pid,args` and `communicate()` synchronously inside the privileged mechanism D-Bus method, blocking the mechanism main loop while it scans every process to find one watcher PID. | Track watcher PIDs (from `Popen` in `_open_rfcomm`) keyed by port and kill by stored PID instead of scanning `ps`. Cross-ref dist-3. | ## system design From 7ae412aae0ca614ca159b9c56e719187416305aa Mon Sep 17 00:00:00 2001 From: Geraldo Netto Date: Fri, 19 Jun 2026 21:35:24 +0200 Subject: [PATCH 30/42] docs(todo): close Sendto.py items implemented in fix/sendto-hardening Remove the eight Sendto.py findings addressed on the fix/sendto-hardening branch (PR to upstream): time-2, rob-7, vec-1, dup-7, obs-8, prodeng-1, prodeng-2, ux-1. Park leg-3/ux-6 (async-dialog conversion) with rationale alongside the other parked GTK deprecation items. Co-Authored-By: Claude Opus 4.8 (1M context) --- TODO.md | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/TODO.md b/TODO.md index 9c5b9e422..ad07be586 100644 --- a/TODO.md +++ b/TODO.md @@ -75,7 +75,6 @@ Status: `open`, `in-progress`, `blocked`. Effort: `S` (≤1h), `M` (half-day), ` | dup-2 | open | S | `blueman/gui/manager/ManagerDeviceMenu.py:141-188` `connect_service`/`disconnect_service` duplicate nested success/error callbacks | extract async-DBus template | | dup-6 | open | S | `blueman/main/Applet.py:78-90` `_on_dbus_name_appeared/_vanished` repeat plugin notify loop | `_notify_manager_state_change(state)` | | dup-1 | open | M | `blueman/main/Applet.py:92-118` 8× identical plugin broadcast loops | `_broadcast(event, *args)` helper | -| dup-7 | open | S | `blueman/main/Sendto.py:47-55` 6× identical `connect_signal` boilerplate | `_setup_signal_handlers(source, handlers)` | | dup-8 | open | S | `blueman/main/Services.py:86` bare `except:` with `# noqa: E722` | narrow to expected exceptions | ## API contract & compatibility @@ -136,7 +135,6 @@ Status: `open`, `in-progress`, `blocked`. Effort: `S` (≤1h), `M` (half-day), ` | obs-11 | open | S | `blueman/main/DNSServerProvider.py:48` `GLib.Error` swallowed | `logging.debug("DNS lookup failed, using fallback")` | | obs-3 | open | S | `blueman/main/Manager.py:62` `print()` in exception handler | `logging.error(..., exc_info=True)` | | obs-6 | open | S | `blueman/main/PluginManager.py:64,123` `LoadException` swallowed silently | `logging.warning` with plugin name | -| obs-8 | open | S | `blueman/main/Sendto.py:286` `logging.debug(e.message)` on `GLib.Error` | use `str(e)` | | obs-15 | open | S | `blueman/plugins/applet/AutoConnect.py:116-117` ignores automatic connection failures with `pass`, so failed auto-connect attempts leave no log trail and are hard to diagnose. | Log the target service/device and failure reason at debug or warning level, with rate limiting if needed. | | obs-12 | open | S | `blueman/plugins/mechanism/Network.py:46` exception only routed to error callback, no local log | add `logging.error` with trace | | obs-14 | open | S | `sendto/blueman_sendto.py.in:14,17,29,33` `print()` for user-facing messages | replace with `logging` where plugin host allows | @@ -252,7 +250,6 @@ _(none open)_ | leg-6 | open | S | `blueman/gui/GtkAnimation.py:200` FIXME `Gtk.render_background()` wrong colors | investigate + fix or document | | leg-4 | open | M | `blueman/gui/manager/ManagerMenu.py:45,47` `Gtk.ImageMenuItem` in manager UI | migrate to `Gtk.MenuItem` | | leg-9 | open | S | `blueman/main/indicators/GtkStatusIcon.py:44` `# type: ignore` on submenu enumerate | proper overload/typing | -| leg-3 | open | M | `blueman/main/Sendto.py:178,190,291,461` deprecated dialog `.run()`/`.destroy()` | async response handlers | ## configuration @@ -289,7 +286,6 @@ _(none open)_ | id | status | effort | description | notes | |----|--------|--------|-------------|-------| | vec-2 | open | L | `blueman/bluez/Manager.py:138-149` `get_devices()` rescans all objects per `find_device()` | cache indexed by adapter, batch GetAll (dup perf-5) | -| vec-1 | open | M | `blueman/main/Sendto.py:140-143` per-property-change loop over UUIDs for OBEX_OBJPUSH | set membership / `any()` | ## robustiness @@ -300,7 +296,6 @@ _(none open)_ | rob-1 | open | M | `blueman/gui/manager/ManagerProgressbar.py:178` `timeout_add(41,pulse)` source id not captured; pulses after `stop()` | store + remove source id (overlaps perf-14) | | rob-3 | open | M | `blueman/main/DhcpClient.py:49-50` two timeout sources never stored/removed (dup wd-3) | store ids, remove on exit | | rob-5 | open | S | `blueman/main/PPPConnection.py:182-197` OSError path may skip `source_remove(io_watch)` before cleanup → leaked source | remove source in except (overlaps rel-7) | -| rob-7 | open | M | `blueman/main/Sendto.py:351-378` `on_transfer_progress` divides by `spd` without re-guard after ZeroDivisionError | `if spd>0` guard + log | | rob-6 | open | S | `blueman/plugins/applet/NetUsage.py:79-80` Monitor `__del__` doesn't remove timeout source | guard + `source_remove(poller)` | ## ui / ux @@ -313,8 +308,6 @@ _(none open)_ | ux-4 | open | M | `blueman/gui/Notification.py:168-169` bare `except ValueError: pass` on hint set (dup obs-7) | log when fallback occurs | | ux-2 | open | S | `blueman/gui/Notification.py:51` hardcoded notification size 350x50 | responsive sizing | | ux-8 | open | S | `blueman/main/Manager.py:183` FIXME BlueZ stop/start not surfaced to user | notification/infobar on daemon loss | -| ux-6 | open | M | `blueman/main/Sendto.py:178,188,291,461` blocking `dialog.run()` freeze UI (overlaps leg-3) | non-blocking response signals | -| ux-1 | open | M | `blueman/main/Sendto.py:310` blocking `time.sleep(1)` on UI thread during discovery stop | `GLib.timeout_add_seconds` | ## accessibility @@ -377,7 +370,6 @@ _(none open)_ | id | status | effort | description | notes | |----|--------|--------|-------------|-------| | time-4 | open | M | `blueman/main/MechanismApplication.py:20-29` the idle-exit timer counts 1s `timeout_add` ticks (`self.time += 1` to 30) instead of comparing a monotonic deadline; GLib coalesces/delays timeouts under load or suspend, so the "30s idle" auto-exit drifts and can fire much later than intended. | Record `GLib.get_monotonic_time()` on activity and exit once `now - last >= 30s`, independent of tick count. Cross-ref cfg-2. | -| time-2 | open | S | `blueman/main/Sendto.py:360` transfer-progress throttle `tm - self._last_update > 0.5` uses `time.time()`; a backward clock step stalls all speed/ETA UI updates until wall time catches up, a forward step fires every call. | Use `time.monotonic()` for `tm`/`self._last_update`. | | time-1 | open | M | `blueman/main/SpeedCalc.py:21` `calc()` keys elapsed-time/speed math on wall clock `time.time()`; an NTP step or manual clock change can skew the divisor across retained samples and produce erratic speeds (the zero-elapsed guard only catches exact ties/backsteps within the window). | Sample with `time.monotonic()` / `GLib.get_monotonic_time()`; a monotonic clock never steps. Distinct from ds-1 (log prune) and adapt-2 (clock_gettime portability). | | time-3 | open | S | `blueman/plugins/applet/NetUsage.py:201` session duration `datetime.now() - fromtimestamp(config["time"])` is pure wall-clock; if the clock moved backward since the stored start, the delta is negative and renders nonsense durations. | Clamp negative deltas to 0 (or store a monotonic anchor) before formatting. | @@ -409,8 +401,6 @@ _(none open)_ | id | status | effort | description | notes | |----|--------|--------|-------------|-------| -| prodeng-1 | open | S | `blueman/main/Sendto.py:61-63` aborts with a bare log "Error: No Adapters present" (no GUI dialog, no remedy) when no adapter is present; a user who launched sendto from a file manager's "Send To" sees nothing actionable. | Show a GTK error dialog telling the user to enable/plug in a Bluetooth adapter, mirroring `check_bluetooth_status`. | -| prodeng-2 | open | S | `blueman/main/Sendto.py:69` `--source` with an unknown adapter logs "Unknown adapter, trying first available" only to console and silently falls back; a CLI user who mistyped `-s` never learns their choice was ignored. | Print the fallback notice to stderr (or error out) instead of silently switching adapters. | ## design thinking @@ -451,6 +441,14 @@ _(none open)_ image/label box) changes the child structure and image handling — a visible UI change only validatable by running the GUI, so it can't meet the headless coverage bar. Park for the GTK4 migration alongside leg-4 (`ManagerMenu` `ImageMenuItem`). +- **leg-3 / ux-6** (`Sendto.py` deprecated `dialog.run()`/`.destroy()` → async response + handlers) — three of the four `.run()` sites (`select_files`, `select_device`, the + obex-start error dialog) are synchronous startup gates in `SendTo`/`Sender.__init__` whose + return values drive control flow before the GTK main loop runs. Converting to the async + response-signal pattern requires restructuring both `__init__`s into continuation-based + flows, which cannot reach genuine coverage headless and carries high regression risk on the + core send path. Park for the GTK4 migration (same rationale as leg-1). The other Sendto.py + findings were fixed in fix/sendto-hardening. ## Audit picks deliberately rejected From 563144c62d3250dd69d05e6e2e46d7b5620435c4 Mon Sep 17 00:00:00 2001 From: Geraldo Netto Date: Sat, 20 Jun 2026 12:24:40 +0200 Subject: [PATCH 31/42] security: escape configured share path in invalid-dir notification (sec-1) The fallback "incoming files directory does not exist" notification interpolated the user-configured `shared-path` and the default fallback path into Pango `%s` markup without escaping. A path containing markup or entities could alter the notification body, and daemons that render body markup could misinterpret it. Escape both interpolated paths with `html.escape` before formatting. Add regression tests covering `<`/`>`, `&`, and quote characters. Co-Authored-By: Claude Opus 4.8 (1M context) --- TODO.md | 1 - blueman/plugins/applet/TransferService.py | 3 +- test/plugins/applet/Makefile.am | 3 +- test/plugins/applet/test_transfer_service.py | 43 ++++++++++++++++++++ 4 files changed, 47 insertions(+), 3 deletions(-) create mode 100644 test/plugins/applet/test_transfer_service.py diff --git a/TODO.md b/TODO.md index ad07be586..93ffbc4ac 100644 --- a/TODO.md +++ b/TODO.md @@ -10,7 +10,6 @@ Status: `open`, `in-progress`, `blocked`. Effort: `S` (≤1h), `M` (half-day), ` | id | status | effort | description | notes | |----|--------|--------|-------------|-------| -| sec-1 | open | S | `blueman/plugins/applet/TransferService.py:181-186` interpolates the user-configured shared path into notification markup without escaping it. A path containing Pango markup can alter the fallback notification body and may be interpreted by notification daemons that support body markup. | Escape `shared-path` and the fallback path before formatting, or use a plain-text notification path. Add a regression test with ``, `&`, and quote characters. | ## input validation / command safety diff --git a/blueman/plugins/applet/TransferService.py b/blueman/plugins/applet/TransferService.py index 29a47769b..833971d96 100644 --- a/blueman/plugins/applet/TransferService.py +++ b/blueman/plugins/applet/TransferService.py @@ -181,7 +181,8 @@ def on_reset(_action: str) -> None: text = _('Configured directory for incoming files does not exist') secondary_text = _('Please make sure that directory "%s" exists or ' 'configure it with blueman-services. Until then the default "%s" will be used') - self._notification = Notification(text, secondary_text % (self._config["shared-path"], share_path), + self._notification = Notification(text, secondary_text % (escape(self._config["shared-path"]), + escape(share_path.as_posix())), icon_name='blueman', timeout=30000, actions=[('reset', 'Reset to default')], actions_cb=on_reset) self._notification.show() diff --git a/test/plugins/applet/Makefile.am b/test/plugins/applet/Makefile.am index 3e92b4be6..0530f72df 100644 --- a/test/plugins/applet/Makefile.am +++ b/test/plugins/applet/Makefile.am @@ -1,3 +1,4 @@ EXTRA_DIST = \ __init__.py \ - test_imports.py + test_imports.py \ + test_transfer_service.py diff --git a/test/plugins/applet/test_transfer_service.py b/test/plugins/applet/test_transfer_service.py new file mode 100644 index 000000000..4dea85de8 --- /dev/null +++ b/test/plugins/applet/test_transfer_service.py @@ -0,0 +1,43 @@ +from pathlib import Path +from unittest import TestCase +from unittest.mock import MagicMock, patch + +from blueman.plugins.applet.TransferService import TransferService + + +def _make_plugin(configured_share_path: str) -> TransferService: + plugin = TransferService.__new__(TransferService) + config = MagicMock() + config.__getitem__.side_effect = lambda key: {"shared-path": configured_share_path}[key] + plugin._config = config + return plugin + + +@patch("blueman.plugins.applet.TransferService.Manager") +@patch("blueman.plugins.applet.TransferService.Notification") +@patch("blueman.plugins.applet.TransferService.Gio.Settings") +class TestSharePathEscaping(TestCase): + def _body(self, settings_mock: MagicMock, notification_mock: MagicMock, configured: str) -> str: + plugin = _make_plugin(configured) + settings_mock.return_value = plugin._config + with patch.object(TransferService, "_make_share_path", return_value=(Path("/srv/Downloads"), True)): + plugin.on_load() + notification_mock.assert_called_once() + return notification_mock.call_args.args[1] + + def test_escapes_angle_brackets(self, settings_mock: MagicMock, notification_mock: MagicMock, + _manager_mock: MagicMock) -> None: + body = self._body(settings_mock, notification_mock, "/home/evil") + self.assertNotIn("evil", body) + self.assertIn("<b>evil</b>", body) + + def test_escapes_ampersand(self, settings_mock: MagicMock, notification_mock: MagicMock, + _manager_mock: MagicMock) -> None: + body = self._body(settings_mock, notification_mock, "/home/a & b") + self.assertIn("&", body) + + def test_escapes_quotes(self, settings_mock: MagicMock, notification_mock: MagicMock, + _manager_mock: MagicMock) -> None: + body = self._body(settings_mock, notification_mock, '/home/"quoted"') + self.assertNotIn('"quoted"', body) + self.assertIn(""quoted"", body) From fbd806f3e02ca92564bf4f62e6b03a316688f430 Mon Sep 17 00:00:00 2001 From: Geraldo Netto Date: Sat, 20 Jun 2026 12:25:29 +0200 Subject: [PATCH 32/42] i18n: translate "Reset to default" notification action (i18n-3) The reset action on the invalid-share-path notification used a bare English string, so it never localized. Wrap it in `_()`; the file is already listed in `po/POTFILES.in`, so the string is now extracted. Add a test that the label is routed through gettext. Co-Authored-By: Claude Opus 4.8 (1M context) --- TODO.md | 1 - blueman/plugins/applet/TransferService.py | 2 +- test/plugins/applet/test_transfer_service.py | 15 +++++++++++++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/TODO.md b/TODO.md index 93ffbc4ac..95bc605dd 100644 --- a/TODO.md +++ b/TODO.md @@ -319,7 +319,6 @@ _(none open)_ | id | status | effort | description | notes | |----|--------|--------|-------------|-------| | i18n-2 | open | S | `blueman/main/applet/BluezAgent.py:201-229` builds authentication notification sentences by concatenating translated fragments with device names, PINs, and markup. Translators cannot reorder the whole sentence or place punctuation naturally. | Use one format string per complete sentence/message with named placeholders, e.g. `%(device)s` and `%(passkey)s`, preserving markup escaping. | -| i18n-3 | open | S | `blueman/plugins/applet/TransferService.py:186` uses the action label `"Reset to default"` without gettext, so the fallback notification action is always English. | Wrap the action label in `_()` and ensure it appears in `po/POTFILES.in`. | | i18n-1 | open | S | `sendto/blueman_sendto.py.in:46-50` hardcodes Nautilus/Caja/Nemo menu labels and tips in English, and `sendto/blueman_sendto.py.in` is not listed in `po/POTFILES.in`, so translators never see them. | Wrap file-manager extension labels/tips in gettext and add the generated/template source to extraction. | ## documentation diff --git a/blueman/plugins/applet/TransferService.py b/blueman/plugins/applet/TransferService.py index 833971d96..d6dd39151 100644 --- a/blueman/plugins/applet/TransferService.py +++ b/blueman/plugins/applet/TransferService.py @@ -184,7 +184,7 @@ def on_reset(_action: str) -> None: self._notification = Notification(text, secondary_text % (escape(self._config["shared-path"]), escape(share_path.as_posix())), icon_name='blueman', timeout=30000, - actions=[('reset', 'Reset to default')], actions_cb=on_reset) + actions=[('reset', _('Reset to default'))], actions_cb=on_reset) self._notification.show() self._watch = Manager.watch_name_owner(self._on_dbus_name_appeared, self._on_dbus_name_vanished) diff --git a/test/plugins/applet/test_transfer_service.py b/test/plugins/applet/test_transfer_service.py index 4dea85de8..7ebf19396 100644 --- a/test/plugins/applet/test_transfer_service.py +++ b/test/plugins/applet/test_transfer_service.py @@ -41,3 +41,18 @@ def test_escapes_quotes(self, settings_mock: MagicMock, notification_mock: Magic body = self._body(settings_mock, notification_mock, '/home/"quoted"') self.assertNotIn('"quoted"', body) self.assertIn(""quoted"", body) + + +@patch("blueman.plugins.applet.TransferService.Manager") +@patch("blueman.plugins.applet.TransferService.Notification") +@patch("blueman.plugins.applet.TransferService.Gio.Settings") +class TestResetActionTranslated(TestCase): + def test_reset_label_routed_through_gettext(self, settings_mock: MagicMock, notification_mock: MagicMock, + _manager_mock: MagicMock) -> None: + plugin = _make_plugin("/does/not/matter") + settings_mock.return_value = plugin._config + with patch.object(TransferService, "_make_share_path", return_value=(Path("/srv/Downloads"), True)), \ + patch("blueman.plugins.applet.TransferService._", lambda s: f"{s}"): + plugin.on_load() + actions = notification_mock.call_args.kwargs["actions"] + self.assertEqual(actions, [("reset", "Reset to default")]) From 8dedf40b9847f2e29593f7f125cb33389d3bb6e0 Mon Sep 17 00:00:00 2001 From: Geraldo Netto Date: Sat, 20 Jun 2026 12:27:04 +0200 Subject: [PATCH 33/42] fix: capture allowed address in removal timeout closure (rel-10) The 60s timeout that revokes a one-shot OPP authorization read `self._pending_transfer['address']` when it fired, not when it was scheduled. An overlapping push or cleared pending state could revoke the wrong device or trip the assertion. Capture the accepted address as a default argument bound at schedule time and revoke it idempotently. Switch `_allowed_devices` to a set so removal is `discard`-style and a double fire cannot raise. Co-Authored-By: Claude Opus 4.8 (1M context) --- TODO.md | 1 - blueman/plugins/applet/TransferService.py | 10 +-- test/plugins/applet/test_transfer_service.py | 66 +++++++++++++++++++- 3 files changed, 70 insertions(+), 7 deletions(-) diff --git a/TODO.md b/TODO.md index 95bc605dd..c4db038a4 100644 --- a/TODO.md +++ b/TODO.md @@ -120,7 +120,6 @@ Status: `open`, `in-progress`, `blocked`. Effort: `S` (≤1h), `M` (half-day), ` |----|--------|--------|-------------|-------| | rel-11 | open | S | `blueman/main/applet/BluezAgent.py:201-203` indexes `key[entered]` when displaying a passkey. If BlueZ reports `entered == 6` after all digits are typed, or an invalid value, the notification path raises `IndexError`. | Clamp `entered` to the valid range and render the fully-entered passkey without bolding a missing digit. Cross-ref test-2. | | rel-9 | open | S | `blueman/main/Services.py:86` bare `except: pass` hides errors | narrow exception types | -| rel-10 | open | S | `blueman/plugins/applet/TransferService.py:95-100` schedules removal of an allowed device but the timeout closure reads `self._pending_transfer` later instead of capturing the accepted address. A second pending transfer or cleared state can remove the wrong address or hit the assertion. | Capture `address` in the closure and remove it idempotently (`discard`-style) from the allowed list. Cross-ref sm-8. | | rel-12 | open | S | `blueman/plugins/BasePlugin.py:50` registers `weakref.finalize(self, self._on_plugin_delete)`. Passing a bound method keeps `self` strongly referenced by the finalizer, so plugin instances may not be collected and the delete hook is unreliable. | Register a module-level/static cleanup callback with weak state, or rely on explicit plugin unload and remove the finalizer. | ## observability diff --git a/blueman/plugins/applet/TransferService.py b/blueman/plugins/applet/TransferService.py index d6dd39151..e65103d04 100644 --- a/blueman/plugins/applet/TransferService.py +++ b/blueman/plugins/applet/TransferService.py @@ -61,7 +61,7 @@ def __init__(self, applet: BluemanApplet): self._applet = applet self._config = Gio.Settings(schema_id="org.blueman.transfer") - self._allowed_devices: list[str] = [] + self._allowed_devices: set[BtAddress] = set() self._notification: NotificationType | None = None self._pending_transfer: Optional[PendingTransferDict] = None self.transfers: dict[ObjectPath, TransferDict] = {} @@ -90,11 +90,11 @@ def on_action(action: str) -> None: ok(self.transfers[self._pending_transfer['transfer_path']]['path'].as_posix()) - self._allowed_devices.append(self._pending_transfer['address']) + allowed_address = self._pending_transfer['address'] + self._allowed_devices.add(allowed_address) - def _remove() -> bool: - assert self._pending_transfer is not None # https://github.com/python/mypy/issues/2608 - self._allowed_devices.remove(self._pending_transfer['address']) + def _remove(address: BtAddress = allowed_address) -> bool: + self._allowed_devices.discard(address) return False GLib.timeout_add(60000, _remove) diff --git a/test/plugins/applet/test_transfer_service.py b/test/plugins/applet/test_transfer_service.py index 7ebf19396..af9b3ada2 100644 --- a/test/plugins/applet/test_transfer_service.py +++ b/test/plugins/applet/test_transfer_service.py @@ -2,7 +2,35 @@ from unittest import TestCase from unittest.mock import MagicMock, patch -from blueman.plugins.applet.TransferService import TransferService +from blueman.plugins.applet.TransferService import Agent, TransferService + + +def _make_agent() -> Agent: + agent = Agent.__new__(Agent) + agent._allowed_devices = set() + agent._notification = None + agent._pending_transfer = None + agent.transfers = {} + config = MagicMock() + config.__getitem__.side_effect = lambda key: True if key == "opp-accept" else "" + agent._config = config + agent._applet = MagicMock() + device = agent._applet.Manager.find_device.return_value + device.display_name = "Phone" + device.__getitem__.side_effect = lambda key: True if key == "Trusted" else None + return agent + + +def _configure_transfer(transfer_mock: MagicMock, session_mock: MagicMock, *, address: str, + name: str = "file.bin", size: int = 10) -> None: + transfer = transfer_mock.return_value + transfer.session = "/sess" + transfer.name = name + transfer.size = size + session = session_mock.return_value + session.root = "/root" + session.address = address + session.source = "/org/bluez/hci0" def _make_plugin(configured_share_path: str) -> TransferService: @@ -56,3 +84,39 @@ def test_reset_label_routed_through_gettext(self, settings_mock: MagicMock, noti plugin.on_load() actions = notification_mock.call_args.kwargs["actions"] self.assertEqual(actions, [("reset", "Reset to default")]) + + +@patch("blueman.plugins.applet.TransferService.GLib") +@patch("blueman.plugins.applet.TransferService.Notification") +@patch("blueman.plugins.applet.TransferService.Session") +@patch("blueman.plugins.applet.TransferService.Transfer") +class TestAllowedDeviceRemoval(TestCase): + def _authorize(self, transfer_mock: MagicMock, session_mock: MagicMock, glib_mock: MagicMock, + address: str) -> tuple[Agent, object]: + agent = _make_agent() + _configure_transfer(transfer_mock, session_mock, address=address) + agent._authorize_push("/transfer", MagicMock(), MagicMock()) + remove_cb = glib_mock.timeout_add.call_args.args[1] + return agent, remove_cb + + def test_address_allowed_then_removed(self, transfer_mock: MagicMock, session_mock: MagicMock, + _notification_mock: MagicMock, glib_mock: MagicMock) -> None: + agent, remove_cb = self._authorize(transfer_mock, session_mock, glib_mock, "AA:BB:CC:DD:EE:FF") + self.assertIn("AA:BB:CC:DD:EE:FF", agent._allowed_devices) + remove_cb() + self.assertNotIn("AA:BB:CC:DD:EE:FF", agent._allowed_devices) + + def test_removal_is_idempotent(self, transfer_mock: MagicMock, session_mock: MagicMock, + _notification_mock: MagicMock, glib_mock: MagicMock) -> None: + agent, remove_cb = self._authorize(transfer_mock, session_mock, glib_mock, "AA:BB:CC:DD:EE:FF") + remove_cb() + remove_cb() # must not raise even though the address is already gone + + def test_removes_captured_address_not_current_pending(self, transfer_mock: MagicMock, session_mock: MagicMock, + _notification_mock: MagicMock, glib_mock: MagicMock) -> None: + agent, remove_cb = self._authorize(transfer_mock, session_mock, glib_mock, "AA:BB:CC:DD:EE:FF") + agent._allowed_devices.add("11:22:33:44:55:66") + agent._pending_transfer = {"address": "11:22:33:44:55:66"} # a later, overlapping request + remove_cb() + self.assertNotIn("AA:BB:CC:DD:EE:FF", agent._allowed_devices) + self.assertIn("11:22:33:44:55:66", agent._allowed_devices) From 1ae7d1df2bbab9cb201954d4ab1f86c39024847c Mon Sep 17 00:00:00 2001 From: Geraldo Netto Date: Sat, 20 Jun 2026 12:29:34 +0200 Subject: [PATCH 34/42] fix: track pending OPP authorizations by transfer path (sm-8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A single `_pending_transfer` slot held the in-flight authorization, so a second incoming push arriving before the user answered the first overwrote the state the first notification's callback later read — the wrong file could be accepted or the action could fail. Key pending records by `transfer_path` in a dict and bind each notification callback to its own immutable record (captured as a default argument). The callback pops only its own record on accept/reject, so overlapping requests stay independent. Co-Authored-By: Claude Opus 4.8 (1M context) --- TODO.md | 1 - blueman/plugins/applet/TransferService.py | 57 ++++++++++---------- test/plugins/applet/test_transfer_service.py | 56 +++++++++++++++++-- 3 files changed, 82 insertions(+), 32 deletions(-) diff --git a/TODO.md b/TODO.md index c4db038a4..d2be5c26f 100644 --- a/TODO.md +++ b/TODO.md @@ -196,7 +196,6 @@ _(none open)_ | sm-3 | open | L | `blueman/main/PPPConnection.py:213-224` `on_timeout` closure captures stale `command_id` if `send_commands` reused before fire | bind per-command state / cancel prior timeout | | sm-6 | open | L | `blueman/plugins/applet/PowerManager.py:97,109` Callback timer source id not tracked; orphan timeout fires on GC'd object | store source id, remove in destructor | | sm-9 | open | S | `blueman/plugins/applet/ShowConnected.py:86-92` schedules delayed `enumerate_connections()` calls on every manager-state-enabled event without storing/canceling the source. A fast state flap can let a stale enumeration update the icon after the manager is disabled. | Store the pending source id, cancel it on manager disable/unload, and ignore callbacks if manager state changed. | -| sm-8 | open | M | `blueman/plugins/applet/TransferService.py:78-123` tracks only one `_pending_transfer` for authorization, but multiple incoming pushes can overlap before the user answers. A later request overwrites the pending state used by the first notification action. | Track pending transfers by `transfer_path`; bind notification callbacks to an immutable pending-transfer record. Cross-ref rel-10. | ## composition diff --git a/blueman/plugins/applet/TransferService.py b/blueman/plugins/applet/TransferService.py index e65103d04..058ec5464 100644 --- a/blueman/plugins/applet/TransferService.py +++ b/blueman/plugins/applet/TransferService.py @@ -4,7 +4,7 @@ import shutil import logging from html import escape -from typing import Optional, TypedDict, Union +from typing import TypedDict, Union from collections.abc import Callable from blueman.bluemantyping import ObjectPath, BtAddress @@ -63,7 +63,7 @@ def __init__(self, applet: BluemanApplet): self._allowed_devices: set[BtAddress] = set() self._notification: NotificationType | None = None - self._pending_transfer: Optional[PendingTransferDict] = None + self._pending_transfers: dict[ObjectPath, PendingTransferDict] = {} self.transfers: dict[ObjectPath, TransferDict] = {} def register_at_manager(self) -> None: @@ -77,30 +77,6 @@ def _release(self) -> None: def _authorize_push(self, transfer_path: ObjectPath, ok: Callable[[str], None], err: Callable[[ObexErrorRejected], None]) -> None: - def on_action(action: str) -> None: - logging.info(f"Action {action}") - - if action == "accept": - assert self._pending_transfer - self.transfers[self._pending_transfer['transfer_path']] = { - 'path': self._pending_transfer['root'] / self._pending_transfer['filename'], - 'size': self._pending_transfer['size'], - 'name': self._pending_transfer['name'] - } - - ok(self.transfers[self._pending_transfer['transfer_path']]['path'].as_posix()) - - allowed_address = self._pending_transfer['address'] - self._allowed_devices.add(allowed_address) - - def _remove(address: BtAddress = allowed_address) -> bool: - self._allowed_devices.discard(address) - return False - - GLib.timeout_add(60000, _remove) - else: - err(ObexErrorRejected("Rejected")) - transfer = Transfer(obj_path=transfer_path) session = Session(obj_path=transfer.session) root = Path(session.root) @@ -119,8 +95,33 @@ def _remove(address: BtAddress = allowed_address) -> bool: name = address trusted = False - self._pending_transfer = {'transfer_path': transfer_path, 'address': address, 'root': root, - 'filename': filename, 'size': size, 'name': name} + pending: PendingTransferDict = {'transfer_path': transfer_path, 'address': address, 'root': root, + 'filename': filename, 'size': size, 'name': name} + self._pending_transfers[transfer_path] = pending + + def on_action(action: str, pending: PendingTransferDict = pending) -> None: + logging.info(f"Action {action}") + self._pending_transfers.pop(pending['transfer_path'], None) + + if action == "accept": + self.transfers[pending['transfer_path']] = { + 'path': pending['root'] / pending['filename'], + 'size': pending['size'], + 'name': pending['name'] + } + + ok(self.transfers[pending['transfer_path']]['path'].as_posix()) + + allowed_address = pending['address'] + self._allowed_devices.add(allowed_address) + + def _remove(address: BtAddress = allowed_address) -> bool: + self._allowed_devices.discard(address) + return False + + GLib.timeout_add(60000, _remove) + else: + err(ObexErrorRejected("Rejected")) # This device was neither allowed nor is it trusted -> ask for confirmation if address not in self._allowed_devices and not (self._config['opp-accept'] and trusted): diff --git a/test/plugins/applet/test_transfer_service.py b/test/plugins/applet/test_transfer_service.py index af9b3ada2..33e4c5070 100644 --- a/test/plugins/applet/test_transfer_service.py +++ b/test/plugins/applet/test_transfer_service.py @@ -9,7 +9,7 @@ def _make_agent() -> Agent: agent = Agent.__new__(Agent) agent._allowed_devices = set() agent._notification = None - agent._pending_transfer = None + agent._pending_transfers = {} agent.transfers = {} config = MagicMock() config.__getitem__.side_effect = lambda key: True if key == "opp-accept" else "" @@ -115,8 +115,58 @@ def test_removal_is_idempotent(self, transfer_mock: MagicMock, session_mock: Mag def test_removes_captured_address_not_current_pending(self, transfer_mock: MagicMock, session_mock: MagicMock, _notification_mock: MagicMock, glib_mock: MagicMock) -> None: agent, remove_cb = self._authorize(transfer_mock, session_mock, glib_mock, "AA:BB:CC:DD:EE:FF") - agent._allowed_devices.add("11:22:33:44:55:66") - agent._pending_transfer = {"address": "11:22:33:44:55:66"} # a later, overlapping request + agent._allowed_devices.add("11:22:33:44:55:66") # a later, overlapping request's address remove_cb() self.assertNotIn("AA:BB:CC:DD:EE:FF", agent._allowed_devices) self.assertIn("11:22:33:44:55:66", agent._allowed_devices) + + +@patch("blueman.plugins.applet.TransferService.GLib") +@patch("blueman.plugins.applet.TransferService.Notification") +@patch("blueman.plugins.applet.TransferService.Session") +@patch("blueman.plugins.applet.TransferService.Transfer") +class TestOverlappingPending(TestCase): + def _setup_two_untrusted(self, agent: Agent, transfer_mock: MagicMock, session_mock: MagicMock) -> None: + device = agent._applet.Manager.find_device.return_value + device.__getitem__.side_effect = lambda key: False if key == "Trusted" else None + + def make_transfer(obj_path: str) -> MagicMock: + transfer = MagicMock() + transfer.session = "/s" + obj_path + transfer.name = "first.bin" if obj_path == "/t1" else "second.bin" + transfer.size = 10 + return transfer + + def make_session(obj_path: str) -> MagicMock: + session = MagicMock() + session.root = "/root" + session.address = "AA:AA" if obj_path == "/s/t1" else "BB:BB" + session.source = "/org/bluez/hci0" + return session + + transfer_mock.side_effect = make_transfer + session_mock.side_effect = make_session + + def test_overlapping_requests_keep_independent_records(self, transfer_mock: MagicMock, session_mock: MagicMock, + notification_mock: MagicMock, _glib_mock: MagicMock) -> None: + agent = _make_agent() + self._setup_two_untrusted(agent, transfer_mock, session_mock) + ok_a, err_a, ok_b, err_b = MagicMock(), MagicMock(), MagicMock(), MagicMock() + + agent._authorize_push("/t1", ok_a, err_a) + agent._authorize_push("/t2", ok_b, err_b) + self.assertEqual(set(agent._pending_transfers), {"/t1", "/t2"}) + + on_action_a = notification_mock.call_args_list[0].kwargs["actions_cb"] + on_action_b = notification_mock.call_args_list[1].kwargs["actions_cb"] + + on_action_a("accept") + self.assertEqual(agent.transfers["/t1"]["path"].name, "first.bin") + ok_a.assert_called_once() + self.assertNotIn("/t1", agent._pending_transfers) + self.assertIn("/t2", agent._pending_transfers) # the second request is untouched by the first's action + + on_action_b("reject") + err_b.assert_called_once() + self.assertNotIn("/t2", agent._pending_transfers) + self.assertNotIn("/t2", agent.transfers) From 7bdc824d2320d161b14032bd84a6fd10ca1332ef Mon Sep 17 00:00:00 2001 From: Geraldo Netto Date: Sat, 20 Jun 2026 12:31:18 +0200 Subject: [PATCH 35/42] fix: reserve incoming-file destination atomically (data-1) Collision handling prefixed only a second-resolution timestamp and then moved without rechecking the timestamped destination, so two same-named transfers completing in the same second could collide and overwrite/fail. Add `reserve_destination`, which walks `name`, `timestamp_name`, `timestamp_1_name`, ... and reserves the first free candidate with an O_EXCL create, then move the source onto the reservation. Clean up the reserved placeholder if the move fails. Covered by same-second and fuzz tests over odd filenames. Co-Authored-By: Claude Opus 4.8 (1M context) --- TODO.md | 1 - blueman/plugins/applet/TransferService.py | 39 +++++++++++--- test/plugins/applet/test_transfer_service.py | 57 +++++++++++++++++++- 3 files changed, 89 insertions(+), 8 deletions(-) diff --git a/TODO.md b/TODO.md index d2be5c26f..950bb5e37 100644 --- a/TODO.md +++ b/TODO.md @@ -21,7 +21,6 @@ Status: `open`, `in-progress`, `blocked`. Effort: `S` (≤1h), `M` (half-day), ` | id | status | effort | description | notes | |----|--------|--------|-------------|-------| -| data-1 | open | S | `blueman/plugins/applet/TransferService.py:296-303` resolves incoming-file name collisions by prefixing only second-resolution time, then moves without rechecking the timestamped destination. Two same-named transfers completing in the same second can collide and overwrite/fail depending on platform semantics. | Generate a unique destination with an exclusive create/rename loop (`name`, `timestamp_name`, `timestamp_1_name`, ...), and test repeated same-second completions. | | data-2 | open | S | `blueman/plugins/manager/Notes.py:32-35` creates a `.vnt` temporary file with `delete=False` and relies on the launched sendto process to delete it. If launch fails or the process never starts, the note body remains in `/tmp` indefinitely. | Delete the temp file when `launch()` returns false or raises; consider creating it in an app-owned temp directory with cleanup on startup. Cross-ref gov-5. | ## performance diff --git a/blueman/plugins/applet/TransferService.py b/blueman/plugins/applet/TransferService.py index 058ec5464..6e61eae64 100644 --- a/blueman/plugins/applet/TransferService.py +++ b/blueman/plugins/applet/TransferService.py @@ -1,11 +1,12 @@ from datetime import datetime from gettext import gettext as _, ngettext from pathlib import Path +import os import shutil import logging from html import escape from typing import TypedDict, Union -from collections.abc import Callable +from collections.abc import Callable, Iterator from blueman.bluemantyping import ObjectPath, BtAddress from blueman.bluez.obex.AgentManager import AgentManager @@ -38,6 +39,33 @@ class PendingTransferDict(TypedDict): NotificationType = Union[_NotificationBubble, _NotificationDialog] +_MAX_DESTINATION_ATTEMPTS = 10000 + + +def _destination_candidates(filename: str, stamp: str) -> "Iterator[str]": + yield filename + yield f"{stamp}_{filename}" + for index in range(1, _MAX_DESTINATION_ATTEMPTS): + yield f"{stamp}_{index}_{filename}" + + +def reserve_destination(dest_dir: Path, filename: str, now: datetime) -> Path: + """Atomically reserve a unique destination, returning the reserved (empty) path. + + Uses O_EXCL so two transfers completing in the same second cannot pick the + same name and overwrite each other; the caller moves the source onto it. + """ + stamp = now.strftime('%Y%m%d%H%M%S') + for candidate in _destination_candidates(filename, stamp): + dest = dest_dir / candidate + try: + fd = os.open(dest, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) + except FileExistsError: + continue + os.close(fd) + return dest + raise FileExistsError(f"No free destination for {filename} in {dest_dir}") + class ObexErrorRejected(DbusError): _name = "org.bluez.obex.Error.Rejected" @@ -293,18 +321,17 @@ def _on_transfer_completed(self, _manager: Manager, transfer_path: ObjectPath, s src = attributes['path'] dest_dir, ignored = self._make_share_path() - filename = src.name - if dest_dir.joinpath(filename).exists(): - now = datetime.now() - filename = f"{now.strftime('%Y%m%d%H%M%S')}_{filename}" + dest = reserve_destination(dest_dir, src.name, datetime.now()) + filename = dest.name + if filename != src.name: logging.info(f"Destination file exists, renaming to: {filename}") - dest = dest_dir.joinpath(filename) try: shutil.move(src, dest) except (OSError, PermissionError): logging.error("Failed to move files", exc_info=True) + dest.unlink(missing_ok=True) success = False if success: diff --git a/test/plugins/applet/test_transfer_service.py b/test/plugins/applet/test_transfer_service.py index 33e4c5070..cbcc42565 100644 --- a/test/plugins/applet/test_transfer_service.py +++ b/test/plugins/applet/test_transfer_service.py @@ -1,8 +1,10 @@ +import tempfile +from datetime import datetime from pathlib import Path from unittest import TestCase from unittest.mock import MagicMock, patch -from blueman.plugins.applet.TransferService import Agent, TransferService +from blueman.plugins.applet.TransferService import Agent, TransferService, reserve_destination def _make_agent() -> Agent: @@ -170,3 +172,56 @@ def test_overlapping_requests_keep_independent_records(self, transfer_mock: Magi err_b.assert_called_once() self.assertNotIn("/t2", agent._pending_transfers) self.assertNotIn("/t2", agent.transfers) + + +class TestReserveDestination(TestCase): + _NOW = datetime(2020, 1, 2, 3, 4, 5) + _STAMP = "20200102030405" + + def setUp(self) -> None: + self._tmp = tempfile.TemporaryDirectory() + self.dir = Path(self._tmp.name) + self.addCleanup(self._tmp.cleanup) + + def test_no_collision_uses_plain_name(self) -> None: + dest = reserve_destination(self.dir, "file.bin", self._NOW) + self.assertEqual(dest, self.dir / "file.bin") + self.assertTrue(dest.exists()) + + def test_first_collision_uses_timestamp(self) -> None: + (self.dir / "file.bin").write_text("existing") + dest = reserve_destination(self.dir, "file.bin", self._NOW) + self.assertEqual(dest, self.dir / f"{self._STAMP}_file.bin") + + def test_second_collision_uses_indexed_timestamp(self) -> None: + (self.dir / "file.bin").write_text("existing") + (self.dir / f"{self._STAMP}_file.bin").write_text("existing") + dest = reserve_destination(self.dir, "file.bin", self._NOW) + self.assertEqual(dest, self.dir / f"{self._STAMP}_1_file.bin") + + def test_same_second_calls_never_collide(self) -> None: + # Two transfers of the same name completing in the same second must each + # reserve a distinct, freshly-created destination — no overwrite. + reserved = [reserve_destination(self.dir, "photo.jpg", self._NOW) for _ in range(5)] + self.assertEqual(len(set(reserved)), 5) + for dest in reserved: + self.assertTrue(dest.exists()) + + def test_reserved_file_is_exclusive(self) -> None: + dest = reserve_destination(self.dir, "x", self._NOW) + # A second reservation must not hand back the same path it just created. + other = reserve_destination(self.dir, "x", self._NOW) + self.assertNotEqual(dest, other) + + def test_fuzz_weird_names_stay_unique_and_safe(self) -> None: + names = ["a b.bin", "résumé.pdf", ".hidden", "name.with.dots.tar.gz", + "UPPER.TXT", " spaces ", "emoji-😀.png", "a" * 200 + ".bin"] + for raw in names: + with self.subTest(name=raw): + first = reserve_destination(self.dir, raw, self._NOW) + second = reserve_destination(self.dir, raw, self._NOW) + self.assertNotEqual(first, second) + self.assertTrue(first.exists() and second.exists()) + # Reserved name must stay within the destination directory. + self.assertEqual(first.parent, self.dir) + self.assertEqual(second.parent, self.dir) From c6bc292b4c10e0dafab6765ac624430c68bc125e Mon Sep 17 00:00:00 2001 From: Geraldo Netto Date: Sat, 20 Jun 2026 12:33:38 +0200 Subject: [PATCH 36/42] refactor: inject a device resolver into the OBEX Agent (dec-1) The Agent took the whole `BluemanApplet` only to reach `parent.Manager.get_adapter`/`find_device` for the pushing device's name and trust state, coupling the D-Bus agent to the applet object graph. Introduce a `DeviceResolver` callable `(source, address) -> (name, trusted)`; the plugin supplies one bound to its `parent.Manager` and the Agent depends only on that. Drops the `BluemanApplet` import and makes authorization unit-testable with a plain resolver stub. Co-Authored-By: Claude Opus 4.8 (1M context) --- TODO.md | 1 - blueman/plugins/applet/TransferService.py | 22 ++++---- test/plugins/applet/test_transfer_service.py | 56 +++++++++++++++++--- 3 files changed, 62 insertions(+), 17 deletions(-) diff --git a/TODO.md b/TODO.md index 950bb5e37..05f54f5c6 100644 --- a/TODO.md +++ b/TODO.md @@ -100,7 +100,6 @@ Status: `open`, `in-progress`, `blocked`. Effort: `S` (≤1h), `M` (half-day), ` |----|--------|--------|-------------|-------| | dec-3 | open | S | `blueman/plugins/applet/AutoConnect.py:62` `self.parent.Manager.find_device()` reach-through | `parent.find_device_by_address(addr)` API | | dec-4 | open | S | `blueman/plugins/applet/KillSwitch.py:147-149` direct `self.parent.Plugins.StatusIcon/PowerManager` access | optional plugin query w/ fallback | -| dec-1 | open | M | `blueman/plugins/applet/TransferService.py:17` reaches into `parent.Plugins`/`parent.Manager` | DI via plugin interface or signal | | dec-6 | open | S | `blueman/plugins/AppletPlugin.py:32` hardcoded fallback icon name | constant + GSettings override | | dec-5 | open | S | `blueman/plugins/manager/Services.py:82` plugin discovery via `ServicePlugin.__subclasses__()` | registry or `importlib.metadata.entry_points` | | dec-2 | open | M | `blueman/plugins/manager/Services.py:8` `ManagerPlugin` imports `ManagerDeviceMenu`, `MenuItemsProvider` (GUI layer) | event-based provider interface | diff --git a/blueman/plugins/applet/TransferService.py b/blueman/plugins/applet/TransferService.py index 6e61eae64..dbc444bf3 100644 --- a/blueman/plugins/applet/TransferService.py +++ b/blueman/plugins/applet/TransferService.py @@ -15,7 +15,6 @@ from blueman.bluez.obex.Session import Session from blueman.Functions import launch from blueman.gui.Notification import Notification, _NotificationBubble, _NotificationDialog -from blueman.main.Applet import BluemanApplet from blueman.main.DbusService import DbusService, DbusError from blueman.plugins.AppletPlugin import AppletPlugin @@ -39,6 +38,9 @@ class PendingTransferDict(TypedDict): NotificationType = Union[_NotificationBubble, _NotificationDialog] +# Resolve (display name, trusted) for a device given its adapter source path and address. +DeviceResolver = Callable[[str, BtAddress], tuple[str, bool]] + _MAX_DESTINATION_ATTEMPTS = 10000 @@ -78,7 +80,7 @@ class ObexErrorCanceled(DbusError): class Agent(DbusService): __agent_path = ObjectPath('/org/bluez/obex/agent/blueman') - def __init__(self, applet: BluemanApplet): + def __init__(self, resolve_device: "DeviceResolver"): super().__init__(None, "org.bluez.obex.Agent1", self.__agent_path, Gio.BusType.SESSION) self.add_method("Release", (), "", self._release) @@ -86,7 +88,7 @@ def __init__(self, applet: BluemanApplet): self.add_method("AuthorizePush", ("o",), "s", self._authorize_push, is_async=True) self.register() - self._applet = applet + self._resolve_device = resolve_device self._config = Gio.Settings(schema_id="org.blueman.transfer") self._allowed_devices: set[BtAddress] = set() @@ -113,11 +115,7 @@ def _authorize_push(self, transfer_path: ObjectPath, ok: Callable[[str], None], size = transfer.size try: - adapter = self._applet.Manager.get_adapter(session.source) - device = self._applet.Manager.find_device(address, adapter.get_object_path()) - assert device is not None - name = device.display_name - trusted = device["Trusted"] + name, trusted = self._resolve_device(session.source, address) except Exception as e: logging.exception(e) name = address @@ -250,9 +248,15 @@ def _make_share_path(self) -> tuple[Path, bool]: return path, error + def _resolve_device(self, source: str, address: BtAddress) -> tuple[str, bool]: + adapter = self.parent.Manager.get_adapter(source) + device = self.parent.Manager.find_device(address, adapter.get_object_path()) + assert device is not None + return device.display_name, device["Trusted"] + def _register_agent(self) -> None: if not self._agent: - self._agent = Agent(self.parent) + self._agent = Agent(self._resolve_device) self._agent.register_at_manager() def _unregister_agent(self) -> None: diff --git a/test/plugins/applet/test_transfer_service.py b/test/plugins/applet/test_transfer_service.py index cbcc42565..0c6ad7b32 100644 --- a/test/plugins/applet/test_transfer_service.py +++ b/test/plugins/applet/test_transfer_service.py @@ -7,7 +7,7 @@ from blueman.plugins.applet.TransferService import Agent, TransferService, reserve_destination -def _make_agent() -> Agent: +def _make_agent(resolve_device: object = None) -> Agent: agent = Agent.__new__(Agent) agent._allowed_devices = set() agent._notification = None @@ -16,10 +16,7 @@ def _make_agent() -> Agent: config = MagicMock() config.__getitem__.side_effect = lambda key: True if key == "opp-accept" else "" agent._config = config - agent._applet = MagicMock() - device = agent._applet.Manager.find_device.return_value - device.display_name = "Phone" - device.__getitem__.side_effect = lambda key: True if key == "Trusted" else None + agent._resolve_device = resolve_device or (lambda source, address: ("Phone", True)) return agent @@ -129,8 +126,7 @@ def test_removes_captured_address_not_current_pending(self, transfer_mock: Magic @patch("blueman.plugins.applet.TransferService.Transfer") class TestOverlappingPending(TestCase): def _setup_two_untrusted(self, agent: Agent, transfer_mock: MagicMock, session_mock: MagicMock) -> None: - device = agent._applet.Manager.find_device.return_value - device.__getitem__.side_effect = lambda key: False if key == "Trusted" else None + agent._resolve_device = lambda source, address: ("Phone", False) def make_transfer(obj_path: str) -> MagicMock: transfer = MagicMock() @@ -225,3 +221,49 @@ def test_fuzz_weird_names_stay_unique_and_safe(self) -> None: # Reserved name must stay within the destination directory. self.assertEqual(first.parent, self.dir) self.assertEqual(second.parent, self.dir) + + +class TestDeviceResolverInjection(TestCase): + def test_plugin_resolver_delegates_to_manager(self) -> None: + plugin = TransferService.__new__(TransferService) + plugin.parent = MagicMock() + device = plugin.parent.Manager.find_device.return_value + device.display_name = "Watch" + device.__getitem__.side_effect = lambda key: True if key == "Trusted" else None + + name, trusted = plugin._resolve_device("/org/bluez/hci0", "AA:BB:CC:DD:EE:FF") + + self.assertEqual((name, trusted), ("Watch", True)) + plugin.parent.Manager.get_adapter.assert_called_once_with("/org/bluez/hci0") + plugin.parent.Manager.find_device.assert_called_once() + + @patch("blueman.plugins.applet.TransferService.GLib") + @patch("blueman.plugins.applet.TransferService.Notification") + @patch("blueman.plugins.applet.TransferService.Session") + @patch("blueman.plugins.applet.TransferService.Transfer") + def test_agent_uses_injected_resolver(self, transfer_mock: MagicMock, session_mock: MagicMock, + _notification_mock: MagicMock, _glib_mock: MagicMock) -> None: + resolver = MagicMock(return_value=("Laptop", True)) + agent = _make_agent(resolver) + _configure_transfer(transfer_mock, session_mock, address="AA:BB:CC:DD:EE:FF") + + agent._authorize_push("/transfer", MagicMock(), MagicMock()) + + resolver.assert_called_once_with("/org/bluez/hci0", "AA:BB:CC:DD:EE:FF") + self.assertFalse(hasattr(agent, "_applet")) + + @patch("blueman.plugins.applet.TransferService.GLib") + @patch("blueman.plugins.applet.TransferService.Notification") + @patch("blueman.plugins.applet.TransferService.Session") + @patch("blueman.plugins.applet.TransferService.Transfer") + def test_resolver_failure_falls_back_to_address(self, transfer_mock: MagicMock, session_mock: MagicMock, + notification_mock: MagicMock, _glib_mock: MagicMock) -> None: + resolver = MagicMock(side_effect=RuntimeError("no device")) + agent = _make_agent(resolver) + agent._config.__getitem__.side_effect = lambda key: False # force the confirmation path + _configure_transfer(transfer_mock, session_mock, address="AA:BB:CC:DD:EE:FF", size=10) + + agent._authorize_push("/transfer", MagicMock(), MagicMock()) + + body = notification_mock.call_args.args[1] + self.assertIn("AA:BB:CC:DD:EE:FF", body) # falls back to the raw address as the display name From 263a788210ea7a5659dddfadef7d0a9ef6056d1e Mon Sep 17 00:00:00 2001 From: Geraldo Netto Date: Sat, 20 Jun 2026 12:37:39 +0200 Subject: [PATCH 37/42] test: cover OBEX transfer authorization and completion paths (test-3) Add focused unit tests for the incoming-transfer paths that previously had none: overlapping authorization requests, allowed-device expiry, filename collisions, failed final moves, transfer counters, session summaries, auto-accept, agent control, and share-path resolution. They exercise the helpers extracted for data-1/sm-8/dec-1 with mocked Transfer, Session, and Notification. Module statement coverage is now 83% (from 51%). Co-Authored-By: Claude Opus 4.8 (1M context) --- TODO.md | 1 - test/plugins/applet/test_transfer_service.py | 211 +++++++++++++++++++ 2 files changed, 211 insertions(+), 1 deletion(-) diff --git a/TODO.md b/TODO.md index 05f54f5c6..2c63efe6f 100644 --- a/TODO.md +++ b/TODO.md @@ -335,7 +335,6 @@ _(none open)_ | id | status | effort | description | notes | |----|--------|--------|-------------|-------| -| test-3 | open | M | Incoming OBEX transfer authorization and completion paths in `blueman/plugins/applet/TransferService.py:78-123,286-329` have no focused tests for overlapping requests, allowed-device expiry, filename collisions, or failed final moves. Current coverage would miss data-1, rel-10, and sm-8. | Extract testable helpers for pending-transfer records and destination selection; add unit tests with mocked `Transfer`, `Session`, and notifications. | | test-4 | open | S | No tests cover `blueman/gui/Animation.py` timer source lifecycle. The `start()`/`stop()` path can leak sources if `start()` is called repeatedly, and current tests would not detect it. | Add a focused test with mocked `GLib.timeout_add`/`source_remove` for idempotent start and complete cleanup. Cross-ref rob-8. | | test-2 | open | S | No tests cover `BluezAgent._on_display_passkey` boundary values for `entered`. `blueman/main/applet/BluezAgent.py:201-203` indexes `key[entered]`, so an out-of-range or fully-entered value can crash the agent notification path. | Add focused tests for `entered` values 0, 5, 6, and invalid values; clamp or render without bolding when all digits are entered. | | test-1 | open | S | No tests cover `sendto/blueman_sendto.py.in` command construction for selected file paths. The quoting bug in cmd-1 would pass unnoticed for paths with quotes, semicolons, or leading dashes. | Add a small unit test around the file-list-to-launch-command path after extracting it into a pure helper. Cross-ref cmd-1. | diff --git a/test/plugins/applet/test_transfer_service.py b/test/plugins/applet/test_transfer_service.py index 0c6ad7b32..7a715efcd 100644 --- a/test/plugins/applet/test_transfer_service.py +++ b/test/plugins/applet/test_transfer_service.py @@ -267,3 +267,214 @@ def test_resolver_failure_falls_back_to_address(self, transfer_mock: MagicMock, body = notification_mock.call_args.args[1] self.assertIn("AA:BB:CC:DD:EE:FF", body) # falls back to the raw address as the display name + + +@patch("blueman.plugins.applet.TransferService.Notification") +class TestTransferCompleted(TestCase): + def setUp(self) -> None: + self._tmp = tempfile.TemporaryDirectory() + self.addCleanup(self._tmp.cleanup) + root = Path(self._tmp.name) + self.dest_dir = root / "Downloads" + self.dest_dir.mkdir() + self.src = root / "incoming.bin" + self.src.write_text("payload") + + def _plugin(self, size: int = 10) -> TransferService: + plugin = TransferService.__new__(TransferService) + plugin._agent = MagicMock() + plugin._agent.transfers = {"/t": {"path": self.src, "size": size, "name": "Phone"}} + plugin._normal_transfers = 0 + plugin._silent_transfers = 1 + plugin._notification = None + return plugin + + def test_unauthorized_transfer_ignored(self, notification_mock: MagicMock) -> None: + plugin = self._plugin() + plugin._agent.transfers = {} + plugin._on_transfer_completed(MagicMock(), "/t", True) + notification_mock.assert_not_called() + + def test_success_moves_file_and_notifies(self, notification_mock: MagicMock) -> None: + plugin = self._plugin() + with patch.object(TransferService, "_make_share_path", return_value=(self.dest_dir, False)): + plugin._on_transfer_completed(MagicMock(), "/t", True) + self.assertTrue((self.dest_dir / "incoming.bin").exists()) + self.assertFalse(self.src.exists()) + notification_mock.assert_called_once() + self.assertNotIn("/t", plugin._agent.transfers) + + def test_failed_move_cleans_placeholder_and_decrements(self, notification_mock: MagicMock) -> None: + plugin = self._plugin(size=10) + with patch.object(TransferService, "_make_share_path", return_value=(self.dest_dir, False)), \ + patch("blueman.plugins.applet.TransferService.shutil.move", side_effect=OSError("boom")): + plugin._on_transfer_completed(MagicMock(), "/t", True) + self.assertEqual(list(self.dest_dir.iterdir()), []) # reserved placeholder cleaned up + self.assertEqual(plugin._silent_transfers, 0) + self.assertNotIn("/t", plugin._agent.transfers) + + def test_failed_move_decrements_normal_for_large_file(self, notification_mock: MagicMock) -> None: + plugin = self._plugin(size=400000) + plugin._normal_transfers = 1 + plugin._silent_transfers = 0 + with patch.object(TransferService, "_make_share_path", return_value=(self.dest_dir, False)), \ + patch("blueman.plugins.applet.TransferService.shutil.move", side_effect=PermissionError): + plugin._on_transfer_completed(MagicMock(), "/t", False) + self.assertEqual(plugin._normal_transfers, 0) + + +class TestTransferStarted(TestCase): + def _plugin(self, size: int) -> TransferService: + plugin = TransferService.__new__(TransferService) + plugin._agent = MagicMock() + plugin._agent.transfers = {"/t": {"path": Path("/x"), "size": size, "name": "Phone"}} + plugin._normal_transfers = 0 + plugin._silent_transfers = 0 + return plugin + + def test_large_file_counts_as_normal(self) -> None: + plugin = self._plugin(400000) + plugin._on_transfer_started(MagicMock(), "/t") + self.assertEqual((plugin._normal_transfers, plugin._silent_transfers), (1, 0)) + + def test_small_file_counts_as_silent(self) -> None: + plugin = self._plugin(10) + plugin._on_transfer_started(MagicMock(), "/t") + self.assertEqual((plugin._normal_transfers, plugin._silent_transfers), (0, 1)) + + def test_unauthorized_transfer_ignored(self) -> None: + plugin = self._plugin(10) + plugin._agent = None + plugin._on_transfer_started(MagicMock(), "/t") # must not raise + + +@patch("blueman.plugins.applet.TransferService.Notification") +class TestSessionRemoved(TestCase): + def _plugin(self, silent: int, normal: int) -> TransferService: + plugin = TransferService.__new__(TransferService) + plugin._silent_transfers = silent + plugin._normal_transfers = normal + plugin._notification = None + return plugin + + def test_no_silent_transfers_does_nothing(self, notification_mock: MagicMock) -> None: + plugin = self._plugin(silent=0, normal=0) + plugin._on_session_removed(MagicMock(), "/sess") + notification_mock.assert_not_called() + + def test_only_silent_transfers_notifies(self, notification_mock: MagicMock) -> None: + plugin = self._plugin(silent=2, normal=0) + with patch.object(TransferService, "_make_share_path", return_value=(Path("/dl"), False)): + plugin._on_session_removed(MagicMock(), "/sess") + notification_mock.assert_called_once() + + def test_mixed_transfers_notifies_more_variant(self, notification_mock: MagicMock) -> None: + plugin = self._plugin(silent=1, normal=1) + with patch.object(TransferService, "_make_share_path", return_value=(Path("/dl"), False)): + plugin._on_session_removed(MagicMock(), "/sess") + notification_mock.assert_called_once() + + +class TestAgentControl(TestCase): + def test_cancel_closes_notification_and_raises(self) -> None: + from blueman.plugins.applet.TransferService import ObexErrorCanceled + agent = _make_agent() + agent._notification = MagicMock() + with self.assertRaises(ObexErrorCanceled): + agent._cancel() + agent._notification.close.assert_called_once() + + def test_release_raises(self) -> None: + agent = _make_agent() + with self.assertRaises(Exception): + agent._release() + + +@patch("blueman.plugins.applet.TransferService.GLib") +class TestMakeSharePath(TestCase): + def _plugin(self, configured: str) -> TransferService: + plugin = TransferService.__new__(TransferService) + config = MagicMock() + config.__getitem__.side_effect = lambda key: configured + config.__setitem__ = MagicMock() + plugin._config = config + return plugin + + def test_empty_config_uses_download_dir(self, glib_mock: MagicMock) -> None: + glib_mock.get_user_special_dir.return_value = "/dl" + plugin = self._plugin("") + path, error = plugin._make_share_path() + self.assertEqual(path, Path("/dl")) + self.assertFalse(error) + + def test_invalid_dir_flags_error(self, glib_mock: MagicMock) -> None: + glib_mock.get_user_special_dir.return_value = "/dl" + plugin = self._plugin("/does/not/exist/here") + path, error = plugin._make_share_path() + self.assertEqual(path, Path("/dl")) + self.assertTrue(error) + + def test_valid_dir_used(self, glib_mock: MagicMock) -> None: + glib_mock.get_user_special_dir.return_value = "/dl" + with tempfile.TemporaryDirectory() as tmp: + plugin = self._plugin(tmp) + path, error = plugin._make_share_path() + self.assertEqual(path, Path(tmp)) + self.assertFalse(error) + + +@patch("blueman.plugins.applet.TransferService.GLib") +@patch("blueman.plugins.applet.TransferService.Notification") +@patch("blueman.plugins.applet.TransferService.Session") +@patch("blueman.plugins.applet.TransferService.Transfer") +class TestAutoAccept(TestCase): + def test_trusted_large_file_auto_accepts_with_notification(self, transfer_mock: MagicMock, session_mock: MagicMock, + notification_mock: MagicMock, + _glib_mock: MagicMock) -> None: + agent = _make_agent() # default resolver -> trusted + _configure_transfer(transfer_mock, session_mock, address="AA:BB:CC:DD:EE:FF", size=400001) + ok = MagicMock() + agent._authorize_push("/t", ok, MagicMock()) + ok.assert_called_once() + self.assertIn("/t", agent.transfers) + self.assertIn("AA:BB:CC:DD:EE:FF", agent._allowed_devices) + notification_mock.assert_called_once() + + +class TestRegisterAgent(TestCase): + @patch("blueman.plugins.applet.TransferService.Agent") + def test_register_then_unregister(self, agent_cls: MagicMock) -> None: + plugin = TransferService.__new__(TransferService) + plugin._agent = None + plugin._register_agent() + agent_cls.assert_called_once_with(plugin._resolve_device) + agent_cls.return_value.register_at_manager.assert_called_once() + agent = plugin._agent + plugin._unregister_agent() + agent.unregister_from_manager.assert_called_once() + agent.unregister.assert_called_once() + self.assertIsNone(plugin._agent) + + +@patch("blueman.plugins.applet.TransferService.launch") +@patch("blueman.plugins.applet.TransferService.Notification") +class TestOpenAction(TestCase): + def test_open_action_launches_xdg_open(self, notification_mock: MagicMock, launch_mock: MagicMock) -> None: + with tempfile.TemporaryDirectory() as tmp: + dest_dir = Path(tmp) + src = dest_dir / "incoming.bin" + src.write_text("x") + plugin = TransferService.__new__(TransferService) + plugin._agent = MagicMock() + plugin._agent.transfers = {"/t": {"path": src, "size": 10, "name": "Phone"}} + plugin._normal_transfers = 0 + plugin._silent_transfers = 1 + plugin._notification = None + notification_mock.return_value.actions_supported = True + dst = dest_dir / "out" + dst.mkdir() + with patch.object(TransferService, "_make_share_path", return_value=(dst, False)): + plugin._on_transfer_completed(MagicMock(), "/t", True) + on_open = notification_mock.return_value.add_action.call_args.args[2] + on_open("open") + launch_mock.assert_called_once() From 1b37e86c7bbb616982ee78d35d5916e9b9de1b6f Mon Sep 17 00:00:00 2001 From: Geraldo Netto Date: Sat, 20 Jun 2026 12:46:24 +0200 Subject: [PATCH 38/42] chore: drop rescan scaffolding docs from this branch Remove AGENTS.md, CLAUDE.md, and TODO.md; they are project-rescan-todo scaffolding, not part of the TransferService changes. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 200 ----------------------- CLAUDE.md | 10 -- TODO.md | 461 ------------------------------------------------------ 3 files changed, 671 deletions(-) delete mode 100644 AGENTS.md delete mode 100644 CLAUDE.md delete mode 100644 TODO.md diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index ae3df6e9c..000000000 --- a/AGENTS.md +++ /dev/null @@ -1,200 +0,0 @@ -# Agents Behavior Guide - -This file defines the expected behavior and usage model for AI agents working in this repository. - -## Purpose - -- Provide a standard set of guidelines for agent interactions. -- Ensure consistent behavior when using AI tooling in this workspace. - -## General Agent Behavior - -- Always be polite and concise. -- Prefer short, actionable responses. -- Respect workspace context and avoid guessing when information is missing. -- When making code changes, clearly describe what was changed and why. -- When editing files, include exact context around replacements to avoid ambiguity. - -## Rules - -- Don't assume. Don't hide confusion. Surface tradeoffs and ask the user when unclear. -- Write the minimum code that solves the problem. Avoid speculative or unneeded changes. -- Touch only what you must. Clean up only your own mess and leave the workspace cleaner than you found it. -- Define success criteria before making changes. Verify against those criteria and iterate until satisfied. -- Keep code complexity <= 10 for any new function, class, or method. -- Avoid code duplication and apply SOLID principles where practical. -- Document assumptions, constraints, and design intent in comments or commit notes when they matter. -- Do not add comments that restate what the code plainly says. Comment only the non-obvious: why a choice was made, a constraint, or a subtle edge case. Delete redundant comments rather than write them. -- Prefer explicit, maintainable solutions over clever shortcuts. -- Propose business/design patterns and DDD only when they improve clarity or structure. -- ALWAYS record review findings in `TODO.md` — never report them only in chat. Any time you - scan, review, audit, or "look for issues" (not just major changes), add each finding to the - matching category table in `TODO.md` before/while reporting it. -- ALWAYS remove completed items from `TODO.md` — once a finding is implemented + tested + merged, - delete its row from the table outright. No "shipped" sub-sections, no struck-through entries. - `git log` is the durable record. Exceptions: the "Open — parked" section keeps open-but-deferred - items with a why-not-now annotation; the "Audit picks deliberately rejected" section keeps the - rationale so future passes don't re-pick the same items. -- When making major changes, rescan the whole project and create or update `TODO.md` with one - table per review category defined below. Each table uses the format: - `id | status | effort | description | notes`. -- Keep every `TODO.md` table sorted by the `description` column. Each description starts with the - affected `file:line`, so sorting clusters findings in the same file together — letting related - items be fixed in one batch. Re-sort a table whenever you add or edit its rows. - -### Category definitions - -Use the lens that fits the finding; when a category names a framework, cite the specific -framework/law in the finding's `notes`. - -- **security** — injection boundaries (command, path traversal, D-Bus/IPC input) and - privilege boundaries (the polkit mechanism, setuid/root helpers). Every code path that - acts on untrusted input validates it first. Threat-model through complementary lenses - and name the one used: **STRIDE** (Spoofing, Tampering, Repudiation, Information - disclosure, Denial of service, Elevation of privilege) per data-flow boundary; the - **OWASP ASVS** checklist where it maps; and **attack trees** to decompose a high-value - target (gain root via the mechanism, spoof a device, intercept a connection) into - concrete leaf attacks. -- **input validation / command safety** — network and device inputs are validated before - they are used to build shell, `iptables`, AT, or D-Bus commands; no argument injection - via embedded spaces/newlines; command arguments are passed as argv lists, never a - split string. -- **data integrity** — in-memory/UI state (e.g. the device liststore) stays consistent - with the underlying bluez/system state: no stale row points at a removed device, signal - handlers keep derived state in sync on add/remove/rename. -- **data governance** — no private absolute paths (`/home/…`), secrets, or API keys are - committed, with a guard (CI grep / pre-commit) enforcing it; logs minimize sensitive - identifiers (BT addresses, object paths) to what is needed and never leak them beyond - the local session. -- **reliability / correctness** — logic bugs under normal flow. -- **robustness / recovery** — kill-safety, atomic writes (write-temp-then-rename), - partial-state recovery (a dying process or interrupted operation can't corrupt state or - orphan a resource), and cleanup of orphaned resources. -- **dependability** — stays useful when a dependency, provider, or optional subsystem - fails: graceful degradation, retry/backoff with timeout coverage, fallback chains that - stop before they amplify damage or hide partial failure. -- **observability / operability** — failures are *surfaced*, not merely logged; every - background mechanism exposes liveness + last result; D-Bus timeouts are sensible and - logging is actionable. Assess via the three pillars (logs / metrics / health) scaled to - a desktop app, and a silent-failure audit — enumerate every way the system can degrade - with no user-visible symptom. -- **concurrency** — concurrency-correctness on shared state and coordination; guards - against interleaved updates and double-submit. -- **multithreading** — thread-safety and thread-resource issues beyond concurrency: - background-thread lifecycle, swallowed futures whose exceptions are never checked, lock - granularity, and GLib main-loop vs worker-thread boundaries. -- **distributed systems** — multi-process coordination across the applet / mechanism / - services split (even on one box): lock correctness, idempotent re-runs, shared-resource - contention, and partial-write durability across processes. -- **watchdog** — liveness/stall detection for long-running operations (DHCP, PPP, - transfers, scans): timeouts, heartbeats, progress-stall detection, and automatic - abort/recovery semantics. -- **state machine integrity** — every lifecycle transition (connect/disconnect, adapter - power, agent pairing, lock/unlock, transfer/download) guards illegal transitions, - prevents terminal-state re-entry, and cleans up on every error path — not just the - cancel path. -- **time & scheduling correctness** — elapsed-time math uses a monotonic clock; timeouts - and intervals (D-Bus timeouts, autoconnect interval, speed sampling) are keyed so replay - or clock skew never double-fires or stalls; guard zero/negative elapsed time. -- **platform** — cross-distro/runtime portability: POSIX-only primitives, signal - handling, and version assumptions on GLib/GTK/PyGObject, bluez, and D-Bus availability; - production-vs-local divergence. -- **performance** — bottlenecks on hot paths (device-list render, signal handling, repeated - property reads). -- **scalability** — behavior as adapters, devices, batteries, and signal traffic grow. -- **N+1 / call efficiency** — avoid per-row repeated D-Bus property `Get` calls where one - `GetAll`/cached read suffices; batch lookups; UI refreshes don't fan out one IPC round-trip - per item. -- **caching strategy** — every cache declares key shape + size cap + invalidation trigger - + a public reset hook; derived UI state invalidates on the source signal. -- **data structure** — right structures on hot paths: sets/maps for membership, no O(N²) - dedup, no per-item re-parse where a cache belongs. -- **memory and cpu management** — peak memory, streaming vs materialization, and CPU-heavy - work kept off the GLib main loop. -- **code complexity** — cognitive complexity ≤ 10; fat methods split into helpers. -- **code duplication** — shared logic (input validation, D-Bus read/write, command - building) lives in one place, not copy-pasted across modules. -- **architecture / modularity / SOLID** — proper boundaries: GUI thin, business logic in - services, D-Bus/system access behind the bluez layer, no logic buried in widget code. -- **system design** — end-to-end subsystem boundaries and feedback loops: whether the - architecture preserves isolation, operability, and extension seams across module - boundaries. -- **decoupling** — separation of concerns across module seams; the bluez layer, GUI, and - plugins are independently testable. -- **composition** — prefer small collaborators and explicit composition over god - objects, inheritance-heavy shapes, and copy-pasted registries when that reduces - coupling. -- **dependency** — third-party and optional imports are justified, pinned sensibly, and - degrade gracefully when absent; a vendor dependency sits behind an app-owned adapter - rather than being imported/`new`-ed across the codebase. -- **configuration discoverability** — every runtime knob (GSettings / config) has a - default, a typed accessor, documented deployment coverage, validation where needed, and - tests for security-sensitive defaults. -- **API contract & compatibility** — D-Bus and other IPC surfaces are reviewed as - compatibility artifacts, not just docs: introspection ⇄ implementation parity in both - directions, the full error/signal surface declared, stable signatures, and breaking - changes that are deliberate, named, and versioned. -- **CLI / option integrity** — command options, help text, and defaults match actual - behavior across the `blueman-*` entry points; ignored or misleading flags are findings. -- **wiring gaps** — shipped classes, services, plugins, commands, or signal handlers that - exist and pass tests but are not connected to the runtime path expected by docs or - tests. A feature is "shipped" only when the dispatcher actually invokes it. -- **unused code** — public-shaped methods/handlers with no caller, no test, no view; each - finding records keep / inline / delete. -- **unused functions/methods** — narrower grep-proven dead or test-only callable symbols - (no leading `_`, imported by no production code), including `__init__` re-exports no - caller pulls; each finding records delete / wire / intentionally keep in `notes`. -- **legacy / deprecation** — back-compat shims whose constituency is grep-proven gone are - flagged to remove; still-live shims are recorded as "do not remove" with the live caller - so a future pass doesn't re-pick them. -- **plugin extensibility** — advertised extension points (applet / manager / mechanism - plugins) stay open through registries and documented contracts rather than closed - `if`/`switch` dispatch or private-only hooks. -- **adaptability** — hardcoded assumptions that block change without a code edit: magic - numbers, locale/timeout/path constants, and lookup maps that should be config or a - documented invariant. -- **business / design patterns / DDD** — apply patterns only when they remove a concrete - pain; a missing pattern is a finding only when a named pattern would clarify a real - boundary or lifecycle. -- **release & deploy engineering** — the path from green CI to a healthy installed build - is engineered, not improvised: CI gates fail closed and mirror reality (job ordering, - smoke tests against the real build, pinned actions, reproducible builds from committed - lockfiles), with a documented upgrade/rollback story for the meson + autotools - packaging. -- **UI / UX** — GTK surfaces render without dead controls; empty/loading/error states are - handled; keyboard-first flows work. Assess through the **Laws of UX** - ([lawsofux.com](https://lawsofux.com/)) and cite the relevant law per finding (Fitts's - Law, Hick's Law, Jakob's Law, Doherty Threshold, Miller's Law, etc.). -- **accessibility** — semantic widgets, ATK/ARIA on interactive controls, keyboard - navigation, focus management, and sufficient contrast. -- **product engineering** — shipped-default sanity, setup/onboarding friction, actionable - runtime failures, and docs-vs-behavior drift from an end-user perspective. -- **design thinking** — user-centered empty/loading/error states, recovery paths, and - decisions grounded in observed user needs rather than internal convenience. -- **documentation** — `README`, man pages, and setup docs stay truthful: documented - commands work as written and advertised features/flags match the code. -- **i18n** — UI strings route through the gettext catalog; no hard-coded English on user - surfaces. -- **purpose** — mission alignment to a Bluetooth manager; scope-creep subsystems flagged. -- **test coverage** — new functions carry focused unit/feature coverage before merge - (≥80% target in CI); critical paths — input validation, command building, D-Bus signal - handling, device-list updates — carry focused tests. -- **test / fuzz coverage** — property/fuzz/adversarial coverage exists for parsers and - command builders (`ps` output, AT/`iptables`/shell argument construction, network - inputs), concurrency, and IPC contracts; counts toward the same coverage gate. - -## File Editing - -- Avoid overwriting existing files unless the user explicitly asks or the file is missing. -- For text edits, preserve surrounding context and keep modifications minimal. -- Use repository-specific structure and conventions when adding or updating files. - -## Communications - -- Use headings and bullets for readability. -- Highlight changed files and key points. -- Keep final answers brief and professional. - -## References - -- This workspace currently contains only a small Python utility script, so agent actions should remain lightweight and focused. diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 01db940e5..000000000 --- a/CLAUDE.md +++ /dev/null @@ -1,10 +0,0 @@ -# Claude Agent Reference - -This document refers to the repository's agent behavior guidelines. - -For the canonical agent behavior rules, see `AGENTS.md`. - -## Usage - -- When interacting with this workspace, follow the behavior defined in `AGENTS.md`. -- Use `AGENTS.md` as the primary source for agent conduct, editing norms, and response expectations. diff --git a/TODO.md b/TODO.md deleted file mode 100644 index 2c63efe6f..000000000 --- a/TODO.md +++ /dev/null @@ -1,461 +0,0 @@ -# TODO - -Project-wide rescan findings per AGENTS.md categories. Format: `id | status | effort | description | notes`. - -Status: `open`, `in-progress`, `blocked`. Effort: `S` (≤1h), `M` (half-day), `L` (≥1 day). - ---- - -## security - -| id | status | effort | description | notes | -|----|--------|--------|-------------|-------| - -## input validation / command safety - -| id | status | effort | description | notes | -|----|--------|--------|-------------|-------| -| cmd-1 | open | S | `sendto/blueman_sendto.py.in:20-28` builds a shell-style command line by wrapping file paths in double quotes and passing the joined string to `Gio.AppInfo.create_from_commandline`. A filename containing quotes or command separators can break argument boundaries when launched through the desktop shell parser. | Build a `Gio.AppInfo`/`Gio.Subprocess` invocation from an argv vector, or escape with GLib shell-quoting for every path. Add a regression test with spaces, quotes, and semicolons in filenames. Cross-ref test-1. | - -## data integrity - -| id | status | effort | description | notes | -|----|--------|--------|-------------|-------| -| data-2 | open | S | `blueman/plugins/manager/Notes.py:32-35` creates a `.vnt` temporary file with `delete=False` and relies on the launched sendto process to delete it. If launch fails or the process never starts, the note body remains in `/tmp` indefinitely. | Delete the temp file when `launch()` returns false or raises; consider creating it in an app-owned temp directory with cleanup on startup. Cross-ref gov-5. | - -## performance - -| id | status | effort | description | notes | -|----|--------|--------|-------------|-------| -| perf-5 | open | M | `blueman/bluez/Manager.py:115-149` `get_adapter_paths`/`get_devices` iterate `_object_manager.get_objects()` per call | cache, invalidate on object-added/removed | -| perf-10 | open | S | `blueman/gui/manager/ManagerMenu.py:53` creates Adapter proxies for all adapters in `__init__` | lazy-instantiate on selection | -| perf-13 | open | S | `blueman/main/Applet.py:93-118` plugin broadcast loop runs full plugin set per property change → O(plugins × props × devices) | debounce/batch property events | -| perf-9 | open | S | `blueman/main/DhcpClient.py:48-50,68` `subprocess.poll()` blocking in 1s `GLib.timeout` | use `Gio.Subprocess` + `wait_check_async` or `GLib.child_watch_add` | -| perf-11 | open | S | `blueman/main/Manager.py:161-164` `find_device()` linear scan over all objects | address-indexed dict | -| perf-1 | open | M | `blueman/main/ManagerStats.py:107` polls device stats via `GLib.timeout_add(1000, ...)` every second | switch to event-driven update or `timeout_add_seconds` | - -## scalability - -| id | status | effort | description | notes | -|----|--------|--------|-------------|-------| -| scale-3 | open | S | `blueman/main/BatteryWatcher.py:18` creates `Battery` per creation signal without dedup | check existence before create | -| scale-1 | open | M | `blueman/main/Manager.py:149-159` `populate_devices` emits per-device add signal serially | single batch signal | -| scale-2 | open | S | `blueman/main/PulseAudioUtils.py:216-218` PA subscribe callback fires unthrottled on rapid card changes | debounce | - -## caching strategy - -| id | status | effort | description | notes | -|----|--------|--------|-------------|-------| - -## concurrency - -| id | status | effort | description | notes | -|----|--------|--------|-------------|-------| -| conc-3 | open | S | `blueman/main/PulseAudioUtils.py:372-379` `weakref.proxy(self)` in callback silently no-ops if GC'd | hold hard ref or explicit lifecycle | - -## code complexity - -| id | status | effort | description | notes | -|----|--------|--------|-------------|-------| -| cx-3 | open | L | `blueman/gui/manager/ManagerDeviceList.py:412-540` 4 coupled power-level methods (>100 LOC) | extract `PowerLevelMonitor` class | -| cx-1 | open | M | `blueman/gui/manager/ManagerDeviceList.py:453` `row_update_event` 7-elif on property name | dict dispatch `{key: handler}` | -| cx-5 | open | M | `blueman/gui/manager/ManagerDeviceList.py:553` `tooltip_query` ~102 LOC nested conditions | extract `TooltipBuilder` | -| cx-2 | open | M | `blueman/main/Manager.py:224` `simple_action()` 13-case match mixes routing + business logic | extract `{action: (handler, needs_device)}` table | -| cx-4 | open | M | `blueman/main/PluginManager.py:132-174` `__load_plugin` ~43 LOC, 15+ conditionals (deps/conflicts/priority) | extract `PluginDependencyResolver` | -| cx-6 | open | S | `blueman/main/Services.py:58` `on_query_apply_state` returns -1/bool mixed protocol | replace with `ApplyState` enum | - -## code duplication - -| id | status | effort | description | notes | -|----|--------|--------|-------------|-------| -| dup-4 | open | S | `blueman/gui/manager/ManagerDeviceList.py:498-540` `_update_power_levels` + `_disable_power_levels` duplicate bar lookup | extract `BarRenderer` | -| dup-5 | open | S | `blueman/gui/manager/ManagerDeviceList.py:655-677` `_set_cell_data` repeats if/elif for battery/rssi/tpl | polymorphic bar renderers | -| dup-2 | open | S | `blueman/gui/manager/ManagerDeviceMenu.py:141-188` `connect_service`/`disconnect_service` duplicate nested success/error callbacks | extract async-DBus template | -| dup-6 | open | S | `blueman/main/Applet.py:78-90` `_on_dbus_name_appeared/_vanished` repeat plugin notify loop | `_notify_manager_state_change(state)` | -| dup-1 | open | M | `blueman/main/Applet.py:92-118` 8× identical plugin broadcast loops | `_broadcast(event, *args)` helper | -| dup-8 | open | S | `blueman/main/Services.py:86` bare `except:` with `# noqa: E722` | narrow to expected exceptions | - -## API contract & compatibility - -| id | status | effort | description | notes | -|----|--------|--------|-------------|-------| -| api-1 | open | S | `blueman/main/DBusProxies.py:91` exposes the Python proxy method as `dchp_client`, while the DBus method and interface are `DhcpClient`. The typo is now part of the local Python call surface and makes future refactors/API docs error-prone. | Add correctly spelled `dhcp_client()` as the public method, keep `dchp_client()` as a deprecated alias until callers/tests migrate, then remove the alias in a later cleanup. | - -## architecture/modularity/SOLID - -| id | status | effort | description | notes | -|----|--------|--------|-------------|-------| -| arch-8 | open | S | `blueman/gui/manager/ManagerDeviceList.py:334-351` UI-formatting `@staticmethod`s placed on liststore class | move to `DeviceDisplayFormatter` | -| arch-7 | open | S | `blueman/gui/manager/ManagerDeviceMenu.py:64-65` `__ops__`/`__instances__` class-level globals | DI or event-emitter | -| arch-1 | open | L | `blueman/main/Applet.py:25-148` `BluemanApplet` is God object (init Manager, plugins, broadcasts, state) | extract `PluginBroadcaster`, `ManagerWatcher` | -| arch-2 | open | L | `blueman/main/Manager.py:37-363` `Blueman` mixes lifecycle, UI, device actions, settings | split into `ManagerUI`, `DeviceActionHandler`, `SettingsManager` | -| arch-5 | open | S | `blueman/main/MechanismApplication.py:15-39` Timer reads `BLUEMAN_SOURCE` env var for test mode | subclass `TestTimer` or inject duration | -| arch-4 | open | M | `blueman/main/MechanismApplication.py:42-100` mixes timer, PolicyKit, plugin loading, DBus registration | extract `TimerManager`, `PluginLoader` | -| arch-6 | open | S | `blueman/main/PluginManager.py:139,200` raise bare `Exception(...)` | introduce `PluginDependencyError`, `PluginError` | -| arch-3 | open | M | `blueman/main/PluginManager.py:176` `__getattr__` magic for plugin lookup breaks IDE/refactor | explicit `get_plugin(name)` accessor | - -## decoupling - -| id | status | effort | description | notes | -|----|--------|--------|-------------|-------| -| dec-3 | open | S | `blueman/plugins/applet/AutoConnect.py:62` `self.parent.Manager.find_device()` reach-through | `parent.find_device_by_address(addr)` API | -| dec-4 | open | S | `blueman/plugins/applet/KillSwitch.py:147-149` direct `self.parent.Plugins.StatusIcon/PowerManager` access | optional plugin query w/ fallback | -| dec-6 | open | S | `blueman/plugins/AppletPlugin.py:32` hardcoded fallback icon name | constant + GSettings override | -| dec-5 | open | S | `blueman/plugins/manager/Services.py:82` plugin discovery via `ServicePlugin.__subclasses__()` | registry or `importlib.metadata.entry_points` | -| dec-2 | open | M | `blueman/plugins/manager/Services.py:8` `ManagerPlugin` imports `ManagerDeviceMenu`, `MenuItemsProvider` (GUI layer) | event-based provider interface | - -## business/design patterns/DDD - -| id | status | effort | description | notes | -|----|--------|--------|-------------|-------| -| pat-2 | open | S | `on_query_apply_state` magic-return protocol | State enum (DDD value object) | -| pat-1 | open | M | `row_update_event`, `simple_action`, `_set_cell_data` all have type/key switch ladders | Strategy or dispatch-table | -| pat-3 | open | M | Plugin lifecycle scattered (load/unload/deps/conflicts/state) | introduce `PluginLifecycle` state machine | - -## reliability/correctness - -| id | status | effort | description | notes | -|----|--------|--------|-------------|-------| -| rel-11 | open | S | `blueman/main/applet/BluezAgent.py:201-203` indexes `key[entered]` when displaying a passkey. If BlueZ reports `entered == 6` after all digits are typed, or an invalid value, the notification path raises `IndexError`. | Clamp `entered` to the valid range and render the fully-entered passkey without bolding a missing digit. Cross-ref test-2. | -| rel-9 | open | S | `blueman/main/Services.py:86` bare `except: pass` hides errors | narrow exception types | -| rel-12 | open | S | `blueman/plugins/BasePlugin.py:50` registers `weakref.finalize(self, self._on_plugin_delete)`. Passing a bound method keeps `self` strongly referenced by the finalizer, so plugin instances may not be collected and the delete hook is unreliable. | Register a module-level/static cleanup callback with weak state, or rely on explicit plugin unload and remove the finalizer. | - -## observability - -| id | status | effort | description | notes | -|----|--------|--------|-------------|-------| -| obs-4 | open | S | `blueman/bluez/obex/Manager.py:51,59,68,75` `logging.info(object_path)` lacks event/context | prefix with event name | -| obs-10 | open | S | `blueman/gui/GenericList.py:116` silent `ValueError` from `get_iter` | `logging.debug` invalid path | -| obs-9 | open | S | `blueman/gui/GtkAnimation.py:79` silent `ZeroDivisionError` on duration=0 | `logging.debug("Animation duration zero")` | -| obs-7 | open | S | `blueman/gui/Notification.py:169` silent `ValueError` on notification hints | `logging.debug` unsupported hint | -| obs-11 | open | S | `blueman/main/DNSServerProvider.py:48` `GLib.Error` swallowed | `logging.debug("DNS lookup failed, using fallback")` | -| obs-3 | open | S | `blueman/main/Manager.py:62` `print()` in exception handler | `logging.error(..., exc_info=True)` | -| obs-6 | open | S | `blueman/main/PluginManager.py:64,123` `LoadException` swallowed silently | `logging.warning` with plugin name | -| obs-15 | open | S | `blueman/plugins/applet/AutoConnect.py:116-117` ignores automatic connection failures with `pass`, so failed auto-connect attempts leave no log trail and are hard to diagnose. | Log the target service/device and failure reason at debug or warning level, with rate limiting if needed. | -| obs-12 | open | S | `blueman/plugins/mechanism/Network.py:46` exception only routed to error callback, no local log | add `logging.error` with trace | -| obs-14 | open | S | `sendto/blueman_sendto.py.in:14,17,29,33` `print()` for user-facing messages | replace with `logging` where plugin host allows | - -## wiring gaps - -| id | status | effort | description | notes | -|----|--------|--------|-------------|-------| - -_(none open)_ - -## unused functions/methods - -| id | status | effort | description | notes | -|----|--------|--------|-------------|-------| - -_(none open)_ - -## STRIDE - -| id | status | effort | description | notes | -|----|--------|--------|-------------|-------| -| stride-1 | open | M | `blueman/main/DbusService.py:162-170` unhandled exceptions return full traceback in DBus errors, leaking internal paths to any caller | sanitize error messages on the bus; detailed traces to daemon log only | -| stride-4 | open | M | `blueman/main/MechanismApplication.py:50` if `POLKIT=False` at build, PolicyKit auth skipped silently (Elevation of privilege) | fail-closed; never silently skip authorization; log when disabled | - -## data governance - -| id | status | effort | description | notes | -|----|--------|--------|-------------|-------| -| gov-3 | open | M | `blueman/plugins/applet/NetUsage.py:40,64-65` per-device tx/rx stats persisted at `/org/blueman/plugins/netusages/{Address}/` reveal connection history + volume | document retention; add auto-expire option | -| gov-4 | open | S | `blueman/plugins/applet/RecentConns.py:120` user device aliases (may contain PII) stored plaintext | document plaintext storage; UI warning | -| gov-1 | open | M | `blueman/plugins/applet/RecentConns.py:127-139` device object paths + UUIDs stored unencrypted in GSettings | store only address/UUID; audit schema permissions | -| gov-2 | open | S | `blueman/plugins/applet/RecentConns.py:144` BT addresses (quasi-permanent IDs) logged via `logging.info` | redact/rate-limit address logging in production | -| gov-5 | open | S | `blueman/plugins/manager/Notes.py:32-35` can leave plaintext note bodies in temporary `.vnt` files when send launch fails. These notes are user-authored content and can include sensitive data. | Ensure temp-note lifecycle is owned by Blueman until a child process has definitely taken responsibility; clean stale `note*.vnt` files where safe. Cross-ref data-2. | - -## multithreading - -| id | status | effort | description | notes | -|----|--------|--------|-------------|-------| -| mt-1 | open | S | `blueman/gui/manager/ManagerMenu.py:96` `GLib.idle_add()` return value/source id ignored; no cleanup if parent destroyed | store source id, remove on teardown | - -## watchdog - -| id | status | effort | description | notes | -|----|--------|--------|-------------|-------| -| wd-3 | open | M | `blueman/main/DhcpClient.py:49-50` two `timeout_add` sources, neither stored; `_check_client` keeps polling dead process after `_on_timeout` | store + `source_remove` both on exit (overlaps rob-3) | -| wd-8 | open | S | `blueman/main/indicators/StatusNotifierItem.py:32-42` starts a repeating revision-advertisement timeout and discards the source id. The menu service cannot remove the source on unregister/teardown, so it can keep emitting after the tray path is gone. | Store the source id and remove it in an explicit `unregister`/delete path; add a test that teardown removes the source. | -| wd-2 | open | M | `blueman/main/PPPConnection.py:76` `cleanup()` only closes fd, leaves io_watch/timeout sources registered | remove all GLib sources in cleanup (overlaps rel-7) | -| wd-1 | open | M | `blueman/main/PPPConnection.py:82-87` pppd spawned with no liveness monitoring; orphan pppd possible on error path | add `GLib.child_watch_add`, kill on cleanup | -| wd-6 | open | S | `blueman/plugins/applet/PPPSupport.py:40` synchronous `Popen(['ps'])` blocks main loop until ps returns | use async `Gio.Subprocess` | -| wd-4 | open | M | `blueman/plugins/mechanism/Rfcomm.py:14` rfcomm watcher Popen fire-and-forget, no PID tracking/liveness; only killed via grepped `ps` | track PID, supervise (overlaps sec-2, rel-8) | -| wd-5 | open | M | `blueman/services/meta/SerialService.py:75` `Popen([RFCOMM_WATCHER_PATH])` no exit/return-code monitoring; crash leaves rfcomm broken | child watch + restart/notify | - -## state machine - -| id | status | effort | description | notes | -|----|--------|--------|-------------|-------| -| sm-4 | open | M | `blueman/main/DhcpClient.py:39-51` no state flag; `_check_client` + `_on_timeout` both call `querying.remove()` → possible `ValueError` | guard with done-flag, single removal (overlaps wd-3, rob-3) | -| sm-5 | open | M | `blueman/main/NetworkManager.py:38,69-70` `_statehandler` asserted not-None but state change can fire before assignment | assign handler before connect / null-guard | -| sm-2 | open | M | `blueman/main/PPPConnection.py:181-210` `on_data_ready` can run cleanup while `on_timeout` still pending → double `error-occurred` emit | explicit connection-state guard, single emit | -| sm-3 | open | L | `blueman/main/PPPConnection.py:213-224` `on_timeout` closure captures stale `command_id` if `send_commands` reused before fire | bind per-command state / cancel prior timeout | -| sm-6 | open | L | `blueman/plugins/applet/PowerManager.py:97,109` Callback timer source id not tracked; orphan timeout fires on GC'd object | store source id, remove in destructor | -| sm-9 | open | S | `blueman/plugins/applet/ShowConnected.py:86-92` schedules delayed `enumerate_connections()` calls on every manager-state-enabled event without storing/canceling the source. A fast state flap can let a stale enumeration update the icon after the manager is disabled. | Store the pending source id, cancel it on manager disable/unload, and ignore callbacks if manager state changed. | - -## composition - -| id | status | effort | description | notes | -|----|--------|--------|-------------|-------| -| comp-2 | open | M | `blueman/bluez/obex/Base.py:5` obex Base subclasses bluez Base, both override metaclass attrs; class-attr duplication | pass bus config to `__init__` instead of subclassing | -| comp-3 | open | S | `blueman/gui/manager/ManagerDeviceList.py:45` 4-level inheritance (Gtk.TreeView→GenericList→DeviceList→ManagerDeviceList) + parent-chain coupling | inject deps via constructor, prefer composition | -| comp-4 | open | M | `blueman/plugins/MechanismPlugin.py:8-12` copies parent methods (timer, confirm_authorization) into `__init__`; tight bind to concrete app | abstract plugin interface + DI | - -## dependency - -| id | status | effort | description | notes | -|----|--------|--------|-------------|-------| -| dep-5 | open | S | `blueman/main/Applet.py:10` wildcard `from blueman.Functions import *` obscures deps | explicit imports | -| dep-7 | open | M | `blueman/main/DNSServerProvider.py:12` hardcoded `RESOLVER_PATH="/etc/resolv.conf"` | configurable + DNSProvider abstraction (dup cfg-004) | -| dep-2 | open | L | `blueman/main/NetworkManager.py:9-12` import-time `gi.require_version` raises if NM bindings missing | move into lazy init try-block | -| dep-1 | open | L | `blueman/main/PulseAudioUtils.py:14-18` import-time `CDLL` load raises ImportError if libpulse absent, failing module | lazy loader + optional-support flag | -| dep-4 | open | L | `blueman/plugins/applet/GameControllerWakelock.py:14-16,22-23` import-time GdkX11/X11 screen check raises | move platform check to `on_load()` | -| dep-6 | open | S | `blueman/plugins/applet/NetUsage.py:8` wildcard import from `blueman.Functions` | explicit imports | -| dep-3 | open | L | `blueman/plugins/mechanism/RfKill.py:6-7` import-time `/dev/rfkill` check raises, blocks plugin discovery on systems without it | move check to `on_load()` | - -## adaptability - -| id | status | effort | description | notes | -|----|--------|--------|-------------|-------| -| adapt-1 | open | M | `blueman/gui/manager/ManagerDeviceMenu.py:225-242` hardcoded BlueZ error-string mapping with version-specific comments; breaks on newer BlueZ | parse error codes dynamically + version detect | -| adapt-4 | open | S | `blueman/main/DbusService.py` bus type hardcoded to SESSION; assumes single-user desktop | make bus_type configurable | -| adapt-2 | open | M | `blueman/main/Functions.py:104` (`blueman/Functions.py:104`) `time.clock_gettime(CLOCK_MONOTONIC_RAW)` fallback not portable | use `GLib.get_monotonic_time()` consistently | -| adapt-3 | open | M | `blueman/plugins/mechanism/Ppp.py:24` hardcoded `/dev/rfcomm{port}` template | inject device-path factory | - -## extensibility - -| id | status | effort | description | notes | -|----|--------|--------|-------------|-------| -| ext-7 | open | S | `blueman/gui/DeviceList.py:147-162` override hooks only; no registry for third-party extensions | signal-based hooks / extension protocol | -| ext-5 | open | M | `blueman/main/indicators/IndicatorInterface.py` StatusIcon vs StatusNotifierItem hardcoded; no pluggable indicator backend | IndicatorBackend protocol via PluginManager | -| ext-1 | open | M | `blueman/main/PluginManager.py:132-174` load logic embedded in manager; hard to add plugin types/async loaders | extract LoadStrategy / load pipeline | -| ext-6 | open | M | `blueman/plugins/applet/Menu.py` menu structure hardcoded; plugins can't extend menus without parent coupling | MenuRegistry / signal-based insertion | -| ext-2 | open | M | `blueman/plugins/AppletPlugin.py:35-41` DBus service opt-in via `__dbus_iface_name__` is intricate | `@dbus_service` decorator / ServiceRegistry | -| ext-3 | open | L | `blueman/plugins/ServicePlugin.py:12-62` separate hierarchy from BasePlugin; no depends/conflicts declarations | unify to BasePlugin, add `__depends__`/`__conflicts__` | -| ext-4 | open | M | `blueman/services/meta/NetworkService.py` new service types must implement props; no extension hooks | ServiceRegistry + `@service_provider` | - -## legacy - -| id | status | effort | description | notes | -|----|--------|--------|-------------|-------| -| leg-7 | open | S | `blueman/bluez/Device.py:22,29` `# type: ignore` on connect/disconnect masking signature mismatch | resolve override signatures | -| leg-8 | open | S | `blueman/bluez/Network.py:17,26` `# type: ignore` on connect/disconnect | resolve signatures | -| leg-6 | open | S | `blueman/gui/GtkAnimation.py:200` FIXME `Gtk.render_background()` wrong colors | investigate + fix or document | -| leg-4 | open | M | `blueman/gui/manager/ManagerMenu.py:45,47` `Gtk.ImageMenuItem` in manager UI | migrate to `Gtk.MenuItem` | -| leg-9 | open | S | `blueman/main/indicators/GtkStatusIcon.py:44` `# type: ignore` on submenu enumerate | proper overload/typing | - -## configuration - -| id | status | effort | description | notes | -|----|--------|--------|-------------|-------| -| cfg-7 | open | S | `blueman/config/AutoConnectConfig.py:10` GSettings schema id hardcoded, duplicated across plugins | module constant | -| cfg-1 | open | M | `blueman/Constants.py.in:22` `BLUEMAN_SOURCE` env var checked inline, undocumented feature flag | centralize in config module + document | -| cfg-5 | open | S | `blueman/main/DhcpClient.py:17-20` DHCP client search order hardcoded (dhclient/dhcpcd/udhcpc) | configurable list | -| cfg-4 | open | M | `blueman/main/DNSServerProvider.py:12` hardcoded `/etc/resolv.conf`, precedence undocumented | document resolved-first precedence | -| cfg-2 | open | M | `blueman/main/MechanismApplication.py:25` idle timeout hardcoded (30s / 9999 dev) keyed on `BLUEMAN_SOURCE` | make configurable, document dev mode (overlaps arch-5) | -| cfg-6 | open | M | `blueman/plugins/services/Network.py` DHCP handler selection (dnsmasq/dhcpd/udhcpd) no user config, undocumented fallback chain | document + expose config | - -## platform - -| id | status | effort | description | notes | -|----|--------|--------|-------------|-------| -| plat-2 | open | M | `blueman/main/PPPConnection.py:83` hardcoded `/usr/sbin/pppd` | dynamic `have()` lookup | -| plat-5 | open | M | `blueman/plugins/applet/KillSwitch.py:59,87` hardcoded `/dev/rfkill`, silent fail without it | feature-detect + graceful degrade | -| plat-7 | open | M | `blueman/plugins/applet/NetUsage.py:84,87` hardcoded `/sys/class/net` sysfs paths, Linux-only | abstraction + degrade | -| plat-6 | open | S | `blueman/plugins/mechanism/RfKill.py:6` module-level `/dev/rfkill` check raises at import (dup dep-3) | move to `on_load()` | -| plat-10 | open | S | `blueman/services/meta/SerialService.py` hardcoded `/dev/rfcomm{port}` naming | abstract device node | - -## data structure - -| id | status | effort | description | notes | -|----|--------|--------|-------------|-------| -| ds-5 | open | M | `blueman/bluez/Manager.py:160-164` `find_device()` scans all DBus objects, repeated Address lookups | cached device index (dup perf-11, scale) | -| ds-2 | open | M | `blueman/plugins/applet/NetUsage.py:261-268` linear liststore scan by address in `monitor_added` | address→iter dict | -| ds-3 | open | M | `blueman/plugins/applet/NetUsage.py:276-283` linear liststore scan by address in `monitor_removed` | address→iter dict | -| ds-4 | open | M | `blueman/plugins/applet/RecentConns.py:129-137` linear scan of `stored_items` by (adapter,address,uuid) | tuple-keyed dict | - -## vectorization - -| id | status | effort | description | notes | -|----|--------|--------|-------------|-------| -| vec-2 | open | L | `blueman/bluez/Manager.py:138-149` `get_devices()` rescans all objects per `find_device()` | cache indexed by adapter, batch GetAll (dup perf-5) | - -## robustiness - -| id | status | effort | description | notes | -|----|--------|--------|-------------|-------| -| rob-8 | open | S | `blueman/gui/Animation.py:28-35` `start()` is not idempotent: calling it twice overwrites `self.timer` and leaks the first `GLib.timeout_add` source, so `stop()` can remove only the newest timer. | Return early if already started, or stop the existing source before starting a new one; add a start/stop source-id test. Cross-ref test-4. | -| rob-2 | open | M | `blueman/gui/manager/ManagerProgressbar.py:117` `timeout_add(timeout,finalize)` id discarded; double-finalize | capture + remove before re-call | -| rob-1 | open | M | `blueman/gui/manager/ManagerProgressbar.py:178` `timeout_add(41,pulse)` source id not captured; pulses after `stop()` | store + remove source id (overlaps perf-14) | -| rob-3 | open | M | `blueman/main/DhcpClient.py:49-50` two timeout sources never stored/removed (dup wd-3) | store ids, remove on exit | -| rob-5 | open | S | `blueman/main/PPPConnection.py:182-197` OSError path may skip `source_remove(io_watch)` before cleanup → leaked source | remove source in except (overlaps rel-7) | -| rob-6 | open | S | `blueman/plugins/applet/NetUsage.py:79-80` Monitor `__del__` doesn't remove timeout source | guard + `source_remove(poller)` | - -## ui / ux - -| id | status | effort | description | notes | -|----|--------|--------|-------------|-------| -| ux-7 | open | S | `blueman/gui/manager/ManagerDeviceList.py:508` FIXME "horrible workaround" inadequate feedback | proper user feedback | -| ux-3 | open | S | `blueman/gui/manager/ManagerProgressbar.py:50` hardcoded progressbar 100x15 | flexible sizing | -| ux-5 | open | S | `blueman/gui/Notification.py:107-108` empty `add_action()` stub logs warning | implement or remove stub | -| ux-4 | open | M | `blueman/gui/Notification.py:168-169` bare `except ValueError: pass` on hint set (dup obs-7) | log when fallback occurs | -| ux-2 | open | S | `blueman/gui/Notification.py:51` hardcoded notification size 350x50 | responsive sizing | -| ux-8 | open | S | `blueman/main/Manager.py:183` FIXME BlueZ stop/start not surfaced to user | notification/infobar on daemon loss | - -## accessibility - -| id | status | effort | description | notes | -|----|--------|--------|-------------|-------| -| a11y-1 | open | S | `blueman/gui/applet/PluginDialog.py:87-112` dynamically creates labels next to `Gtk.SpinButton`/`Gtk.Entry` controls but does not set mnemonic widgets or accessible label relationships. Screen readers and keyboard users get weaker context for plugin preference fields. | Use mnemonic labels (`use_underline`) where possible and set label/accessibility relationships for generated controls; add an accessibility smoke test for generated preference widgets. | - -## i18n - -| id | status | effort | description | notes | -|----|--------|--------|-------------|-------| -| i18n-2 | open | S | `blueman/main/applet/BluezAgent.py:201-229` builds authentication notification sentences by concatenating translated fragments with device names, PINs, and markup. Translators cannot reorder the whole sentence or place punctuation naturally. | Use one format string per complete sentence/message with named placeholders, e.g. `%(device)s` and `%(passkey)s`, preserving markup escaping. | -| i18n-1 | open | S | `sendto/blueman_sendto.py.in:46-50` hardcodes Nautilus/Caja/Nemo menu labels and tips in English, and `sendto/blueman_sendto.py.in` is not listed in `po/POTFILES.in`, so translators never see them. | Wrap file-manager extension labels/tips in gettext and add the generated/template source to extraction. | - -## documentation - -| id | status | effort | description | notes | -|----|--------|--------|-------------|-------| -| doc-7 | open | M | `blueman/gui/CommonUi.py` `ErrorDialog` lacks docstring; `excp` param undocumented | document exception UI | -| doc-3 | open | M | `blueman/gui/DeviceList.py` class docstring missing; signals only in `__gsignals__` | document model + key signals | -| doc-6 | open | M | `blueman/gui/DeviceSelectorDialog.py` `DeviceRow`/`DeviceSelector` lack docstrings | document selector pattern | -| doc-2 | open | M | `blueman/gui/GenericList.py` no module/class docstring | document TreeView wrapper + signals | -| doc-9 | open | M | `blueman/gui/GsmSettings.py` class lacks docstring | document GSM settings binding | -| doc-8 | open | S | `blueman/gui/manager/ManagerProgressbar.py` class undocumented (cancellable/text params) | document progress lifecycle | -| doc-5 | open | M | `blueman/gui/Notification.py` `Notification()` factory + bubble/dialog undocumented | document return-type selection | -| doc-4 | open | S | `blueman/main/Builder.py` class lacks docstring | document Gtk.Builder wrapper behavior | -| doc-10 | open | M | `README` lacks plugin/dev API docs | document plugin loading + extension points | - -## test coverage - -| id | status | effort | description | notes | -|----|--------|--------|-------------|-------| -| test-4 | open | S | No tests cover `blueman/gui/Animation.py` timer source lifecycle. The `start()`/`stop()` path can leak sources if `start()` is called repeatedly, and current tests would not detect it. | Add a focused test with mocked `GLib.timeout_add`/`source_remove` for idempotent start and complete cleanup. Cross-ref rob-8. | -| test-2 | open | S | No tests cover `BluezAgent._on_display_passkey` boundary values for `entered`. `blueman/main/applet/BluezAgent.py:201-203` indexes `key[entered]`, so an out-of-range or fully-entered value can crash the agent notification path. | Add focused tests for `entered` values 0, 5, 6, and invalid values; clamp or render without bolding when all digits are entered. | -| test-1 | open | S | No tests cover `sendto/blueman_sendto.py.in` command construction for selected file paths. The quoting bug in cmd-1 would pass unnoticed for paths with quotes, semicolons, or leading dashes. | Add a small unit test around the file-list-to-launch-command path after extracting it into a pure helper. Cross-ref cmd-1. | - -## release & deploy engineering - -| id | status | effort | description | notes | -|----|--------|--------|-------------|-------| -| releng-1 | open | S | `make_release.sh:3-7` archives `HEAD` using the latest tag name from `git describe --tags --abbrev=0`, without verifying that `HEAD` is exactly that tag or that the working tree is clean. A release tarball can be mislabeled with the previous tag or include unintended worktree attributes. | Require `git describe --tags --exact-match`, fail on dirty status, and print the commit/tag being archived. | -| releng-2 | open | S | `make_release.sh:9-16` produces `.tar.xz` and `.tar.gz` but no checksums or signatures. Downstream packagers/users have no release-integrity artifact from the script. | Generate SHA256 sums and optionally detached signatures as part of the release script, documenting the expected verification flow. | - -## dependability - -| id | status | effort | description | notes | -|----|--------|--------|-------------|-------| -| depend-3 | open | S | `blueman/main/DNSServerProvider.py:29,102` `_get_servers_from_systemd_resolved`/`_subscribe_systemd_resolved` call `Gio.bus_get_sync(SYSTEM)` and `DBusProxy.new_for_bus_sync` with no error handling around bus/proxy acquisition (only the later `Get` at :48 is guarded). A briefly-unavailable system bus makes `__init__` raise and the whole provider fail rather than falling back to resolv.conf. | Wrap bus/proxy acquisition in try/except `GLib.Error` and degrade to the resolv.conf path. Cross-ref mem-2. | - -## distributed systems - -| id | status | effort | description | notes | -|----|--------|--------|-------------|-------| -| dist-3 | open | S | `blueman/plugins/mechanism/Rfcomm.py:13-14` `_open_rfcomm` spawns a watcher per call with no dedup; two `OpenRFCOMM` calls for the same `port_id` start two `blueman-rfcomm-watcher /dev/rfcommN` processes, and `_close_rfcomm` kills only by matching the `ps` cmdline (can leave orphans or signal a recycled/foreign PID). | Before launching, scan for an existing watcher on that port and skip if present; track watcher PIDs in the mechanism rather than re-deriving from `ps`. Cross-ref wd-4, mem-1. | - -## time & scheduling correctness - -| id | status | effort | description | notes | -|----|--------|--------|-------------|-------| -| time-4 | open | M | `blueman/main/MechanismApplication.py:20-29` the idle-exit timer counts 1s `timeout_add` ticks (`self.time += 1` to 30) instead of comparing a monotonic deadline; GLib coalesces/delays timeouts under load or suspend, so the "30s idle" auto-exit drifts and can fire much later than intended. | Record `GLib.get_monotonic_time()` on activity and exit once `now - last >= 30s`, independent of tick count. Cross-ref cfg-2. | -| time-1 | open | M | `blueman/main/SpeedCalc.py:21` `calc()` keys elapsed-time/speed math on wall clock `time.time()`; an NTP step or manual clock change can skew the divisor across retained samples and produce erratic speeds (the zero-elapsed guard only catches exact ties/backsteps within the window). | Sample with `time.monotonic()` / `GLib.get_monotonic_time()`; a monotonic clock never steps. Distinct from ds-1 (log prune) and adapt-2 (clock_gettime portability). | -| time-3 | open | S | `blueman/plugins/applet/NetUsage.py:201` session duration `datetime.now() - fromtimestamp(config["time"])` is pure wall-clock; if the clock moved backward since the stored start, the delta is negative and renders nonsense durations. | Clamp negative deltas to 0 (or store a monotonic anchor) before formatting. | - -## memory and cpu management - -| id | status | effort | description | notes | -|----|--------|--------|-------------|-------| -| mem-2 | open | S | `blueman/main/DNSServerProvider.py:29-79` `_get_servers_from_systemd_resolved` issues a chain of synchronous `call_sync` D-Bus calls (Get DNS, then per-interface GetLink + DefaultRoute Get) with `-1` (infinite) timeout on the main loop whenever DHCP servers are resolved, scaling with interface count and able to hang indefinitely. | Use finite timeouts and/or move resolution off the main loop; cache across the `changed` signal instead of re-walking all links each call. Cross-ref depend-3. | -| mem-1 | open | S | `blueman/plugins/mechanism/Rfcomm.py:17` `_close_rfcomm` shells out `ps -e o pid,args` and `communicate()` synchronously inside the privileged mechanism D-Bus method, blocking the mechanism main loop while it scans every process to find one watcher PID. | Track watcher PIDs (from `Popen` in `_open_rfcomm`) keyed by port and kill by stored PID instead of scanning `ps`. Cross-ref dist-3. | - -## system design - -| id | status | effort | description | notes | -|----|--------|--------|-------------|-------| -| sysd-2 | open | M | `blueman/main/PluginManager.py:92,117` plugin discovery uses `plugin_class.__subclasses__()` (import side effects) and mutates shared class attributes (`cls.__unloadable__ = False`); two PluginManager instances (applet vs mechanism) or a reload mutate shared class state, so load order/conflict resolution is global, not per-manager. | Register plugins explicitly into a per-manager registry and keep per-instance load flags off the class object. Cross-ref dec-5, ext-1. | - -## CLI / option integrity - -| id | status | effort | description | notes | -|----|--------|--------|-------------|-------| -| cli-3 | open | S | `apps/blueman-adapters.in:24` `--socket-id` (XEmbed) is undocumented — no `help=`, absent from `data/man/blueman-adapters.1` — yet plumbed into `BluemanAdapters(... socket_id)`. | Add `help=` text and document, or mark intentionally internal. | -| cli-2 | open | S | `apps/blueman-mechanism.in:38,57-58` `-d/--debug` only logs "Enabled verbose output" and does nothing else; the level is driven by `--loglevel`, so `--debug` does NOT enable debug logging — a dead/misleading flag. | Make `--debug` set `log_level = logging.DEBUG`, or remove it and document `--loglevel debug`. | -| cli-6 | open | S | `data/man/blueman-adapters.1:1` `.TH` header is `BLUEMAN-SENDTO` (copy-paste), so `man blueman-adapters` shows the wrong title/section. | Fix `.TH` to `BLUEMAN-ADAPTERS`. | -| cli-7 | open | S | `data/man/blueman-adapters.1` says the `adapter` arg selects the initial tab in `hci0` form, but `blueman/main/Adapter.py:74-76` matches tab keys and derives the page via `int(name[3:])`; any non-`hciN` value is silently dropped, and the positional has no CLI `help=` (`apps/blueman-adapters.in:25`). | Add `help=` to the positional stating the `hciN` format/behavior; align the man page. | -| cli-5 | open | S | `data/man/blueman-applet.1`, `blueman-manager.1`, `blueman-services.1` state "There are no options.", but each accepts `--loglevel`/`--syslog` via `create_parser`. Docs contradict behavior. | Replace "no options" with the actual flags. | -| cli-4 | open | S | `data/man/blueman-sendto.1` documents only `--device=ADDRESS`, but `apps/blueman-sendto.in:32-38` also ships `-d/--dest`, `-s/--source`, `-u/--delete` and a positional `FILE`. Man page is out of date vs `--help`. | Update the man page to list all options and the `FILE` positional. | - -## product engineering - -| id | status | effort | description | notes | -|----|--------|--------|-------------|-------| - -## design thinking - -| id | status | effort | description | notes | -|----|--------|--------|-------------|-------| -| dsgn-1 | open | S | `blueman/main/Adapter.py:73-78` when `blueman-adapters ` names a nonexistent adapter, it logs "the selected adapter does not exist" to console but still opens the window on the default tab — a GUI-launched user gets no on-screen feedback that their argument was ignored (silent dead-end). | Show an in-window/infobar message (or a toast) and fall through to the first tab. | - -## test / fuzz coverage - -| id | status | effort | description | notes | -|----|--------|--------|-------------|-------| -| fuzz-3 | open | S | `blueman/DeviceClass.py:473-555` `get_major_class`/`get_minor_class`/`gatt_appearance_to_name` decode raw class-of-device and GATT appearance bitfields, untested. Hostile/boundary inputs: negative ints, values exceeding 16 bits, out-of-range minor indices, and appearance category boundaries around the reserved/invalid guards (:541-547). | Add `test/test_deviceclass.py` parametrized over major indices + overflow, each minor family in/out of range, and `gatt_appearance_to_name` at category edges plus a sweep asserting no `KeyError`/`IndexError` escapes. | -| fuzz-1 | open | M | `blueman/main/DhcpClient.py:25-77` has NO test file. `__init__` builds the client argv from `have()` across dhclient/dhcpcd/udhcpc; `_check_client` parses `poll()` status and reads `netifs[self._interface][0]`. Untested: client selection when several/none exist, argv assembly, poll-status branching, and the `KeyError`/`IndexError` when the bound interface is absent from `get_local_interfaces()`. | Add `test/main/test_dhcpclient.py` mocking `have`/`Popen`/`get_local_interfaces`; cover argv per client, run() raising when none found, double-run, poll 0/1/None, and hostile interface maps (missing key, empty tuple). | -| fuzz-2 | open | S | `blueman/Sdp.py:358-385` `ServiceUUID` is untested. `UUID(uuid)` raises `ValueError` on malformed input; `name`/`short_uuid`/`reserved` decode the 128-bit int and index `uuid_names[short_uuid]`. Untested: short vs full UUIDs, the all-zero case, Proprietary (non-reserved) UUIDs, unknown reserved short ids (KeyError→"Unknown"), and malformed/empty/garbage strings from the BlueZ wire. | Add `test/test_sdp.py` covering reserved short UUIDs, `int==0`, a non-Bluetooth-base UUID, an unknown reserved id, and a fuzz set of malformed strings asserting only `ValueError` escapes construction. | - ---- - -## Open — parked - -- **perf-12** (`ManagerProgressbar` instance cleanup) — the loop is GTK-widget-bound - (`finalize()` touches builder/window/hbox/Stats) and is actually O(n), not O(n²); no real - perf win and not unit-testable without a full Manager app. Park until reworked alongside the - rob-1/rob-2 source-id fixes in the same file. -- **perf-14** (`GtkAnimation` per-animation timer) — the fix ("unify tick clock") is a - shared-timer architecture change, not a low-risk edit, and can't reach genuine coverage - headless. Park for a dedicated animation-scheduler change. -- **leg-1** (`check_bluetooth_status` `Gtk.Dialog.run()`/`.destroy()`) — `run()` is deprecated - but still supported in GTK3. The function is a synchronous startup gate called by every entry - point before the GLib main loop, and its result (via `exitfunc`) decides whether the app - proceeds. A non-blocking response-signal rewrite must run a main loop and turn every caller - into a continuation — a cross-file behavioural change on the bluetooth-enable path that cannot - reach genuine coverage headless (needs a live dialog + main loop). Park for the GTK4 migration - or a dedicated async-dialog change (overlaps leg-3, ux-6). -- **leg-2** (`create_menuitem` `Gtk.ImageMenuItem`) — deprecated but functional in GTK3. - `create_menuitem` is the single chokepoint returning a `Gtk.ImageMenuItem`, consumed by ~20 - call sites (manager menu, status icon, plugins) that rely on the returned item's child being - an `AccelLabel` with markup. The GTK3-supported replacement (`Gtk.MenuItem` + manual - image/label box) changes the child structure and image handling — a visible UI change only - validatable by running the GUI, so it can't meet the headless coverage bar. Park for the - GTK4 migration alongside leg-4 (`ManagerMenu` `ImageMenuItem`). -- **leg-3 / ux-6** (`Sendto.py` deprecated `dialog.run()`/`.destroy()` → async response - handlers) — three of the four `.run()` sites (`select_files`, `select_device`, the - obex-start error dialog) are synchronous startup gates in `SendTo`/`Sender.__init__` whose - return values drive control flow before the GTK main loop runs. Converting to the async - response-signal pattern requires restructuring both `__init__`s into continuation-based - flows, which cannot reach genuine coverage headless and carries high regression risk on the - core send path. Park for the GTK4 migration (same rationale as leg-1). The other Sendto.py - findings were fixed in fix/sendto-hardening. - -## Audit picks deliberately rejected - -- **wire-3** (`AppletStatusIconService` "has no public methods") — false positive. It is a - signal-only `Gio.DBusProxy` for the `org.blueman.Applet.StatusIcon` interface. A proxy emits - `g-signal` only for its own interface, so `Tray.py` needs this distinct proxy to receive - `IconNameChanged`/`VisibilityChanged`/`ToolTipTitleChanged`/`ToolTipTextChanged` - (`AppletMenuService` only delivers `MenuChanged` on the Menu interface). Deleting/inlining it - would silence tray-icon updates. Kept; guarded by a test in `test/main/test_dbus_proxies.py`. -- **dead-1 / dead-2 / dead-3** (`set_proc_title`/`create_logger`/`create_parser` "no production - callers") — false positives. The audit scanned `*.py` only and missed the entry-point - templates: all three are imported and called by every binary in `apps/*.in` - (`blueman-applet`, `blueman-manager`, `blueman-sendto`, `blueman-adapters`, `blueman-services`, - `blueman-tray`, `blueman-mechanism`, plus `set_proc_title` in `blueman-rfcomm-watcher`). - Deleting them would break startup of every executable. Kept and documented (doc-1); the - related real issues were fixed instead: `create_parser` loglevel validation (cli-1), - `create_logger` syslog fallback (plat-8), and `set_proc_title` non-Linux guard (leg-5). From ecd17f2af8065fda8ff812547304ec1711f36a12 Mon Sep 17 00:00:00 2001 From: Geraldo Netto Date: Sat, 20 Jun 2026 12:50:45 +0200 Subject: [PATCH 39/42] docs: note the DeviceResolver raise contract (dec-1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document that a resolver may raise and that callers treat a raised resolver as an untrusted/unknown device — a behavioral contract the type alias alone does not convey. Co-Authored-By: Claude Opus 4.8 (1M context) --- blueman/plugins/applet/TransferService.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/blueman/plugins/applet/TransferService.py b/blueman/plugins/applet/TransferService.py index dbc444bf3..75e31b4d5 100644 --- a/blueman/plugins/applet/TransferService.py +++ b/blueman/plugins/applet/TransferService.py @@ -38,7 +38,8 @@ class PendingTransferDict(TypedDict): NotificationType = Union[_NotificationBubble, _NotificationDialog] -# Resolve (display name, trusted) for a device given its adapter source path and address. +# Resolve (display name, trusted) for a device by adapter source path + address. +# May raise; callers treat a raised resolver as an untrusted/unknown device. DeviceResolver = Callable[[str, BtAddress], tuple[str, bool]] _MAX_DESTINATION_ATTEMPTS = 10000 From ae3dd09b0942956ae42f449174d0262654bd0fd0 Mon Sep 17 00:00:00 2001 From: Geraldo Netto Date: Sat, 20 Jun 2026 13:00:17 +0200 Subject: [PATCH 40/42] fix: correct transfer counters, instance handler list, and XDG fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up review of TransferService.py surfaced four issues beyond the original rescan items: A. reliability — _on_session_removed reported the silent/normal transfer counts but never reset them, so every session after the first double-counted background transfers. Reset both counters once the summary has consumed them. B. correctness — `_handlerids` was a class-level mutable list shared by every instance; _on_dbus_name_appeared mutated the class list before any instance copy existed. Initialize it per-instance in on_load. D. robustness — _make_share_path built `Path(GLib.get_user_special_dir( DOWNLOAD))` unconditionally; when XDG returns no download dir this raised TypeError on Path(None) before the `~` fallback could run. Build default_path only when XDG yields a value so the existing fallback applies. Also collapse a redundant `elif not success` to `else` (C). Tests bring module coverage to 100% (50 cases), including agent lifecycle, D-Bus name appeared/vanished, on_load/on_unload, and every _make_share_path branch. Co-Authored-By: Claude Opus 4.8 (1M context) --- blueman/plugins/applet/TransferService.py | 10 +- test/plugins/applet/test_transfer_service.py | 203 +++++++++++++++++++ 2 files changed, 211 insertions(+), 2 deletions(-) diff --git a/blueman/plugins/applet/TransferService.py b/blueman/plugins/applet/TransferService.py index 75e31b4d5..bf797eb36 100644 --- a/blueman/plugins/applet/TransferService.py +++ b/blueman/plugins/applet/TransferService.py @@ -202,6 +202,7 @@ def on_reset(_action: str) -> None: logging.info('Reset share path') self._config = Gio.Settings(schema_id="org.blueman.transfer") + self._handlerids = [] # per-instance; avoid sharing the class-level list across instances share_path, invalid_share_path = self._make_share_path() @@ -225,7 +226,8 @@ def on_unload(self) -> None: def _make_share_path(self) -> tuple[Path, bool]: config_path = Path(self._config["shared-path"]) - default_path = Path(GLib.get_user_special_dir(GLib.UserDirectory.DIRECTORY_DOWNLOAD)) + download_dir = GLib.get_user_special_dir(GLib.UserDirectory.DIRECTORY_DOWNLOAD) + default_path = Path(download_dir) if download_dir else None path = None error = False @@ -347,7 +349,7 @@ def _on_transfer_completed(self, _manager: Manager, transfer_path: ObjectPath, s icon_name="blueman") self._add_open(self._notification, _("Open"), dest) self._notification.show() - elif not success: + else: n = Notification( _("Transfer failed"), _("Transfer of file %(0)s failed") % { @@ -386,3 +388,7 @@ def _on_session_removed(self, _manager: Manager, _session_path: str) -> None: icon_name="blueman") self._add_open(self._notification, _("Open Location"), share_path) self._notification.show() + + # The summary consumes the counts; reset so the next session starts fresh. + self._silent_transfers = 0 + self._normal_transfers = 0 diff --git a/test/plugins/applet/test_transfer_service.py b/test/plugins/applet/test_transfer_service.py index 7a715efcd..1a8254258 100644 --- a/test/plugins/applet/test_transfer_service.py +++ b/test/plugins/applet/test_transfer_service.py @@ -478,3 +478,206 @@ def test_open_action_launches_xdg_open(self, notification_mock: MagicMock, launc on_open = notification_mock.return_value.add_action.call_args.args[2] on_open("open") launch_mock.assert_called_once() + + +@patch("blueman.plugins.applet.TransferService.AgentManager") +@patch("blueman.plugins.applet.TransferService.Gio") +@patch("blueman.plugins.applet.TransferService.DbusService.__init__", return_value=None) +class TestAgentLifecycle(TestCase): + def _make(self) -> tuple: + with patch.object(Agent, "add_method") as add, patch.object(Agent, "register") as reg: + agent = Agent(lambda source, address: ("X", True)) + return agent, add, reg + + def test_init_registers_three_methods_and_state(self, _dbus: MagicMock, _gio: MagicMock, + _am: MagicMock) -> None: + agent, add, reg = self._make() + self.assertEqual(add.call_count, 3) + reg.assert_called_once() + self.assertEqual(agent._allowed_devices, set()) + self.assertEqual(agent._pending_transfers, {}) + self.assertEqual(agent.transfers, {}) + + def test_register_and_unregister_at_manager(self, _dbus: MagicMock, _gio: MagicMock, + am_mock: MagicMock) -> None: + agent, _add, _reg = self._make() + agent.register_at_manager() + am_mock.return_value.register_agent.assert_called_once() + agent.unregister_from_manager() + am_mock.return_value.unregister_agent.assert_called_once() + + +@patch("blueman.plugins.applet.TransferService.Manager") +@patch("blueman.plugins.applet.TransferService.Notification") +@patch("blueman.plugins.applet.TransferService.Gio.Settings") +class TestOnLoadClosures(TestCase): + def _load(self, settings_mock: MagicMock, notification_mock: MagicMock, invalid: bool) -> TransferService: + plugin = _make_plugin("/configured") + settings_mock.return_value = plugin._config + with patch.object(TransferService, "_make_share_path", return_value=(Path("/srv/Downloads"), invalid)): + plugin.on_load() + return plugin + + def test_handlerids_are_per_instance(self, settings_mock: MagicMock, notification_mock: MagicMock, + _manager_mock: MagicMock) -> None: + plugin = self._load(settings_mock, notification_mock, invalid=False) + self.assertEqual(plugin._handlerids, []) + self.assertIsNot(plugin._handlerids, TransferService._handlerids) + + def test_on_reset_clears_config_and_notification(self, settings_mock: MagicMock, notification_mock: MagicMock, + _manager_mock: MagicMock) -> None: + plugin = self._load(settings_mock, notification_mock, invalid=True) + on_reset = notification_mock.call_args.kwargs["actions_cb"] + on_reset("reset") + plugin._config.reset.assert_called_once_with("shared-path") + self.assertIsNone(plugin._notification) + + +@patch("blueman.plugins.applet.TransferService.Gio") +class TestOnUnload(TestCase): + def test_unwatch_and_unregister(self, gio_mock: MagicMock) -> None: + plugin = TransferService.__new__(TransferService) + plugin._watch = 42 + agent = MagicMock() + plugin._agent = agent + plugin.on_unload() + gio_mock.bus_unwatch_name.assert_called_once_with(42) + agent.unregister_from_manager.assert_called_once() + self.assertIsNone(plugin._agent) + + def test_noop_without_watch_or_agent(self, gio_mock: MagicMock) -> None: + plugin = TransferService.__new__(TransferService) + plugin._watch = None + plugin._agent = None + plugin.on_unload() + gio_mock.bus_unwatch_name.assert_not_called() + + +@patch("blueman.plugins.applet.TransferService.Agent") +@patch("blueman.plugins.applet.TransferService.Manager") +class TestDbusNameLifecycle(TestCase): + def _plugin(self) -> TransferService: + plugin = TransferService.__new__(TransferService) + plugin._agent = None + plugin._manager = None + plugin._handlerids = [] + return plugin + + def test_appeared_connects_signals_and_registers(self, manager_mock: MagicMock, agent_mock: MagicMock) -> None: + plugin = self._plugin() + plugin._on_dbus_name_appeared(MagicMock(), "name", "owner") + self.assertEqual(len(plugin._handlerids), 3) + agent_mock.assert_called_once_with(plugin._resolve_device) + + def test_appeared_handles_manager_failure(self, manager_mock: MagicMock, agent_mock: MagicMock) -> None: + from gi.repository import GLib + manager_mock.side_effect = GLib.Error("obex down") + plugin = self._plugin() + plugin._on_dbus_name_appeared(MagicMock(), "name", "owner") + self.assertEqual(plugin._handlerids, []) + agent_mock.assert_not_called() + + def test_vanished_disconnects_and_clears(self, manager_mock: MagicMock, _agent_mock: MagicMock) -> None: + plugin = self._plugin() + manager = MagicMock() + plugin._manager = manager + plugin._handlerids = [1, 2, 3] + agent = MagicMock() + plugin._agent = agent + plugin._on_dbus_name_vanished(MagicMock(), "name") + self.assertEqual(manager.disconnect.call_count, 3) + self.assertIsNone(plugin._manager) + self.assertEqual(plugin._handlerids, []) + agent.unregister.assert_called_once() + self.assertIsNone(plugin._agent) + + def test_vanished_noop_when_idle(self, manager_mock: MagicMock, _agent_mock: MagicMock) -> None: + plugin = self._plugin() + plugin._on_dbus_name_vanished(MagicMock(), "name") # must not raise + + +class TestReserveDestinationExhaustion(TestCase): + def test_raises_when_no_free_candidate(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + d = Path(tmp) + now = datetime(2020, 1, 2, 3, 4, 5) + stamp = "20200102030405" + with patch("blueman.plugins.applet.TransferService._MAX_DESTINATION_ATTEMPTS", 2): + (d / "f").write_text("") + (d / f"{stamp}_f").write_text("") + (d / f"{stamp}_1_f").write_text("") + with self.assertRaises(FileExistsError): + reserve_destination(d, "f", now) + + +@patch("blueman.plugins.applet.TransferService.GLib") +class TestMakeSharePathReset(TestCase): + def test_config_equal_to_default_is_reset(self, glib_mock: MagicMock) -> None: + with tempfile.TemporaryDirectory() as tmp: + glib_mock.get_user_special_dir.return_value = tmp + plugin = TransferService.__new__(TransferService) + config = MagicMock() + config.__getitem__.side_effect = lambda key: tmp + config.__setitem__ = MagicMock() + plugin._config = config + path, error = plugin._make_share_path() + self.assertEqual(path, Path(tmp)) + self.assertFalse(error) + config.__setitem__.assert_called_with("shared-path", "") + + +@patch("blueman.plugins.applet.TransferService.Notification") +class TestCompletionRenameAndReset(TestCase): + def setUp(self) -> None: + self._tmp = tempfile.TemporaryDirectory() + self.addCleanup(self._tmp.cleanup) + root = Path(self._tmp.name) + self.dest_dir = root / "Downloads" + self.dest_dir.mkdir() + self.src = root / "incoming.bin" + self.src.write_text("payload") + + def test_collision_renames_with_timestamp(self, _notification_mock: MagicMock) -> None: + (self.dest_dir / "incoming.bin").write_text("pre-existing") + plugin = TransferService.__new__(TransferService) + plugin._agent = MagicMock() + plugin._agent.transfers = {"/t": {"path": self.src, "size": 10, "name": "Phone"}} + plugin._normal_transfers = 0 + plugin._silent_transfers = 1 + plugin._notification = None + with patch.object(TransferService, "_make_share_path", return_value=(self.dest_dir, False)): + plugin._on_transfer_completed(MagicMock(), "/t", True) + moved = [p.name for p in self.dest_dir.iterdir()] + self.assertIn("incoming.bin", moved) # the pre-existing file + self.assertTrue(any(n.endswith("_incoming.bin") for n in moved)) # the renamed arrival + + +@patch("blueman.plugins.applet.TransferService.Notification") +class TestSessionCounterReset(TestCase): + def _plugin(self, silent: int, normal: int) -> TransferService: + plugin = TransferService.__new__(TransferService) + plugin._silent_transfers = silent + plugin._normal_transfers = normal + plugin._notification = None + return plugin + + def test_counters_reset_after_summary(self, _notification_mock: MagicMock) -> None: + plugin = self._plugin(silent=2, normal=1) + with patch.object(TransferService, "_make_share_path", return_value=(Path("/dl"), False)): + plugin._on_session_removed(MagicMock(), "/sess") + self.assertEqual(plugin._silent_transfers, 0) + self.assertEqual(plugin._normal_transfers, 0) + + +@patch("blueman.plugins.applet.TransferService.GLib") +class TestMakeSharePathXdgMissing(TestCase): + def test_falls_back_to_home_when_xdg_unavailable(self, glib_mock: MagicMock) -> None: + glib_mock.get_user_special_dir.return_value = None + plugin = TransferService.__new__(TransferService) + config = MagicMock() + config.__getitem__.side_effect = lambda key: "" # no configured path + config.__setitem__ = MagicMock() + plugin._config = config + path, error = plugin._make_share_path() + self.assertEqual(path, Path("~").expanduser()) + self.assertFalse(error) From 7c65dc50ca5be592df29b9c29f554f7ef1578a4d Mon Sep 17 00:00:00 2001 From: Geraldo Netto Date: Sat, 20 Jun 2026 13:59:05 +0200 Subject: [PATCH 41/42] style: use logging.exception in except handlers (Functions.py) Replace logging.error(..., exc_info=True) with logging.exception(...) at the three exception handlers (applet proxy failure, set_proc_title, socket creation). Equivalent output; clearer intent and satisfies the static-analysis rule. Co-Authored-By: Claude Opus 4.8 (1M context) --- blueman/Functions.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/blueman/Functions.py b/blueman/Functions.py index 687fe05a6..7a13a4e28 100644 --- a/blueman/Functions.py +++ b/blueman/Functions.py @@ -62,7 +62,7 @@ def check_bluetooth_status(message: str, exitfunc: Callable[[], Any]) -> None: applet = AppletService() powermanager = AppletPowerManagerService() except DBusProxyFailed: - logging.error("Blueman applet needs to be running", exc_info=True) + logging.exception("Blueman applet needs to be running") exitfunc() return @@ -255,7 +255,7 @@ def set_proc_title(name: str | None = None) -> int: buff.value = name.encode("UTF-8") ret: int = libc.prctl(15, byref(buff), 0, 0, 0) except (OSError, AttributeError): - logging.error("Failed to set process title", exc_info=True) + logging.exception("Failed to set process title") return -1 if ret != 0: @@ -380,7 +380,7 @@ def get_local_interfaces() -> dict[str, tuple[str, str | None]]: mask = _netmask_for_ifacename(name, sock) ip_dict[name] = (ipaddr, mask) except OSError: - logging.error('Socket creation failed', exc_info=True) + logging.exception('Socket creation failed') return {} return ip_dict From 44844175099482750d9e57f1b8907bf5f1f33879 Mon Sep 17 00:00:00 2001 From: Geraldo Netto Date: Sat, 20 Jun 2026 15:36:07 +0200 Subject: [PATCH 42/42] test(dns): wait for changed signal instead of draining pending events test_resolver_changed truncated the resolver file then drained only already-queued GLib events. The Gio.FileMonitor CHANGED event is delivered asynchronously and was often not queued yet, so the assertion saw the changed signal as never emitted. Block the main loop until the signal fires, bounded by a 5s timeout backstop. Co-Authored-By: Claude Opus 4.8 (1M context) --- test/main/test_dns_server_provider.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/test/main/test_dns_server_provider.py b/test/main/test_dns_server_provider.py index 1de3dd0c3..2224988a4 100644 --- a/test/main/test_dns_server_provider.py +++ b/test/main/test_dns_server_provider.py @@ -78,6 +78,14 @@ def _test_changed(action: Callable[[], None]) -> None: action() context = GLib.MainContext.default() - while context.pending(): - context.iteration() + timed_out = False + + def on_timeout() -> bool: + nonlocal timed_out + timed_out = True + return GLib.SOURCE_REMOVE + + GLib.timeout_add_seconds(5, on_timeout) + while not mock.called and not timed_out: + context.iteration(may_block=True) mock.assert_called_with(provider)