openadapt-tray 0.0.1__py3-none-any.whl

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.
@@ -0,0 +1,16 @@
1
+ """OpenAdapt Tray - System tray application for OpenAdapt."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from openadapt_tray.app import TrayApplication, main
6
+ from openadapt_tray.state import TrayState, AppState, StateManager
7
+ from openadapt_tray.config import TrayConfig
8
+
9
+ __all__ = [
10
+ "TrayApplication",
11
+ "TrayState",
12
+ "AppState",
13
+ "StateManager",
14
+ "TrayConfig",
15
+ "main",
16
+ ]
@@ -0,0 +1,10 @@
1
+ """Entry point for running openadapt_tray as a module.
2
+
3
+ Usage:
4
+ python -m openadapt_tray
5
+ """
6
+
7
+ from openadapt_tray.app import main
8
+
9
+ if __name__ == "__main__":
10
+ main()
openadapt_tray/app.py ADDED
@@ -0,0 +1,336 @@
1
+ """Main system tray application for OpenAdapt."""
2
+
3
+ import sys
4
+ import threading
5
+ import subprocess
6
+ import webbrowser
7
+ from typing import Optional
8
+
9
+ import pystray
10
+ from PIL import Image
11
+
12
+ from openadapt_tray.state import StateManager, TrayState, AppState
13
+ from openadapt_tray.menu import MenuBuilder
14
+ from openadapt_tray.icons import IconManager
15
+ from openadapt_tray.shortcuts import HotkeyManager
16
+ from openadapt_tray.notifications import NotificationManager
17
+ from openadapt_tray.ipc import IPCClient, IPCMessageType
18
+ from openadapt_tray.config import TrayConfig
19
+ from openadapt_tray.platform import get_platform_handler
20
+
21
+
22
+ class TrayApplication:
23
+ """Main system tray application."""
24
+
25
+ def __init__(self, config: Optional[TrayConfig] = None):
26
+ """Initialize the tray application.
27
+
28
+ Args:
29
+ config: Optional configuration. If None, loads from file or defaults.
30
+ """
31
+ self.config = config or TrayConfig.load()
32
+ self.state = StateManager()
33
+ self.platform = get_platform_handler()
34
+
35
+ # Initialize components
36
+ self.icons = IconManager()
37
+ self.notifications = NotificationManager()
38
+ self.menu_builder = MenuBuilder(self)
39
+ self.hotkeys = HotkeyManager(self.config.hotkeys)
40
+ self.ipc = IPCClient()
41
+
42
+ # Process handle for capture subprocess
43
+ self._capture_process: Optional[subprocess.Popen] = None
44
+
45
+ # Create tray icon
46
+ self.icon = pystray.Icon(
47
+ name="openadapt",
48
+ icon=self.icons.get(TrayState.IDLE),
49
+ title="OpenAdapt",
50
+ menu=self.menu_builder.build(),
51
+ )
52
+
53
+ # Set tray icon reference for Windows notifications
54
+ self.notifications.set_tray_icon(self.icon)
55
+
56
+ # Register state change handler
57
+ self.state.add_listener(self._on_state_change)
58
+
59
+ # Register IPC handlers
60
+ self._setup_ipc_handlers()
61
+
62
+ # Register hotkey handlers
63
+ self._setup_hotkeys()
64
+
65
+ def _setup_hotkeys(self) -> None:
66
+ """Configure global hotkeys."""
67
+ self.hotkeys.register(
68
+ self.config.hotkeys.toggle_recording,
69
+ self._toggle_recording,
70
+ )
71
+ self.hotkeys.register(
72
+ self.config.hotkeys.open_dashboard,
73
+ self._open_dashboard,
74
+ )
75
+
76
+ # Register triple-ctrl for stop recording (legacy compatibility)
77
+ if self.config.stop_on_triple_ctrl:
78
+ self.hotkeys.register(
79
+ "<ctrl>+<ctrl>+<ctrl>",
80
+ self._stop_recording_if_active,
81
+ )
82
+
83
+ def _setup_ipc_handlers(self) -> None:
84
+ """Configure IPC message handlers."""
85
+ self.ipc.register_handler(
86
+ IPCMessageType.RECORDING_STARTED,
87
+ self._on_ipc_recording_started,
88
+ )
89
+ self.ipc.register_handler(
90
+ IPCMessageType.RECORDING_STOPPED,
91
+ self._on_ipc_recording_stopped,
92
+ )
93
+ self.ipc.register_handler(
94
+ IPCMessageType.RECORDING_ERROR,
95
+ self._on_ipc_recording_error,
96
+ )
97
+ self.ipc.register_handler(
98
+ IPCMessageType.STATUS_UPDATE,
99
+ self._on_ipc_status_update,
100
+ )
101
+ self.ipc.register_handler(
102
+ IPCMessageType.TRAINING_PROGRESS,
103
+ self._on_ipc_training_progress,
104
+ )
105
+
106
+ def _on_state_change(self, state: AppState) -> None:
107
+ """Handle state changes.
108
+
109
+ Args:
110
+ state: New application state.
111
+ """
112
+ # Update icon
113
+ self.icon.icon = self.icons.get(state.state)
114
+
115
+ # Update menu
116
+ self.icon.menu = self.menu_builder.build()
117
+
118
+ # Show notification if appropriate
119
+ if self.config.show_notifications:
120
+ self._show_state_notification(state)
121
+
122
+ def _show_state_notification(self, state: AppState) -> None:
123
+ """Show notification for state transitions.
124
+
125
+ Args:
126
+ state: Current application state.
127
+ """
128
+ messages = {
129
+ TrayState.RECORDING: (
130
+ "Recording Started",
131
+ f"Capturing: {state.current_capture or 'session'}",
132
+ ),
133
+ TrayState.IDLE: ("Recording Stopped", "Capture saved"),
134
+ TrayState.TRAINING: ("Training Started", "Model training in progress"),
135
+ TrayState.ERROR: ("Error", state.error_message or "An error occurred"),
136
+ }
137
+
138
+ if state.state in messages:
139
+ title, body = messages[state.state]
140
+ self.notifications.show(
141
+ title,
142
+ body,
143
+ duration_ms=self.config.notification_duration_ms,
144
+ )
145
+
146
+ def _toggle_recording(self) -> None:
147
+ """Toggle recording state."""
148
+ if self.state.current.can_start_recording():
149
+ self.start_recording()
150
+ elif self.state.current.can_stop_recording():
151
+ self.stop_recording()
152
+
153
+ def _stop_recording_if_active(self) -> None:
154
+ """Stop recording if currently active (for triple-ctrl)."""
155
+ if self.state.current.can_stop_recording():
156
+ self.stop_recording()
157
+
158
+ def start_recording(self, name: Optional[str] = None) -> None:
159
+ """Start a new capture session.
160
+
161
+ Args:
162
+ name: Optional name for the capture. If None, prompts user.
163
+ """
164
+ if not self.state.current.can_start_recording():
165
+ return
166
+
167
+ # Prompt for name if not provided
168
+ if name is None and self.config.use_native_dialogs:
169
+ name = self.platform.prompt_input(
170
+ "New Recording",
171
+ "Enter a name for this capture:",
172
+ )
173
+ if not name:
174
+ return # User cancelled
175
+
176
+ # Use default name if still not set
177
+ if not name:
178
+ from datetime import datetime
179
+
180
+ name = datetime.now().strftime("capture_%Y%m%d_%H%M%S")
181
+
182
+ self.state.transition(TrayState.RECORDING_STARTING, current_capture=name)
183
+
184
+ # Start capture in background thread
185
+ threading.Thread(
186
+ target=self._run_capture,
187
+ args=(name,),
188
+ daemon=True,
189
+ ).start()
190
+
191
+ def _run_capture(self, name: str) -> None:
192
+ """Run capture in background thread.
193
+
194
+ Args:
195
+ name: Capture session name.
196
+ """
197
+ try:
198
+ # Start capture via CLI subprocess
199
+ self._capture_process = subprocess.Popen(
200
+ ["openadapt", "record", name],
201
+ stdout=subprocess.PIPE,
202
+ stderr=subprocess.PIPE,
203
+ )
204
+ self.state.transition(TrayState.RECORDING, current_capture=name)
205
+
206
+ # Wait for process to complete (when stopped externally)
207
+ self._capture_process.wait()
208
+
209
+ # Check if we're still in recording state (vs explicit stop)
210
+ if self.state.current.state == TrayState.RECORDING:
211
+ self.state.transition(TrayState.IDLE)
212
+
213
+ except FileNotFoundError:
214
+ self.state.transition(
215
+ TrayState.ERROR,
216
+ error_message="openadapt CLI not found. Please install openadapt.",
217
+ )
218
+ except Exception as e:
219
+ self.state.transition(TrayState.ERROR, error_message=str(e))
220
+
221
+ def stop_recording(self) -> None:
222
+ """Stop the current capture session."""
223
+ if not self.state.current.can_stop_recording():
224
+ return
225
+
226
+ self.state.transition(TrayState.RECORDING_STOPPING)
227
+
228
+ # Terminate capture process
229
+ if self._capture_process and self._capture_process.poll() is None:
230
+ try:
231
+ # Send SIGTERM for graceful shutdown
232
+ self._capture_process.terminate()
233
+
234
+ # Wait briefly for process to cleanup
235
+ try:
236
+ self._capture_process.wait(timeout=5)
237
+ except subprocess.TimeoutExpired:
238
+ # Force kill if not responding
239
+ self._capture_process.kill()
240
+ except Exception as e:
241
+ print(f"Error stopping capture process: {e}")
242
+
243
+ self._capture_process = None
244
+ self.state.transition(TrayState.IDLE)
245
+
246
+ def _open_dashboard(self) -> None:
247
+ """Open the web dashboard."""
248
+ webbrowser.open(f"http://localhost:{self.config.dashboard_port}")
249
+
250
+ # IPC event handlers
251
+
252
+ def _on_ipc_recording_started(self, message) -> None:
253
+ """Handle recording started IPC event."""
254
+ data = message.data or {}
255
+ self.state.transition(
256
+ TrayState.RECORDING,
257
+ current_capture=data.get("name"),
258
+ )
259
+
260
+ def _on_ipc_recording_stopped(self, message) -> None:
261
+ """Handle recording stopped IPC event."""
262
+ self.state.transition(TrayState.IDLE)
263
+
264
+ def _on_ipc_recording_error(self, message) -> None:
265
+ """Handle recording error IPC event."""
266
+ data = message.data or {}
267
+ self.state.transition(
268
+ TrayState.ERROR,
269
+ error_message=data.get("error", "Recording error"),
270
+ )
271
+
272
+ def _on_ipc_status_update(self, message) -> None:
273
+ """Handle status update IPC event."""
274
+ # Generic status updates - could update UI or log
275
+ pass
276
+
277
+ def _on_ipc_training_progress(self, message) -> None:
278
+ """Handle training progress IPC event."""
279
+ data = message.data or {}
280
+ progress = data.get("progress", 0)
281
+ self.state.transition(
282
+ TrayState.TRAINING,
283
+ training_progress=progress,
284
+ )
285
+
286
+ def run(self) -> None:
287
+ """Run the application."""
288
+ # Start hotkey listener
289
+ self.hotkeys.start()
290
+
291
+ # Platform-specific setup
292
+ self.platform.setup()
293
+
294
+ # Try to connect to IPC server (optional - won't block if unavailable)
295
+ try:
296
+ connected = self.ipc.connect()
297
+ if connected:
298
+ print("IPC connected successfully")
299
+ else:
300
+ print("IPC server not available - running in standalone mode")
301
+ except Exception as e:
302
+ print(f"IPC connection failed: {e} - running in standalone mode")
303
+
304
+ # Run the tray icon (blocks)
305
+ self.icon.run()
306
+
307
+ def quit(self) -> None:
308
+ """Quit the application."""
309
+ # Stop any active recording
310
+ if self.state.current.is_recording():
311
+ self.stop_recording()
312
+
313
+ # Cleanup components
314
+ self.hotkeys.stop()
315
+ self.ipc.close()
316
+ self.notifications.cleanup()
317
+ self.platform.cleanup()
318
+
319
+ # Stop tray icon
320
+ self.icon.stop()
321
+
322
+
323
+ def main() -> None:
324
+ """Entry point for the tray application."""
325
+ app = TrayApplication()
326
+ try:
327
+ app.run()
328
+ except KeyboardInterrupt:
329
+ app.quit()
330
+ except Exception as e:
331
+ print(f"Fatal error: {e}")
332
+ sys.exit(1)
333
+
334
+
335
+ if __name__ == "__main__":
336
+ main()
@@ -0,0 +1,124 @@
1
+ """Configuration management for OpenAdapt Tray."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+ from typing import Optional, Any, Dict
6
+ import json
7
+ import os
8
+
9
+ from openadapt_tray.shortcuts import HotkeyConfig
10
+
11
+
12
+ @dataclass
13
+ class TrayConfig:
14
+ """Tray application configuration."""
15
+
16
+ # Hotkeys
17
+ hotkeys: HotkeyConfig = field(default_factory=HotkeyConfig)
18
+
19
+ # Paths
20
+ captures_directory: str = "~/openadapt/captures"
21
+ training_output_directory: str = "~/openadapt/training"
22
+
23
+ # Dashboard
24
+ dashboard_port: int = 8080
25
+ auto_launch_dashboard: bool = True
26
+
27
+ # Behavior
28
+ auto_start_on_login: bool = False
29
+ minimize_to_tray: bool = True
30
+ show_notifications: bool = True
31
+ notification_duration_ms: int = 5000
32
+
33
+ # Recording
34
+ default_record_audio: bool = True
35
+ default_transcribe: bool = True
36
+ stop_on_triple_ctrl: bool = True
37
+
38
+ # Appearance
39
+ use_native_dialogs: bool = True
40
+
41
+ @classmethod
42
+ def config_path(cls) -> Path:
43
+ """Get configuration file path."""
44
+ # Use XDG_CONFIG_HOME on Linux, ~/Library/Application Support on macOS,
45
+ # %APPDATA% on Windows
46
+ if os.name == "nt":
47
+ base = Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming"))
48
+ elif os.name == "posix":
49
+ if "darwin" in os.sys.platform:
50
+ base = Path.home() / "Library" / "Application Support"
51
+ else:
52
+ base = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
53
+ else:
54
+ base = Path.home() / ".config"
55
+
56
+ return base / "openadapt" / "tray.json"
57
+
58
+ @classmethod
59
+ def load(cls) -> "TrayConfig":
60
+ """Load configuration from file."""
61
+ path = cls.config_path()
62
+ if path.exists():
63
+ try:
64
+ data = json.loads(path.read_text())
65
+ return cls._from_dict(data)
66
+ except Exception as e:
67
+ print(f"Warning: Could not load config: {e}")
68
+ return cls()
69
+
70
+ @classmethod
71
+ def _from_dict(cls, data: Dict[str, Any]) -> "TrayConfig":
72
+ """Create a TrayConfig from a dictionary."""
73
+ hotkeys_data = data.pop("hotkeys", {})
74
+ hotkeys = HotkeyConfig(
75
+ toggle_recording=hotkeys_data.get(
76
+ "toggle_recording", HotkeyConfig.toggle_recording
77
+ ),
78
+ open_dashboard=hotkeys_data.get(
79
+ "open_dashboard", HotkeyConfig.open_dashboard
80
+ ),
81
+ stop_recording=hotkeys_data.get(
82
+ "stop_recording", HotkeyConfig.stop_recording
83
+ ),
84
+ )
85
+
86
+ return cls(hotkeys=hotkeys, **data)
87
+
88
+ def save(self) -> None:
89
+ """Save configuration to file."""
90
+ path = self.config_path()
91
+ path.parent.mkdir(parents=True, exist_ok=True)
92
+
93
+ data = self.to_dict()
94
+ path.write_text(json.dumps(data, indent=2))
95
+
96
+ def to_dict(self) -> Dict[str, Any]:
97
+ """Convert configuration to a dictionary."""
98
+ return {
99
+ "hotkeys": {
100
+ "toggle_recording": self.hotkeys.toggle_recording,
101
+ "open_dashboard": self.hotkeys.open_dashboard,
102
+ "stop_recording": self.hotkeys.stop_recording,
103
+ },
104
+ "captures_directory": self.captures_directory,
105
+ "training_output_directory": self.training_output_directory,
106
+ "dashboard_port": self.dashboard_port,
107
+ "auto_launch_dashboard": self.auto_launch_dashboard,
108
+ "auto_start_on_login": self.auto_start_on_login,
109
+ "minimize_to_tray": self.minimize_to_tray,
110
+ "show_notifications": self.show_notifications,
111
+ "notification_duration_ms": self.notification_duration_ms,
112
+ "default_record_audio": self.default_record_audio,
113
+ "default_transcribe": self.default_transcribe,
114
+ "stop_on_triple_ctrl": self.stop_on_triple_ctrl,
115
+ "use_native_dialogs": self.use_native_dialogs,
116
+ }
117
+
118
+ def get_captures_path(self) -> Path:
119
+ """Get the expanded captures directory path."""
120
+ return Path(self.captures_directory).expanduser()
121
+
122
+ def get_training_path(self) -> Path:
123
+ """Get the expanded training output directory path."""
124
+ return Path(self.training_output_directory).expanduser()
@@ -0,0 +1,171 @@
1
+ """Icon management for OpenAdapt Tray."""
2
+
3
+ from pathlib import Path
4
+ from typing import Optional, Dict
5
+ import sys
6
+
7
+ from PIL import Image
8
+
9
+ from openadapt_tray.state import TrayState
10
+
11
+
12
+ class IconManager:
13
+ """Manages loading and caching of tray icons."""
14
+
15
+ # State to icon filename mapping
16
+ STATE_ICONS: Dict[TrayState, str] = {
17
+ TrayState.IDLE: "idle.png",
18
+ TrayState.RECORDING_STARTING: "recording.png",
19
+ TrayState.RECORDING: "recording.png",
20
+ TrayState.RECORDING_STOPPING: "recording.png",
21
+ TrayState.TRAINING: "training.png",
22
+ TrayState.TRAINING_PAUSED: "training.png",
23
+ TrayState.ERROR: "error.png",
24
+ }
25
+
26
+ # Icon colors for fallback generation
27
+ STATE_COLORS: Dict[TrayState, str] = {
28
+ TrayState.IDLE: "#4A90D9", # Blue
29
+ TrayState.RECORDING_STARTING: "#F5A623", # Yellow/Orange
30
+ TrayState.RECORDING: "#D0021B", # Red
31
+ TrayState.RECORDING_STOPPING: "#F5A623", # Yellow/Orange
32
+ TrayState.TRAINING: "#7B68EE", # Purple
33
+ TrayState.TRAINING_PAUSED: "#9B59B6", # Lighter Purple
34
+ TrayState.ERROR: "#D0021B", # Red
35
+ }
36
+
37
+ def __init__(self, assets_dir: Optional[Path] = None):
38
+ """Initialize the icon manager.
39
+
40
+ Args:
41
+ assets_dir: Path to the assets directory. If None, uses package assets.
42
+ """
43
+ if assets_dir is None:
44
+ # Find assets directory relative to this module
45
+ module_dir = Path(__file__).parent
46
+ assets_dir = module_dir.parent.parent / "assets" / "icons"
47
+
48
+ self.assets_dir = assets_dir
49
+ self._cache: Dict[str, Image.Image] = {}
50
+ self._retina_scale = self._detect_retina()
51
+
52
+ def _detect_retina(self) -> int:
53
+ """Detect if we're on a retina/HiDPI display."""
54
+ if sys.platform == "darwin":
55
+ # On macOS, assume retina by default for newer systems
56
+ return 2
57
+ return 1
58
+
59
+ def get(self, state: TrayState) -> Image.Image:
60
+ """Get the icon for a given state.
61
+
62
+ Args:
63
+ state: The application state.
64
+
65
+ Returns:
66
+ PIL Image for the icon.
67
+ """
68
+ icon_name = self.STATE_ICONS.get(state, "idle.png")
69
+
70
+ # Check cache first
71
+ cache_key = f"{icon_name}_{self._retina_scale}"
72
+ if cache_key in self._cache:
73
+ return self._cache[cache_key]
74
+
75
+ # Try to load icon file
76
+ icon = self._load_icon(icon_name)
77
+ if icon is None:
78
+ # Generate fallback icon
79
+ icon = self._generate_fallback(state)
80
+
81
+ self._cache[cache_key] = icon
82
+ return icon
83
+
84
+ def _load_icon(self, icon_name: str) -> Optional[Image.Image]:
85
+ """Try to load an icon from the assets directory.
86
+
87
+ Args:
88
+ icon_name: The icon filename.
89
+
90
+ Returns:
91
+ PIL Image if found, None otherwise.
92
+ """
93
+ if not self.assets_dir.exists():
94
+ return None
95
+
96
+ # Try retina version first on HiDPI displays
97
+ if self._retina_scale > 1:
98
+ retina_name = icon_name.replace(".png", "@2x.png")
99
+ retina_path = self.assets_dir / retina_name
100
+ if retina_path.exists():
101
+ try:
102
+ return Image.open(retina_path)
103
+ except Exception:
104
+ pass
105
+
106
+ # Try regular version
107
+ icon_path = self.assets_dir / icon_name
108
+ if icon_path.exists():
109
+ try:
110
+ return Image.open(icon_path)
111
+ except Exception:
112
+ pass
113
+
114
+ return None
115
+
116
+ def _generate_fallback(self, state: TrayState, size: int = 64) -> Image.Image:
117
+ """Generate a simple colored square icon as fallback.
118
+
119
+ Args:
120
+ state: The application state.
121
+ size: The icon size in pixels.
122
+
123
+ Returns:
124
+ Generated PIL Image.
125
+ """
126
+ color = self.STATE_COLORS.get(state, "#4A90D9")
127
+ return self.create_colored_icon(color, size)
128
+
129
+ @staticmethod
130
+ def create_colored_icon(color: str, size: int = 64) -> Image.Image:
131
+ """Create a simple colored square icon.
132
+
133
+ Args:
134
+ color: Hex color string (e.g., "#FF0000").
135
+ size: Icon size in pixels.
136
+
137
+ Returns:
138
+ PIL Image with the specified color.
139
+ """
140
+ # Create RGBA image with rounded corners effect
141
+ image = Image.new("RGBA", (size, size), (0, 0, 0, 0))
142
+
143
+ # Parse hex color
144
+ if color.startswith("#"):
145
+ color = color[1:]
146
+ r = int(color[0:2], 16)
147
+ g = int(color[2:4], 16)
148
+ b = int(color[4:6], 16)
149
+
150
+ # Draw a filled circle for a cleaner look
151
+ from PIL import ImageDraw
152
+
153
+ draw = ImageDraw.Draw(image)
154
+
155
+ # Draw circle with slight padding
156
+ padding = size // 8
157
+ draw.ellipse(
158
+ [padding, padding, size - padding, size - padding],
159
+ fill=(r, g, b, 255),
160
+ )
161
+
162
+ return image
163
+
164
+ def clear_cache(self) -> None:
165
+ """Clear the icon cache."""
166
+ self._cache.clear()
167
+
168
+ def preload_all(self) -> None:
169
+ """Preload all state icons into cache."""
170
+ for state in TrayState:
171
+ self.get(state)