Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,11 @@ jobs:
- name: Install locked dependencies
run: uv sync --locked --extra dev

# The whole repository, not just src/tests/scripts. The narrower form
# left examples/ and the four root-level helper scripts outside every
# gate -- 15 findings sat there unreported while CI showed a green lint.
- name: Ruff lint
run: uv run ruff check src tests scripts
run: uv run ruff check .

- name: Build distributions
run: uv build
Expand Down
5 changes: 4 additions & 1 deletion .github/workflows/secret-scan.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@ jobs:
env:
GITLEAKS_VERSION: 8.30.1
run: |
curl -sSL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \
# pipefail: without it the pipeline reports tar's status, so a failed
# download would only be caught incidentally by the extract.
set -euo pipefail
curl -sSL --fail "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \
| tar -xz -C /usr/local/bin gitleaks
gitleaks version
- name: Scan git history for secrets
Expand Down
Empty file modified examples/notification_examples.py
100644 → 100755
Empty file.
18 changes: 12 additions & 6 deletions generate_screenshots.py
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@
10. Settings/configuration (if available)
"""

import time
import sys
import time
from pathlib import Path

# Try to import the notification system
Expand Down Expand Up @@ -187,18 +187,24 @@ def main():
print()

# Countdown before action
countdown(3, f" Triggering")
countdown(3, " Triggering")

# Execute action
# Execute action. The notification helpers return whether the
# notification was actually delivered; printing a tick regardless told
# the operator to photograph a notification that was never shown.
shown = True
if callable(shot['action']):
result = shot['action']()
shown = shot['action']() is not False

# Wait for notification to appear and give time to screenshot
print(f" ✓ Triggered! Capture screenshot now...")
if shown:
print(" ✓ Triggered! Capture screenshot now...")
else:
print(" ✗ NOT delivered — there is nothing on screen to capture.")
time.sleep(shot['wait'])

# Give extra time to capture
countdown(shot['wait'] + 3, f" Time remaining")
countdown(shot['wait'] + 3, " Time remaining")

print()
print("-" * 80)
Expand Down
15 changes: 8 additions & 7 deletions rename_screenshots.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,26 +5,25 @@
Usage: Just take screenshots with Cmd+Shift+4, then run this script.
"""

import os
import shutil
from datetime import datetime, timedelta, timezone
from pathlib import Path
from datetime import datetime, timedelta


def find_recent_screenshots(minutes=30):
"""Find screenshots from Desktop taken in last N minutes."""
desktop = Path.home() / "Desktop"
cutoff = datetime.now() - timedelta(minutes=minutes)
cutoff = datetime.now(tz=timezone.utc) - timedelta(minutes=minutes)

screenshots = []
for file in desktop.glob("Screen Shot *.png"):
mtime = datetime.fromtimestamp(file.stat().st_mtime)
mtime = datetime.fromtimestamp(file.stat().st_mtime, tz=timezone.utc)
if mtime > cutoff:
screenshots.append(file)

# Also check for Screenshot (macOS Ventura+)
for file in desktop.glob("Screenshot *.png"):
mtime = datetime.fromtimestamp(file.stat().st_mtime)
mtime = datetime.fromtimestamp(file.stat().st_mtime, tz=timezone.utc)
if mtime > cutoff:
screenshots.append(file)

Expand All @@ -50,11 +49,13 @@ def rename_and_move_screenshots(screenshots, target_dir):

print(f"\nFound {len(screenshots)} recent screenshots:")
for i, screenshot in enumerate(screenshots, 1):
mtime = datetime.fromtimestamp(screenshot.stat().st_mtime)
mtime = datetime.fromtimestamp(
screenshot.stat().st_mtime, tz=timezone.utc
).astimezone()
time_str = mtime.strftime("%H:%M:%S")
print(f" {i}. {screenshot.name} (taken at {time_str})")

print(f"\nWill rename them to:")
print("\nWill rename them to:")
for i, name in enumerate(names[:len(screenshots)], 1):
print(f" {i}. {name}")

Expand Down
69 changes: 58 additions & 11 deletions src/openadapt_tray/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,14 @@

import pystray

from openadapt_tray.config import TrayConfig
from openadapt_tray.config import ConfigLoadError, TrayConfig
from openadapt_tray.hosted import CountResult, HostedPoller, route_break_click
from openadapt_tray.icons import IconManager
from openadapt_tray.ipc import IPCClient, IPCMessageType
from openadapt_tray.menu import MenuBuilder
from openadapt_tray.notifications import NotificationManager
from openadapt_tray.platform import get_platform_handler
from openadapt_tray.platform.base import DialogUnavailableError
from openadapt_tray.shortcuts import HotkeyManager
from openadapt_tray.state import (
LANE_BYOC,
Expand All @@ -46,7 +47,15 @@ def __init__(self, config: TrayConfig | None = None):
Args:
config: Optional configuration. If None, loads from file or defaults.
"""
self.config = config or TrayConfig.load()
# An unreadable tray.json must not quietly become "default settings" --
# the default lane is "cloud", and a byoc install silently moved onto
# the hosted route is exactly the kind of wrong-but-confident state the
# tray exists to expose. Keep the error and tell the user in run().
self._config_error: ConfigLoadError | None = None
if config is not None:
self.config = config
else:
self.config, self._config_error = TrayConfig.load_or_defaults()
self.state = StateManager()
self.platform = get_platform_handler()

Expand Down Expand Up @@ -254,12 +263,24 @@ def start_recording(self, name: str | None = None) -> None:

# Prompt for name if not provided
if name is None and self.config.use_native_dialogs:
name = self.platform.prompt_input(
"New Recording",
"Enter a name for this capture:",
)
if not name:
return # User cancelled
try:
name = self.platform.prompt_input(
"New Recording",
"Enter a name for this capture:",
)
except DialogUnavailableError as e:
# The user asked to record and the prompt never appeared. This
# used to look identical to "the user cancelled", so the click
# did nothing and said nothing. Honour the click with a default
# name and say why there was no prompt.
print(f"Could not show the capture-name dialog: {e}")
self.notifications.show(
"No naming dialog available",
"Recording started with a default name.",
)
else:
if not name:
return # User cancelled

# Use default name if still not set
if not name:
Expand Down Expand Up @@ -327,12 +348,24 @@ def open_cloud_dashboard(self) -> None:
"""Open the hosted cloud dashboard in the system browser."""
webbrowser.open(self.config.hosted_url)

def open_needs_attention(self) -> None:
"""Route a needs-attention click by deployment lane (§3c)."""
route_break_click(
def open_needs_attention(self) -> bool:
"""Route a needs-attention click by deployment lane (§3c).

Returns:
True if something opened. When nothing opened the user is told,
rather than being left in front of an unchanged screen.
"""
routed = route_break_click(
self.config,
ipc_client=self.ipc if self.ipc.is_connected() else None,
)
if not routed:
self.notifications.show(
"Could not open needs-attention",
"Neither the desktop app nor a browser could be opened. "
f"Open {self.config.hosted_url.rstrip('/')}/dashboard manually.",
)
return routed

def login(self) -> None:
"""Start the hosted login flow.
Expand All @@ -356,6 +389,17 @@ def resume_sync(self) -> None:
if self.ipc.is_connected():
self.ipc.send_resume_sync()

def _report_config_error(self) -> None:
"""Tell the user when the tray is running on defaults it did not choose."""
if self._config_error is None:
return
self.notifications.show(
"Settings could not be read",
f"{self._config_error} — running on default settings "
f"(lane: {self.config.deployment_lane}).",
urgency="critical",
)

# --- hosted poller callbacks --------------------------------------------

def _on_hosted_count(self, result: CountResult) -> None:
Expand Down Expand Up @@ -457,6 +501,9 @@ def _apply_sync_state(self, name: str | None) -> None:

def run(self) -> None:
"""Run the application."""
# Say it out loud before anything relies on the settings.
self._report_config_error()

# Start hotkey listener
self.hotkeys.start()

Expand Down
70 changes: 62 additions & 8 deletions src/openadapt_tray/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,29 @@
OFFLINE_POLL_INTERVAL_S = 300


class ConfigLoadError(Exception):
"""``tray.json`` exists but could not be read.

Distinct from "no config file", which legitimately means defaults.
"""

def __init__(self, path: Path, cause: Exception):
"""Record which file failed and why.

Args:
path: The configuration file that could not be read.
cause: The underlying parse/IO error.
"""
super().__init__(f"Could not read {path}: {cause}")
self.path = path
self.cause = cause

@property
def defaults(self) -> "TrayConfig":
"""The default configuration, for a caller that chooses to continue."""
return TrayConfig()


@dataclass
class TrayConfig:
"""Tray application configuration.
Expand Down Expand Up @@ -76,15 +99,46 @@ def config_path(cls) -> Path:

@classmethod
def load(cls) -> "TrayConfig":
"""Load configuration from file."""
"""Load configuration from file.

A file that is not there means "this user has no saved preferences",
and defaults are the right answer. A file that IS there but cannot be
read is a different thing entirely, and defaults are the WRONG answer:
``deployment_lane`` defaults to ``"cloud"``, so silently substituting
defaults would move a ``byoc`` install -- where the fix must stay local
-- onto the hosted route without telling anybody.

Returns:
The saved configuration, or defaults when no file exists.

Raises:
ConfigLoadError: The file exists but could not be read or parsed.
It carries ``defaults`` so a caller that chooses to continue
does so knowingly.
"""
path = cls.config_path()
if path.exists():
try:
data = json.loads(path.read_text())
return cls._from_dict(data)
except Exception as e:
print(f"Warning: Could not load config: {e}")
return cls()
if not path.exists():
return cls()
try:
data = json.loads(path.read_text())
return cls._from_dict(data)
except Exception as e:
raise ConfigLoadError(path, e) from e

@classmethod
def load_or_defaults(cls) -> tuple["TrayConfig", "ConfigLoadError | None"]:
"""Load the configuration, reporting an unreadable file instead of hiding it.

Returns:
``(config, error)``. ``error`` is ``None`` on a clean load. When it
is set, ``config`` is the default configuration AND the caller is
obliged to surface the error -- that is the whole point of handing
both back instead of one silently-defaulted object.
"""
try:
return cls.load(), None
except ConfigLoadError as e:
return e.defaults, e

@classmethod
def _from_dict(cls, data: dict[str, Any]) -> "TrayConfig":
Expand Down
Loading