Added sway support and created initial xp overlay with gtk-layer-shell

This commit is contained in:
2025-06-15 01:31:43 -06:00
parent d34cccc253
commit 4c07690e85
151 changed files with 7290 additions and 247 deletions
+9
View File
@@ -0,0 +1,9 @@
{
"permissions": {
"allow": [
"dart",
"flutter"
],
"deny": []
}
}
+248 -46
View File
@@ -2,75 +2,277 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
XP Nix is a productivity tracking and gamification system designed for Linux desktop environments, supporting both
Hyprland and Sway window managers. It transforms daily computer usage into a gamified experience by monitoring
activity patterns and providing real-time feedback through an XP (experience points) system.
### Window Manager Support
**Hyprland**: Full feature support including visual desktop enhancements, idle monitoring, and activity detection
**Sway**: Core functionality with idle monitoring and activity detection (visual enhancements disabled by design)
**Detection**: Automatic runtime detection via environment variables (`HYPRLAND_INSTANCE_SIGNATURE`, `SWAYSOCK`)
## Commands
### Development
- `dart run bin/xp_nix.dart` - Run the main application
- `dart test` - Run all tests
- `dart run bin/xp_nix.dart` - Run the main application server
- `dart run bin/xp_nix.dart --help` - Show command line options
- `dart run bin/xp_nix.dart --db custom.db --port 8081` - Run with custom database and port
- `dart test` - Run all tests
- `dart test test/specific_test.dart` - Run a specific test file
- `dart analyze` - Run static analysis
- `dart analyze` - Run static analysis and linting
- `dart pub get` - Install dependencies
- `nix develop` - Enter development environment with Dart and SQLite
### Interactive Commands (while running)
Once the server is running, these commands are available in the terminal:
- `stats` - Show current productivity stats
- `test [level]` - Test theme for specific level
- `restore` - Restore desktop backup
- `refresh` - Refresh base config from current system config
- `build` - Build Flutter dashboard and copy to static files
- `help` - Show available commands
### Nix Environment
This project uses Nix flakes for development environment management. The flake provides Dart SDK and SQLite with proper library paths configured.
This project uses Nix flakes for development environment management. The flake provides Dart SDK and SQLite with proper library paths configured:
- `nix develop` - Enter development environment with Dart and SQLite
## Architecture Overview
### Dependency Injection & Testing
The codebase uses dependency injection through interfaces to enable both production and testing modes:
- **Interfaces** (`lib/src/interfaces/`): Define contracts for core components
- `IActivityDetector` - Window/application activity detection
- `IIdleMonitor` - User idle state monitoring
- `IDesktopEnhancer` - Desktop theme/visual enhancements
- `ITimeProvider` - Time operations (for testing time manipulation)
- **Production Implementations**: Real system integrations (Hyprland, system time)
- **Test Mocks** (`lib/src/testing/`): Controllable implementations for testing
### Core Components
**ProductivityMonitor** - Central orchestrator that:
**ProductivityMonitor** (`lib/src/monitors/productivity_monitor.dart`) - Central orchestrator that:
- Coordinates activity detection, idle monitoring, and desktop enhancement
- Calculates XP rewards based on activity types and time multipliers
- Manages level progression and achievement system
- Handles both event-driven (via IActivityDetector) and polling modes
- Supports both event-driven (via IActivityDetector) and legacy polling modes
- Broadcasts updates via WebSocket for real-time dashboard updates
**Activity Classification System**:
- Uses `ActivityEventType` enum for categorizing activities (coding, meetings, etc.)
- Supports user-defined application classifications stored in database
- Fallback categorization based on application names and window titles
**DashboardServer** (`lib/src/web/dashboard_server.dart`) - Web server providing:
- RESTful API endpoints for stats, achievements, activities, and configuration
- Real-time WebSocket connections for live updates
- Serves Flutter-built dashboard from static files
- Configurable port (default: 8080)
**XP & Gamification**:
- Time-based multipliers for deep work hours vs late night penalty
- Focus session bonuses with milestone rewards (60min, 120min, 180min)
- Level-based visual themes applied via desktop enhancer
- Achievement system with level, focus, and session-based rewards
### Database Schema
SQLite database with tables for:
- Daily stats (XP, focus time, meeting time, level)
**DatabaseManager** (`lib/src/database/database_manager.dart`) - SQLite database operations for:
- Daily stats tracking (XP, focus time, meeting time, level)
- Activity events with duration and metadata
- Application classifications (user-defined)
- Achievements and focus sessions
- Theme change history and streak tracking
### Configuration
JSON-based configuration (`config/xp_config.json`) defines:
- XP multipliers by activity type and time of day
- Achievement definitions and rewards
- Focus session bonus structure
- Monitoring intervals and thresholds
### Dependency Injection & Testing
### Web Dashboard
Built-in web server (`lib/src/web/`) provides real-time dashboard at http://localhost:8080 showing stats, recent activities, and progress visualization.
The codebase uses dependency injection through interfaces to enable both production and testing modes:
**Interfaces** (`lib/src/interfaces/`):
- `IActivityDetector` - Window/application activity detection
- `IIdleMonitor` - User idle state monitoring
- `IDesktopEnhancer` - Desktop theme/visual enhancements
- `ITimeProvider` - Time operations (for testing time manipulation)
- `IWindowManager` - Window manager operations abstraction
**Hyprland Implementations**:
- `HyprlandActivityDetector` - Uses `hyprctl activewindow` for activity detection
- `IdleMonitor` - Uses `hypridle` for idle state monitoring
- `HyprlandEnhancer` - Full desktop theming and visual enhancements
- `HyprlandManager` - Window manager operations via `hyprctl`
**Sway Implementations**:
- `SwayActivityDetector` - Uses `swaymsg -t get_tree` for activity detection
- `SwayIdleMonitor` - Uses `swayidle` for idle state monitoring
- `SwayEnhancer` - Notifications-only enhancer (no visual changes)
- `SwayManager` - Window manager operations via `swaymsg`
**Factory Classes** (`lib/src/factories/`):
- `IdleMonitorFactory` - Creates appropriate idle monitor for detected window manager
- `ActivityDetectorFactory` - Creates appropriate activity detector for detected window manager
- `DesktopEnhancerFactory` - Creates appropriate enhancer for detected window manager
- `WindowManagerFactory` - Creates appropriate window manager interface
**Test Mocks** (`lib/src/testing/`):
- `MockActivityDetector` - Controllable activity simulation
- `MockIdleMonitor` - Controllable idle state simulation
- `MockDesktopEnhancer` - No-op desktop enhancement for testing
- `MockTimeProvider` - Controllable time for testing
- `MockWindowManagerDetector` - Test scenarios for different window managers
### Activity Classification System
**ActivityEventType** enum categorizes activities:
- `coding` - Development work (10 XP/min base)
- `focusedBrowsing` - Research and documentation (6 XP/min base)
- `collaboration` - Team communication (7 XP/min base)
- `meetings` - Video conferences and calls (3 XP/min base)
- `misc` - General productivity (2 XP/min base)
- `uncategorized` - Unknown activities (1 XP/min base)
**Classification Logic**:
- Uses user-defined application classifications stored in database
- Fallback categorization based on application names and window titles
- Tracks unclassified applications for manual categorization
### XP & Gamification System
**XP Calculation**:
- Base XP per minute varies by activity type
- Time-based multipliers from configuration:
- Deep work hours (1.5x): 09:00-11:00, 14:00-16:00
- Late night penalty (0.8x): 22:00-06:00
- Focus session bonuses: 60min (+100 XP), 120min (+200 XP), 180min (+500 XP)
**Level System**:
- 100 XP per level progression
- Visual desktop themes applied at different levels
- Achievement system with level, focus, and session-based rewards
**Achievements**:
- Level-based: Rising Star (L5), Productivity Warrior (L10), Focus Master (L15), Legendary Achiever (L25)
- Focus-based: Deep Focus (4h), Focus Titan (8h)
- Session-based: Session Master (5+ sessions)
- Meeting-based: Communication Pro (3h meetings)
### Configuration Management
**ConfigManager** (`lib/src/config/config_manager.dart`):
- Loads from `config/xp_config.json`
- Manages XP multipliers, achievement definitions, focus bonuses
- Supports runtime configuration updates via web API
**Key Configuration Sections**:
- `xp_rewards` - Base multipliers and time-based modifiers
- `achievements` - Achievement definitions and rewards
- `level_system` - XP per level and max level
- `monitoring` - Polling intervals and thresholds
- `logging` - Log levels and file management
### Web Dashboard & API
**Dashboard Features**:
- Real-time stats display with WebSocket updates
- Activity timeline and XP breakdown charts
- Achievement gallery and progress tracking
- Application classification management
- Configuration editor
- Log viewer with filtering
**API Endpoints**:
- `GET /api/stats` - Current day statistics
- `GET /api/stats/history?days=7` - Historical stats
- `GET /api/achievements` - All achievements
- `GET /api/activities?limit=100` - Recent activities
- `GET /api/focus-sessions` - Focus session history
- `GET /api/xp-breakdown` - XP breakdown by activity type
- `GET /api/classifications` - Application classifications
- `POST /api/classifications` - Save application classification
- `GET /api/config` - Current configuration
- `POST /api/config` - Update configuration
**WebSocket Events**:
- `level_up` - Level progression notifications
- `achievement_unlocked` - Achievement notifications
- `stats_update` - Real-time stats updates
- `xp_breakdown_update` - XP distribution updates
- `focus_session_complete` - Focus session completions
### Flutter Dashboard Build System
**DashboardBuilder** (`lib/src/build/dashboard_builder.dart`):
- Builds Flutter dashboard from `../xp_dashboard` directory
- Copies built web files to `lib/src/web/static/`
- Accessible via `build` command while server is running
- Requires Flutter to be available in system PATH
## Testing Strategy
The architecture supports comprehensive testing through:
- Interface-based dependency injection
- Time manipulation via `ITimeProvider`
- Mock implementations that simulate user behavior
- Simulation tests that model complete work days
- Integration tests for idle detection and activity consolidation
### Test Structure
- **Unit Tests**: Individual component testing with mocks
- **Integration Tests**: WebSocket and database integration
- **Simulation Tests**: Complete work day scenarios
- **Configuration Tests**: Hyprland config parsing
### Key Test Files
- `test/xp_nix_test.dart` - Main productivity monitor tests
- `test/websocket_integration_test.dart` - WebSocket functionality
- `test/simulation/` - Work day simulation scenarios
- `test/deep_idle_test.dart` - Idle detection edge cases
- `test/hyprland_config_parser_test.dart` - Configuration parsing
- `test/sway_integration_test.dart` - Sway window manager integration tests
### Testing Utilities
- `test/create_golden_db.dart` - Creates test database with sample data
- Time manipulation via `MockTimeProvider` for deterministic testing
- Activity simulation via `MockActivityDetector` for scenario testing
## System Requirements & Compatibility
### Window Manager Requirements
**Hyprland Environment**:
- `hyprctl` - Window management and querying
- `hypridle` - Idle detection and monitoring
- Environment: `$HYPRLAND_INSTANCE_SIGNATURE` must be set
**Sway Environment**:
- `swaymsg` - Window management and querying
- `swayidle` - Idle detection and monitoring
- Environment: `$SWAYSOCK` must be set
**Optional Dependencies**:
- `notify-send` - Desktop notifications (both window managers)
- `mako` or `dunst` - Enhanced notification daemons (Sway)
### Feature Comparison
| Feature | Hyprland | Sway | Notes |
|---------|----------|------|-------|
| Activity Detection | ✅ | ✅ | Uses `hyprctl` vs `swaymsg` |
| Idle Monitoring | ✅ | ✅ | Uses `hypridle` vs `swayidle` |
| Visual Enhancements | ✅ | ❌ | Disabled by design for Sway |
| Desktop Theming | ✅ | ❌ | Config modifications disabled |
| Notifications | ✅ | ✅ | Level-up and achievement alerts |
| WebSocket API | ✅ | ✅ | Full dashboard functionality |
| Database Tracking | ✅ | ✅ | Identical functionality |
### Runtime Detection
The system automatically detects your window manager at startup:
1. **Environment Variables**: Checks `$HYPRLAND_INSTANCE_SIGNATURE` and `$SWAYSOCK`
2. **Process Detection**: Falls back to checking available commands
3. **Graceful Degradation**: Uses appropriate implementations or fallbacks
## Development Workflow
### Initial Setup
1. `dart pub get` - Install dependencies
2. `dart run bin/xp_nix.dart` - Start server (auto-detects window manager)
3. Visit `http://localhost:8080` for dashboard
### Making Changes
1. Run tests: `dart test`
2. Check analysis: `dart analyze`
3. Test with custom database: `dart run bin/xp_nix.dart --db test.db`
4. Build dashboard: Use `build` command at runtime command `flutter build web` in `../xp_dashboard`
### Configuration Changes
- Edit `config/xp_config.json` for XP rules and achievements
- Use `refresh` command to reload configuration
- Test theme changes with `test [level]` command
- Use `restore` command if desktop theme issues occur
## Roadmap Integration
The project roadmap includes ambitious enhancements:
- **Cursor-following XP numbers** - RPG-style floating damage numbers
- **Dual currency shop system** - XP points and real-world reward tokens
- **Enhanced celebrations** - Full-screen level-up animations
- **Environmental feedback** - Focus-based ambient changes
- **Real-world purchase integration** - Automated reward fulfillment
Current architecture supports these extensions through:
- Modular desktop enhancement system
- Extensible notification framework
- Configurable reward and achievement system
- Database schema designed for expansion
+72 -10
View File
@@ -7,15 +7,23 @@ import 'package:xp_nix/src/config/config_manager.dart';
import 'package:xp_nix/src/logging/logger.dart';
import 'package:xp_nix/src/web/dashboard_server.dart';
import 'package:xp_nix/src/database/database_manager.dart';
import 'package:xp_nix/src/detectors/idle_monitor.dart';
import 'package:xp_nix/src/providers/system_time_provider.dart';
import 'package:xp_nix/src/enhancers/hyprland_enhancer.dart';
import 'package:xp_nix/src/build/dashboard_builder.dart';
// Window manager detection and factories
import 'package:xp_nix/src/detectors/window_manager_detector.dart';
import 'package:xp_nix/src/factories/idle_monitor_factory.dart';
import 'package:xp_nix/src/factories/activity_detector_factory.dart';
import 'package:xp_nix/src/factories/desktop_enhancer_factory.dart';
// Interfaces
import 'package:xp_nix/src/interfaces/i_idle_monitor.dart';
import 'package:xp_nix/src/interfaces/i_activity_detector.dart';
import 'package:xp_nix/src/interfaces/i_desktop_enhancer.dart';
late final Database _db;
late final IdleMonitor _idleMonitor;
late final IIdleMonitor _idleMonitor;
late final SystemTimeProvider _timeProvider;
late final HyprlandEnhancer _desktopEnhancer;
late final IDesktopEnhancer _desktopEnhancer;
late final IActivityDetector? _activityDetector;
late final ProductivityMonitor _monitor;
late final DashboardServer _dashboardServer;
@@ -48,7 +56,10 @@ void main(List<String> args) async {
case '--help':
case '-h':
print('''
XP Nix Server
XP Nix Server - Productivity Tracking with Gamification
A productivity tracking system that works with both Hyprland and Sway window managers.
Automatically detects your environment and adapts functionality accordingly.
Usage: dart xp_nix.dart [options]
@@ -57,9 +68,18 @@ Options:
--port PORT Server port (default: 8080)
--help, -h Show this help message
Supported Window Managers:
• Hyprland: Full visual effects, idle monitoring, activity detection
• Sway: Activity detection, idle monitoring (no visual effects)
• Detection: Automatic via environment variables
Examples:
dart xp_nix.dart
dart xp_nix.dart --db test_data.db --port 8081
Requirements:
• Hyprland: hypridle, hyprctl
• Sway: swayidle, swaymsg
''');
exit(0);
}
@@ -74,12 +94,54 @@ Examples:
// Initialize configuration manager
await ConfigManager.instance.initialize();
// Detect window manager and display information
final windowManager = WindowManagerDetector.instance.detect();
final detectionInfo = WindowManagerDetector.instance.getDetectionInfo();
print('🖼️ Detected window manager: ${windowManager.toString()}');
print(' Detection method: ${detectionInfo['detection_method']}');
// Log additional environment info
Logger.info('Window manager detection: $detectionInfo');
_db = sqlite3.open(dbPath);
// Create production dependencies
_idleMonitor = IdleMonitor();
_timeProvider = SystemTimeProvider();
_desktopEnhancer = HyprlandEnhancer();
// Create window manager-specific dependencies using factories
try {
_idleMonitor = IdleMonitorFactory.create(windowManager);
_desktopEnhancer = DesktopEnhancerFactory.create(windowManager);
_activityDetector = ActivityDetectorFactory.create(windowManager);
_timeProvider = SystemTimeProvider();
// Validate requirements for the detected window manager
final idleSupported = await IdleMonitorFactory.validateRequirements(windowManager);
final activitySupported = await ActivityDetectorFactory.validateRequirements(windowManager);
final enhancerSupported = await DesktopEnhancerFactory.validateRequirements(windowManager);
print('💡 Component availability:');
print(' Idle monitoring: ${idleSupported ? '' : ''}');
print(' Activity detection: ${activitySupported ? '' : ''}');
print(' Visual enhancements: ${enhancerSupported ? '' : ''}');
if (!idleSupported) {
print(' ⚠️ Idle monitoring may not work properly');
}
if (!activitySupported) {
print(' ⚠️ Activity detection will fall back to legacy polling');
}
if (!enhancerSupported) {
print(' ⚠️ Visual enhancements may be limited');
}
} catch (e) {
print('❌ Error creating window manager components: $e');
print(' Falling back to legacy mode...');
// Fallback to original Hyprland implementations (import at top of file)
// Note: This requires adding imports back if needed
Logger.error('Component creation failed, falling back to legacy mode: $e');
exit(1); // For now, exit on component creation failure
}
// Create monitor with dependency injection
_monitor = ProductivityMonitor(
@@ -87,7 +149,7 @@ Examples:
idleMonitor: _idleMonitor,
timeProvider: _timeProvider,
desktopEnhancer: _desktopEnhancer,
// No activity detector provided - will use legacy polling mode
activityDetector: _activityDetector, // May be null for fallback to legacy polling
);
_dashboardServer = DashboardServer.withDatabase(DatabaseManager(_db));
-61
View File
@@ -1,61 +0,0 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1749285348,
"narHash": "sha256-frdhQvPbmDYaScPFiCnfdh3B/Vh81Uuoo0w5TkWmmjU=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "3e3afe5174c561dee0df6f2c2b2236990146329f",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}
-59
View File
@@ -1,59 +0,0 @@
{
description = "Simple flutter and dart flake, creating the fart stack";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
inputs.flake-utils.url = "github:numtide/flake-utils";
outputs = {
flake-utils,
nixpkgs,
...
}:
flake-utils.lib.eachDefaultSystem (system: let
lib = nixpkgs.lib;
pkgs = import nixpkgs {
inherit system;
config.allowUnfreePredicate = pkg: builtins.elem (lib.getName pkg) [
"android-studio-stable"
];
};
in {
devShell = pkgs.mkShell {
buildInputs = with pkgs; [
flutter
dart
clang
cmake
ninja
pkg-config
gtk3
pcre
libepoxy
# For drift
sqlite
# Dev deps
libuuid # for mount.pc
xorg.libXdmcp.dev
# python310Packages.libselinux.dev # for libselinux.pc
libsepol.dev
libthai.dev
libdatrie.dev
libxkbcommon.dev
dbus.dev
at-spi2-core.dev
xorg.libXtst.out
pcre2.dev
jdk11
android-studio
android-tools
];
LD_LIBRARY_PATH = "${pkgs.sqlite.out}/lib";
shellHook = ''
export PATH="$PATH":"$HOME/.pub-cache/bin"
echo -e "\e[44m \e[0m"
echo " FartStack Enabled 💨"
echo -e "\e[44m \e[0m"
'';
};
});
}
@@ -0,0 +1,223 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import '../interfaces/i_activity_detector.dart';
import '../models/activity_event.dart';
/// Sway-specific activity detector that polls the focused window
class SwayActivityDetector implements IActivityDetector {
final StreamController<ActivityEvent> _activityController = StreamController<ActivityEvent>.broadcast();
Timer? _pollTimer;
String? _lastActiveWindow;
String? _lastActiveWindowTitle;
DateTime? _lastActivityTime;
bool _isRunning = false;
@override
Stream<ActivityEvent> get activityStream => _activityController.stream;
@override
Future<void> start() async {
if (_isRunning) return;
_isRunning = true;
_pollTimer = Timer.periodic(Duration(seconds: 5), (_) => _pollActivity());
print('🔍 Started Sway activity detection...');
}
@override
void stop() {
_isRunning = false;
_pollTimer?.cancel();
_pollTimer = null;
// Flush any remaining activity
if (_lastActiveWindow != null && _lastActivityTime != null) {
final duration = DateTime.now().difference(_lastActivityTime!).inSeconds;
if (duration >= 10) {
_emitActivityEvent(_lastActiveWindow!, _lastActiveWindowTitle ?? '', duration);
}
}
_activityController.close();
print('🛑 Stopped Sway activity detection');
}
Future<void> _pollActivity() async {
try {
final result = await Process.run('swaymsg', ['-t', 'get_tree']);
if (result.exitCode != 0) {
print('Warning: swaymsg get_tree failed with exit code ${result.exitCode}');
return;
}
final treeData = jsonDecode(result.stdout);
final focusedNode = _findFocusedNode(treeData);
if (focusedNode == null) {
// No focused window found
return;
}
final currentApp = _extractAppId(focusedNode);
final currentWindowTitle = _extractWindowTitle(focusedNode);
final now = DateTime.now();
// If this is a different activity from the last one, emit the previous activity
if (_lastActiveWindow != null &&
(_lastActiveWindow != currentApp || _lastActiveWindowTitle != currentWindowTitle)) {
final duration = now.difference(_lastActivityTime ?? now).inSeconds;
if (duration >= 10) {
_emitActivityEvent(_lastActiveWindow!, _lastActiveWindowTitle ?? '', duration);
}
}
// Update current activity
_lastActiveWindow = currentApp;
_lastActiveWindowTitle = currentWindowTitle;
_lastActivityTime = now;
} catch (e) {
print('Error polling Sway activity: $e');
}
}
/// Recursively find the focused node in the Sway tree
Map<String, dynamic>? _findFocusedNode(Map<String, dynamic> node) {
// Check if this node is focused
if (node['focused'] == true) {
return node;
}
// Check child nodes
final nodes = node['nodes'] as List<dynamic>? ?? [];
for (final childNode in nodes) {
if (childNode is Map<String, dynamic>) {
final focused = _findFocusedNode(childNode);
if (focused != null) {
return focused;
}
}
}
// Check floating nodes
final floatingNodes = node['floating_nodes'] as List<dynamic>? ?? [];
for (final floatingNode in floatingNodes) {
if (floatingNode is Map<String, dynamic>) {
final focused = _findFocusedNode(floatingNode);
if (focused != null) {
return focused;
}
}
}
return null;
}
/// Extract application ID from a Sway node
String _extractAppId(Map<String, dynamic> node) {
// In Sway, the application identifier can be in different fields depending on the application type
// For Wayland applications, use app_id
final appId = node['app_id'] as String?;
if (appId != null && appId.isNotEmpty) {
return appId;
}
// For X11 applications, use window_properties.class
final windowProperties = node['window_properties'] as Map<String, dynamic>?;
if (windowProperties != null) {
final windowClass = windowProperties['class'] as String?;
if (windowClass != null && windowClass.isNotEmpty) {
return windowClass;
}
}
// Fallback to window type or node type
final type = node['type'] as String?;
if (type != null && type != 'con') {
return type;
}
// Last resort fallback
return 'unknown';
}
/// Extract window title from a Sway node
String _extractWindowTitle(Map<String, dynamic> node) {
// Try the name field first
final name = node['name'] as String?;
if (name != null && name.isNotEmpty) {
return name;
}
// For X11 applications, try window_properties.title
final windowProperties = node['window_properties'] as Map<String, dynamic>?;
if (windowProperties != null) {
final title = windowProperties['title'] as String?;
if (title != null && title.isNotEmpty) {
return title;
}
}
// Fallback to app_id if name is empty
final appId = node['app_id'] as String?;
if (appId != null && appId.isNotEmpty) {
return appId;
}
return '';
}
void _emitActivityEvent(String application, String title, int durationSeconds) {
final event = ActivityEvent(
type: ActivityEventType.categorize(applicationId: application, applicationTitle: title),
application: application,
metadata: title,
timestamp: DateTime.now(),
);
_activityController.add(event);
}
@override
Future<ActivityEvent> getCurrentActivity() {
return _activityController.stream.single;
}
/// Check if swaymsg is available on the system
static Future<bool> isAvailable() async {
try {
final result = await Process.run('which', ['swaymsg']);
return result.exitCode == 0;
} catch (e) {
return false;
}
}
/// Get Sway version information
static Future<String?> getVersion() async {
try {
final result = await Process.run('swaymsg', ['-t', 'get_version']);
if (result.exitCode == 0) {
final versionData = jsonDecode(result.stdout);
return versionData['human_readable'] as String? ?? 'Sway (version unknown)';
}
return null;
} catch (e) {
return null;
}
}
/// Test the Sway connection and get basic info
static Future<Map<String, dynamic>?> getSwayInfo() async {
try {
final result = await Process.run('swaymsg', ['-t', 'get_version']);
if (result.exitCode == 0) {
return jsonDecode(result.stdout) as Map<String, dynamic>;
}
return null;
} catch (e) {
return null;
}
}
}
@@ -0,0 +1,128 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import '../interfaces/i_idle_monitor.dart';
/// Sway-specific idle monitor using swayidle
class SwayIdleMonitor implements IIdleMonitor {
static const String _idleLightStartKey = 'SWAY_IDLE_LIGHT_START';
static const String _idleDeepStartKey = 'SWAY_IDLE_DEEP_START';
static const String _idleEndKey = 'SWAY_IDLE_END';
Process? _swayidleProcess;
final StreamController<IdleStatus> _idleStateController = StreamController<IdleStatus>.broadcast();
IdleStatus _currentIdleStatus = IdleStatus.active;
@override
Stream<IdleStatus> get idleStateStream => _idleStateController.stream;
@override
IdleStatus get status => _currentIdleStatus;
@override
Future<void> start() async {
// Start in active state
_currentIdleStatus = IdleStatus.active;
_idleStateController.add(IdleStatus.active);
// Build swayidle command arguments
final swayidleArgs = [
'-w', // Wait for command to finish before continuing
'timeout', '20', 'echo "$_idleLightStartKey"',
'resume', 'echo "$_idleEndKey"',
'timeout', '120', 'echo "$_idleDeepStartKey"',
'resume', 'echo "$_idleEndKey"',
];
try {
// Start swayidle with our configuration
_swayidleProcess = await Process.start('swayidle', swayidleArgs);
// Listen to swayidle output
_swayidleProcess!.stdout
.transform(utf8.decoder)
.transform(LineSplitter())
.listen(_handleSwayidleOutput);
_swayidleProcess!.stderr
.transform(utf8.decoder)
.transform(LineSplitter())
.listen((line) {
if (line.isNotEmpty) {
print('SwayIdle stderr: $line');
}
});
print('🔄 Started Sway idle monitoring with swayidle');
} catch (e) {
print('❌ Failed to start swayidle: $e');
print(' Make sure swayidle is installed and available in PATH');
rethrow;
}
}
void _handleSwayidleOutput(String line) {
if (line.isEmpty) return;
switch (line) {
case String s when s.contains(_idleLightStartKey):
_idleStateController.add(IdleStatus.lightIdle);
_currentIdleStatus = IdleStatus.lightIdle;
print('🌙 User went light idle (Sway)');
case String s when s.contains(_idleDeepStartKey):
_idleStateController.add(IdleStatus.deepIdle);
_currentIdleStatus = IdleStatus.deepIdle;
print('😴 User went deep idle (Sway)');
case String s when s.contains(_idleEndKey):
_idleStateController.add(IdleStatus.active);
_currentIdleStatus = IdleStatus.active;
print('⚡ User went active (Sway)');
default:
// Handle other swayidle output if needed
if (line.trim().isNotEmpty) {
print('SwayIdle output: $line');
}
}
}
@override
void stop() {
try {
_swayidleProcess?.kill();
_swayidleProcess = null;
print('🛑 Stopped Sway idle monitoring');
} catch (e) {
print('Warning: Error stopping swayidle process: $e');
}
try {
_idleStateController.close();
} catch (e) {
print('Warning: Error closing idle state controller: $e');
}
}
/// Check if swayidle is available on the system
static Future<bool> isAvailable() async {
try {
final result = await Process.run('which', ['swayidle']);
return result.exitCode == 0;
} catch (e) {
return false;
}
}
/// Get swayidle version information
static Future<String?> getVersion() async {
try {
final result = await Process.run('swayidle', ['-h']);
if (result.exitCode == 0) {
// swayidle doesn't have a version flag, but -h should work
return 'swayidle (available)';
}
return null;
} catch (e) {
return null;
}
}
}
@@ -0,0 +1,143 @@
import 'dart:io';
/// Supported window managers
enum WindowManager {
hyprland,
sway,
unknown;
@override
String toString() {
return switch (this) {
WindowManager.hyprland => 'Hyprland',
WindowManager.sway => 'Sway',
WindowManager.unknown => 'Unknown',
};
}
}
/// Singleton class for detecting the current window manager at runtime
class WindowManagerDetector {
static WindowManagerDetector? _instance;
static WindowManagerDetector get instance => _instance ??= WindowManagerDetector._();
WindowManagerDetector._();
WindowManager? _detectedWindowManager;
/// Detect the current window manager based on environment variables
WindowManager detect() {
if (_detectedWindowManager != null) {
return _detectedWindowManager!;
}
final env = Platform.environment;
// Check for Hyprland signature
if (env.containsKey('HYPRLAND_INSTANCE_SIGNATURE') && env['HYPRLAND_INSTANCE_SIGNATURE']!.isNotEmpty) {
_detectedWindowManager = WindowManager.hyprland;
return _detectedWindowManager!;
}
// Check for Sway socket
if (env.containsKey('SWAYSOCK') && env['SWAYSOCK']!.isNotEmpty) {
_detectedWindowManager = WindowManager.sway;
return _detectedWindowManager!;
}
// Fallback: Try to detect by checking running processes
_detectedWindowManager = _detectByProcess();
return _detectedWindowManager!;
}
/// Fallback detection by checking running processes
WindowManager _detectByProcess() {
try {
// Check if hyprctl is available and working
final hyprResult = Process.runSync('which', ['hyprctl']);
if (hyprResult.exitCode == 0) {
final hyprTest = Process.runSync('hyprctl', ['version']);
if (hyprTest.exitCode == 0) {
return WindowManager.hyprland;
}
}
// Check if swaymsg is available and working
final swayResult = Process.runSync('which', ['swaymsg']);
if (swayResult.exitCode == 0) {
final swayTest = Process.runSync('swaymsg', ['-t', 'get_version']);
if (swayTest.exitCode == 0) {
return WindowManager.sway;
}
}
} catch (e) {
// Ignore errors in fallback detection
}
return WindowManager.unknown;
}
/// Get information about the detected window manager
Map<String, dynamic> getDetectionInfo() {
final windowManager = detect();
final env = Platform.environment;
return {
'detected_window_manager': windowManager.toString(),
'detection_method': _getDetectionMethod(),
'environment_variables': {
'HYPRLAND_INSTANCE_SIGNATURE': env['HYPRLAND_INSTANCE_SIGNATURE'],
'SWAYSOCK': env['SWAYSOCK'],
'WAYLAND_DISPLAY': env['WAYLAND_DISPLAY'],
'XDG_CURRENT_DESKTOP': env['XDG_CURRENT_DESKTOP'],
'XDG_SESSION_TYPE': env['XDG_SESSION_TYPE'],
},
};
}
String _getDetectionMethod() {
final env = Platform.environment;
if (env.containsKey('HYPRLAND_INSTANCE_SIGNATURE') && env['HYPRLAND_INSTANCE_SIGNATURE']!.isNotEmpty) {
return 'HYPRLAND_INSTANCE_SIGNATURE environment variable';
}
if (env.containsKey('SWAYSOCK') && env['SWAYSOCK']!.isNotEmpty) {
return 'SWAYSOCK environment variable';
}
return 'Process detection fallback';
}
/// Reset detection (useful for testing)
void reset() {
_detectedWindowManager = null;
}
/// Check if the window manager supports visual enhancements
bool supportsVisualEnhancements(WindowManager windowManager) {
return switch (windowManager) {
WindowManager.hyprland => true,
WindowManager.sway => false, // As requested: no visual enhancements for Sway
WindowManager.unknown => false,
};
}
/// Check if the window manager supports idle monitoring
bool supportsIdleMonitoring(WindowManager windowManager) {
return switch (windowManager) {
WindowManager.hyprland => true,
WindowManager.sway => true,
WindowManager.unknown => false,
};
}
/// Check if the window manager supports activity detection
bool supportsActivityDetection(WindowManager windowManager) {
return switch (windowManager) {
WindowManager.hyprland => true,
WindowManager.sway => true,
WindowManager.unknown => false,
};
}
}
@@ -0,0 +1,148 @@
import 'dart:io';
import '../interfaces/i_desktop_enhancer.dart';
/// Sway desktop enhancer with minimal visual changes
///
/// As requested, this implementation does not perform aesthetic desktop config changes
/// for Sway systems. It maintains API compatibility while providing basic notifications.
class SwayEnhancer implements IDesktopEnhancer {
String _currentTheme = 'default';
@override
Future<void> celebrateLevelUp(int level) async {
print('🎉 Celebrating level up to $level! (Sway)');
// Update internal theme tracking for API compatibility
_updateThemeForLevel(level);
// Send celebration notification via notify-send
try {
await Process.run('notify-send', [
'🎉 LEVEL UP!',
'Welcome to Level $level!\nKeep up the great productivity!',
'--urgency=normal',
'--expire-time=5000',
'--app-name=XP Nix',
]);
} catch (e) {
print('Could not send level up notification: $e');
}
// Optional: Try to send via Sway notification daemon if available
await _trySwayNotification(level);
}
@override
Future<void> applyThemeForLevel(int level) async {
// Update internal theme tracking only
_updateThemeForLevel(level);
print('🎨 Theme updated to $_currentTheme for level $level (Sway - visual changes disabled)');
// No actual visual changes are applied for Sway as requested
// This maintains compatibility with the API while respecting the no-visual-changes requirement
}
void _updateThemeForLevel(int level) {
if (level >= 25) {
_currentTheme = 'legendary';
} else if (level >= 15) {
_currentTheme = 'master';
} else if (level >= 10) {
_currentTheme = 'advanced';
} else if (level >= 5) {
_currentTheme = 'intermediate';
} else {
_currentTheme = 'default';
}
}
/// Try to send notification via Sway-specific methods if available
Future<void> _trySwayNotification(int level) async {
try {
// Check if mako (Sway notification daemon) is running
final makoCheck = await Process.run('pgrep', ['mako']);
if (makoCheck.exitCode == 0) {
// Use makoctl if available for enhanced notifications
final makoctl = await Process.run('which', ['makoctl']);
if (makoctl.exitCode == 0) {
// Could add mako-specific notification features here if desired
print('📱 Mako notification daemon detected');
}
}
} catch (e) {
// Silently ignore errors in optional notification enhancements
}
}
@override
String getCurrentThemeInfo() {
return 'Theme: $_currentTheme (Sway - visual effects disabled)';
}
@override
Future<void> refreshBaseConfig() async {
print('🔄 Refresh base config requested (Sway - no config changes applied)');
// No-op for Sway as we don't modify configs
}
@override
Future<void> restoreBackup() async {
print('🔧 Restore backup requested (Sway - no backups to restore)');
// No-op for Sway as we don't create backups
}
/// Get information about Sway enhancer capabilities
Map<String, dynamic> getCapabilityInfo() {
return {
'window_manager': 'Sway',
'visual_enhancements': false,
'notifications': true,
'config_backup': false,
'theme_tracking': true,
'current_theme': _currentTheme,
'supported_features': [
'Level-up notifications',
'Theme name tracking',
'API compatibility',
],
'disabled_features': [
'Visual effects',
'Config modifications',
'Backup/restore',
'Desktop theming',
],
};
}
/// Check if notification systems are available
static Future<Map<String, bool>> checkNotificationAvailability() async {
final availability = <String, bool>{};
try {
// Check for notify-send (libnotify)
final notifySend = await Process.run('which', ['notify-send']);
availability['notify-send'] = notifySend.exitCode == 0;
} catch (e) {
availability['notify-send'] = false;
}
try {
// Check for mako (Sway notification daemon)
final mako = await Process.run('pgrep', ['mako']);
availability['mako'] = mako.exitCode == 0;
} catch (e) {
availability['mako'] = false;
}
try {
// Check for dunst (alternative notification daemon)
final dunst = await Process.run('pgrep', ['dunst']);
availability['dunst'] = dunst.exitCode == 0;
} catch (e) {
availability['dunst'] = false;
}
return availability;
}
}
@@ -0,0 +1,143 @@
import '../interfaces/i_activity_detector.dart';
import '../detectors/hyprland_activity_detector.dart';
import '../detectors/sway_activity_detector.dart';
import '../detectors/window_manager_detector.dart';
import '../testing/mock_activity_detector.dart';
import 'dart:io';
/// Factory for creating appropriate activity detector implementations based on the window manager
class ActivityDetectorFactory {
/// Create an activity detector based on the detected window manager
static IActivityDetector? create([WindowManager? windowManager]) {
windowManager ??= WindowManagerDetector.instance.detect();
return switch (windowManager) {
WindowManager.hyprland => HyprlandActivityDetector(), // Existing Hyprland implementation
WindowManager.sway => SwayActivityDetector(), // New Sway implementation
WindowManager.unknown => _createFallbackDetector(),
};
}
/// Create a mock activity detector for testing
static IActivityDetector createMock() {
return MockActivityDetector();
}
/// Create an activity detector for a specific window manager (useful for testing)
static IActivityDetector? createForWindowManager(WindowManager windowManager) {
return create(windowManager);
}
/// Create a fallback activity detector when window manager is unknown
static IActivityDetector? _createFallbackDetector() {
print('⚠️ Unknown window manager detected for activity detection');
print(' Activity detection will be disabled - only legacy polling mode will be available');
// Return null to indicate activity detection is not available
// This will cause the ProductivityMonitor to fall back to legacy polling mode
return null;
}
/// Get information about available activity detector implementations
static Map<String, dynamic> getAvailableImplementations() {
return {
'hyprland': {
'class': 'HyprlandActivityDetector',
'description': 'Uses hyprctl activewindow for activity detection',
'requirements': ['hyprctl'],
'polling_interval': '5 seconds',
},
'sway': {
'class': 'SwayActivityDetector',
'description': 'Uses swaymsg get_tree for activity detection',
'requirements': ['swaymsg'],
'polling_interval': '5 seconds',
},
'mock': {
'class': 'MockActivityDetector',
'description': 'Controllable mock for testing',
'requirements': [],
'polling_interval': 'configurable',
},
};
}
/// Check if activity detection is supported for the given window manager
static bool isSupported(WindowManager windowManager) {
return WindowManagerDetector.instance.supportsActivityDetection(windowManager);
}
/// Validate that required tools are available for the window manager
static Future<bool> validateRequirements(WindowManager windowManager) async {
return switch (windowManager) {
WindowManager.hyprland => await _validateHyprlandRequirements(),
WindowManager.sway => await SwayActivityDetector.isAvailable(),
WindowManager.unknown => false,
};
}
static Future<bool> _validateHyprlandRequirements() async {
try {
final result = await Process.run('which', ['hyprctl']);
if (result.exitCode != 0) return false;
// Test that hyprctl actually works
final testResult = await Process.run('hyprctl', ['version']);
return testResult.exitCode == 0;
} catch (e) {
return false;
}
}
/// Get version information for activity detection tools
static Future<Map<String, String?>> getToolVersions() async {
final versions = <String, String?>{};
// Check Hyprland version
try {
final hyprResult = await Process.run('hyprctl', ['version']);
if (hyprResult.exitCode == 0) {
final output = hyprResult.stdout as String;
final lines = output.split('\n');
if (lines.isNotEmpty) {
versions['hyprland'] = lines.first.trim();
}
}
} catch (e) {
versions['hyprland'] = null;
}
// Check Sway version
try {
final swayVersion = await SwayActivityDetector.getVersion();
versions['sway'] = swayVersion;
} catch (e) {
versions['sway'] = null;
}
return versions;
}
/// Test connectivity to window manager APIs
static Future<Map<String, bool>> testConnectivity() async {
final connectivity = <String, bool>{};
// Test Hyprland connectivity
try {
final hyprResult = await Process.run('hyprctl', ['activewindow', '-j']);
connectivity['hyprland'] = hyprResult.exitCode == 0;
} catch (e) {
connectivity['hyprland'] = false;
}
// Test Sway connectivity
try {
final swayResult = await Process.run('swaymsg', ['-t', 'get_tree']);
connectivity['sway'] = swayResult.exitCode == 0;
} catch (e) {
connectivity['sway'] = false;
}
return connectivity;
}
}
@@ -0,0 +1,147 @@
import 'dart:io';
import '../interfaces/i_desktop_enhancer.dart';
import '../enhancers/hyprland_enhancer.dart';
import '../enhancers/sway_enhancer.dart';
import '../detectors/window_manager_detector.dart';
import '../testing/mock_desktop_enhancer.dart';
/// Factory for creating appropriate desktop enhancer implementations based on the window manager
class DesktopEnhancerFactory {
/// Create a desktop enhancer based on the detected window manager
static IDesktopEnhancer create([WindowManager? windowManager]) {
windowManager ??= WindowManagerDetector.instance.detect();
return switch (windowManager) {
WindowManager.hyprland => HyprlandEnhancer(), // Existing Hyprland implementation with full visual effects
WindowManager.sway => SwayEnhancer(), // New Sway implementation with no visual effects
WindowManager.unknown => _createFallbackEnhancer(),
};
}
/// Create a mock desktop enhancer for testing
static IDesktopEnhancer createMock() {
return MockDesktopEnhancer();
}
/// Create a desktop enhancer for a specific window manager (useful for testing)
static IDesktopEnhancer createForWindowManager(WindowManager windowManager) {
return create(windowManager);
}
/// Create a fallback desktop enhancer when window manager is unknown
static IDesktopEnhancer _createFallbackEnhancer() {
print('⚠️ Unknown window manager detected, using no-op desktop enhancer');
print(' Visual enhancements will be disabled');
// Use Sway enhancer as fallback since it's no-op
return SwayEnhancer();
}
/// Get information about available desktop enhancer implementations
static Map<String, dynamic> getAvailableImplementations() {
return {
'hyprland': {
'class': 'HyprlandEnhancer',
'description': 'Full visual enhancements for Hyprland',
'features': [
'Dynamic blur effects',
'Progressive shadows',
'Animated borders',
'Level-based themes',
'Config backup/restore',
'Real-time visual updates',
],
'requirements': ['hyprctl'],
'visual_enhancements': true,
},
'sway': {
'class': 'SwayEnhancer',
'description': 'Minimal enhancer for Sway (notifications only)',
'features': ['Level-up notifications', 'Theme name tracking', 'API compatibility'],
'requirements': ['notify-send (optional)'],
'visual_enhancements': false,
},
'mock': {
'class': 'MockDesktopEnhancer',
'description': 'No-op mock for testing',
'features': ['API compatibility'],
'requirements': [],
'visual_enhancements': false,
},
};
}
/// Check if visual enhancements are supported for the given window manager
static bool supportsVisualEnhancements(WindowManager windowManager) {
return WindowManagerDetector.instance.supportsVisualEnhancements(windowManager);
}
/// Get the feature set available for a window manager
static List<String> getAvailableFeatures(WindowManager windowManager) {
final implementations = getAvailableImplementations();
return switch (windowManager) {
WindowManager.hyprland => List<String>.from(implementations['hyprland']['features']),
WindowManager.sway => List<String>.from(implementations['sway']['features']),
WindowManager.unknown => ['Basic notifications'],
};
}
/// Validate that required tools are available for the window manager
static Future<bool> validateRequirements(WindowManager windowManager) async {
return switch (windowManager) {
WindowManager.hyprland => await _validateHyprlandRequirements(),
WindowManager.sway => await _validateSwayRequirements(),
WindowManager.unknown => true, // Fallback always works
};
}
static Future<bool> _validateHyprlandRequirements() async {
try {
final result = await Process.run('which', ['hyprctl']);
if (result.exitCode != 0) return false;
// Test that hyprctl actually works
final testResult = await Process.run('hyprctl', ['version']);
return testResult.exitCode == 0;
} catch (e) {
return false;
}
}
static Future<bool> _validateSwayRequirements() async {
// Sway enhancer has no hard requirements, but check for optional notify-send
try {
final result = await Process.run('which', ['notify-send']);
// Even if notify-send is not available, Sway enhancer still works
return true;
} catch (e) {
return true;
}
}
/// Get capability information for the current window manager
static Future<Map<String, dynamic>> getCapabilityInfo([WindowManager? windowManager]) async {
windowManager ??= WindowManagerDetector.instance.detect();
final enhancer = create(windowManager);
final implementations = getAvailableImplementations();
final info = {
'window_manager': windowManager.toString(),
'enhancer_class': enhancer.runtimeType.toString(),
'visual_enhancements': supportsVisualEnhancements(windowManager),
'available_features': getAvailableFeatures(windowManager),
'requirements_met': await validateRequirements(windowManager),
};
// Add window manager specific info
if (windowManager == WindowManager.sway && enhancer is SwayEnhancer) {
info['sway_capabilities'] = enhancer.getCapabilityInfo();
info['notification_availability'] = await SwayEnhancer.checkNotificationAvailability();
}
return info;
}
}
@@ -0,0 +1,80 @@
import 'dart:io';
import '../interfaces/i_idle_monitor.dart';
import '../detectors/idle_monitor.dart';
import '../detectors/sway_idle_monitor.dart';
import '../detectors/window_manager_detector.dart';
import '../testing/mock_idle_monitor.dart';
/// Factory for creating appropriate idle monitor implementations based on the window manager
class IdleMonitorFactory {
/// Create an idle monitor based on the detected window manager
static IIdleMonitor create([WindowManager? windowManager]) {
windowManager ??= WindowManagerDetector.instance.detect();
return switch (windowManager) {
WindowManager.hyprland => IdleMonitor(), // Existing Hyprland implementation
WindowManager.sway => SwayIdleMonitor(), // New Sway implementation
WindowManager.unknown => _createFallbackMonitor(),
};
}
/// Create a mock idle monitor for testing
static IIdleMonitor createMock() {
return MockIdleMonitor();
}
/// Create an idle monitor for a specific window manager (useful for testing)
static IIdleMonitor createForWindowManager(WindowManager windowManager) {
return create(windowManager);
}
/// Create a fallback idle monitor when window manager is unknown
static IIdleMonitor _createFallbackMonitor() {
print('⚠️ Unknown window manager detected, attempting Hyprland idle monitor as fallback');
print(' If this fails, consider running in a supported environment (Hyprland or Sway)');
// Try Hyprland first as fallback
return IdleMonitor();
}
/// Get information about available idle monitor implementations
static Map<String, dynamic> getAvailableImplementations() {
return {
'hyprland': {
'class': 'IdleMonitor',
'description': 'Uses hypridle for idle detection',
'requirements': ['hypridle'],
},
'sway': {
'class': 'SwayIdleMonitor',
'description': 'Uses swayidle for idle detection',
'requirements': ['swayidle'],
},
'mock': {'class': 'MockIdleMonitor', 'description': 'Controllable mock for testing', 'requirements': []},
};
}
/// Check if idle monitoring is supported for the given window manager
static bool isSupported(WindowManager windowManager) {
return WindowManagerDetector.instance.supportsIdleMonitoring(windowManager);
}
/// Validate that required tools are available for the window manager
static Future<bool> validateRequirements(WindowManager windowManager) async {
return switch (windowManager) {
WindowManager.hyprland => await _validateHyprlandRequirements(),
WindowManager.sway => await SwayIdleMonitor.isAvailable(),
WindowManager.unknown => false,
};
}
static Future<bool> _validateHyprlandRequirements() async {
try {
final result = await Process.run('which', ['hypridle']);
return result.exitCode == 0;
} catch (e) {
return false;
}
}
}
@@ -0,0 +1,94 @@
import '../interfaces/i_window_manager.dart';
import '../window_managers/hyprland_manager.dart';
import '../window_managers/sway_manager.dart';
import '../detectors/window_manager_detector.dart';
/// Factory for creating appropriate window manager implementations
class WindowManagerFactory {
/// Create a window manager based on the detected window manager
static IWindowManager? create([WindowManager? windowManager]) {
windowManager ??= WindowManagerDetector.instance.detect();
return switch (windowManager) {
WindowManager.hyprland => HyprlandManager(),
WindowManager.sway => SwayManager(),
WindowManager.unknown => null, // No window manager available
};
}
/// Create a window manager for a specific type (useful for testing)
static IWindowManager? createForWindowManager(WindowManager windowManager) {
return create(windowManager);
}
/// Get information about available window manager implementations
static Map<String, dynamic> getAvailableImplementations() {
return {
'hyprland': {
'class': 'HyprlandManager',
'description': 'Uses hyprctl commands for window management',
'commands': ['hyprctl activewindow', 'hyprctl workspaces', 'hyprctl clients'],
'requirements': ['hyprctl'],
},
'sway': {
'class': 'SwayManager',
'description': 'Uses swaymsg commands for window management',
'commands': ['swaymsg -t get_tree', 'swaymsg -t get_workspaces'],
'requirements': ['swaymsg'],
},
};
}
/// Check if window management is supported for the given window manager
static bool isSupported(WindowManager windowManager) {
return windowManager != WindowManager.unknown;
}
/// Validate that required tools are available for the window manager
static Future<bool> validateRequirements(WindowManager windowManager) async {
return switch (windowManager) {
WindowManager.hyprland => await _validateHyprlandRequirements(),
WindowManager.sway => await _validateSwayRequirements(),
WindowManager.unknown => false,
};
}
static Future<bool> _validateHyprlandRequirements() async {
try {
final manager = HyprlandManager();
return await manager.isAvailable();
} catch (e) {
return false;
}
}
static Future<bool> _validateSwayRequirements() async {
try {
final manager = SwayManager();
return await manager.isAvailable();
} catch (e) {
return false;
}
}
/// Get version information for available window managers
static Future<Map<String, String?>> getVersions() async {
final versions = <String, String?>{};
try {
final hyprland = HyprlandManager();
versions['hyprland'] = await hyprland.getVersion();
} catch (e) {
versions['hyprland'] = null;
}
try {
final sway = SwayManager();
versions['sway'] = await sway.getVersion();
} catch (e) {
versions['sway'] = null;
}
return versions;
}
}
@@ -0,0 +1,50 @@
/// Interface for window manager operations
///
/// This interface abstracts window manager-specific operations to provide
/// a consistent API for querying window information across different
/// window managers (Hyprland, Sway, etc.)
abstract interface class IWindowManager {
/// Get information about the currently active/focused window
///
/// Returns a map containing:
/// - 'application': Application identifier (class/app_id)
/// - 'title': Window title
/// - 'pid': Process ID (if available)
/// - 'workspace': Current workspace (if available)
///
/// Returns null if no active window or on error
Future<Map<String, dynamic>?> getCurrentWindow();
/// Get the application identifier of the active window
///
/// Returns the application class/app_id, or 'unknown' if unavailable
Future<String> getActiveApplication();
/// Get the title of the active window
///
/// Returns the window title, or empty string if unavailable
Future<String> getWindowTitle();
/// Get the current workspace information
///
/// Returns a map containing workspace details, or null if unavailable
Future<Map<String, dynamic>?> getCurrentWorkspace();
/// Check if the window manager is available and responding
Future<bool> isAvailable();
/// Get version information about the window manager
Future<String?> getVersion();
/// Get current cursor position in global coordinates
///
/// Returns a map containing:
/// - 'x': X coordinate in pixels
/// - 'y': Y coordinate in pixels
///
/// Returns null if cursor position is unavailable or on error
Future<Map<String, int>?> getCursorPosition();
/// Get the window manager type identifier
String get managerType;
}
@@ -8,12 +8,16 @@ import '../interfaces/i_idle_monitor.dart';
import '../interfaces/i_activity_detector.dart';
import '../interfaces/i_time_provider.dart';
import '../interfaces/i_desktop_enhancer.dart';
import '../interfaces/i_window_manager.dart';
import '../detectors/zoom_detector.dart';
import '../database/database_manager.dart';
import '../config/config_manager.dart';
import '../logging/logger.dart';
import '../notifications/xp_notification_manager.dart';
import '../web/websocket_manager.dart';
import '../factories/window_manager_factory.dart';
import '../detectors/window_manager_detector.dart';
import '../overlays/cursor_overlay_manager.dart';
/// Unified ProductivityMonitor with dependency injection for both production and testing
class ProductivityMonitor {
@@ -25,6 +29,8 @@ class ProductivityMonitor {
late final IDesktopEnhancer _desktopEnhancer;
late final XPNotificationManager _xpNotificationManager;
late final ZoomDetector? _zoomDetector;
late final IWindowManager? _windowManager;
late final CursorOverlayManager? _cursorOverlayManager;
Timer? _pollTimer;
String? _lastActiveWindow;
@@ -55,6 +61,20 @@ class ProductivityMonitor {
_desktopEnhancer = desktopEnhancer;
_xpNotificationManager = XPNotificationManager(_dbManager);
// Initialize window manager for legacy polling mode
_windowManager = WindowManagerFactory.create();
// Initialize cursor overlay manager
if (_windowManager != null) {
final overlayAppPath = '/home/nate/source/xp_nix/xp_overlay_app/build/linux/x64/release/bundle/xp_overlay_app';
_cursorOverlayManager = CursorOverlayManager(
windowManager: _windowManager!,
overlayAppPath: overlayAppPath,
);
} else {
_cursorOverlayManager = null;
}
// Initialize zoom detector (only if not in test mode)
if (_activityDetector == null) {
_zoomDetector = ZoomDetector();
@@ -326,6 +346,9 @@ class ProductivityMonitor {
// Add bonus XP
_dbManager.updateDailyStats(achievement['xp_reward'] as int, 0, 0);
// Show cursor overlay for achievement bonus XP
_cursorOverlayManager?.showXPNumber(achievement['xp_reward'] as int);
print('🏆 ACHIEVEMENT UNLOCKED: ${achievement['name']}');
print(' ${achievement['description']}');
print(' Bonus: +${achievement['xp_reward']} XP');
@@ -405,6 +428,9 @@ class ProductivityMonitor {
_dbManager.saveFocusSession(today, focusMinutes, bonusXP, _timeProvider.now().millisecondsSinceEpoch);
_dbManager.updateDailyStats(bonusXP, 0, 0);
// Show cursor overlay for focus session bonus XP
_cursorOverlayManager?.showXPNumber(bonusXP);
String message = '🎯 Focus session complete: +$bonusXP XP for $focusMinutes min!';
if (focusMinutes >= 180) {
message = '🔥 LEGENDARY FOCUS! +$bonusXP XP for $focusMinutes min! 🔥';
@@ -499,6 +525,49 @@ class ProductivityMonitor {
try {
if (_idleMonitor.status != IdleStatus.active) return;
// Use window manager abstraction if available
if (_windowManager != null) {
final windowInfo = await _windowManager!.getCurrentWindow();
if (windowInfo == null) return;
final currentApp = windowInfo['application'] as String? ?? 'unknown';
final currentWindowTitle = windowInfo['title'] as String? ?? '';
final now = _timeProvider.now();
await _checkZoomActivity(now);
if (_lastActiveWindow != currentApp || _lastActiveWindowTitle != currentWindowTitle) {
if (_lastActiveWindow != null && _lastActivityTime != null) {
_saveActivityEvent(
_lastActiveWindow!,
now.difference(_lastActivityTime!).inSeconds,
_lastActiveWindowTitle ?? '',
);
}
_lastActiveWindow = currentApp;
_lastActiveWindowTitle = currentWindowTitle;
_lastActivityTime = now;
final windowManager = WindowManagerDetector.instance.detect();
print('Switched to: $currentApp (via ${windowManager.toString()})');
}
} else {
// Fallback to Hyprland-specific approach if window manager is not available
print('Warning: No window manager available, attempting Hyprland fallback');
await _pollActivityHyprlandFallback();
}
} catch (e) {
print('Error polling activity: $e');
// Try fallback on error
if (_windowManager != null) {
print('Attempting fallback polling method...');
await _pollActivityHyprlandFallback();
}
}
}
Future<void> _pollActivityHyprlandFallback() async {
try {
final result = await Process.run('hyprctl', ['activewindow', '-j']);
if (result.exitCode != 0) return;
@@ -521,10 +590,10 @@ class ProductivityMonitor {
_lastActiveWindow = currentApp;
_lastActiveWindowTitle = currentWindowTitle;
_lastActivityTime = now;
print('Switched to: $currentApp');
print('Switched to: $currentApp (Hyprland fallback)');
}
} catch (e) {
print('Error polling activity: $e');
print('Error in Hyprland fallback polling: $e');
}
}
@@ -639,6 +708,9 @@ class ProductivityMonitor {
_dbManager.updateDailyStats(xp, focusTime, meetingTime);
print('Logged: ${event.type} for ${durationSeconds}s (+$xp XP)');
// Show cursor overlay for XP gain
_cursorOverlayManager?.showXPNumber(xp);
// Send activity XP notification
_xpNotificationManager.showXPGain(
source: 'activity',
@@ -0,0 +1,129 @@
import 'dart:io';
import '../interfaces/i_window_manager.dart';
import '../logging/logger.dart';
/// Manages cursor-positioned XP overlays by coordinating cursor tracking
/// and spawning Flutter overlay processes
class CursorOverlayManager {
final IWindowManager _windowManager;
final String _overlayAppPath;
CursorOverlayManager({
required IWindowManager windowManager,
required String overlayAppPath,
}) : _windowManager = windowManager,
_overlayAppPath = overlayAppPath;
/// Show XP number at current cursor position
///
/// [xpValue] - XP amount to display
/// [duration] - How long to show the overlay (default 3 seconds)
Future<void> showXPNumber(int xpValue, {Duration duration = const Duration(seconds: 3)}) async {
try {
// Get current cursor position
final cursorPos = await _windowManager.getCursorPosition();
if (cursorPos == null) {
Logger.warn('Could not get cursor position, skipping XP overlay');
return;
}
final x = cursorPos['x']!;
final y = cursorPos['y']!;
Logger.info('Showing XP overlay: +$xpValue at position ($x, $y)');
// Spawn Flutter overlay process
await _spawnOverlayProcess(x, y, xpValue, duration);
} catch (e) {
Logger.error('Failed to show XP overlay', e);
}
}
/// Show XP number at specific position
///
/// [x] - X coordinate in pixels
/// [y] - Y coordinate in pixels
/// [xpValue] - XP amount to display
/// [duration] - How long to show the overlay
Future<void> showXPNumberAt(int x, int y, int xpValue, {Duration duration = const Duration(seconds: 3)}) async {
try {
Logger.info('Showing XP overlay: +$xpValue at position ($x, $y)');
await _spawnOverlayProcess(x, y, xpValue, duration);
} catch (e) {
Logger.error('Failed to show XP overlay at position', e);
}
}
/// Test cursor position tracking
///
/// Returns current cursor coordinates or null if unavailable
Future<Map<String, int>?> getCurrentCursorPosition() async {
try {
return await _windowManager.getCursorPosition();
} catch (e) {
Logger.error('Failed to get cursor position', e);
return null;
}
}
/// Spawn the Flutter overlay process
Future<void> _spawnOverlayProcess(int x, int y, int xpValue, Duration duration) async {
final durationSeconds = duration.inSeconds;
// Check if overlay app exists
if (!await File(_overlayAppPath).exists()) {
Logger.error('Overlay app not found at: $_overlayAppPath');
return;
}
try {
// Spawn overlay process with arguments: x y xp_value duration_seconds
final process = await Process.start(
_overlayAppPath,
[x.toString(), y.toString(), xpValue.toString(), durationSeconds.toString()],
mode: ProcessStartMode.detached,
);
Logger.debug('Spawned overlay process with PID: ${process.pid}');
// Don't wait for the process to complete - let it run independently
process.exitCode.then((exitCode) {
if (exitCode != 0) {
Logger.warn('Overlay process exited with code: $exitCode');
} else {
Logger.debug('Overlay process completed successfully');
}
}).catchError((error) {
Logger.error('Overlay process error', error);
});
} catch (e) {
Logger.error('Failed to spawn overlay process', e);
}
}
/// Test the overlay system by showing a test XP number
Future<void> testOverlay({int testXP = 42}) async {
Logger.info('Testing overlay system with XP value: $testXP');
await showXPNumber(testXP, duration: const Duration(seconds: 5));
}
/// Check if the overlay system is available
Future<bool> isAvailable() async {
try {
// Check if window manager supports cursor position
final cursorPos = await _windowManager.getCursorPosition();
final windowManagerAvailable = cursorPos != null;
// Check if overlay app exists
final overlayAppAvailable = await File(_overlayAppPath).exists();
Logger.debug('Overlay system availability: windowManager=$windowManagerAvailable, overlayApp=$overlayAppAvailable');
return windowManagerAvailable && overlayAppAvailable;
} catch (e) {
Logger.error('Error checking overlay availability', e);
return false;
}
}
}
@@ -0,0 +1,79 @@
import '../detectors/window_manager_detector.dart';
/// Mock window manager detector for testing
///
/// Allows tests to override window manager detection behavior
class MockWindowManagerDetector {
static WindowManager? _mockedWindowManager;
static Map<String, String>? _mockedEnvironment;
/// Set the window manager that should be detected in tests
static void mockDetection(WindowManager windowManager) {
_mockedWindowManager = windowManager;
}
/// Set mock environment variables for testing detection logic
static void mockEnvironment(Map<String, String> environment) {
_mockedEnvironment = environment;
}
/// Clear all mocks
static void clearMocks() {
_mockedWindowManager = null;
_mockedEnvironment = null;
// Reset the detector singleton
WindowManagerDetector.instance.reset();
}
/// Get the currently mocked window manager
static WindowManager? get mockedWindowManager => _mockedWindowManager;
/// Get the currently mocked environment
static Map<String, String>? get mockedEnvironment => _mockedEnvironment;
/// Create a test scenario for Hyprland
static void setupHyprlandTest() {
mockEnvironment({
'HYPRLAND_INSTANCE_SIGNATURE': 'test_signature_12345',
'WAYLAND_DISPLAY': 'wayland-1',
'XDG_CURRENT_DESKTOP': 'Hyprland',
'XDG_SESSION_TYPE': 'wayland',
});
mockDetection(WindowManager.hyprland);
}
/// Create a test scenario for Sway
static void setupSwayTest() {
mockEnvironment({
'SWAYSOCK': '/run/user/1000/sway-ipc.sock',
'WAYLAND_DISPLAY': 'wayland-1',
'XDG_CURRENT_DESKTOP': 'sway',
'XDG_SESSION_TYPE': 'wayland',
});
mockDetection(WindowManager.sway);
}
/// Create a test scenario for unknown window manager
static void setupUnknownTest() {
mockEnvironment({
'XDG_SESSION_TYPE': 'x11',
'XDG_CURRENT_DESKTOP': 'GNOME',
});
mockDetection(WindowManager.unknown);
}
/// Verify detection results match expectations
static bool verifyDetection(WindowManager expected) {
return WindowManagerDetector.instance.detect() == expected;
}
/// Get detailed detection info for debugging tests
static Map<String, dynamic> getTestDetectionInfo() {
return {
'mocked_window_manager': _mockedWindowManager?.toString(),
'mocked_environment': _mockedEnvironment,
'actual_detection': WindowManagerDetector.instance.detect().toString(),
'actual_detection_info': WindowManagerDetector.instance.getDetectionInfo(),
};
}
}
@@ -0,0 +1,203 @@
import 'dart:convert';
import 'dart:io';
import '../interfaces/i_window_manager.dart';
/// Hyprland window manager implementation
class HyprlandManager implements IWindowManager {
@override
String get managerType => 'Hyprland';
@override
Future<Map<String, dynamic>?> getCurrentWindow() async {
try {
final result = await Process.run('hyprctl', ['activewindow', '-j']);
if (result.exitCode != 0) {
return null;
}
final windowData = jsonDecode(result.stdout) as Map<String, dynamic>;
return {
'application': windowData['class'] as String? ?? 'unknown',
'title': windowData['title'] as String? ?? '',
'pid': windowData['pid'] as int? ?? -1,
'workspace': windowData['workspace'] != null
? (windowData['workspace'] as Map<String, dynamic>)['id'] as int? ?? -1
: -1,
'address': windowData['address'] as String? ?? '',
'floating': windowData['floating'] as bool? ?? false,
'fullscreen': windowData['fullscreen'] as bool? ?? false,
'size': windowData['size'] != null
? [
(windowData['size'] as List<dynamic>)[0] as int,
(windowData['size'] as List<dynamic>)[1] as int,
]
: [0, 0],
'position': windowData['at'] != null
? [
(windowData['at'] as List<dynamic>)[0] as int,
(windowData['at'] as List<dynamic>)[1] as int,
]
: [0, 0],
};
} catch (e) {
print('Error getting current window from Hyprland: $e');
return null;
}
}
@override
Future<String> getActiveApplication() async {
final window = await getCurrentWindow();
return window?['application'] as String? ?? 'unknown';
}
@override
Future<String> getWindowTitle() async {
final window = await getCurrentWindow();
return window?['title'] as String? ?? '';
}
@override
Future<Map<String, dynamic>?> getCurrentWorkspace() async {
try {
final result = await Process.run('hyprctl', ['activeworkspace', '-j']);
if (result.exitCode != 0) {
return null;
}
final workspaceData = jsonDecode(result.stdout) as Map<String, dynamic>;
return {
'id': workspaceData['id'] as int? ?? -1,
'name': workspaceData['name'] as String? ?? '',
'monitor': workspaceData['monitor'] as String? ?? '',
'windows': workspaceData['windows'] as int? ?? 0,
'hasfullscreen': workspaceData['hasfullscreen'] as bool? ?? false,
'lastwindow': workspaceData['lastwindow'] as String? ?? '',
'lastwindowtitle': workspaceData['lastwindowtitle'] as String? ?? '',
};
} catch (e) {
print('Error getting current workspace from Hyprland: $e');
return null;
}
}
@override
Future<bool> isAvailable() async {
try {
final result = await Process.run('hyprctl', ['version']);
return result.exitCode == 0;
} catch (e) {
return false;
}
}
@override
Future<String?> getVersion() async {
try {
final result = await Process.run('hyprctl', ['version']);
if (result.exitCode == 0) {
final output = result.stdout as String;
final lines = output.split('\n');
if (lines.isNotEmpty) {
return lines.first.trim();
}
}
return null;
} catch (e) {
return null;
}
}
/// Get all windows from Hyprland
Future<List<Map<String, dynamic>>> getAllWindows() async {
try {
final result = await Process.run('hyprctl', ['clients', '-j']);
if (result.exitCode != 0) {
return [];
}
final clientsData = jsonDecode(result.stdout) as List<dynamic>;
return clientsData.cast<Map<String, dynamic>>();
} catch (e) {
print('Error getting all windows from Hyprland: $e');
return [];
}
}
/// Get all workspaces from Hyprland
Future<List<Map<String, dynamic>>> getAllWorkspaces() async {
try {
final result = await Process.run('hyprctl', ['workspaces', '-j']);
if (result.exitCode != 0) {
return [];
}
final workspacesData = jsonDecode(result.stdout) as List<dynamic>;
return workspacesData.cast<Map<String, dynamic>>();
} catch (e) {
print('Error getting all workspaces from Hyprland: $e');
return [];
}
}
/// Get monitor information from Hyprland
Future<List<Map<String, dynamic>>> getMonitors() async {
try {
final result = await Process.run('hyprctl', ['monitors', '-j']);
if (result.exitCode != 0) {
return [];
}
final monitorsData = jsonDecode(result.stdout) as List<dynamic>;
return monitorsData.cast<Map<String, dynamic>>();
} catch (e) {
print('Error getting monitors from Hyprland: $e');
return [];
}
}
/// Execute a Hyprland dispatch command
Future<bool> dispatch(String command, [List<String> args = const []]) async {
try {
final fullArgs = ['dispatch', command, ...args];
final result = await Process.run('hyprctl', fullArgs);
return result.exitCode == 0;
} catch (e) {
print('Error executing Hyprland dispatch: $e');
return false;
}
}
@override
Future<Map<String, int>?> getCursorPosition() async {
try {
final result = await Process.run('hyprctl', ['cursorpos']);
if (result.exitCode != 0) {
return null;
}
final output = result.stdout as String;
// Parse output format: "123, 456" (x, y coordinates)
final coordinates = output.trim().split(', ');
if (coordinates.length != 2) {
print('Unexpected cursor position format: $output');
return null;
}
final x = int.tryParse(coordinates[0]);
final y = int.tryParse(coordinates[1]);
if (x == null || y == null) {
print('Failed to parse cursor coordinates: $output');
return null;
}
return {'x': x, 'y': y};
} catch (e) {
print('Error getting cursor position from Hyprland: $e');
return null;
}
}
}
@@ -0,0 +1,296 @@
import 'dart:convert';
import 'dart:io';
import '../interfaces/i_window_manager.dart';
/// Sway window manager implementation
class SwayManager implements IWindowManager {
@override
String get managerType => 'Sway';
@override
Future<Map<String, dynamic>?> getCurrentWindow() async {
try {
final result = await Process.run('swaymsg', ['-t', 'get_tree']);
if (result.exitCode != 0) {
return null;
}
final treeData = jsonDecode(result.stdout) as Map<String, dynamic>;
final focusedNode = _findFocusedNode(treeData);
if (focusedNode == null) {
return null;
}
return {
'application': _extractAppId(focusedNode),
'title': _extractWindowTitle(focusedNode),
'pid': focusedNode['pid'] as int? ?? -1,
'workspace': _extractWorkspaceNumber(focusedNode),
'id': focusedNode['id'] as int? ?? -1,
'floating': _isFloating(focusedNode),
'fullscreen_mode': focusedNode['fullscreen_mode'] as int? ?? 0,
'rect': focusedNode['rect'] != null
? Map<String, dynamic>.from(focusedNode['rect'] as Map)
: null,
'window_rect': focusedNode['window_rect'] != null
? Map<String, dynamic>.from(focusedNode['window_rect'] as Map)
: null,
'type': focusedNode['type'] as String? ?? 'unknown',
};
} catch (e) {
print('Error getting current window from Sway: $e');
return null;
}
}
@override
Future<String> getActiveApplication() async {
final window = await getCurrentWindow();
return window?['application'] as String? ?? 'unknown';
}
@override
Future<String> getWindowTitle() async {
final window = await getCurrentWindow();
return window?['title'] as String? ?? '';
}
@override
Future<Map<String, dynamic>?> getCurrentWorkspace() async {
try {
final result = await Process.run('swaymsg', ['-t', 'get_workspaces']);
if (result.exitCode != 0) {
return null;
}
final workspacesData = jsonDecode(result.stdout) as List<dynamic>;
// Find the focused workspace
for (final workspace in workspacesData) {
if (workspace is Map<String, dynamic> && workspace['focused'] == true) {
return {
'id': workspace['num'] as int? ?? -1,
'name': workspace['name'] as String? ?? '',
'output': workspace['output'] as String? ?? '',
'focused': workspace['focused'] as bool? ?? false,
'urgent': workspace['urgent'] as bool? ?? false,
'visible': workspace['visible'] as bool? ?? false,
'rect': workspace['rect'] != null
? Map<String, dynamic>.from(workspace['rect'] as Map)
: null,
};
}
}
return null;
} catch (e) {
print('Error getting current workspace from Sway: $e');
return null;
}
}
@override
Future<bool> isAvailable() async {
try {
final result = await Process.run('swaymsg', ['-t', 'get_version']);
return result.exitCode == 0;
} catch (e) {
return false;
}
}
@override
Future<String?> getVersion() async {
try {
final result = await Process.run('swaymsg', ['-t', 'get_version']);
if (result.exitCode == 0) {
final versionData = jsonDecode(result.stdout) as Map<String, dynamic>;
return versionData['human_readable'] as String? ?? 'Sway (version unknown)';
}
return null;
} catch (e) {
return null;
}
}
/// Recursively find the focused node in the Sway tree
Map<String, dynamic>? _findFocusedNode(Map<String, dynamic> node) {
// Check if this node is focused
if (node['focused'] == true) {
return node;
}
// Check child nodes
final nodes = node['nodes'] as List<dynamic>? ?? [];
for (final childNode in nodes) {
if (childNode is Map<String, dynamic>) {
final focused = _findFocusedNode(childNode);
if (focused != null) {
return focused;
}
}
}
// Check floating nodes
final floatingNodes = node['floating_nodes'] as List<dynamic>? ?? [];
for (final floatingNode in floatingNodes) {
if (floatingNode is Map<String, dynamic>) {
final focused = _findFocusedNode(floatingNode);
if (focused != null) {
return focused;
}
}
}
return null;
}
/// Extract application ID from a Sway node
String _extractAppId(Map<String, dynamic> node) {
// For Wayland applications, use app_id
final appId = node['app_id'] as String?;
if (appId != null && appId.isNotEmpty) {
return appId;
}
// For X11 applications, use window_properties.class
final windowProperties = node['window_properties'] as Map<String, dynamic>?;
if (windowProperties != null) {
final windowClass = windowProperties['class'] as String?;
if (windowClass != null && windowClass.isNotEmpty) {
return windowClass;
}
}
// Fallback to window type or node type
final type = node['type'] as String?;
if (type != null && type != 'con') {
return type;
}
return 'unknown';
}
/// Extract window title from a Sway node
String _extractWindowTitle(Map<String, dynamic> node) {
// Try the name field first
final name = node['name'] as String?;
if (name != null && name.isNotEmpty) {
return name;
}
// For X11 applications, try window_properties.title
final windowProperties = node['window_properties'] as Map<String, dynamic>?;
if (windowProperties != null) {
final title = windowProperties['title'] as String?;
if (title != null && title.isNotEmpty) {
return title;
}
}
return '';
}
/// Extract workspace number from a Sway node by traversing up the tree
int _extractWorkspaceNumber(Map<String, dynamic> node) {
// If this node has a workspace number, return it
final num = node['num'] as int?;
if (num != null && num > 0) {
return num;
}
// This is a simplified approach - in practice, you might need to
// maintain the parent relationship while traversing the tree
return -1;
}
/// Check if a node represents a floating window
bool _isFloating(Map<String, dynamic> node) {
final type = node['type'] as String?;
return type == 'floating_con';
}
/// Get all workspaces from Sway
Future<List<Map<String, dynamic>>> getAllWorkspaces() async {
try {
final result = await Process.run('swaymsg', ['-t', 'get_workspaces']);
if (result.exitCode != 0) {
return [];
}
final workspacesData = jsonDecode(result.stdout) as List<dynamic>;
return workspacesData.cast<Map<String, dynamic>>();
} catch (e) {
print('Error getting all workspaces from Sway: $e');
return [];
}
}
/// Get outputs (monitors) from Sway
Future<List<Map<String, dynamic>>> getOutputs() async {
try {
final result = await Process.run('swaymsg', ['-t', 'get_outputs']);
if (result.exitCode != 0) {
return [];
}
final outputsData = jsonDecode(result.stdout) as List<dynamic>;
return outputsData.cast<Map<String, dynamic>>();
} catch (e) {
print('Error getting outputs from Sway: $e');
return [];
}
}
/// Get the full window tree from Sway
Future<Map<String, dynamic>?> getWindowTree() async {
try {
final result = await Process.run('swaymsg', ['-t', 'get_tree']);
if (result.exitCode != 0) {
return null;
}
return jsonDecode(result.stdout) as Map<String, dynamic>;
} catch (e) {
print('Error getting window tree from Sway: $e');
return null;
}
}
/// Execute a Sway command
Future<bool> executeCommand(String command) async {
try {
final result = await Process.run('swaymsg', [command]);
return result.exitCode == 0;
} catch (e) {
print('Error executing Sway command: $e');
return false;
}
}
@override
Future<Map<String, int>?> getCursorPosition() async {
try {
// Sway doesn't have a direct cursor position query command
// As a fallback, we'll return the center of the focused window
final window = await getCurrentWindow();
if (window == null) {
return null;
}
final rect = window['rect'] as Map<String, dynamic>?;
if (rect == null) {
return null;
}
final x = (rect['x'] as int? ?? 0) + ((rect['width'] as int? ?? 0) ~/ 2);
final y = (rect['y'] as int? ?? 0) + ((rect['height'] as int? ?? 0) ~/ 2);
return {'x': x, 'y': y};
} catch (e) {
print('Error getting cursor position from Sway: $e');
return null;
}
}
}