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.
Files changed (140) hide show
  1. {android_watcher-1.0.0 → android_watcher-1.0.1}/.github/workflows/release.yml +34 -7
  2. {android_watcher-1.0.0 → android_watcher-1.0.1}/Formula/android-watcher.rb +2 -2
  3. {android_watcher-1.0.0 → android_watcher-1.0.1}/PKG-INFO +1 -1
  4. {android_watcher-1.0.0 → android_watcher-1.0.1}/pyproject.toml +1 -1
  5. {android_watcher-1.0.0 → android_watcher-1.0.1}/src/android_watcher/config.py +48 -0
  6. {android_watcher-1.0.0 → android_watcher-1.0.1}/src/android_watcher/doctor.py +21 -0
  7. android_watcher-1.0.1/src/android_watcher/notify/__init__.py +1 -0
  8. android_watcher-1.0.1/src/android_watcher/notify/desktop.py +95 -0
  9. {android_watcher-1.0.0 → android_watcher-1.0.1}/src/android_watcher/run.py +2 -0
  10. {android_watcher-1.0.0 → android_watcher-1.0.1}/src/android_watcher/schedule.py +48 -10
  11. {android_watcher-1.0.0 → android_watcher-1.0.1}/src/android_watcher/tui/configio.py +24 -2
  12. {android_watcher-1.0.0 → android_watcher-1.0.1}/src/android_watcher/tui/screens.py +24 -4
  13. android_watcher-1.0.1/tests/notify/test_desktop.py +152 -0
  14. {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/run/test_run_once.py +26 -2
  15. {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/test_config.py +32 -0
  16. {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/test_configio.py +46 -0
  17. {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/test_doctor.py +53 -1
  18. {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/test_schedule_crontab.py +20 -0
  19. {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/test_schedule_install.py +14 -2
  20. {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/test_schedule_plist.py +25 -0
  21. {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/test_schedule_systemd.py +14 -0
  22. {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/test_tui_smoke.py +44 -0
  23. {android_watcher-1.0.0 → android_watcher-1.0.1}/uv.lock +1 -1
  24. android_watcher-1.0.0/src/android_watcher/notify/__init__.py +0 -1
  25. {android_watcher-1.0.0 → android_watcher-1.0.1}/.editorconfig +0 -0
  26. {android_watcher-1.0.0 → android_watcher-1.0.1}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  27. {android_watcher-1.0.0 → android_watcher-1.0.1}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  28. {android_watcher-1.0.0 → android_watcher-1.0.1}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  29. {android_watcher-1.0.0 → android_watcher-1.0.1}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  30. {android_watcher-1.0.0 → android_watcher-1.0.1}/.github/workflows/ci.yml +0 -0
  31. {android_watcher-1.0.0 → android_watcher-1.0.1}/.github/workflows/seed.yml +0 -0
  32. {android_watcher-1.0.0 → android_watcher-1.0.1}/.gitignore +0 -0
  33. {android_watcher-1.0.0 → android_watcher-1.0.1}/.pre-commit-config.yaml +0 -0
  34. {android_watcher-1.0.0 → android_watcher-1.0.1}/CLAUDE.md +0 -0
  35. {android_watcher-1.0.0 → android_watcher-1.0.1}/CODE_OF_CONDUCT.md +0 -0
  36. {android_watcher-1.0.0 → android_watcher-1.0.1}/CONTRIBUTING.md +0 -0
  37. {android_watcher-1.0.0 → android_watcher-1.0.1}/LICENSE +0 -0
  38. {android_watcher-1.0.0 → android_watcher-1.0.1}/README.md +0 -0
  39. {android_watcher-1.0.0 → android_watcher-1.0.1}/SECURITY.md +0 -0
  40. {android_watcher-1.0.0 → android_watcher-1.0.1}/assets/logo.svg +0 -0
  41. {android_watcher-1.0.0 → android_watcher-1.0.1}/docs/scheduling.md +0 -0
  42. {android_watcher-1.0.0 → android_watcher-1.0.1}/scripts/build_seed.py +0 -0
  43. {android_watcher-1.0.0 → android_watcher-1.0.1}/scripts/verify_catalog.py +0 -0
  44. {android_watcher-1.0.0 → android_watcher-1.0.1}/src/android_watcher/__init__.py +0 -0
  45. {android_watcher-1.0.0 → android_watcher-1.0.1}/src/android_watcher/catalog/__init__.py +0 -0
  46. {android_watcher-1.0.0 → android_watcher-1.0.1}/src/android_watcher/catalog/catalog.toml +0 -0
  47. {android_watcher-1.0.0 → android_watcher-1.0.1}/src/android_watcher/cli.py +0 -0
  48. {android_watcher-1.0.0 → android_watcher-1.0.1}/src/android_watcher/detect/__init__.py +0 -0
  49. {android_watcher-1.0.0 → android_watcher-1.0.1}/src/android_watcher/detect/_normalize.py +0 -0
  50. {android_watcher-1.0.0 → android_watcher-1.0.1}/src/android_watcher/detect/android_sitemap.py +0 -0
  51. {android_watcher-1.0.0 → android_watcher-1.0.1}/src/android_watcher/detect/base.py +0 -0
  52. {android_watcher-1.0.0 → android_watcher-1.0.1}/src/android_watcher/detect/content.py +0 -0
  53. {android_watcher-1.0.0 → android_watcher-1.0.1}/src/android_watcher/detect/feed.py +0 -0
  54. {android_watcher-1.0.0 → android_watcher-1.0.1}/src/android_watcher/detect/sitemap.py +0 -0
  55. {android_watcher-1.0.0 → android_watcher-1.0.1}/src/android_watcher/fetch.py +0 -0
  56. {android_watcher-1.0.0 → android_watcher-1.0.1}/src/android_watcher/group.py +0 -0
  57. {android_watcher-1.0.0 → android_watcher-1.0.1}/src/android_watcher/lock.py +0 -0
  58. {android_watcher-1.0.0 → android_watcher-1.0.1}/src/android_watcher/models.py +0 -0
  59. {android_watcher-1.0.0 → android_watcher-1.0.1}/src/android_watcher/notify/base.py +0 -0
  60. {android_watcher-1.0.0 → android_watcher-1.0.1}/src/android_watcher/notify/email.py +0 -0
  61. {android_watcher-1.0.0 → android_watcher-1.0.1}/src/android_watcher/notify/html.py +0 -0
  62. {android_watcher-1.0.0 → android_watcher-1.0.1}/src/android_watcher/notify/render.py +0 -0
  63. {android_watcher-1.0.0 → android_watcher-1.0.1}/src/android_watcher/notify/slack.py +0 -0
  64. {android_watcher-1.0.0 → android_watcher-1.0.1}/src/android_watcher/notify/telegram.py +0 -0
  65. {android_watcher-1.0.0 → android_watcher-1.0.1}/src/android_watcher/rank.py +0 -0
  66. {android_watcher-1.0.0 → android_watcher-1.0.1}/src/android_watcher/registry.py +0 -0
  67. {android_watcher-1.0.0 → android_watcher-1.0.1}/src/android_watcher/seed/__init__.py +0 -0
  68. {android_watcher-1.0.0 → android_watcher-1.0.1}/src/android_watcher/seed/seed.sql.gz +0 -0
  69. {android_watcher-1.0.0 → android_watcher-1.0.1}/src/android_watcher/store.py +0 -0
  70. {android_watcher-1.0.0 → android_watcher-1.0.1}/src/android_watcher/triage/__init__.py +0 -0
  71. {android_watcher-1.0.0 → android_watcher-1.0.1}/src/android_watcher/triage/base.py +0 -0
  72. {android_watcher-1.0.0 → android_watcher-1.0.1}/src/android_watcher/triage/claude_cli.py +0 -0
  73. {android_watcher-1.0.0 → android_watcher-1.0.1}/src/android_watcher/triage/noop.py +0 -0
  74. {android_watcher-1.0.0 → android_watcher-1.0.1}/src/android_watcher/tui/__init__.py +0 -0
  75. {android_watcher-1.0.0 → android_watcher-1.0.1}/src/android_watcher/tui/app.py +0 -0
  76. {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/conftest.py +0 -0
  77. {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/detect/__init__.py +0 -0
  78. {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/detect/test_android_sitemap.py +0 -0
  79. {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/detect/test_base.py +0 -0
  80. {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/detect/test_confirm_shared.py +0 -0
  81. {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/detect/test_content.py +0 -0
  82. {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/detect/test_feed.py +0 -0
  83. {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/detect/test_normalize.py +0 -0
  84. {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/detect/test_registry_autoload.py +0 -0
  85. {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/detect/test_registry_resolution.py +0 -0
  86. {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/detect/test_sitemap.py +0 -0
  87. {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/fixtures/claude_cli_envelope.json +0 -0
  88. {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/fixtures/content_after_chrome_only.html +0 -0
  89. {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/fixtures/content_after_real_change.html +0 -0
  90. {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/fixtures/content_before.html +0 -0
  91. {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/fixtures/content_js_shell.html +0 -0
  92. {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/fixtures/feed_guid_reuse.xml +0 -0
  93. {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/fixtures/feed_initial.xml +0 -0
  94. {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/fixtures/feed_updated_summary.xml +0 -0
  95. {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/fixtures/schedule/android-watcher.service +0 -0
  96. {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/fixtures/schedule/android-watcher.timer +0 -0
  97. {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/fixtures/schedule/launchd_daily.plist +0 -0
  98. {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/fixtures/sitemap_index.xml +0 -0
  99. {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/fixtures/sitemap_shard0.xml +0 -0
  100. {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/fixtures/sitemap_shard_i18n.xml +0 -0
  101. {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/fixtures/sitemap_simple.xml +0 -0
  102. {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/notify/__init__.py +0 -0
  103. {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/notify/snapshots/normal_email.html +0 -0
  104. {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/notify/snapshots/normal_email.txt +0 -0
  105. {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/notify/snapshots/normal_slack.json +0 -0
  106. {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/notify/test_base.py +0 -0
  107. {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/notify/test_email.py +0 -0
  108. {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/notify/test_html.py +0 -0
  109. {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/notify/test_render.py +0 -0
  110. {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/notify/test_slack.py +0 -0
  111. {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/notify/test_telegram.py +0 -0
  112. {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/run/__init__.py +0 -0
  113. {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/run/test_detect_and_persist.py +0 -0
  114. {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/test_catalog.py +0 -0
  115. {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/test_catalog_data.py +0 -0
  116. {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/test_cli.py +0 -0
  117. {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/test_cli_schedule.py +0 -0
  118. {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/test_configio_custom_source_roundtrip.py +0 -0
  119. {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/test_contributing_docs.py +0 -0
  120. {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/test_docs_scheduling.py +0 -0
  121. {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/test_fetch.py +0 -0
  122. {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/test_group.py +0 -0
  123. {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/test_homebrew_formula.py +0 -0
  124. {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/test_lock.py +0 -0
  125. {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/test_models.py +0 -0
  126. {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/test_packaging.py +0 -0
  127. {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/test_rank.py +0 -0
  128. {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/test_readme_disclosures.py +0 -0
  129. {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/test_registry.py +0 -0
  130. {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/test_release_workflow.py +0 -0
  131. {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/test_schedule_status.py +0 -0
  132. {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/test_seed.py +0 -0
  133. {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/test_seed_workflow.py +0 -0
  134. {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/test_store.py +0 -0
  135. {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/test_verify_catalog_smoke.py +0 -0
  136. {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/triage/__init__.py +0 -0
  137. {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/triage/test_base.py +0 -0
  138. {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/triage/test_claude_cli.py +0 -0
  139. {android_watcher-1.0.0 → android_watcher-1.0.1}/tests/triage/test_claude_cli_prompt.py +0 -0
  140. {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, 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` 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/source/a/android-watcher/android_watcher-0.0.0.tar.gz"
10
- sha256 "0000000000000000000000000000000000000000000000000000000000000000"
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.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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "android-watcher"
3
- version = "1.0.0"
3
+ version = "1.0.1"
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
  )
@@ -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 for name, ch in (("slack", c.slack), ("telegram", c.telegram)) if ch.enabled
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 (("slack", "Slack", c.slack), ("telegram", "Telegram", c.telegram))
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 = [n for n, ch in (("slack", c.slack), ("telegram", c.telegram)) if ch.enabled]
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",