android-watcher 1.0.0__tar.gz → 1.0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {android_watcher-1.0.0 → android_watcher-1.0.1}/.github/workflows/release.yml +34 -7
- {android_watcher-1.0.0 → android_watcher-1.0.1}/Formula/android-watcher.rb +2 -2
- {android_watcher-1.0.0 → android_watcher-1.0.1}/PKG-INFO +1 -1
- {android_watcher-1.0.0 → android_watcher-1.0.1}/pyproject.toml +1 -1
- {android_watcher-1.0.0 → android_watcher-1.0.1}/src/android_watcher/config.py +48 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/src/android_watcher/doctor.py +21 -0
- android_watcher-1.0.1/src/android_watcher/notify/__init__.py +1 -0
- android_watcher-1.0.1/src/android_watcher/notify/desktop.py +95 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/src/android_watcher/run.py +2 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/src/android_watcher/schedule.py +48 -10
- {android_watcher-1.0.0 → android_watcher-1.0.1}/src/android_watcher/tui/configio.py +24 -2
- {android_watcher-1.0.0 → android_watcher-1.0.1}/src/android_watcher/tui/screens.py +24 -4
- android_watcher-1.0.1/tests/notify/test_desktop.py +152 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/run/test_run_once.py +26 -2
- {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/test_config.py +32 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/test_configio.py +46 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/test_doctor.py +53 -1
- {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/test_schedule_crontab.py +20 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/test_schedule_install.py +14 -2
- {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/test_schedule_plist.py +25 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/test_schedule_systemd.py +14 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/test_tui_smoke.py +44 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/uv.lock +1 -1
- android_watcher-1.0.0/src/android_watcher/notify/__init__.py +0 -1
- {android_watcher-1.0.0 → android_watcher-1.0.1}/.editorconfig +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/.github/workflows/ci.yml +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/.github/workflows/seed.yml +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/.gitignore +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/.pre-commit-config.yaml +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/CLAUDE.md +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/CODE_OF_CONDUCT.md +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/CONTRIBUTING.md +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/LICENSE +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/README.md +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/SECURITY.md +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/assets/logo.svg +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/docs/scheduling.md +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/scripts/build_seed.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/scripts/verify_catalog.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/src/android_watcher/__init__.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/src/android_watcher/catalog/__init__.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/src/android_watcher/catalog/catalog.toml +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/src/android_watcher/cli.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/src/android_watcher/detect/__init__.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/src/android_watcher/detect/_normalize.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/src/android_watcher/detect/android_sitemap.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/src/android_watcher/detect/base.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/src/android_watcher/detect/content.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/src/android_watcher/detect/feed.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/src/android_watcher/detect/sitemap.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/src/android_watcher/fetch.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/src/android_watcher/group.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/src/android_watcher/lock.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/src/android_watcher/models.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/src/android_watcher/notify/base.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/src/android_watcher/notify/email.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/src/android_watcher/notify/html.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/src/android_watcher/notify/render.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/src/android_watcher/notify/slack.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/src/android_watcher/notify/telegram.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/src/android_watcher/rank.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/src/android_watcher/registry.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/src/android_watcher/seed/__init__.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/src/android_watcher/seed/seed.sql.gz +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/src/android_watcher/store.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/src/android_watcher/triage/__init__.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/src/android_watcher/triage/base.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/src/android_watcher/triage/claude_cli.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/src/android_watcher/triage/noop.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/src/android_watcher/tui/__init__.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/src/android_watcher/tui/app.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/conftest.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/detect/__init__.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/detect/test_android_sitemap.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/detect/test_base.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/detect/test_confirm_shared.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/detect/test_content.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/detect/test_feed.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/detect/test_normalize.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/detect/test_registry_autoload.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/detect/test_registry_resolution.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/detect/test_sitemap.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/fixtures/claude_cli_envelope.json +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/fixtures/content_after_chrome_only.html +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/fixtures/content_after_real_change.html +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/fixtures/content_before.html +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/fixtures/content_js_shell.html +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/fixtures/feed_guid_reuse.xml +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/fixtures/feed_initial.xml +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/fixtures/feed_updated_summary.xml +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/fixtures/schedule/android-watcher.service +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/fixtures/schedule/android-watcher.timer +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/fixtures/schedule/launchd_daily.plist +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/fixtures/sitemap_index.xml +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/fixtures/sitemap_shard0.xml +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/fixtures/sitemap_shard_i18n.xml +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/fixtures/sitemap_simple.xml +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/notify/__init__.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/notify/snapshots/normal_email.html +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/notify/snapshots/normal_email.txt +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/notify/snapshots/normal_slack.json +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/notify/test_base.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/notify/test_email.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/notify/test_html.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/notify/test_render.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/notify/test_slack.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/notify/test_telegram.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/run/__init__.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/run/test_detect_and_persist.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/test_catalog.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/test_catalog_data.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/test_cli.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/test_cli_schedule.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/test_configio_custom_source_roundtrip.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/test_contributing_docs.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/test_docs_scheduling.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/test_fetch.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/test_group.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/test_homebrew_formula.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/test_lock.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/test_models.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/test_packaging.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/test_rank.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/test_readme_disclosures.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/test_registry.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/test_release_workflow.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/test_schedule_status.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/test_seed.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/test_seed_workflow.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/test_store.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/test_verify_catalog_smoke.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/triage/__init__.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/triage/test_base.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/triage/test_claude_cli.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/triage/test_claude_cli_prompt.py +0 -0
- {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/triage/test_noop.py +0 -0
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
name: release
|
|
2
2
|
|
|
3
3
|
# Manual, bump-driven release. Pick the semver part to bump; the job computes the
|
|
4
|
-
# new version from the current one, commits it to main, publishes to PyPI,
|
|
5
|
-
#
|
|
4
|
+
# new version from the current one, commits it to main, publishes to PyPI, tags and
|
|
5
|
+
# publishes a GitHub release, then refreshes the Homebrew formula as a best-effort,
|
|
6
|
+
# non-blocking step.
|
|
6
7
|
on:
|
|
7
8
|
workflow_dispatch:
|
|
8
9
|
inputs:
|
|
@@ -33,6 +34,11 @@ jobs:
|
|
|
33
34
|
with:
|
|
34
35
|
ref: main
|
|
35
36
|
fetch-depth: 0 # full history so we can push back and tag
|
|
37
|
+
# Push to main as the repo owner (a ruleset bypass actor) rather than the
|
|
38
|
+
# ambient GITHUB_TOKEN / github-actions[bot], which the main ruleset blocks.
|
|
39
|
+
# On a user-owned repo the GitHub Actions integration cannot be added to the
|
|
40
|
+
# bypass list, so a fine-grained PAT (contents: write) is used instead.
|
|
41
|
+
token: ${{ secrets.RELEASE_TOKEN }}
|
|
36
42
|
|
|
37
43
|
- name: Configure git as the release bot
|
|
38
44
|
run: |
|
|
@@ -72,7 +78,33 @@ jobs:
|
|
|
72
78
|
with:
|
|
73
79
|
password: ${{ secrets.PYPI_TOKEN }}
|
|
74
80
|
|
|
81
|
+
# Tag and publish a GitHub release right after the publish succeeds, before
|
|
82
|
+
# the best-effort Homebrew step. A formula hiccup must never leave a
|
|
83
|
+
# published version untagged or unreleased. --generate-notes builds the
|
|
84
|
+
# changelog from merged PRs and commits since the previous release.
|
|
85
|
+
- name: Tag and publish GitHub release
|
|
86
|
+
env:
|
|
87
|
+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
88
|
+
run: |
|
|
89
|
+
VERSION="${{ steps.bump.outputs.version }}"
|
|
90
|
+
git tag "v${VERSION}"
|
|
91
|
+
git push origin "v${VERSION}"
|
|
92
|
+
gh release create "v${VERSION}" \
|
|
93
|
+
--title "v${VERSION}" \
|
|
94
|
+
--generate-notes \
|
|
95
|
+
--verify-tag \
|
|
96
|
+
--latest
|
|
97
|
+
|
|
98
|
+
- name: Set up Homebrew
|
|
99
|
+
uses: Homebrew/actions/setup-homebrew@master
|
|
100
|
+
|
|
101
|
+
# Best-effort and non-blocking: `brew update-python-resources` can fail to
|
|
102
|
+
# resolve a same-day PyPI upload (its reproducibility cutoff predates the
|
|
103
|
+
# publish) and requires a tap on newer Homebrew. Never let that fail the
|
|
104
|
+
# release; regenerate the resource blocks out-of-band once PyPI indexing
|
|
105
|
+
# settles, then commit the refreshed formula.
|
|
75
106
|
- name: Update Homebrew formula
|
|
107
|
+
continue-on-error: true
|
|
76
108
|
run: |
|
|
77
109
|
VERSION="${{ steps.bump.outputs.version }}"
|
|
78
110
|
# PyPI's JSON index lags publish by a few seconds; poll until the release shows up.
|
|
@@ -105,8 +137,3 @@ jobs:
|
|
|
105
137
|
git add Formula/android-watcher.rb
|
|
106
138
|
git commit -m "chore: update homebrew formula for v${VERSION}"
|
|
107
139
|
git push origin HEAD:main
|
|
108
|
-
|
|
109
|
-
- name: Tag the release
|
|
110
|
-
run: |
|
|
111
|
-
git tag "v${{ steps.bump.outputs.version }}"
|
|
112
|
-
git push origin "v${{ steps.bump.outputs.version }}"
|
|
@@ -6,8 +6,8 @@ class AndroidWatcher < Formula
|
|
|
6
6
|
# url + sha256 point at the PyPI sdist for the released version. The release
|
|
7
7
|
# workflow rewrites both on every version bump, then regenerates the `resource`
|
|
8
8
|
# blocks below with `brew update-python-resources`. Do not hand-edit.
|
|
9
|
-
url "https://files.pythonhosted.org/packages/
|
|
10
|
-
sha256 "
|
|
9
|
+
url "https://files.pythonhosted.org/packages/31/5c/0ac31004a57aaa32b117c1d318cb5c8256adae9f95b7c874187335f47b3b/android_watcher-1.0.0.tar.gz"
|
|
10
|
+
sha256 "0844583243b9d94ef4211c80a8be6e5b0efac44d21bdb224aa6b02a5670cc7c3"
|
|
11
11
|
license "MIT"
|
|
12
12
|
|
|
13
13
|
depends_on "python@3.11"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: android-watcher
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.1
|
|
4
4
|
Summary: Self-hosted CLI that watches Google's official Android sites and delivers AI-triaged change digests.
|
|
5
5
|
Project-URL: Homepage, https://github.com/krayong/android-watcher
|
|
6
6
|
Project-URL: Repository, https://github.com/krayong/android-watcher
|
|
@@ -4,6 +4,8 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import os
|
|
6
6
|
import re
|
|
7
|
+
import shutil
|
|
8
|
+
import sys
|
|
7
9
|
import tomllib
|
|
8
10
|
from dataclasses import dataclass, field
|
|
9
11
|
from typing import Any, Literal
|
|
@@ -16,6 +18,7 @@ __all__ = [
|
|
|
16
18
|
"AIConfig",
|
|
17
19
|
"Config",
|
|
18
20
|
"ConfigError",
|
|
21
|
+
"DesktopChannel",
|
|
19
22
|
"DigestConfig",
|
|
20
23
|
"EmailChannel",
|
|
21
24
|
"ScheduleConfig",
|
|
@@ -24,6 +27,8 @@ __all__ = [
|
|
|
24
27
|
"config_path",
|
|
25
28
|
"data_path",
|
|
26
29
|
"db_path",
|
|
30
|
+
"desktop_mechanism_available",
|
|
31
|
+
"digests_dir",
|
|
27
32
|
"load_config",
|
|
28
33
|
"log_path",
|
|
29
34
|
]
|
|
@@ -80,6 +85,12 @@ class TelegramChannel:
|
|
|
80
85
|
chat_id: str = ""
|
|
81
86
|
|
|
82
87
|
|
|
88
|
+
@dataclass
|
|
89
|
+
class DesktopChannel:
|
|
90
|
+
enabled: bool = False
|
|
91
|
+
sound: str = "Glass" # macOS notification sound name
|
|
92
|
+
|
|
93
|
+
|
|
83
94
|
@dataclass
|
|
84
95
|
class Config:
|
|
85
96
|
schedule: ScheduleConfig
|
|
@@ -90,6 +101,9 @@ class Config:
|
|
|
90
101
|
slack: SlackChannel
|
|
91
102
|
telegram: TelegramChannel
|
|
92
103
|
custom_sources: list[Source]
|
|
104
|
+
# Defaulted (disabled) so existing Config(...) call sites need no change; a new
|
|
105
|
+
# optional local channel that is off until the user opts in.
|
|
106
|
+
desktop: DesktopChannel = field(default_factory=DesktopChannel)
|
|
93
107
|
enabled_source_ids: set[str] = field(default_factory=set)
|
|
94
108
|
|
|
95
109
|
|
|
@@ -105,6 +119,30 @@ def db_path() -> str:
|
|
|
105
119
|
return os.path.join(data_path(), "state.db")
|
|
106
120
|
|
|
107
121
|
|
|
122
|
+
def digests_dir() -> str:
|
|
123
|
+
"""Directory where the desktop channel writes click-to-open HTML digests."""
|
|
124
|
+
return os.path.join(data_path(), "digests")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _desktop_binary() -> str | None:
|
|
128
|
+
"""The required desktop-notification binary for this platform, or None.
|
|
129
|
+
|
|
130
|
+
macOS uses terminal-notifier (the only mechanism that opens the digest on
|
|
131
|
+
click); Linux uses notify-send. Other platforms have no supported mechanism.
|
|
132
|
+
"""
|
|
133
|
+
if sys.platform == "darwin":
|
|
134
|
+
return "terminal-notifier"
|
|
135
|
+
if sys.platform.startswith("linux"):
|
|
136
|
+
return "notify-send"
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def desktop_mechanism_available() -> bool:
|
|
141
|
+
"""True if this platform's required desktop-notification binary is installed."""
|
|
142
|
+
binary = _desktop_binary()
|
|
143
|
+
return bool(binary and shutil.which(binary))
|
|
144
|
+
|
|
145
|
+
|
|
108
146
|
def log_path() -> str:
|
|
109
147
|
"""Path to the run log. ~/Library/Logs/android-watcher.log on macOS."""
|
|
110
148
|
import sys # noqa: PLC0415 - localized; keeps module import graph lean
|
|
@@ -160,6 +198,7 @@ def load_config(path: str | None = None, *, expand: bool = True) -> Config:
|
|
|
160
198
|
email = _load_email(channels.get("email", {}), expand=expand)
|
|
161
199
|
slack = _load_slack(channels.get("slack", {}), expand=expand)
|
|
162
200
|
telegram = _load_telegram(channels.get("telegram", {}), expand=expand)
|
|
201
|
+
desktop = _load_desktop(channels.get("desktop", {}))
|
|
163
202
|
custom_sources = [_load_source(e) for e in raw.get("custom_source", [])]
|
|
164
203
|
enabled = set(raw.get("enabled_sources", []))
|
|
165
204
|
|
|
@@ -174,6 +213,7 @@ def load_config(path: str | None = None, *, expand: bool = True) -> Config:
|
|
|
174
213
|
email=email,
|
|
175
214
|
slack=slack,
|
|
176
215
|
telegram=telegram,
|
|
216
|
+
desktop=desktop,
|
|
177
217
|
custom_sources=custom_sources,
|
|
178
218
|
enabled_source_ids=enabled,
|
|
179
219
|
)
|
|
@@ -247,6 +287,14 @@ def _load_telegram(d: dict[str, Any], *, expand: bool) -> TelegramChannel:
|
|
|
247
287
|
)
|
|
248
288
|
|
|
249
289
|
|
|
290
|
+
def _load_desktop(d: dict[str, Any]) -> DesktopChannel:
|
|
291
|
+
# No secret-bearing fields, so no ${ENV} interpolation needed.
|
|
292
|
+
return DesktopChannel(
|
|
293
|
+
enabled=bool(d.get("enabled", False)),
|
|
294
|
+
sound=d.get("sound", "Glass"),
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
|
|
250
298
|
def _load_source(e: dict[str, Any]) -> Source:
|
|
251
299
|
return Source(
|
|
252
300
|
id=e["id"],
|
|
@@ -34,6 +34,25 @@ def _check_ai(config: Config) -> Check:
|
|
|
34
34
|
return Check("ai-backend", False, "claude not found on PATH")
|
|
35
35
|
|
|
36
36
|
|
|
37
|
+
def _check_desktop(config: Config) -> Check:
|
|
38
|
+
"""Verify the platform's required desktop-notification binary is installed."""
|
|
39
|
+
from android_watcher.config import ( # noqa: PLC0415 - localized to keep imports lean
|
|
40
|
+
_desktop_binary,
|
|
41
|
+
desktop_mechanism_available,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
binary = _desktop_binary()
|
|
45
|
+
if binary is None:
|
|
46
|
+
return Check("desktop", False, "no supported desktop notifier for this platform")
|
|
47
|
+
if desktop_mechanism_available():
|
|
48
|
+
return Check("desktop", True, f"{binary} found")
|
|
49
|
+
return Check(
|
|
50
|
+
"desktop",
|
|
51
|
+
False,
|
|
52
|
+
f"{binary} not found on PATH; install it to enable desktop notifications",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
37
56
|
def _check_seed() -> Check:
|
|
38
57
|
"""Report the imported baseline seed date and snapshot count, if any."""
|
|
39
58
|
store = Store(db_path())
|
|
@@ -121,5 +140,7 @@ def run_doctor(config: Config) -> list[Check]:
|
|
|
121
140
|
checks.extend(_check_prefixes(config))
|
|
122
141
|
checks.append(_check_seed())
|
|
123
142
|
checks.append(_check_ai(config))
|
|
143
|
+
if config.desktop.enabled:
|
|
144
|
+
checks.append(_check_desktop(config))
|
|
124
145
|
checks.append(_check_schedule())
|
|
125
146
|
return checks
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from . import desktop, email, slack, telegram # noqa: F401
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Desktop notifier: a native notification that opens a local HTML digest on click.
|
|
2
|
+
|
|
3
|
+
macOS uses ``terminal-notifier`` (whose ``-execute`` runs ``open <file>`` on click);
|
|
4
|
+
Linux uses ``notify-send`` (no portable click action, so the digest path is shown in
|
|
5
|
+
the body). Both render the full digest to ``<data_dir>/digests`` first.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import shlex
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from android_watcher.config import Config, desktop_mechanism_available, digests_dir
|
|
17
|
+
from android_watcher.models import Digest, DigestGroup, NotifyError
|
|
18
|
+
from android_watcher.notify.base import NOTIFIERS
|
|
19
|
+
from android_watcher.notify.html import render_html
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
_TIMEOUT = 30.0
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _member_ids(groups: list[DigestGroup]) -> set[int]:
|
|
27
|
+
return {m.id for g in groups for m in g.members if m.id is not None}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _digest_filename(digest: Digest) -> str:
|
|
31
|
+
# Local time in the human-facing filename; UTC is kept internally on the digest.
|
|
32
|
+
local = digest.generated_at.astimezone()
|
|
33
|
+
return f"android-watcher-digest-{local:%d%m%Y-%H%M%S}.html"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _write_html(digest: Digest) -> Path:
|
|
37
|
+
out_dir = Path(digests_dir())
|
|
38
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
path = out_dir / _digest_filename(digest)
|
|
40
|
+
path.write_text(render_html(digest), encoding="utf-8")
|
|
41
|
+
return path
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _summary(digest: Digest) -> str:
|
|
45
|
+
"""Counts only — never raw page titles, which come from untrusted sources."""
|
|
46
|
+
n = digest.change_count()
|
|
47
|
+
g = len(digest.groups)
|
|
48
|
+
return f"{n} change{'' if n == 1 else 's'} across {g} group{'' if g == 1 else 's'}"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _notify(title: str, message: str, path: Path, sound: str) -> None:
|
|
52
|
+
if sys.platform == "darwin":
|
|
53
|
+
argv = [
|
|
54
|
+
"terminal-notifier",
|
|
55
|
+
"-title",
|
|
56
|
+
title,
|
|
57
|
+
"-message",
|
|
58
|
+
message,
|
|
59
|
+
"-sound",
|
|
60
|
+
sound,
|
|
61
|
+
"-group",
|
|
62
|
+
"android-watcher",
|
|
63
|
+
# The path is app-generated; quote it so a space in the data dir is safe.
|
|
64
|
+
"-execute",
|
|
65
|
+
f"open {shlex.quote(str(path))}",
|
|
66
|
+
]
|
|
67
|
+
elif sys.platform.startswith("linux"):
|
|
68
|
+
# notify-send has no portable click-to-run action; show the digest URI instead.
|
|
69
|
+
argv = ["notify-send", title, f"{message}\n{path.as_uri()}"]
|
|
70
|
+
else: # pragma: no cover - guarded earlier by desktop_mechanism_available()
|
|
71
|
+
raise NotifyError(f"desktop notifications unsupported on {sys.platform!r}")
|
|
72
|
+
try:
|
|
73
|
+
subprocess.run(argv, check=True, capture_output=True, timeout=_TIMEOUT)
|
|
74
|
+
except FileNotFoundError as exc:
|
|
75
|
+
raise NotifyError(f"desktop notifier binary not found: {argv[0]}") from exc
|
|
76
|
+
except subprocess.CalledProcessError as exc:
|
|
77
|
+
raise NotifyError(f"{argv[0]} exited {exc.returncode}") from exc
|
|
78
|
+
except subprocess.TimeoutExpired as exc:
|
|
79
|
+
raise NotifyError(f"{argv[0]} timed out after {_TIMEOUT}s") from exc
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@NOTIFIERS.register("desktop")
|
|
83
|
+
class DesktopNotifier:
|
|
84
|
+
name = "desktop"
|
|
85
|
+
|
|
86
|
+
def send(self, digest: Digest, config: Config) -> set[int]:
|
|
87
|
+
# Notify only when there is something to show; no daily empty-digest pop.
|
|
88
|
+
if not digest.groups:
|
|
89
|
+
return set()
|
|
90
|
+
if not desktop_mechanism_available():
|
|
91
|
+
raise NotifyError("no desktop notification mechanism available")
|
|
92
|
+
path = _write_html(digest)
|
|
93
|
+
logger.info("desktop: wrote digest page %s; firing notification", path)
|
|
94
|
+
_notify("Android Watcher", _summary(digest), path, config.desktop.sound)
|
|
95
|
+
return _member_ids(digest.groups)
|
|
@@ -166,8 +166,18 @@ def _calendar_intervals(sched: ScheduleConfig) -> list[dict[str, int]]:
|
|
|
166
166
|
raise ScheduleError(f"unknown interval {sched.interval!r}")
|
|
167
167
|
|
|
168
168
|
|
|
169
|
-
def render_plist(
|
|
170
|
-
|
|
169
|
+
def render_plist(
|
|
170
|
+
label: str,
|
|
171
|
+
program_args: list[str],
|
|
172
|
+
sched: ScheduleConfig,
|
|
173
|
+
path_env: str | None = None,
|
|
174
|
+
) -> str:
|
|
175
|
+
"""Render a launchd plist string for the given label, args, and schedule.
|
|
176
|
+
|
|
177
|
+
A launchd job inherits a bare PATH (``/usr/bin:/bin:/usr/sbin:/sbin``), so
|
|
178
|
+
when *path_env* is given it is embedded as ``EnvironmentVariables/PATH`` —
|
|
179
|
+
without it the run cannot reach the ``claude`` CLI for triage.
|
|
180
|
+
"""
|
|
171
181
|
intervals = _calendar_intervals(sched)
|
|
172
182
|
payload: dict[str, object] = {
|
|
173
183
|
"Label": label,
|
|
@@ -175,6 +185,8 @@ def render_plist(label: str, program_args: list[str], sched: ScheduleConfig) ->
|
|
|
175
185
|
"RunAtLoad": False,
|
|
176
186
|
"StartCalendarInterval": intervals[0] if len(intervals) == 1 else intervals,
|
|
177
187
|
}
|
|
188
|
+
if path_env:
|
|
189
|
+
payload["EnvironmentVariables"] = {"PATH": path_env}
|
|
178
190
|
return plistlib.dumps(payload, sort_keys=True).decode("utf-8")
|
|
179
191
|
|
|
180
192
|
|
|
@@ -231,15 +243,22 @@ def _on_calendar(sched: ScheduleConfig, tz: str) -> str:
|
|
|
231
243
|
raise ScheduleError(f"unknown interval {sched.interval!r}")
|
|
232
244
|
|
|
233
245
|
|
|
234
|
-
def render_service(exec_path: str, args: list[str]) -> str:
|
|
235
|
-
"""Render a systemd .service unit for android-watcher.
|
|
246
|
+
def render_service(exec_path: str, args: list[str], path_env: str | None = None) -> str:
|
|
247
|
+
"""Render a systemd .service unit for android-watcher.
|
|
248
|
+
|
|
249
|
+
systemd user services start from a minimal PATH, so when *path_env* is given
|
|
250
|
+
it is embedded as ``Environment=PATH=`` — without it the run cannot reach the
|
|
251
|
+
``claude`` CLI for triage.
|
|
252
|
+
"""
|
|
236
253
|
exec_start = " ".join([exec_path, *args])
|
|
254
|
+
env_line = f"Environment=PATH={path_env}\n" if path_env else ""
|
|
237
255
|
return (
|
|
238
256
|
"[Unit]\n"
|
|
239
257
|
"Description=android-watcher scheduled run\n"
|
|
240
258
|
"\n"
|
|
241
259
|
"[Service]\n"
|
|
242
260
|
"Type=oneshot\n"
|
|
261
|
+
f"{env_line}"
|
|
243
262
|
f"ExecStart={exec_start}\n"
|
|
244
263
|
)
|
|
245
264
|
|
|
@@ -281,10 +300,18 @@ def _cron_line(sched: ScheduleConfig) -> str:
|
|
|
281
300
|
return _cron_lines(sched)[0]
|
|
282
301
|
|
|
283
302
|
|
|
284
|
-
def render_crontab(
|
|
285
|
-
|
|
303
|
+
def render_crontab(
|
|
304
|
+
line_command: str, sched: ScheduleConfig, tz: str, path_env: str | None = None
|
|
305
|
+
) -> str:
|
|
306
|
+
"""Render a marked crontab block for android-watcher (one line per scheduled time).
|
|
307
|
+
|
|
308
|
+
cron runs with a minimal PATH, so when *path_env* is given a ``PATH=``
|
|
309
|
+
assignment is emitted ahead of the schedule lines — without it the run cannot
|
|
310
|
+
reach the ``claude`` CLI for triage.
|
|
311
|
+
"""
|
|
286
312
|
body = "\n".join(f"{spec} {line_command}" for spec in _cron_lines(sched))
|
|
287
|
-
|
|
313
|
+
path_line = f"PATH={path_env}\n" if path_env else ""
|
|
314
|
+
return f"{CRON_BEGIN}\nCRON_TZ={tz}\n{path_line}{body}\n{CRON_END}\n"
|
|
288
315
|
|
|
289
316
|
|
|
290
317
|
# ---------------------------------------------------------------------------
|
|
@@ -319,6 +346,17 @@ def _program_args() -> list[str]:
|
|
|
319
346
|
return [exe, "run"]
|
|
320
347
|
|
|
321
348
|
|
|
349
|
+
def _env_path() -> str:
|
|
350
|
+
"""Snapshot the install-time PATH to embed in the scheduled unit.
|
|
351
|
+
|
|
352
|
+
Native schedulers (launchd, systemd user, cron) run with a minimal PATH that
|
|
353
|
+
omits per-user bin dirs like ``~/.local/bin``, so the ``claude`` CLI would be
|
|
354
|
+
unreachable. Capturing the PATH from the shell that ran the install preserves
|
|
355
|
+
parity with the environment where ``claude`` was found.
|
|
356
|
+
"""
|
|
357
|
+
return os.environ.get("PATH", "")
|
|
358
|
+
|
|
359
|
+
|
|
322
360
|
def _launchd_plist_path() -> str:
|
|
323
361
|
return str(Path.home() / "Library" / "LaunchAgents" / f"{LAUNCHD_LABEL}.plist")
|
|
324
362
|
|
|
@@ -357,7 +395,7 @@ def _run(argv: list[str], *, input: str | None = None) -> subprocess.CompletedPr
|
|
|
357
395
|
def _install_macos(config: Config) -> None:
|
|
358
396
|
path = Path(_launchd_plist_path())
|
|
359
397
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
360
|
-
path.write_text(render_plist(LAUNCHD_LABEL, _program_args(), config.schedule))
|
|
398
|
+
path.write_text(render_plist(LAUNCHD_LABEL, _program_args(), config.schedule, _env_path()))
|
|
361
399
|
_run(["launchctl", "unload", str(path)])
|
|
362
400
|
_run(["launchctl", "load", "-w", str(path)])
|
|
363
401
|
_warn(
|
|
@@ -370,7 +408,7 @@ def _install_systemd(config: Config) -> None:
|
|
|
370
408
|
d = Path(_systemd_dir())
|
|
371
409
|
d.mkdir(parents=True, exist_ok=True)
|
|
372
410
|
exe, *run_args = _program_args()
|
|
373
|
-
(d / f"{SYSTEMD_UNIT_NAME}.service").write_text(render_service(exe, run_args))
|
|
411
|
+
(d / f"{SYSTEMD_UNIT_NAME}.service").write_text(render_service(exe, run_args, _env_path()))
|
|
374
412
|
(d / f"{SYSTEMD_UNIT_NAME}.timer").write_text(render_timer(config.schedule, _local_tz()))
|
|
375
413
|
_run(["systemctl", "--user", "daemon-reload"])
|
|
376
414
|
_run(["systemctl", "--user", "enable", "--now", f"{SYSTEMD_UNIT_NAME}.timer"])
|
|
@@ -400,7 +438,7 @@ def _strip_cron_block(text: str) -> str:
|
|
|
400
438
|
|
|
401
439
|
def _install_crontab(config: Config) -> None:
|
|
402
440
|
existing = _run(["crontab", "-l"]).stdout
|
|
403
|
-
block = render_crontab(" ".join(_program_args()), config.schedule, _local_tz())
|
|
441
|
+
block = render_crontab(" ".join(_program_args()), config.schedule, _local_tz(), _env_path())
|
|
404
442
|
cleaned = _strip_cron_block(existing)
|
|
405
443
|
new = (cleaned.rstrip("\n") + "\n" if cleaned.strip() else "") + block
|
|
406
444
|
_run(["crontab", "-"], input=new)
|
|
@@ -15,12 +15,14 @@ from android_watcher.config import (
|
|
|
15
15
|
AIConfig,
|
|
16
16
|
Config,
|
|
17
17
|
ConfigError,
|
|
18
|
+
DesktopChannel,
|
|
18
19
|
DigestConfig,
|
|
19
20
|
EmailChannel,
|
|
20
21
|
ScheduleConfig,
|
|
21
22
|
SlackChannel,
|
|
22
23
|
TelegramChannel,
|
|
23
24
|
config_path,
|
|
25
|
+
desktop_mechanism_available,
|
|
24
26
|
load_config,
|
|
25
27
|
)
|
|
26
28
|
from android_watcher.models import Source
|
|
@@ -136,6 +138,12 @@ def config_to_toml(config: Config) -> str:
|
|
|
136
138
|
lines.append(f"chat_id = {_toml_str(tg.chat_id)}")
|
|
137
139
|
lines.append("")
|
|
138
140
|
|
|
141
|
+
ds = config.desktop
|
|
142
|
+
lines.append("[channels.desktop]")
|
|
143
|
+
lines.append(f"enabled = {'true' if ds.enabled else 'false'}")
|
|
144
|
+
lines.append(f"sound = {_toml_str(ds.sound)}")
|
|
145
|
+
lines.append("")
|
|
146
|
+
|
|
139
147
|
for s in config.custom_sources:
|
|
140
148
|
lines.append(_source_table("custom_source", s))
|
|
141
149
|
|
|
@@ -173,8 +181,16 @@ def validate_config(config: Config) -> list[str]:
|
|
|
173
181
|
errors.append(str(exc))
|
|
174
182
|
finally:
|
|
175
183
|
os.unlink(tmp)
|
|
176
|
-
if not (
|
|
177
|
-
|
|
184
|
+
if not (
|
|
185
|
+
config.email.enabled
|
|
186
|
+
or config.slack.enabled
|
|
187
|
+
or config.telegram.enabled
|
|
188
|
+
or config.desktop.enabled
|
|
189
|
+
):
|
|
190
|
+
errors.append(
|
|
191
|
+
"enable at least one delivery channel (Email, Slack, Telegram, or Desktop) "
|
|
192
|
+
"to receive digests"
|
|
193
|
+
)
|
|
178
194
|
sl = config.slack
|
|
179
195
|
if sl.enabled and not (sl.bot_token and sl.channel):
|
|
180
196
|
errors.append("slack channel is enabled but bot_token + channel are required")
|
|
@@ -183,6 +199,11 @@ def validate_config(config: Config) -> list[str]:
|
|
|
183
199
|
errors.append("telegram channel is enabled but bot_token is empty")
|
|
184
200
|
if tg.enabled and not tg.chat_id:
|
|
185
201
|
errors.append("telegram channel is enabled but chat_id is empty")
|
|
202
|
+
if config.desktop.enabled and not desktop_mechanism_available():
|
|
203
|
+
errors.append(
|
|
204
|
+
"desktop channel is enabled but no notifier is available "
|
|
205
|
+
"(install terminal-notifier on macOS or notify-send on Linux)"
|
|
206
|
+
)
|
|
186
207
|
if _in_git_worktree(config_path()):
|
|
187
208
|
errors.append(
|
|
188
209
|
f"warning: config path {config_path()} is inside a git work tree; "
|
|
@@ -209,6 +230,7 @@ def load_or_default() -> tuple[Config, bool]:
|
|
|
209
230
|
email=EmailChannel(),
|
|
210
231
|
slack=SlackChannel(),
|
|
211
232
|
telegram=TelegramChannel(),
|
|
233
|
+
desktop=DesktopChannel(),
|
|
212
234
|
custom_sources=[],
|
|
213
235
|
enabled_source_ids=set(),
|
|
214
236
|
)
|
|
@@ -12,7 +12,7 @@ from textual.widgets import Input, OptionList, Static
|
|
|
12
12
|
from textual.widgets.option_list import Option
|
|
13
13
|
|
|
14
14
|
from android_watcher.catalog import load_catalog
|
|
15
|
-
from android_watcher.config import Config
|
|
15
|
+
from android_watcher.config import Config, desktop_mechanism_available
|
|
16
16
|
from android_watcher.models import Source
|
|
17
17
|
|
|
18
18
|
NONE_SENTINEL = "__none__"
|
|
@@ -432,7 +432,9 @@ class MainMenuScreen(_Nav):
|
|
|
432
432
|
def _summaries(self) -> list[tuple[str, str, str]]:
|
|
433
433
|
c = self._config
|
|
434
434
|
channels = [
|
|
435
|
-
name
|
|
435
|
+
name
|
|
436
|
+
for name, ch in (("slack", c.slack), ("telegram", c.telegram), ("desktop", c.desktop))
|
|
437
|
+
if ch.enabled
|
|
436
438
|
]
|
|
437
439
|
sched = c.schedule
|
|
438
440
|
when = sched.cron if sched.interval == "cron" else f"{sched.interval} {sched.at}".strip()
|
|
@@ -746,7 +748,11 @@ class ChannelsScreen(_Nav):
|
|
|
746
748
|
|
|
747
749
|
def _channels(self):
|
|
748
750
|
c = self._config
|
|
749
|
-
return (
|
|
751
|
+
return (
|
|
752
|
+
("slack", "Slack", c.slack),
|
|
753
|
+
("telegram", "Telegram", c.telegram),
|
|
754
|
+
("desktop", "Desktop", c.desktop),
|
|
755
|
+
)
|
|
750
756
|
|
|
751
757
|
def compose(self) -> ComposeResult:
|
|
752
758
|
yield from _heading("Channels", "Where digests are delivered")
|
|
@@ -786,6 +792,16 @@ class ChannelsScreen(_Nav):
|
|
|
786
792
|
cid = listing.get_option_at_index(listing.highlighted).id
|
|
787
793
|
for oid, _name, ch in self._channels():
|
|
788
794
|
if oid == cid:
|
|
795
|
+
# Desktop click-to-open needs a notifier binary; refuse to enable it
|
|
796
|
+
# when none is installed (terminal-notifier on macOS, notify-send on Linux).
|
|
797
|
+
if oid == "desktop" and not ch.enabled and not desktop_mechanism_available():
|
|
798
|
+
self.app.bell()
|
|
799
|
+
self.app.notify(
|
|
800
|
+
"Install terminal-notifier (macOS) or notify-send (Linux) first.",
|
|
801
|
+
title="Desktop notifications unavailable",
|
|
802
|
+
severity="error",
|
|
803
|
+
)
|
|
804
|
+
return
|
|
789
805
|
ch.enabled = not ch.enabled
|
|
790
806
|
self._populate()
|
|
791
807
|
return
|
|
@@ -900,7 +916,11 @@ class ReviewScreen(_Nav):
|
|
|
900
916
|
else:
|
|
901
917
|
when = f"daily at {s.at}"
|
|
902
918
|
ai = "off" if c.ai.mode == "off" else f"claude ({c.ai.model})"
|
|
903
|
-
channels = [
|
|
919
|
+
channels = [
|
|
920
|
+
n
|
|
921
|
+
for n, ch in (("slack", c.slack), ("telegram", c.telegram), ("desktop", c.desktop))
|
|
922
|
+
if ch.enabled
|
|
923
|
+
]
|
|
904
924
|
channels_str = ", ".join(channels) if channels else "none — pick one to finish!"
|
|
905
925
|
return [
|
|
906
926
|
f"Sources {watched_count(c)} selected",
|