android-watcher 1.0.0__tar.gz → 1.0.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. {android_watcher-1.0.0 → android_watcher-1.0.2}/.github/workflows/release.yml +44 -8
  2. android_watcher-1.0.2/Formula/android-watcher.rb +110 -0
  3. {android_watcher-1.0.0 → android_watcher-1.0.2}/PKG-INFO +1 -1
  4. {android_watcher-1.0.0 → android_watcher-1.0.2}/pyproject.toml +1 -1
  5. {android_watcher-1.0.0 → android_watcher-1.0.2}/src/android_watcher/config.py +48 -0
  6. {android_watcher-1.0.0 → android_watcher-1.0.2}/src/android_watcher/doctor.py +21 -0
  7. android_watcher-1.0.2/src/android_watcher/notify/__init__.py +1 -0
  8. android_watcher-1.0.2/src/android_watcher/notify/desktop.py +95 -0
  9. {android_watcher-1.0.0 → android_watcher-1.0.2}/src/android_watcher/run.py +2 -0
  10. {android_watcher-1.0.0 → android_watcher-1.0.2}/src/android_watcher/schedule.py +48 -10
  11. {android_watcher-1.0.0 → android_watcher-1.0.2}/src/android_watcher/tui/configio.py +24 -2
  12. {android_watcher-1.0.0 → android_watcher-1.0.2}/src/android_watcher/tui/screens.py +24 -4
  13. android_watcher-1.0.2/tests/notify/test_desktop.py +152 -0
  14. {android_watcher-1.0.0 → android_watcher-1.0.2}/tests/run/test_run_once.py +26 -2
  15. {android_watcher-1.0.0 → android_watcher-1.0.2}/tests/test_config.py +32 -0
  16. {android_watcher-1.0.0 → android_watcher-1.0.2}/tests/test_configio.py +46 -0
  17. {android_watcher-1.0.0 → android_watcher-1.0.2}/tests/test_doctor.py +53 -1
  18. {android_watcher-1.0.0 → android_watcher-1.0.2}/tests/test_schedule_crontab.py +20 -0
  19. {android_watcher-1.0.0 → android_watcher-1.0.2}/tests/test_schedule_install.py +14 -2
  20. {android_watcher-1.0.0 → android_watcher-1.0.2}/tests/test_schedule_plist.py +25 -0
  21. {android_watcher-1.0.0 → android_watcher-1.0.2}/tests/test_schedule_systemd.py +14 -0
  22. {android_watcher-1.0.0 → android_watcher-1.0.2}/tests/test_tui_smoke.py +44 -0
  23. {android_watcher-1.0.0 → android_watcher-1.0.2}/uv.lock +1 -1
  24. android_watcher-1.0.0/Formula/android-watcher.rb +0 -27
  25. android_watcher-1.0.0/src/android_watcher/notify/__init__.py +0 -1
  26. {android_watcher-1.0.0 → android_watcher-1.0.2}/.editorconfig +0 -0
  27. {android_watcher-1.0.0 → android_watcher-1.0.2}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  28. {android_watcher-1.0.0 → android_watcher-1.0.2}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  29. {android_watcher-1.0.0 → android_watcher-1.0.2}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  30. {android_watcher-1.0.0 → android_watcher-1.0.2}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  31. {android_watcher-1.0.0 → android_watcher-1.0.2}/.github/workflows/ci.yml +0 -0
  32. {android_watcher-1.0.0 → android_watcher-1.0.2}/.github/workflows/seed.yml +0 -0
  33. {android_watcher-1.0.0 → android_watcher-1.0.2}/.gitignore +0 -0
  34. {android_watcher-1.0.0 → android_watcher-1.0.2}/.pre-commit-config.yaml +0 -0
  35. {android_watcher-1.0.0 → android_watcher-1.0.2}/CLAUDE.md +0 -0
  36. {android_watcher-1.0.0 → android_watcher-1.0.2}/CODE_OF_CONDUCT.md +0 -0
  37. {android_watcher-1.0.0 → android_watcher-1.0.2}/CONTRIBUTING.md +0 -0
  38. {android_watcher-1.0.0 → android_watcher-1.0.2}/LICENSE +0 -0
  39. {android_watcher-1.0.0 → android_watcher-1.0.2}/README.md +0 -0
  40. {android_watcher-1.0.0 → android_watcher-1.0.2}/SECURITY.md +0 -0
  41. {android_watcher-1.0.0 → android_watcher-1.0.2}/assets/logo.svg +0 -0
  42. {android_watcher-1.0.0 → android_watcher-1.0.2}/docs/scheduling.md +0 -0
  43. {android_watcher-1.0.0 → android_watcher-1.0.2}/scripts/build_seed.py +0 -0
  44. {android_watcher-1.0.0 → android_watcher-1.0.2}/scripts/verify_catalog.py +0 -0
  45. {android_watcher-1.0.0 → android_watcher-1.0.2}/src/android_watcher/__init__.py +0 -0
  46. {android_watcher-1.0.0 → android_watcher-1.0.2}/src/android_watcher/catalog/__init__.py +0 -0
  47. {android_watcher-1.0.0 → android_watcher-1.0.2}/src/android_watcher/catalog/catalog.toml +0 -0
  48. {android_watcher-1.0.0 → android_watcher-1.0.2}/src/android_watcher/cli.py +0 -0
  49. {android_watcher-1.0.0 → android_watcher-1.0.2}/src/android_watcher/detect/__init__.py +0 -0
  50. {android_watcher-1.0.0 → android_watcher-1.0.2}/src/android_watcher/detect/_normalize.py +0 -0
  51. {android_watcher-1.0.0 → android_watcher-1.0.2}/src/android_watcher/detect/android_sitemap.py +0 -0
  52. {android_watcher-1.0.0 → android_watcher-1.0.2}/src/android_watcher/detect/base.py +0 -0
  53. {android_watcher-1.0.0 → android_watcher-1.0.2}/src/android_watcher/detect/content.py +0 -0
  54. {android_watcher-1.0.0 → android_watcher-1.0.2}/src/android_watcher/detect/feed.py +0 -0
  55. {android_watcher-1.0.0 → android_watcher-1.0.2}/src/android_watcher/detect/sitemap.py +0 -0
  56. {android_watcher-1.0.0 → android_watcher-1.0.2}/src/android_watcher/fetch.py +0 -0
  57. {android_watcher-1.0.0 → android_watcher-1.0.2}/src/android_watcher/group.py +0 -0
  58. {android_watcher-1.0.0 → android_watcher-1.0.2}/src/android_watcher/lock.py +0 -0
  59. {android_watcher-1.0.0 → android_watcher-1.0.2}/src/android_watcher/models.py +0 -0
  60. {android_watcher-1.0.0 → android_watcher-1.0.2}/src/android_watcher/notify/base.py +0 -0
  61. {android_watcher-1.0.0 → android_watcher-1.0.2}/src/android_watcher/notify/email.py +0 -0
  62. {android_watcher-1.0.0 → android_watcher-1.0.2}/src/android_watcher/notify/html.py +0 -0
  63. {android_watcher-1.0.0 → android_watcher-1.0.2}/src/android_watcher/notify/render.py +0 -0
  64. {android_watcher-1.0.0 → android_watcher-1.0.2}/src/android_watcher/notify/slack.py +0 -0
  65. {android_watcher-1.0.0 → android_watcher-1.0.2}/src/android_watcher/notify/telegram.py +0 -0
  66. {android_watcher-1.0.0 → android_watcher-1.0.2}/src/android_watcher/rank.py +0 -0
  67. {android_watcher-1.0.0 → android_watcher-1.0.2}/src/android_watcher/registry.py +0 -0
  68. {android_watcher-1.0.0 → android_watcher-1.0.2}/src/android_watcher/seed/__init__.py +0 -0
  69. {android_watcher-1.0.0 → android_watcher-1.0.2}/src/android_watcher/seed/seed.sql.gz +0 -0
  70. {android_watcher-1.0.0 → android_watcher-1.0.2}/src/android_watcher/store.py +0 -0
  71. {android_watcher-1.0.0 → android_watcher-1.0.2}/src/android_watcher/triage/__init__.py +0 -0
  72. {android_watcher-1.0.0 → android_watcher-1.0.2}/src/android_watcher/triage/base.py +0 -0
  73. {android_watcher-1.0.0 → android_watcher-1.0.2}/src/android_watcher/triage/claude_cli.py +0 -0
  74. {android_watcher-1.0.0 → android_watcher-1.0.2}/src/android_watcher/triage/noop.py +0 -0
  75. {android_watcher-1.0.0 → android_watcher-1.0.2}/src/android_watcher/tui/__init__.py +0 -0
  76. {android_watcher-1.0.0 → android_watcher-1.0.2}/src/android_watcher/tui/app.py +0 -0
  77. {android_watcher-1.0.0 → android_watcher-1.0.2}/tests/conftest.py +0 -0
  78. {android_watcher-1.0.0 → android_watcher-1.0.2}/tests/detect/__init__.py +0 -0
  79. {android_watcher-1.0.0 → android_watcher-1.0.2}/tests/detect/test_android_sitemap.py +0 -0
  80. {android_watcher-1.0.0 → android_watcher-1.0.2}/tests/detect/test_base.py +0 -0
  81. {android_watcher-1.0.0 → android_watcher-1.0.2}/tests/detect/test_confirm_shared.py +0 -0
  82. {android_watcher-1.0.0 → android_watcher-1.0.2}/tests/detect/test_content.py +0 -0
  83. {android_watcher-1.0.0 → android_watcher-1.0.2}/tests/detect/test_feed.py +0 -0
  84. {android_watcher-1.0.0 → android_watcher-1.0.2}/tests/detect/test_normalize.py +0 -0
  85. {android_watcher-1.0.0 → android_watcher-1.0.2}/tests/detect/test_registry_autoload.py +0 -0
  86. {android_watcher-1.0.0 → android_watcher-1.0.2}/tests/detect/test_registry_resolution.py +0 -0
  87. {android_watcher-1.0.0 → android_watcher-1.0.2}/tests/detect/test_sitemap.py +0 -0
  88. {android_watcher-1.0.0 → android_watcher-1.0.2}/tests/fixtures/claude_cli_envelope.json +0 -0
  89. {android_watcher-1.0.0 → android_watcher-1.0.2}/tests/fixtures/content_after_chrome_only.html +0 -0
  90. {android_watcher-1.0.0 → android_watcher-1.0.2}/tests/fixtures/content_after_real_change.html +0 -0
  91. {android_watcher-1.0.0 → android_watcher-1.0.2}/tests/fixtures/content_before.html +0 -0
  92. {android_watcher-1.0.0 → android_watcher-1.0.2}/tests/fixtures/content_js_shell.html +0 -0
  93. {android_watcher-1.0.0 → android_watcher-1.0.2}/tests/fixtures/feed_guid_reuse.xml +0 -0
  94. {android_watcher-1.0.0 → android_watcher-1.0.2}/tests/fixtures/feed_initial.xml +0 -0
  95. {android_watcher-1.0.0 → android_watcher-1.0.2}/tests/fixtures/feed_updated_summary.xml +0 -0
  96. {android_watcher-1.0.0 → android_watcher-1.0.2}/tests/fixtures/schedule/android-watcher.service +0 -0
  97. {android_watcher-1.0.0 → android_watcher-1.0.2}/tests/fixtures/schedule/android-watcher.timer +0 -0
  98. {android_watcher-1.0.0 → android_watcher-1.0.2}/tests/fixtures/schedule/launchd_daily.plist +0 -0
  99. {android_watcher-1.0.0 → android_watcher-1.0.2}/tests/fixtures/sitemap_index.xml +0 -0
  100. {android_watcher-1.0.0 → android_watcher-1.0.2}/tests/fixtures/sitemap_shard0.xml +0 -0
  101. {android_watcher-1.0.0 → android_watcher-1.0.2}/tests/fixtures/sitemap_shard_i18n.xml +0 -0
  102. {android_watcher-1.0.0 → android_watcher-1.0.2}/tests/fixtures/sitemap_simple.xml +0 -0
  103. {android_watcher-1.0.0 → android_watcher-1.0.2}/tests/notify/__init__.py +0 -0
  104. {android_watcher-1.0.0 → android_watcher-1.0.2}/tests/notify/snapshots/normal_email.html +0 -0
  105. {android_watcher-1.0.0 → android_watcher-1.0.2}/tests/notify/snapshots/normal_email.txt +0 -0
  106. {android_watcher-1.0.0 → android_watcher-1.0.2}/tests/notify/snapshots/normal_slack.json +0 -0
  107. {android_watcher-1.0.0 → android_watcher-1.0.2}/tests/notify/test_base.py +0 -0
  108. {android_watcher-1.0.0 → android_watcher-1.0.2}/tests/notify/test_email.py +0 -0
  109. {android_watcher-1.0.0 → android_watcher-1.0.2}/tests/notify/test_html.py +0 -0
  110. {android_watcher-1.0.0 → android_watcher-1.0.2}/tests/notify/test_render.py +0 -0
  111. {android_watcher-1.0.0 → android_watcher-1.0.2}/tests/notify/test_slack.py +0 -0
  112. {android_watcher-1.0.0 → android_watcher-1.0.2}/tests/notify/test_telegram.py +0 -0
  113. {android_watcher-1.0.0 → android_watcher-1.0.2}/tests/run/__init__.py +0 -0
  114. {android_watcher-1.0.0 → android_watcher-1.0.2}/tests/run/test_detect_and_persist.py +0 -0
  115. {android_watcher-1.0.0 → android_watcher-1.0.2}/tests/test_catalog.py +0 -0
  116. {android_watcher-1.0.0 → android_watcher-1.0.2}/tests/test_catalog_data.py +0 -0
  117. {android_watcher-1.0.0 → android_watcher-1.0.2}/tests/test_cli.py +0 -0
  118. {android_watcher-1.0.0 → android_watcher-1.0.2}/tests/test_cli_schedule.py +0 -0
  119. {android_watcher-1.0.0 → android_watcher-1.0.2}/tests/test_configio_custom_source_roundtrip.py +0 -0
  120. {android_watcher-1.0.0 → android_watcher-1.0.2}/tests/test_contributing_docs.py +0 -0
  121. {android_watcher-1.0.0 → android_watcher-1.0.2}/tests/test_docs_scheduling.py +0 -0
  122. {android_watcher-1.0.0 → android_watcher-1.0.2}/tests/test_fetch.py +0 -0
  123. {android_watcher-1.0.0 → android_watcher-1.0.2}/tests/test_group.py +0 -0
  124. {android_watcher-1.0.0 → android_watcher-1.0.2}/tests/test_homebrew_formula.py +0 -0
  125. {android_watcher-1.0.0 → android_watcher-1.0.2}/tests/test_lock.py +0 -0
  126. {android_watcher-1.0.0 → android_watcher-1.0.2}/tests/test_models.py +0 -0
  127. {android_watcher-1.0.0 → android_watcher-1.0.2}/tests/test_packaging.py +0 -0
  128. {android_watcher-1.0.0 → android_watcher-1.0.2}/tests/test_rank.py +0 -0
  129. {android_watcher-1.0.0 → android_watcher-1.0.2}/tests/test_readme_disclosures.py +0 -0
  130. {android_watcher-1.0.0 → android_watcher-1.0.2}/tests/test_registry.py +0 -0
  131. {android_watcher-1.0.0 → android_watcher-1.0.2}/tests/test_release_workflow.py +0 -0
  132. {android_watcher-1.0.0 → android_watcher-1.0.2}/tests/test_schedule_status.py +0 -0
  133. {android_watcher-1.0.0 → android_watcher-1.0.2}/tests/test_seed.py +0 -0
  134. {android_watcher-1.0.0 → android_watcher-1.0.2}/tests/test_seed_workflow.py +0 -0
  135. {android_watcher-1.0.0 → android_watcher-1.0.2}/tests/test_store.py +0 -0
  136. {android_watcher-1.0.0 → android_watcher-1.0.2}/tests/test_verify_catalog_smoke.py +0 -0
  137. {android_watcher-1.0.0 → android_watcher-1.0.2}/tests/triage/__init__.py +0 -0
  138. {android_watcher-1.0.0 → android_watcher-1.0.2}/tests/triage/test_base.py +0 -0
  139. {android_watcher-1.0.0 → android_watcher-1.0.2}/tests/triage/test_claude_cli.py +0 -0
  140. {android_watcher-1.0.0 → android_watcher-1.0.2}/tests/triage/test_claude_cli_prompt.py +0 -0
  141. {android_watcher-1.0.0 → android_watcher-1.0.2}/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, refreshes
5
- # the Homebrew formula, and tags the release last.
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` refuses a loose
102
+ # formula file, so it is staged inside a throwaway local tap below. It can
103
+ # still fail to resolve a same-day PyPI upload, whose publish postdates
104
+ # Homebrew's reproducibility cutoff. Never let that fail the release;
105
+ # regenerate the resource blocks out-of-band when it does, then commit.
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.
@@ -101,12 +133,16 @@ jobs:
101
133
  open(p, "w").write(s)
102
134
  PY
103
135
  # Regenerate every pinned dependency `resource` block against this release.
104
- brew update-python-resources Formula/android-watcher.rb
136
+ # update-python-resources only operates on a formula inside a tap, so stage
137
+ # it in a throwaway local tap, regenerate there, then copy the result back.
138
+ TAP_DIR="$(brew --repository)/Library/Taps/local/homebrew-awtmp"
139
+ rm -rf "$TAP_DIR" && mkdir -p "$TAP_DIR/Formula"
140
+ git -C "$TAP_DIR" init -q
141
+ git -C "$TAP_DIR" -c user.email=ci@local -c user.name=ci commit -q --allow-empty -m init
142
+ cp Formula/android-watcher.rb "$TAP_DIR/Formula/android-watcher.rb"
143
+ brew update-python-resources local/awtmp/android-watcher
144
+ cp "$TAP_DIR/Formula/android-watcher.rb" Formula/android-watcher.rb
145
+ rm -rf "$TAP_DIR"
105
146
  git add Formula/android-watcher.rb
106
147
  git commit -m "chore: update homebrew formula for v${VERSION}"
107
148
  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 }}"
@@ -0,0 +1,110 @@
1
+ class AndroidWatcher < Formula
2
+ include Language::Python::Virtualenv
3
+
4
+ desc "Self-hosted CLI that watches Google's Android sites and delivers AI-triaged digests"
5
+ homepage "https://github.com/krayong/android-watcher"
6
+ # url + sha256 point at the PyPI sdist for the released version. The release
7
+ # workflow rewrites both on every version bump, then regenerates the `resource`
8
+ # blocks below with `brew update-python-resources`. Do not hand-edit.
9
+ url "https://files.pythonhosted.org/packages/1b/60/9497af75a83ed1171ebcee223bf6ee934066765a16395ec2cf3c52311f0a/android_watcher-1.0.1.tar.gz"
10
+ sha256 "e7bc14ef364792caf8b88ce83b4f4934679227627a34b4423b19389c9e4346ec"
11
+ license "MIT"
12
+
13
+ depends_on "python@3.11"
14
+
15
+ # Dependency resource blocks, one hash-pinned sdist per transitive dependency.
16
+ # Regenerated by `brew update-python-resources` (or out-of-band for a same-day
17
+ # release, whose upload postdates Homebrew's reproducibility cutoff).
18
+ resource "anyio" do
19
+ url "https://files.pythonhosted.org/packages/1c/b5/001890774a9552aff22502b8da382593109ce0c95314abaebbb116567545/anyio-4.14.0.tar.gz"
20
+ sha256 "b47c1f9ccf73e67021df785332508f99379c68fa7d0684e8e3492cb1d4b23f89"
21
+ end
22
+
23
+ resource "certifi" do
24
+ url "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz"
25
+ sha256 "024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432"
26
+ end
27
+
28
+ resource "defusedxml" do
29
+ url "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz"
30
+ sha256 "1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69"
31
+ end
32
+
33
+ resource "h11" do
34
+ url "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz"
35
+ sha256 "4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"
36
+ end
37
+
38
+ resource "httpcore" do
39
+ url "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz"
40
+ sha256 "6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"
41
+ end
42
+
43
+ resource "httpx" do
44
+ url "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz"
45
+ sha256 "75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"
46
+ end
47
+
48
+ resource "idna" do
49
+ url "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz"
50
+ sha256 "ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"
51
+ end
52
+
53
+ resource "linkify-it-py" do
54
+ url "https://files.pythonhosted.org/packages/2e/c9/06ea13676ef354f0af6169587ae292d3e2406e212876a413bf9eece4eb23/linkify_it_py-2.1.0.tar.gz"
55
+ sha256 "43360231720999c10e9328dc3691160e27a718e280673d444c38d7d3aaa3b98b"
56
+ end
57
+
58
+ resource "markdown-it-py" do
59
+ url "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz"
60
+ sha256 "04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49"
61
+ end
62
+
63
+ resource "mdit-py-plugins" do
64
+ url "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz"
65
+ sha256 "a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0"
66
+ end
67
+
68
+ resource "mdurl" do
69
+ url "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz"
70
+ sha256 "bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"
71
+ end
72
+
73
+ resource "platformdirs" do
74
+ url "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz"
75
+ sha256 "31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7"
76
+ end
77
+
78
+ resource "pygments" do
79
+ url "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz"
80
+ sha256 "6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"
81
+ end
82
+
83
+ resource "rich" do
84
+ url "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz"
85
+ sha256 "edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36"
86
+ end
87
+
88
+ resource "textual" do
89
+ url "https://files.pythonhosted.org/packages/9b/7a/c519db0aba5024f86e71e9631810bfdd6866ed2c8695bd7fa34b90e7ef59/textual-8.2.7.tar.gz"
90
+ sha256 "658f568ff81e30ed43890c3e07520390e5cf1b4763822006e060656b0a88f105"
91
+ end
92
+
93
+ resource "typing-extensions" do
94
+ url "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz"
95
+ sha256 "0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"
96
+ end
97
+
98
+ resource "uc-micro-py" do
99
+ url "https://files.pythonhosted.org/packages/78/67/9a363818028526e2d4579334460df777115bdec1bb77c08f9db88f6389f2/uc_micro_py-2.0.0.tar.gz"
100
+ sha256 "c53691e495c8db60e16ffc4861a35469b0ba0821fe409a8a7a0a71864d33a811"
101
+ end
102
+
103
+ def install
104
+ virtualenv_install_with_resources
105
+ end
106
+
107
+ test do
108
+ assert_match "android-watcher", shell_output("#{bin}/android-watcher --help")
109
+ end
110
+ end
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: android-watcher
3
- Version: 1.0.0
3
+ Version: 1.0.2
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "android-watcher"
3
- version = "1.0.0"
3
+ version = "1.0.2"
4
4
  description = "Self-hosted CLI that watches Google's official Android sites and delivers AI-triaged change digests."
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -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)
@@ -97,6 +97,8 @@ def _enabled_channels(config: Config) -> set[str]:
97
97
  channels.add("slack")
98
98
  if config.telegram.enabled:
99
99
  channels.add("telegram")
100
+ if config.desktop.enabled:
101
+ channels.add("desktop")
100
102
  return channels
101
103
 
102
104
 
@@ -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(label: str, program_args: list[str], sched: ScheduleConfig) -> str:
170
- """Render a launchd plist string for the given label, args, and schedule."""
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(line_command: str, sched: ScheduleConfig, tz: str) -> str:
285
- """Render a marked crontab block for android-watcher (one line per scheduled time)."""
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
- return f"{CRON_BEGIN}\nCRON_TZ={tz}\n{body}\n{CRON_END}\n"
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 (config.email.enabled or config.slack.enabled or config.telegram.enabled):
177
- errors.append("enable at least one delivery channel (Slack or Telegram) to receive digests")
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
  )