diff --git a/AGENTS.md b/AGENTS.md index e5de0ad..26f146f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,12 +77,19 @@ Quando terminar as tarefas solicitadas faça as seguintes etapas: --- -## 3. Arquitetura de Execução (Dual-Binary) +## 3. Arquitetura de Execução (Dual-Binary + Monorepo) -O projeto compila **2 binários** para resolver problemas de dependência (GTK) e UX: +O projeto usa **monorepo** com 2 pacotes internos e compila **2 binários**: + +**Pacotes (`packages/`):** + +- `crossbar_core`: APIs e modelos Dart puro compartilhados +- `crossbar_cli`: CLI executável, depende de crossbar_core + +**Binários:** 1. **`crossbar` (CLI + Launcher)**: - - Fonte: `bin/crossbar.dart` → `lib/cli/cli_handler.dart` + - Fonte: `packages/crossbar_cli/bin/crossbar.dart` - Função: CLI unificado + launcher. Sem args ou com `gui` → lança GUI. Com args CLI → executa comando. - Comandos: `crossbar cpu`, `crossbar --version`, `crossbar gui` 2. **`crossbar-gui` (Flutter App)**: @@ -352,9 +359,9 @@ Se a context7 não estiver disponível no sistema, faça o seguinte: > Decisões arquiteturais importantes que impactam todo o projeto. -### ADR-001: Unified CLI Binary (2024-12-07) +### ADR-001: Unified CLI Binary (2024-12-07) ⚠️ SUPERSEDED by ADR-011 -**Status**: ✅ Accepted +**Status**: ⚠️ Superseded **Context**: Originalmente havia 3 binários: `crossbar` (launcher), `crossbar-cli` e `crossbar-gui`. Isso causava complexidade na distribuição e spawning de processos. **Decision**: Unificar launcher e CLI em um único `crossbar`. O GUI permanece separado como `crossbar-gui`. **Consequences**: @@ -364,6 +371,20 @@ Se a context7 não estiver disponível no sistema, faça o seguinte: - `crossbar --version` funciona diretamente - `crossbar gui` lança a GUI em modo detached +**Superseded**: Ver ADR-011 para a arquitetura atual. + +### ADR-011: Monorepo com Pacotes Separados (2025-12-22) + +**Status**: ✅ Accepted +**Context**: O ADR-001 unificou CLI e launcher, mas `dart compile exe` falha com imports condicionais `if (dart.library.ui)`. O código CLI dependia transitivamente de Flutter via `plugin_manager.dart`. +**Decision**: Criar monorepo com 3 pacotes: `crossbar_core` (APIs Dart puro), `crossbar_cli` (CLI executável), projeto Flutter principal. +**Consequences**: + +- CLI compila isoladamente sem dependências Flutter +- Código compartilhado em `packages/crossbar_core` evita duplicação +- Config values do CLI lidos de JSON plaintext (`~/.crossbar/config/.json`) +- Makefile: `cd packages/crossbar_cli && dart compile exe bin/crossbar.dart` + ### ADR-002: Embedded Lua Interpreter (2024-12-07) **Status**: ✅ Accepted diff --git a/Makefile b/Makefile index e4d5c3a..17f801d 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,34 @@ .PHONY: all coverage linux macos windows android clean test analyze setup-linux setup-macos setup-windows mix icons \ - install uninstall \ + install uninstall precommit \ docker-build docker-shell docker-test docker-linux podman-build podman-shell podman-test podman-linux +# Pre-commit verification sequence (AGENTS.md compliance) +# Runs: analyze → coverage → linux build → android build +precommit: + @echo "══════════════════════════════════════════════════════════════" + @echo " PRECOMMIT VERIFICATION (AGENTS.md)" + @echo "══════════════════════════════════════════════════════════════" + @echo "" + @echo "Step 1/4: Static Analysis" + @echo "──────────────────────────────────────────────────────────────" + $(MAKE) analyze + @echo "" + @echo "Step 2/4: Tests with Coverage (target: 35-60%)" + @echo "──────────────────────────────────────────────────────────────" + $(MAKE) coverage + @echo "" + @echo "Step 3/4: Linux Build" + @echo "──────────────────────────────────────────────────────────────" + $(MAKE) linux + @echo "" + @echo "Step 4/4: Android Build" + @echo "──────────────────────────────────────────────────────────────" + $(MAKE) android + @echo "" + @echo "══════════════════════════════════════════════════════════════" + @echo " ✅ PRECOMMIT PASSED - Safe to commit!" + @echo "══════════════════════════════════════════════════════════════" + # Paths LINUX_BUNDLE = build/linux/x64/release/bundle MACOS_BUNDLE = build/macos/Build/Products/Release/crossbar.app/Contents/MacOS @@ -34,8 +61,8 @@ linux: flutter build linux --release @echo "Setting up unified architecture..." mv $(LINUX_BUNDLE)/crossbar $(LINUX_BUNDLE)/crossbar-gui - @echo "Compiling unified CLI..." - dart compile exe bin/crossbar.dart -o $(LINUX_BUNDLE)/crossbar + @echo "Compiling unified CLI from packages/crossbar_cli..." + cd packages/crossbar_cli && dart compile exe bin/crossbar.dart -o ../../$(LINUX_BUNDLE)/crossbar @echo "Copying desktop integration files..." cp linux/com.verseles.crossbar.desktop $(LINUX_BUNDLE)/ cp assets/icons/icon_linux.png $(LINUX_BUNDLE)/crossbar.png diff --git a/README.md b/README.md index 8d9255e..53df3ea 100644 --- a/README.md +++ b/README.md @@ -410,7 +410,7 @@ Crossbar includes **24 example plugins** in 6 languages: ``` crossbar/ ├── lib/ -│ ├── core/ # Core plugin system +│ ├── core/ # Core plugin system (Flutter) │ │ ├── plugin_manager.dart # Discovery & lifecycle │ │ ├── script_runner.dart # Execution engine │ │ ├── output_parser.dart # BitBar/JSON parser @@ -429,8 +429,17 @@ crossbar/ │ │ └── widget_service.dart # Home screen widget updates │ ├── ui/ # User interface │ └── l10n/ # 10 languages -├── bin/ -│ └── crossbar.dart # CLI entry point (75+ commands) +├── packages/ # Monorepo packages +│ ├── crossbar_core/ # Pure Dart shared APIs & models +│ │ └── lib/src/ +│ │ ├── core/ # Shared core utilities +│ │ ├── models/ # Plugin, Config models +│ │ └── api/ # System, Network, Media APIs +│ └── crossbar_cli/ # Pure Dart CLI package +│ ├── bin/crossbar.dart # CLI entry point +│ └── lib/src/ +│ ├── core/ # CLI-specific plugin manager +│ └── commands/ # 75+ CLI command handlers ├── plugins/ # Example plugins ├── test/ # 116 tests (>90% coverage) └── .github/workflows/ # CI/CD pipelines diff --git a/lib/core/output_parser.dart b/lib/core/output_parser.dart index 6c8552d..70ef756 100644 --- a/lib/core/output_parser.dart +++ b/lib/core/output_parser.dart @@ -75,46 +75,65 @@ class OutputParser { final menu = []; var inMenu = false; + // Stack to track parent items at each depth level + // Index 0 = root menu, Index 1 = first submenu level, etc. + final parentStack = >[menu]; + for (var i = 1; i < lines.length; i++) { final line = lines[i]; + // Check for separator (exactly ---) - but not submenu indicator (--) if (line.trim() == '---') { - inMenu = true; + if (!inMenu) { + // First --- marks the start of the menu section + inMenu = true; + } else { + // Subsequent --- add visual separators to current level + parentStack.last.add(MenuItem.separator()); + } continue; } if (!inMenu) continue; - final trimmedLine = line.trim(); - if (trimmedLine.isEmpty) continue; - - if (trimmedLine.contains('|')) { - final parts = trimmedLine.split('|'); - final itemText = parts[0].trim(); - String? bash; - String? href; - String? itemColor; - - for (var k = 1; k < parts.length; k++) { - final attr = parts[k].trim(); - if (attr.startsWith('bash=')) { - bash = attr.substring(5); - } else if (attr.startsWith('href=')) { - href = attr.substring(5); - } else if (attr.startsWith('color=')) { - itemColor = attr.substring(6); + // Parse indent level and get actual content + final indentInfo = _parseIndentedLine(line); + final depth = indentInfo.depth; + final content = indentInfo.content; + + if (content.isEmpty) continue; + + // Parse menu item from content + final item = _parseMenuItemFromLine(content); + + // Adjust parent stack to correct depth + // depth 0 = root menu, depth 1 = submenu of last root item, etc. + while (parentStack.length > depth + 1) { + parentStack.removeLast(); + } + + // If we need to go deeper, ensure parent has submenu + while (parentStack.length < depth + 1) { + final lastList = parentStack.last; + if (lastList.isNotEmpty) { + final lastItem = lastList.last; + // Initialize submenu if needed + if (lastItem.submenu == null) { + // Create a new item with submenu + final newItem = lastItem.copyWith(submenu: []); + lastList[lastList.length - 1] = newItem; + parentStack.add(newItem.submenu!); + } else { + parentStack.add(lastItem.submenu!); } + } else { + // Cannot add child to empty parent, add to root + break; } - - menu.add(MenuItem( - text: itemText, - bash: bash, - href: href, - color: itemColor, - )); - } else { - menu.add(MenuItem(text: trimmedLine)); } + + // Add item to current level + parentStack.last.add(item); } return PluginOutput( @@ -126,6 +145,52 @@ class OutputParser { ); } + /// Parses BitBar-style indented line (-- prefix indicates submenu level) + /// Returns the depth level and the actual content without the prefix. + static ({int depth, String content}) _parseIndentedLine(String line) { + var depth = 0; + var current = line; + + // Count leading -- pairs (each -- is one level) + while (current.startsWith('--')) { + depth++; + current = current.substring(2); + } + + return (depth: depth, content: current.trim()); + } + + /// Parses a single menu item line (already stripped of -- prefix) + static MenuItem _parseMenuItemFromLine(String line) { + if (line.contains('|')) { + final parts = line.split('|'); + final itemText = parts[0].trim(); + String? bash; + String? href; + String? itemColor; + + for (var k = 1; k < parts.length; k++) { + final attr = parts[k].trim(); + if (attr.startsWith('bash=')) { + bash = attr.substring(5); + } else if (attr.startsWith('href=')) { + href = attr.substring(5); + } else if (attr.startsWith('color=')) { + itemColor = attr.substring(6); + } + } + + return MenuItem( + text: itemText, + bash: bash, + href: href, + color: itemColor, + ); + } else { + return MenuItem(text: line); + } + } + static ({String icon, String? text}) _parseIconAndText(String input) { if (input.isEmpty) { return (icon: '', text: null); diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index baef975..87e4571 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1061,6 +1061,12 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'No plugins available. Install some plugins first.'** String get noPluginsAvailable; + + /// No description provided for @widgetUpdateNote. + /// + /// In en, this message translates to: + /// **'Widgets update every 15 min in background. Open the app for instant updates.'** + String get widgetUpdateNote; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_ar.dart b/lib/l10n/app_localizations_ar.dart index 813b0b6..b3b1506 100644 --- a/lib/l10n/app_localizations_ar.dart +++ b/lib/l10n/app_localizations_ar.dart @@ -497,18 +497,22 @@ class AppLocalizationsAr extends AppLocalizations { 'Crossbar - نظام إضافات عالمي\n\nحقوق الطبع والنشر (C) 2025\n\nهذا البرنامج هو برنامج حر: يمكنك إعادة توزيعه و/أو تعديله بموجب شروط رخصة GNU Affero العمومية التي نشرتها مؤسسة البرمجيات الحرة، إما الإصدار 3 من الرخصة، أو (حسب اختيارك) أي إصدار لاحق.\n\nيتم توزيع هذا البرنامج على أمل أن يكون مفيدًا، ولكن بدون أي ضمان؛ حتى بدون الضمان الضمني للتسويق أو الملاءمة لغرض معين. راجع رخصة GNU Affero العمومية لمزيد من التفاصيل.\n\nيجب أن تكون قد تلقيت نسخة من رخصة GNU Affero العمومية مع هذا البرنامج. إن لم تفعل، انظر .'; @override - String get widgetConfiguration => 'Widget Configuration'; + String get widgetConfiguration => 'إعداد الأداة'; @override - String get selectPluginsForWidget => 'Select plugins to display'; + String get selectPluginsForWidget => 'اختر الإضافات للعرض'; @override - String get selectOnePlugin => 'Select one plugin'; + String get selectOnePlugin => 'اختر إضافة واحدة'; @override - String get selectPlugins => 'Select plugins'; + String get selectPlugins => 'اختر الإضافات'; @override String get noPluginsAvailable => - 'No plugins available. Install some plugins first.'; + 'لا توجد إضافات متاحة. قم بتثبيت بعض الإضافات أولاً.'; + + @override + String get widgetUpdateNote => + 'يتم تحديث الأدوات كل 15 دقيقة في الخلفية. افتح التطبيق للتحديثات الفورية.'; } diff --git a/lib/l10n/app_localizations_bn.dart b/lib/l10n/app_localizations_bn.dart index 41f7e23..acb8dc3 100644 --- a/lib/l10n/app_localizations_bn.dart +++ b/lib/l10n/app_localizations_bn.dart @@ -499,18 +499,22 @@ class AppLocalizationsBn extends AppLocalizations { 'Crossbar - সার্বজনীন প্লাগইন সিস্টেম\n\nকপিরাইট (C) 2025\n\nএই প্রোগ্রামটি বিনামূল্যে সফ্টওয়্যার: আপনি এটি Free Software Foundation দ্বারা প্রকাশিত GNU Affero General Public License-এর শর্তাবলীর অধীনে পুনরায় বিতরণ এবং/অথবা সংশোধন করতে পারেন, হয় লাইসেন্সের সংস্করণ 3, অথবা (আপনার পছন্দে) পরবর্তী কোন সংস্করণ।\n\nএই প্রোগ্রামটি এই আশায় বিতরণ করা হয় যে এটি উপকারী হবে, কিন্তু কোন ওয়ারেন্টি ছাড়া; এমনকি ব্যবসায়িকতা বা বিশেষ উদ্দেশ্যে মানানসই হওয়ার অন্তর্নিহিত ওয়ারেন্টিও ছাড়া। আরও বিস্তারিত জানতে GNU Affero General Public License দেখুন।\n\nআপনার এই প্রোগ্রামের সাথে GNU Affero General Public License-এর একটি কপি পাওয়া উচিত ছিল। যদি না পান, দেখুন।'; @override - String get widgetConfiguration => 'Widget Configuration'; + String get widgetConfiguration => 'উইজেট কনফিগারেশন'; @override - String get selectPluginsForWidget => 'Select plugins to display'; + String get selectPluginsForWidget => 'প্রদর্শনের জন্য প্লাগইন নির্বাচন করুন'; @override - String get selectOnePlugin => 'Select one plugin'; + String get selectOnePlugin => 'একটি প্লাগইন নির্বাচন করুন'; @override - String get selectPlugins => 'Select plugins'; + String get selectPlugins => 'প্লাগইন নির্বাচন করুন'; @override String get noPluginsAvailable => - 'No plugins available. Install some plugins first.'; + 'কোন প্লাগইন উপলব্ধ নেই। প্রথমে কিছু প্লাগইন ইনস্টল করুন।'; + + @override + String get widgetUpdateNote => + 'উইজেট ব্যাকগ্রাউন্ডে প্রতি ১৫ মিনিটে আপডেট হয়। তাত্ক্ষণিক আপডেটের জন্য অ্যাপ খুলুন।'; } diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 1da78ec..f491561 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -505,18 +505,22 @@ class AppLocalizationsDe extends AppLocalizations { 'Crossbar - Universelles Plugin-System\n\nCopyright (C) 2025\n\nDieses Programm ist freie Software: Sie können es unter den Bedingungen der GNU Affero General Public License wie von der Free Software Foundation veröffentlicht, entweder Version 3 der Lizenz oder (nach Ihrer Wahl) jeder späteren Version, weitergeben und/oder modifizieren.\n\nDieses Programm wird in der Hoffnung verteilt, dass es nützlich sein wird, aber OHNE JEDE GARANTIE; sogar ohne die implizite Garantie der MARKTFÄHIGKEIT oder EIGNUNG FÜR EINEN BESTIMMTEN ZWECK. Siehe die GNU Affero General Public License für weitere Details.\n\nSie sollten eine Kopie der GNU Affero General Public License zusammen mit diesem Programm erhalten haben. Falls nicht, siehe .'; @override - String get widgetConfiguration => 'Widget Configuration'; + String get widgetConfiguration => 'Widget-Konfiguration'; @override - String get selectPluginsForWidget => 'Select plugins to display'; + String get selectPluginsForWidget => 'Plugins zum Anzeigen auswählen'; @override - String get selectOnePlugin => 'Select one plugin'; + String get selectOnePlugin => 'Ein Plugin auswählen'; @override - String get selectPlugins => 'Select plugins'; + String get selectPlugins => 'Plugins auswählen'; @override String get noPluginsAvailable => - 'No plugins available. Install some plugins first.'; + 'Keine Plugins verfügbar. Installieren Sie zuerst einige Plugins.'; + + @override + String get widgetUpdateNote => + 'Widgets werden im Hintergrund alle 15 Min. aktualisiert. Öffnen Sie die App für sofortige Updates.'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index aa6901d..0fc6ab1 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -513,4 +513,8 @@ class AppLocalizationsEn extends AppLocalizations { @override String get noPluginsAvailable => 'No plugins available. Install some plugins first.'; + + @override + String get widgetUpdateNote => + 'Widgets update every 15 min in background. Open the app for instant updates.'; } diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index e37df7d..ed57a4a 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -500,18 +500,22 @@ class AppLocalizationsEs extends AppLocalizations { 'Crossbar - Sistema Universal de Plugins\n\nCopyright (C) 2025\n\nEste programa es software libre: puede redistribuirlo y/o modificarlo bajo los términos de la Licencia Pública General GNU Affero publicada por la Free Software Foundation, ya sea la versión 3 de la Licencia, o (a su elección) cualquier versión posterior.\n\nEste programa se distribuye con la esperanza de que sea útil, pero SIN NINGUNA GARANTÍA; sin siquiera la garantía implícita de COMERCIABILIDAD o APTITUD PARA UN PROPÓSITO PARTICULAR. Consulte la Licencia Pública General GNU Affero para más detalles.\n\nDebería haber recibido una copia de la Licencia Pública General GNU Affero junto con este programa. Si no, vea .'; @override - String get widgetConfiguration => 'Widget Configuration'; + String get widgetConfiguration => 'Configuración del Widget'; @override - String get selectPluginsForWidget => 'Select plugins to display'; + String get selectPluginsForWidget => 'Seleccionar plugins para mostrar'; @override - String get selectOnePlugin => 'Select one plugin'; + String get selectOnePlugin => 'Seleccionar un plugin'; @override - String get selectPlugins => 'Select plugins'; + String get selectPlugins => 'Seleccionar plugins'; @override String get noPluginsAvailable => - 'No plugins available. Install some plugins first.'; + 'No hay plugins disponibles. Instale algunos plugins primero.'; + + @override + String get widgetUpdateNote => + 'Los widgets se actualizan cada 15 min en segundo plano. Abra la app para actualizaciones instantáneas.'; } diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 56a3028..30ff076 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -505,18 +505,22 @@ class AppLocalizationsFr extends AppLocalizations { 'Crossbar - Système Universel de Plugins\n\nCopyright (C) 2025\n\nCe programme est un logiciel libre : vous pouvez le redistribuer et/ou le modifier selon les termes de la Licence Publique Générale GNU Affero telle que publiée par la Free Software Foundation, soit la version 3 de la Licence, ou (à votre choix) toute version ultérieure.\n\nCe programme est distribué dans l\'espoir qu\'il sera utile, mais SANS AUCUNE GARANTIE ; sans même la garantie implicite de COMMERCIALISATION ou d\'ADÉQUATION À UN USAGE PARTICULIER. Voir la Licence Publique Générale GNU Affero pour plus de détails.\n\nVous devriez avoir reçu une copie de la Licence Publique Générale GNU Affero avec ce programme. Sinon, voir .'; @override - String get widgetConfiguration => 'Widget Configuration'; + String get widgetConfiguration => 'Configuration du Widget'; @override - String get selectPluginsForWidget => 'Select plugins to display'; + String get selectPluginsForWidget => 'Sélectionner les plugins à afficher'; @override - String get selectOnePlugin => 'Select one plugin'; + String get selectOnePlugin => 'Sélectionner un plugin'; @override - String get selectPlugins => 'Select plugins'; + String get selectPlugins => 'Sélectionner les plugins'; @override String get noPluginsAvailable => - 'No plugins available. Install some plugins first.'; + 'Aucun plugin disponible. Installez d\'abord quelques plugins.'; + + @override + String get widgetUpdateNote => + 'Les widgets se mettent à jour toutes les 15 min en arrière-plan. Ouvrez l\'app pour des mises à jour instantanées.'; } diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index 138ed6d..f79d901 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -500,18 +500,22 @@ class AppLocalizationsHi extends AppLocalizations { 'Crossbar - यूनिवर्सल प्लगइन सिस्टम\n\nकॉपीराइट (C) 2025\n\nयह कार्यक्रम मुक्त सॉफ्टवेयर है: आप इसे Free Software Foundation द्वारा प्रकाशित GNU Affero सार्वजनिक लाइसेंस की शर्तों के तहत पुनर्वितरित और/या संशोधित कर सकते हैं, या तो लाइसेंस का संस्करण 3, या (आपकी पसंद के अनुसार) कोई बाद का संस्करण।\n\nयह कार्यक्रम इस उम्मीद में वितरित किया जाता है कि यह उपयोगी होगा, लेकिन किसी भी वारंटी के बिना; यहां तक कि व्यापारिकता या किसी विशेष उद्देश्य के लिए उपयुक्तता की निहित वारंटी के बिना भी। अधिक विवरण के लिए GNU Affero सार्वजनिक लाइसेंस देखें।\n\nआपको इस कार्यक्रम के साथ GNU Affero सार्वजनिक लाइसेंस की एक प्रति प्राप्त होनी चाहिए थी। यदि नहीं, तो देखें।'; @override - String get widgetConfiguration => 'Widget Configuration'; + String get widgetConfiguration => 'विजेट कॉन्फ़िगरेशन'; @override - String get selectPluginsForWidget => 'Select plugins to display'; + String get selectPluginsForWidget => 'प्रदर्शित करने के लिए प्लगइन चुनें'; @override - String get selectOnePlugin => 'Select one plugin'; + String get selectOnePlugin => 'एक प्लगइन चुनें'; @override - String get selectPlugins => 'Select plugins'; + String get selectPlugins => 'प्लगइन चुनें'; @override String get noPluginsAvailable => - 'No plugins available. Install some plugins first.'; + 'कोई प्लगइन उपलब्ध नहीं है। पहले कुछ प्लगइन इंस्टॉल करें।'; + + @override + String get widgetUpdateNote => + 'विजेट बैकग्राउंड में हर 15 मिनट में अपडेट होते हैं। तुरंत अपडेट के लिए ऐप खोलें।'; } diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index ccf8e76..15032bb 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -502,18 +502,22 @@ class AppLocalizationsIt extends AppLocalizations { 'Crossbar - Sistema Universale di Plugin\n\nCopyright (C) 2025\n\nQuesto programma è software libero: puoi ridistribuirlo e/o modificarlo secondo i termini della GNU Affero General Public License come pubblicata dalla Free Software Foundation, sia la versione 3 della Licenza, o (a tua scelta) qualsiasi versione successiva.\n\nQuesto programma è distribuito nella speranza che sia utile, ma SENZA ALCUNA GARANZIA; senza nemmeno la garanzia implicita di COMMERCIABILITÀ o IDONEITÀ PER UNO SCOPO PARTICOLARE. Vedi la GNU Affero General Public License per maggiori dettagli.\n\nDovresti aver ricevuto una copia della GNU Affero General Public License insieme a questo programma. In caso contrario, vedi .'; @override - String get widgetConfiguration => 'Widget Configuration'; + String get widgetConfiguration => 'Configurazione Widget'; @override - String get selectPluginsForWidget => 'Select plugins to display'; + String get selectPluginsForWidget => 'Seleziona plugin da visualizzare'; @override - String get selectOnePlugin => 'Select one plugin'; + String get selectOnePlugin => 'Seleziona un plugin'; @override - String get selectPlugins => 'Select plugins'; + String get selectPlugins => 'Seleziona plugin'; @override String get noPluginsAvailable => - 'No plugins available. Install some plugins first.'; + 'Nessun plugin disponibile. Installa prima alcuni plugin.'; + + @override + String get widgetUpdateNote => + 'I widget si aggiornano ogni 15 min in background. Apri l\'app per aggiornamenti istantanei.'; } diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index db53223..5d3d912 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -495,18 +495,21 @@ class AppLocalizationsJa extends AppLocalizations { 'Crossbar - ユニバーサルプラグインシステム\n\nCopyright (C) 2025\n\n本プログラムはフリーソフトウェアです:Free Software Foundationが発行したGNU Affero一般公衆利用許諾書の条件の下で、ライセンスのバージョン3、または(お選びにより)それ以降のバージョンに従って再配布および/または変更することができます。\n\n本プログラムは有用であることを期待して配布されていますが、商品性や特定目的への適合性の暗黙の保証さえも含め、いかなる保証もありません。詳細についてはGNU Affero一般公衆利用許諾書をご覧ください。\n\n本プログラムと共にGNU Affero一般公衆利用許諾書のコピーを受け取っているはずです。受け取っていない場合は、をご覧ください。'; @override - String get widgetConfiguration => 'Widget Configuration'; + String get widgetConfiguration => 'ウィジェット設定'; @override - String get selectPluginsForWidget => 'Select plugins to display'; + String get selectPluginsForWidget => '表示するプラグインを選択'; @override - String get selectOnePlugin => 'Select one plugin'; + String get selectOnePlugin => 'プラグインを1つ選択'; @override - String get selectPlugins => 'Select plugins'; + String get selectPlugins => 'プラグインを選択'; @override - String get noPluginsAvailable => - 'No plugins available. Install some plugins first.'; + String get noPluginsAvailable => '利用可能なプラグインがありません。先にプラグインをインストールしてください。'; + + @override + String get widgetUpdateNote => + 'ウィジェットはバックグラウンドで15分ごとに更新されます。即時更新するにはアプリを開いてください。'; } diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 47958c7..d9c36cf 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -494,18 +494,21 @@ class AppLocalizationsKo extends AppLocalizations { 'Crossbar - 범용 플러그인 시스템\n\nCopyright (C) 2025\n\n이 프로그램은 자유 소프트웨어입니다: Free Software Foundation에서 발행한 GNU Affero 일반 공중 라이선스의 조건에 따라 라이선스 버전 3 또는 (선택에 따라) 이후 버전에 따라 재배포 및/또는 수정할 수 있습니다.\n\n이 프로그램은 유용할 것이라는 희망으로 배포되지만, 상품성 또는 특정 목적에 대한 적합성에 대한 묵시적 보증조차 없이 어떠한 보증도 없이 배포됩니다. 자세한 내용은 GNU Affero 일반 공중 라이선스를 참조하세요.\n\n이 프로그램과 함께 GNU Affero 일반 공중 라이선스 사본을 받았어야 합니다. 받지 못한 경우 를 참조하세요.'; @override - String get widgetConfiguration => 'Widget Configuration'; + String get widgetConfiguration => '위젯 설정'; @override - String get selectPluginsForWidget => 'Select plugins to display'; + String get selectPluginsForWidget => '표시할 플러그인 선택'; @override - String get selectOnePlugin => 'Select one plugin'; + String get selectOnePlugin => '플러그인 하나 선택'; @override - String get selectPlugins => 'Select plugins'; + String get selectPlugins => '플러그인 선택'; @override - String get noPluginsAvailable => - 'No plugins available. Install some plugins first.'; + String get noPluginsAvailable => '사용 가능한 플러그인이 없습니다. 먼저 플러그인을 설치하세요.'; + + @override + String get widgetUpdateNote => + '위젯은 백그라운드에서 15분마다 업데이트됩니다. 즉시 업데이트하려면 앱을 여세요.'; } diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 37f0712..c84e807 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -500,18 +500,22 @@ class AppLocalizationsPt extends AppLocalizations { 'Crossbar - Sistema Universal de Plugins\n\nCopyright (C) 2025\n\nEste programa é software livre: você pode redistribuí-lo e/ou modificá-lo sob os termos da Licença Pública Geral GNU Affero conforme publicada pela Free Software Foundation, seja a versão 3 da Licença, ou (a seu critério) qualquer versão posterior.\n\nEste programa é distribuído na esperança de que seja útil, mas SEM QUALQUER GARANTIA; sem mesmo a garantia implícita de COMERCIALIZAÇÃO ou ADEQUAÇÃO A UM PROPÓSITO PARTICULAR. Consulte a Licença Pública Geral GNU Affero para mais detalhes.\n\nVocê deve ter recebido uma cópia da Licença Pública Geral GNU Affero junto com este programa. Se não, veja .'; @override - String get widgetConfiguration => 'Widget Configuration'; + String get widgetConfiguration => 'Configuração do Widget'; @override - String get selectPluginsForWidget => 'Select plugins to display'; + String get selectPluginsForWidget => 'Selecione plugins para exibir'; @override - String get selectOnePlugin => 'Select one plugin'; + String get selectOnePlugin => 'Selecione um plugin'; @override - String get selectPlugins => 'Select plugins'; + String get selectPlugins => 'Selecione plugins'; @override String get noPluginsAvailable => - 'No plugins available. Install some plugins first.'; + 'Nenhum plugin disponível. Instale alguns plugins primeiro.'; + + @override + String get widgetUpdateNote => + 'Widgets atualizam a cada 15 min em segundo plano. Abra o app para atualizações instantâneas.'; } diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 73bcd27..33667ec 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -499,18 +499,22 @@ class AppLocalizationsRu extends AppLocalizations { 'Crossbar - Универсальная Система Плагинов\n\nCopyright (C) 2025\n\nЭта программа является свободным программным обеспечением: вы можете распространять её и/или модифицировать в соответствии с условиями GNU Affero General Public License, опубликованной Free Software Foundation, версии 3 Лицензии или (по вашему выбору) любой более поздней версии.\n\nЭта программа распространяется в надежде, что она будет полезной, но БЕЗ КАКИХ-ЛИБО ГАРАНТИЙ; даже без подразумеваемой гарантии ТОВАРНОЙ ПРИГОДНОСТИ или ПРИГОДНОСТИ ДЛЯ ОПРЕДЕЛЁННОЙ ЦЕЛИ. См. GNU Affero General Public License для получения дополнительной информации.\n\nВы должны были получить копию GNU Affero General Public License вместе с этой программой. Если нет, см. .'; @override - String get widgetConfiguration => 'Widget Configuration'; + String get widgetConfiguration => 'Настройка Виджета'; @override - String get selectPluginsForWidget => 'Select plugins to display'; + String get selectPluginsForWidget => 'Выберите плагины для отображения'; @override - String get selectOnePlugin => 'Select one plugin'; + String get selectOnePlugin => 'Выберите один плагин'; @override - String get selectPlugins => 'Select plugins'; + String get selectPlugins => 'Выберите плагины'; @override String get noPluginsAvailable => - 'No plugins available. Install some plugins first.'; + 'Плагины недоступны. Сначала установите плагины.'; + + @override + String get widgetUpdateNote => + 'Виджеты обновляются каждые 15 мин в фоне. Откройте приложение для мгновенных обновлений.'; } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 7bef2e0..cb3e584 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -494,18 +494,20 @@ class AppLocalizationsZh extends AppLocalizations { 'Crossbar - 通用插件系统\n\nCopyright (C) 2025\n\n本程序为自由软件:您可以根据自由软件基金会发布的 GNU Affero 通用公共许可证的条款重新分发和/或修改它,可以是许可证的第 3 版,也可以是(由您选择)任何更高版本。\n\n本程序的分发是希望它有用,但没有任何担保;甚至没有适销性或特定用途适用性的暗示担保。有关更多详细信息,请参阅 GNU Affero 通用公共许可证。\n\n您应该已经收到一份 GNU Affero 通用公共许可证的副本以及本程序。如果没有,请参阅 。'; @override - String get widgetConfiguration => 'Widget Configuration'; + String get widgetConfiguration => '小部件配置'; @override - String get selectPluginsForWidget => 'Select plugins to display'; + String get selectPluginsForWidget => '选择要显示的插件'; @override - String get selectOnePlugin => 'Select one plugin'; + String get selectOnePlugin => '选择一个插件'; @override - String get selectPlugins => 'Select plugins'; + String get selectPlugins => '选择插件'; @override - String get noPluginsAvailable => - 'No plugins available. Install some plugins first.'; + String get noPluginsAvailable => '没有可用的插件。请先安装一些插件。'; + + @override + String get widgetUpdateNote => '小部件在后台每 15 分钟更新一次。打开应用获取即时更新。'; } diff --git a/lib/services/tray_service.dart b/lib/services/tray_service.dart index b1269ca..c1f00ff 100644 --- a/lib/services/tray_service.dart +++ b/lib/services/tray_service.dart @@ -7,7 +7,7 @@ import 'package:path/path.dart' as p; import 'package:tray_manager/tray_manager.dart'; import '../core/plugin_manager.dart'; -import '../models/plugin_output.dart' hide MenuItem; +import '../models/plugin_output.dart' as plugin_model; import 'logger_service.dart'; import 'scheduler_service.dart'; import 'window_service.dart'; @@ -25,7 +25,7 @@ class TrayService with TrayListener { static final TrayService _instance = TrayService._internal(); final PluginManager _pluginManager = PluginManager(); - final Map _pluginOutputs = {}; + final Map _pluginOutputs = {}; bool _initialized = false; String? _iconPath; @@ -143,14 +143,25 @@ class TrayService with TrayListener { Future _updateMenu() async { final menuItems = []; - // Plugin outputs - show enabled plugins + // Plugin outputs - show enabled plugins with their menus for (final plugin in _pluginManager.plugins.where((p) => p.enabled)) { final output = _pluginOutputs[plugin.id]; if (output != null && output.text != null && output.text!.isNotEmpty) { - menuItems.add(MenuItem( - label: '${output.icon} ${output.text}', - disabled: true, - )); + // Check if plugin has menu items + if (output.menu.isNotEmpty) { + // Create submenu with plugin output items + final submenuItems = _convertMenuItems(output.menu); + menuItems.add(MenuItem.submenu( + label: '${output.icon} ${output.text}', + submenu: Menu(items: submenuItems), + )); + } else { + // No submenu, just show the output + menuItems.add(MenuItem( + label: '${output.icon} ${output.text}', + disabled: true, + )); + } } } @@ -182,7 +193,30 @@ class TrayService with TrayListener { } } - void updatePluginOutput(String pluginId, PluginOutput output) { + /// Converts plugin model MenuItems to tray_manager MenuItems recursively + List _convertMenuItems(List items) { + final result = []; + for (final item in items) { + if (item.separator) { + result.add(MenuItem.separator()); + } else if (item.submenu != null && item.submenu!.isNotEmpty) { + // Item has submenu - create a submenu + result.add(MenuItem.submenu( + label: item.text ?? '', + submenu: Menu(items: _convertMenuItems(item.submenu!)), + )); + } else { + // Regular menu item + result.add(MenuItem( + key: item.href ?? item.bash ?? item.text, + label: item.text ?? '', + )); + } + } + return result; + } + + void updatePluginOutput(String pluginId, plugin_model.PluginOutput output) { _pluginOutputs[pluginId] = output; _updateMenu(); _updateTitle(pluginId, output); @@ -214,7 +248,7 @@ class TrayService with TrayListener { } } - Future _updateTitle(String pluginId, PluginOutput output) async { + Future _updateTitle(String pluginId, plugin_model.PluginOutput output) async { // Find the first enabled plugin to use as the main tray title final firstEnabled = _pluginManager.plugins.where((p) => p.enabled).firstOrNull; diff --git a/packages/crossbar_cli/bin/crossbar.dart b/packages/crossbar_cli/bin/crossbar.dart new file mode 100644 index 0000000..8e426f0 --- /dev/null +++ b/packages/crossbar_cli/bin/crossbar.dart @@ -0,0 +1,100 @@ +import 'dart:io'; + +import 'package:crossbar_cli/src/cli_handler.dart'; + +/// Crossbar CLI Entry Point +/// +/// This is the main entry point for Crossbar. It handles: +/// - No arguments → launches GUI in tray mode (crossbar-gui --minimized) +/// - 'gui' → launches GUI visible (crossbar-gui) +/// - Other arguments → executes CLI commands directly +/// +/// Architecture: +/// - crossbar (this file) → CLI + launcher (Dart standalone) +/// - crossbar-gui → Flutter GUI application (separate binary) +void main(List args) async { + // GUI launch mode: no args or explicit 'gui' command + if (args.isEmpty || args.first == 'gui') { + await _launchGui(args); + return; + } + + // CLI mode: handle all other commands + final exitCode = await handleCliCommand(args); + exit(exitCode); +} + +/// Launches the GUI application as a separate process. +/// - No args → minimized (tray mode) +/// - 'gui' → visible window +Future _launchGui(List args) async { + final executablePath = Platform.resolvedExecutable; + final executableDir = File(executablePath).parent.path; + final isWindows = Platform.isWindows; + + final guiBinary = isWindows ? 'crossbar-gui.exe' : 'crossbar-gui'; + final guiPath = '$executableDir/$guiBinary'; + + // Check if GUI binary exists + if (!File(guiPath).existsSync()) { + // Try to find it in common locations + final alternativePaths = [ + guiPath, + '$executableDir/../share/crossbar/$guiBinary', // Installed location + '/usr/local/bin/$guiBinary', + '/usr/bin/$guiBinary', + ]; + + String? foundPath; + for (final path in alternativePaths) { + if (File(path).existsSync()) { + foundPath = path; + break; + } + } + + if (foundPath == null) { + stderr.writeln('Error: GUI binary not found.'); + stderr.writeln('Searched in:'); + for (final path in alternativePaths) { + stderr.writeln(' - $path'); + } + stderr.writeln('\nMake sure crossbar-gui is installed alongside crossbar.'); + exit(1); + } + + // Use the found path + await _startGui(foundPath, args); + return; + } + + await _startGui(guiPath, args); +} + +Future _startGui(String guiPath, List args) async { + List guiArgs; + + if (args.isEmpty) { + // Default: start minimized in tray + guiArgs = ['--minimized']; + } else if (args.first == 'gui') { + // Explicit GUI mode: pass remaining args + guiArgs = args.length > 1 ? args.sublist(1) : []; + } else { + guiArgs = args; + } + + try { + // Start GUI as detached process (don't block terminal) + await Process.start( + guiPath, + guiArgs, + mode: ProcessStartMode.detached, + ); + // Exit immediately - GUI runs independently + exit(0); + } catch (e) { + stderr.writeln('Failed to launch GUI: $e'); + exit(1); + } +} diff --git a/packages/crossbar_cli/lib/crossbar_cli.dart b/packages/crossbar_cli/lib/crossbar_cli.dart new file mode 100644 index 0000000..afff632 --- /dev/null +++ b/packages/crossbar_cli/lib/crossbar_cli.dart @@ -0,0 +1,5 @@ +/// Crossbar CLI - Command line interface +library crossbar_cli; + +export 'src/cli_handler.dart'; +export 'src/core/plugin_manager_cli.dart'; diff --git a/packages/crossbar_cli/lib/src/cli_handler.dart b/packages/crossbar_cli/lib/src/cli_handler.dart new file mode 100644 index 0000000..3c6aa40 --- /dev/null +++ b/packages/crossbar_cli/lib/src/cli_handler.dart @@ -0,0 +1,235 @@ +// ignore_for_file: avoid_print +import 'dart:io'; + +import 'commands/audio_command.dart'; +import 'commands/base_command.dart'; +import 'commands/bluetooth_command.dart'; +import 'commands/clipboard_command.dart'; +import 'commands/dnd_command.dart'; +import 'commands/filesystem_commands.dart'; +import 'commands/media_command.dart'; +import 'commands/network_command.dart'; +import 'commands/plugin_commands.dart'; +import 'commands/power_command.dart'; +import 'commands/screen_command.dart'; +import 'commands/system_info_commands.dart'; +import 'commands/utility_commands.dart'; +import 'commands/vpn_command.dart'; +import 'commands/wallpaper_command.dart'; +import 'commands/web_command.dart'; +import 'commands/wifi_command.dart'; + +const String version = '1.4.1'; + +final Map _commands = {}; + +void _registerCommands() { + if (_commands.isNotEmpty) return; + + // Audio & Media + _register(AudioCommand()); + _register(MediaCommand()); + + // System & Hardware + _register(ScreenCommand()); + _register(PowerCommand()); + _register(WallpaperCommand()); + _register(DndCommand()); + + // Network + _register(NetworkCommand()); + _register(WebCommand()); + _register(WifiCommand()); + _register(BluetoothCommand()); + _register(VpnCommand()); + + // Utilities + _register(ClipboardCommand()); + _register(FileCommand()); + _register(DirCommand()); + _register(ExecCommand()); + _register(NotifyCommand()); + _register(OpenCommand()); + + // Misc + _register(TimeCommand()); + _register(DateCommand()); + _register(HashCommand()); + _register(UuidCommand()); + _register(RandomCommand()); + _register(Base64Command()); + + // System Info + _register(CpuCommand()); + _register(MemoryCommand()); + _register(BatteryCommand()); + _register(UptimeCommand()); + _register(DiskCommand()); + _register(OsCommand()); + _register(KernelCommand()); + _register(ArchCommand()); + _register(HostnameCommand()); + _register(UsernameCommand()); + _register(HomeCommand()); + _register(TempCommand()); + _register(EnvCommand()); + _register(LocaleCommand()); + _register(TimezoneCommand()); + + // Plugin + _register(InitCommand()); + _register(InstallCommand()); + _register(RunPluginCommand()); +} + +void _register(CliCommand command) { + _commands[command.name] = command; +} + +/// Handles CLI command execution +/// Returns exit code (0 for success, non-zero for error) +Future handleCliCommand(List args) async { + _registerCommands(); + + if (args.isEmpty) { + _printUsage(); + return 1; + } + + final commandName = args[0]; + + if (commandName == '--version') { + print('Crossbar $version'); + return 0; + } + + if (commandName == '-v') { + print(version); + return 0; + } + + if (commandName == '--help' || commandName == '-h' || commandName == 'help') { + _printUsage(); + return 0; + } + + final command = _commands[commandName]; + if (command != null) { + try { + return await command.execute(args.sublist(1)); + } catch (e) { + stderr.writeln('Error executing $commandName: $e'); + return 1; + } + } else { + stderr.writeln('Error: Unknown command: $commandName'); + _printUsage(); + return 1; + } +} + +void _printUsage() { + print(''' +Crossbar - Universal Plugin System +Version: $version + +Usage: crossbar [command] [subcommand] [value] + +System Info (Simple Getters): + cpu CPU usage percentage + memory RAM usage (used/total) + battery Battery level and status + uptime System uptime + os Operating system + hostname System hostname + username Current username + kernel Kernel version + arch System architecture + +Audio Controls (audio): + audio volume [0-100] Get volume or Set volume + audio mute Toggle mute + audio output [device] Get output or Set device + audio output --list List output devices + +Media Controls (media): + media play Resume playback + media pause Pause playback + media toggle Toggle play/pause + media stop Stop playback + media next Next track + media prev Previous track + media seek Seek (e.g., +30s, -10s) + media playing Current track info + +Screen & Display (screen): + screen brightness [0-100] Get or Set brightness + screen size Get screen resolution + +Wallpaper: + wallpaper [path] Get current path or Set wallpaper + +Do Not Disturb (dnd): + dnd [on|off|toggle] Get status or Set status + +Power Management (power): + power sleep Suspend system + power restart --confirm Restart system + power shutdown --confirm Shutdown system + +Network & Connectivity: + net status Connection status + net ip [--public] Local or Public IP + net ping Ping latency + + wifi [on|off|toggle] Get WiFi status or Set on/off + wifi ssid Get connected SSID + + bluetooth [on|off|toggle] Get status or Set on/off + bluetooth devices List paired devices + + vpn status VPN connection status + +HTTP Client (web): + web Simple GET request + web --json Output as JSON (with headers) + web --method POST Specify HTTP method + web --headers '{}' Custom headers (JSON) + web --body '{}' Request body + web --body-file f Body from file + web --timeout 10 Timeout in seconds + web --insecure Skip SSL verification + +Files & Directories: + file exists Check if file/dir exists + file read Read file contents + file size Get file size + dir list [path] List directory contents + +Utilities: + clipboard [text] Get content or Set text + exec Execute shell command + notify <msg> Send notification + open url <url> Open URL + open file <path> Open file + open app <name> Launch application + + time Current time + date Current date + hash <text> Hash text + uuid Generate UUID + random [min] [max] Random number + base64 encode <text> Base64 encode + base64 decode <text> Base64 decode + +Plugin Management: + init --lang <lang> ... Create a new plugin + install <url> Install plugin from GitHub + +Options: + --json Output in JSON format + --xml Output in XML format + --version, -v Show version + --help, -h Show this help +'''); +} diff --git a/packages/crossbar_cli/lib/src/cli_utils.dart b/packages/crossbar_cli/lib/src/cli_utils.dart new file mode 100644 index 0000000..32dff7f --- /dev/null +++ b/packages/crossbar_cli/lib/src/cli_utils.dart @@ -0,0 +1,44 @@ +/// Convert a Map to XML format +String mapToXml(Map<String, dynamic> data, {String root = 'crossbar'}) { + final buffer = StringBuffer(); + buffer.writeln('<?xml version="1.0" encoding="UTF-8"?>'); + buffer.writeln('<$root>'); + _mapToXml(data, buffer, indent: ' '); + buffer.writeln('</$root>'); + return buffer.toString(); +} + +void _mapToXml(Map<String, dynamic> data, StringBuffer buffer, {String indent = ''}) { + for (final entry in data.entries) { + final key = entry.key.replaceAll(RegExp('[^a-zA-Z0-9_]'), '_'); + final value = entry.value; + if (value is Map<String, dynamic>) { + buffer.writeln('$indent<$key>'); + _mapToXml(value, buffer, indent: '$indent '); + buffer.writeln('$indent</$key>'); + } else if (value is List) { + buffer.writeln('$indent<$key>'); + for (final item in value) { + if (item is Map<String, dynamic>) { + buffer.writeln('$indent <item>'); + _mapToXml(item, buffer, indent: '$indent '); + buffer.writeln('$indent </item>'); + } else { + buffer.writeln('$indent <item>${escapeXml(item.toString())}</item>'); + } + } + buffer.writeln('$indent</$key>'); + } else { + buffer.writeln('$indent<$key>${escapeXml(value.toString())}</$key>'); + } + } +} + +String escapeXml(String text) { + return text + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} diff --git a/packages/crossbar_cli/lib/src/commands/audio_command.dart b/packages/crossbar_cli/lib/src/commands/audio_command.dart new file mode 100644 index 0000000..f8a5a0b --- /dev/null +++ b/packages/crossbar_cli/lib/src/commands/audio_command.dart @@ -0,0 +1,171 @@ +// ignore_for_file: avoid_print +import 'dart:io'; + +import 'package:crossbar_core/crossbar_core.dart'; +import 'base_command.dart'; + +class AudioCommand extends CliCommand { + @override + String get name => 'audio'; + + @override + String get description => 'Audio volume, mute, and output control'; + + @override + Future<int> execute(List<String> args) async { + if (args.isEmpty) { + stderr.writeln('Error: audio command requires a subcommand (volume, mute, output)'); + return 1; + } + + final subcommand = args[0]; + final commandArgs = args.sublist(1); + final jsonOutput = args.contains('--json'); + final xmlOutput = args.contains('--xml'); + + const api = MediaApi(); + + switch (subcommand) { + case 'volume': + return _handleVolume(api, commandArgs, jsonOutput, xmlOutput); + case 'mute': + return _handleMute(api, commandArgs, jsonOutput, xmlOutput); + case 'output': + return _handleOutput(api, commandArgs, jsonOutput, xmlOutput); + default: + stderr.writeln('Error: Unknown audio subcommand: $subcommand'); + return 1; + } + } + + Future<int> _handleVolume(MediaApi api, List<String> args, bool json, bool xml) async { + final values = args.where((a) => !a.startsWith('--')).toList(); + + if (values.isEmpty) { + // Get + final result = await api.getVolume(); + printFormatted( + {'volume': result}, + json: json, + xml: xml, + plain: (_) => '$result%', + ); + } else { + // Set + final level = int.tryParse(values[0]); + if (level == null) { + stderr.writeln('Error: volume requires a number (0-100)'); + return 1; + } + final result = await api.setVolume(level); + if (result) { + printFormatted( + {'success': true, 'volume': level}, + json: json, + xml: xml, + plain: (_) => 'Volume set to $level%', + ); + } else { + stderr.writeln('Failed to set volume'); + return 1; + } + } + return 0; + } + + Future<int> _handleMute(MediaApi api, List<String> args, bool json, bool xml) async { + final values = args.where((a) => !a.startsWith('--')).toList(); + + if (values.isNotEmpty) { + final val = values[0].toLowerCase(); + if (val == 'status') { + final isMuted = await api.isMuted(); + printFormatted( + {'muted': isMuted}, + json: json, xml: xml, + plain: (_) => isMuted ? 'Muted' : 'Unmuted' + ); + return 0; + } else if (val == 'on' || val == 'true') { + final isMuted = await api.isMuted(); + if (!isMuted) await api.toggleMute(); + printFormatted( + {'muted': true}, + json: json, xml: xml, + plain: (_) => 'Muted' + ); + return 0; + } else if (val == 'off' || val == 'false') { + final isMuted = await api.isMuted(); + if (isMuted) await api.toggleMute(); + printFormatted( + {'muted': false}, + json: json, xml: xml, + plain: (_) => 'Unmuted' + ); + return 0; + } + } + + // Default: Toggle + final result = await api.toggleMute(); + if (result) { + final isMuted = await api.isMuted(); + printFormatted( + {'muted': isMuted}, + json: json, xml: xml, + plain: (_) => isMuted ? 'Muted' : 'Unmuted' + ); + } else { + stderr.writeln('Failed to toggle mute'); + return 1; + } + return 0; + } + + Future<int> _handleOutput(MediaApi api, List<String> args, bool json, bool xml) async { + if (args.contains('--list') || args.contains('list')) { + final devices = await api.listAudioOutputs(); + printFormatted( + devices, + json: json, xml: xml, + plain: (_) { + final buffer = StringBuffer(); + for (final device in devices) { + buffer.writeln('${device['id']}: ${device['name']}'); + } + return buffer.toString().trimRight(); + } + ); + return 0; + } + + final values = args.where((a) => !a.startsWith('--')).toList(); + + if (values.isEmpty) { + // Get + final result = await api.getAudioOutput(); + printFormatted( + {'output': result}, + json: json, + xml: xml, + plain: (_) => result, + ); + } else { + // Set + final device = values[0]; + final result = await api.setAudioOutput(device); + if (result) { + printFormatted( + {'success': true, 'output': device}, + json: json, xml: xml, + plain: (_) => 'Output set to $device' + ); + } else { + stderr.writeln('Failed to set output'); + return 1; + } + } + return 0; + } +} diff --git a/packages/crossbar_cli/lib/src/commands/base_command.dart b/packages/crossbar_cli/lib/src/commands/base_command.dart new file mode 100644 index 0000000..eb4ee0c --- /dev/null +++ b/packages/crossbar_cli/lib/src/commands/base_command.dart @@ -0,0 +1,47 @@ +// ignore_for_file: avoid_print + +import 'dart:async'; +import 'dart:convert'; +import '../cli_utils.dart'; + +/// Abstract base class for CLI commands +abstract class CliCommand { + /// The command name (e.g., 'audio', 'cpu') + String get name; + + /// Short description for help output + String get description; + + /// Executes the command with the given arguments + /// Returns the exit code (0 for success) + Future<int> execute(List<String> args); + + /// Helper to print formatted output (JSON, XML, or Plain) + void printFormatted( + dynamic data, { + required bool json, + required bool xml, + String Function(dynamic)? plain, + String xmlRoot = 'crossbar', + }) { + if (json) { + print(jsonEncode(data)); + } else if (xml) { + Map<String, dynamic> mapData; + if (data is Map<String, dynamic>) { + mapData = data; + } else if (data is List) { + mapData = {'item': data}; + } else { + mapData = {'value': data}; + } + print(mapToXml(mapData, root: xmlRoot)); + } else { + if (plain != null) { + print(plain(data)); + } else { + print(data.toString()); + } + } + } +} diff --git a/packages/crossbar_cli/lib/src/commands/bluetooth_command.dart b/packages/crossbar_cli/lib/src/commands/bluetooth_command.dart new file mode 100644 index 0000000..016e84a --- /dev/null +++ b/packages/crossbar_cli/lib/src/commands/bluetooth_command.dart @@ -0,0 +1,78 @@ +// ignore_for_file: avoid_print +import 'dart:io'; + +import 'package:crossbar_core/crossbar_core.dart'; +import 'base_command.dart'; + +class BluetoothCommand extends CliCommand { + @override + String get name => 'bluetooth'; + + @override + String get description => 'Bluetooth control (on, off, devices)'; + + @override + Future<int> execute(List<String> args) async { + const api = UtilsApi(); + final jsonOutput = args.contains('--json'); + final xmlOutput = args.contains('--xml'); + final values = args.where((a) => !a.startsWith('--')).toList(); + + if (values.isEmpty || values[0] == 'status') { + // Get Status + final status = await api.getBluetoothStatus(); + printFormatted( + {'bluetooth': status}, + json: jsonOutput, + xml: xmlOutput, + plain: (_) => 'Bluetooth: $status' + ); + return 0; + } + + final val = values[0].toLowerCase(); + + if (val == 'devices') { + final devices = await api.listBluetoothDevices(); + printFormatted( + devices, + json: jsonOutput, + xml: xmlOutput, + plain: (_) { + if (devices.isEmpty) return 'No paired devices found'; + final buffer = StringBuffer(); + for (final device in devices) { + buffer.writeln('${device['mac']}: ${device['name']}'); + } + return buffer.toString().trimRight(); + } + ); + return 0; + } + + bool newState; + if (val == 'on' || val == 'true') { + newState = true; + } else if (val == 'off' || val == 'false') { + newState = false; + } else if (val == 'toggle') { + final current = await api.getBluetoothStatus(); + newState = !current.startsWith('on'); // if 'on' or 'on:X' -> true + } else { + stderr.writeln('Error: bluetooth requires on|off|toggle|status|devices'); + return 1; + } + + final result = newState + ? await api.enableBluetooth() + : await api.disableBluetooth(); + + printFormatted( + {'success': result, 'bluetooth': newState}, + json: jsonOutput, + xml: xmlOutput, + plain: (_) => result ? 'Bluetooth set to ${newState ? 'on' : 'off'}' : 'Failed to set Bluetooth' + ); + return result ? 0 : 1; + } +} diff --git a/packages/crossbar_cli/lib/src/commands/clipboard_command.dart b/packages/crossbar_cli/lib/src/commands/clipboard_command.dart new file mode 100644 index 0000000..1df47cc --- /dev/null +++ b/packages/crossbar_cli/lib/src/commands/clipboard_command.dart @@ -0,0 +1,65 @@ +// ignore_for_file: avoid_print +import 'dart:io'; + +import 'base_command.dart'; + +class ClipboardCommand extends CliCommand { + @override + String get name => 'clipboard'; + + @override + String get description => 'Get or set clipboard content'; + + @override + Future<int> execute(List<String> args) async { + final values = args.where((a) => !a.startsWith('--')).toList(); + final jsonOutput = args.contains('--json'); + final xmlOutput = args.contains('--xml'); + + if (values.isEmpty) { + // Get + var content = ''; + if (Platform.isLinux) { + final result = await Process.run('xclip', ['-selection', 'clipboard', '-o']); + content = result.stdout as String; + } else if (Platform.isMacOS) { + final result = await Process.run('pbpaste', []); + content = result.stdout as String; + } else if (Platform.isWindows) { + final result = await Process.run('powershell', ['-command', 'Get-Clipboard']); + content = result.stdout as String; + } + + printFormatted( + {'content': content}, + json: jsonOutput, + xml: xmlOutput, + plain: (_) => content + ); + + } else { + // Set + final content = values[0]; + + if (Platform.isLinux) { + final process = await Process.start('xclip', ['-selection', 'clipboard']); + process.stdin.write(content); + await process.stdin.close(); + } else if (Platform.isMacOS) { + final process = await Process.start('pbcopy', []); + process.stdin.write(content); + await process.stdin.close(); + } else if (Platform.isWindows) { + await Process.run('powershell', ['-command', 'Set-Clipboard -Value "$content"']); + } + + printFormatted( + {'success': true, 'action': 'copy'}, + json: jsonOutput, + xml: xmlOutput, + plain: (_) => 'Copied to clipboard' + ); + } + return 0; + } +} diff --git a/packages/crossbar_cli/lib/src/commands/dnd_command.dart b/packages/crossbar_cli/lib/src/commands/dnd_command.dart new file mode 100644 index 0000000..90b8a3c --- /dev/null +++ b/packages/crossbar_cli/lib/src/commands/dnd_command.dart @@ -0,0 +1,58 @@ +// ignore_for_file: avoid_print +import 'dart:io'; + +import 'package:crossbar_core/crossbar_core.dart'; +import 'base_command.dart'; + +class DndCommand extends CliCommand { + @override + String get name => 'dnd'; + + @override + String get description => 'Do Not Disturb control'; + + @override + Future<int> execute(List<String> args) async { + const api = UtilsApi(); + final jsonOutput = args.contains('--json'); + final xmlOutput = args.contains('--xml'); + final values = args.where((a) => !a.startsWith('--')).toList(); + + if (values.isEmpty) { + // Get + final result = await api.getDndStatus(); + printFormatted( + {'dnd': result}, + json: jsonOutput, + xml: xmlOutput, + plain: (_) => result ? 'Do Not Disturb: ON' : 'Do Not Disturb: OFF' + ); + } else { + // Set or Toggle + final val = values[0].toLowerCase(); + bool newState; + + if (val == 'toggle') { + final current = await api.getDndStatus(); + newState = !current; + } else if (val == 'on' || val == 'true') { + newState = true; + } else if (val == 'off' || val == 'false') { + newState = false; + } else { + stderr.writeln('Error: dnd requires on|off|toggle'); + return 1; + } + + final result = await api.setDnd(newState); + printFormatted( + {'success': result, 'dnd': newState}, + json: jsonOutput, + xml: xmlOutput, + plain: (_) => result ? 'DND set to ${newState ? 'on' : 'off'}' : 'Failed to set DND' + ); + return result ? 0 : 1; + } + return 0; + } +} diff --git a/packages/crossbar_cli/lib/src/commands/filesystem_commands.dart b/packages/crossbar_cli/lib/src/commands/filesystem_commands.dart new file mode 100644 index 0000000..7cb7f49 --- /dev/null +++ b/packages/crossbar_cli/lib/src/commands/filesystem_commands.dart @@ -0,0 +1,149 @@ +// ignore_for_file: avoid_print +import 'dart:io'; + +import 'base_command.dart'; + +class FileCommand extends CliCommand { + @override + String get name => 'file'; + + @override + String get description => 'File operations (exists, read, size)'; + + @override + Future<int> execute(List<String> args) async { + if (args.isEmpty) { + stderr.writeln('Error: file command requires a subcommand (exists, read, size)'); + return 1; + } + + final subcommand = args[0]; + final commandArgs = args.sublist(1); + final jsonOutput = args.contains('--json'); + final xmlOutput = args.contains('--xml'); + final values = commandArgs.where((a) => !a.startsWith('--')).toList(); + + if (values.isEmpty) { + stderr.writeln('Error: file $subcommand requires a path'); + return 1; + } + final path = values[0]; + + switch (subcommand) { + case 'exists': + final exists = File(path).existsSync() || Directory(path).existsSync(); + printFormatted( + {'exists': exists, 'path': path}, + json: jsonOutput, + xml: xmlOutput, + plain: (_) => exists ? 'true' : 'false' + ); + return 0; + + case 'read': + final file = File(path); + if (!file.existsSync()) { + stderr.writeln('Error: File not found: $path'); + return 1; + } + final content = file.readAsStringSync(); + printFormatted( + {'content': content, 'path': path}, + json: jsonOutput, + xml: xmlOutput, + plain: (_) => content + ); + return 0; + + case 'size': + final file = File(path); + if (!file.existsSync()) { + stderr.writeln('Error: File not found: $path'); + return 1; + } + final size = file.lengthSync(); + printFormatted( + {'size': size, 'path': path}, + json: jsonOutput, + xml: xmlOutput, + plain: (_) { + if (size < 1024) { + return '$size B'; + } else if (size < 1024 * 1024) { + return '${(size / 1024).toStringAsFixed(2)} KB'; + } else if (size < 1024 * 1024 * 1024) { + return '${(size / (1024 * 1024)).toStringAsFixed(2)} MB'; + } else { + return '${(size / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB'; + } + } + ); + return 0; + + default: + stderr.writeln('Error: Unknown file subcommand: $subcommand'); + return 1; + } + } +} + +class DirCommand extends CliCommand { + @override + String get name => 'dir'; + + @override + String get description => 'Directory operations (list)'; + + @override + Future<int> execute(List<String> args) async { + if (args.isEmpty) { + stderr.writeln('Error: dir command requires a subcommand (list)'); + return 1; + } + + final subcommand = args[0]; + final commandArgs = args.sublist(1); + final jsonOutput = args.contains('--json'); + final xmlOutput = args.contains('--xml'); + final values = commandArgs.where((a) => !a.startsWith('--')).toList(); + final path = values.isNotEmpty ? values[0] : '.'; + + if (subcommand == 'list') { + final dir = Directory(path); + if (!dir.existsSync()) { + stderr.writeln('Error: Directory not found: $path'); + return 1; + } + final entries = dir.listSync(); + final files = entries.map((e) { + final stat = e.statSync(); + return { + 'name': e.path.split(Platform.pathSeparator).last, + 'path': e.path, + 'type': e is File ? 'file' : 'directory', + 'size': stat.size, + 'modified': stat.modified.toIso8601String(), + }; + }).toList(); + + printFormatted( + files, + json: jsonOutput, + xml: xmlOutput, + plain: (_) { + final buffer = StringBuffer(); + for (final entry in entries) { + final name = entry.path.split(Platform.pathSeparator).last; + final prefix = entry is Directory ? 'd' : '-'; + buffer.writeln('$prefix $name'); + } + return buffer.toString().trimRight(); + } + ); + return 0; + } else { + stderr.writeln('Error: Unknown dir subcommand: $subcommand'); + return 1; + } + } +} diff --git a/packages/crossbar_cli/lib/src/commands/media_command.dart b/packages/crossbar_cli/lib/src/commands/media_command.dart new file mode 100644 index 0000000..b273731 --- /dev/null +++ b/packages/crossbar_cli/lib/src/commands/media_command.dart @@ -0,0 +1,130 @@ +// ignore_for_file: avoid_print +import 'dart:io'; + +import 'package:crossbar_core/crossbar_core.dart'; +import 'base_command.dart'; + +class MediaCommand extends CliCommand { + @override + String get name => 'media'; + + @override + String get description => 'Media playback controls (play, pause, next, etc.)'; + + @override + Future<int> execute(List<String> args) async { + if (args.isEmpty) { + stderr.writeln('Error: media command requires a subcommand'); + return 1; + } + + final subcommand = args[0]; + final commandArgs = args.sublist(1); + final jsonOutput = args.contains('--json'); + final xmlOutput = args.contains('--xml'); + + const api = MediaApi(); + + switch (subcommand) { + case 'play': + final result = await api.play(); + printFormatted( + {'success': result, 'action': 'play'}, + json: jsonOutput, xml: xmlOutput, + plain: (_) => result ? 'Playing' : 'Failed to play' + ); + return result ? 0 : 1; + + case 'pause': + final result = await api.pause(); + printFormatted( + {'success': result, 'action': 'pause'}, + json: jsonOutput, xml: xmlOutput, + plain: (_) => result ? 'Paused' : 'Failed to pause' + ); + return result ? 0 : 1; + + case 'toggle': + case 'play-pause': // Alias + final result = await api.playPause(); + printFormatted( + {'success': result, 'action': 'toggle'}, + json: jsonOutput, xml: xmlOutput, + plain: (_) => result ? 'Toggled' : 'Failed to toggle' + ); + return result ? 0 : 1; + + case 'stop': + final result = await api.stop(); + printFormatted( + {'success': result, 'action': 'stop'}, + json: jsonOutput, xml: xmlOutput, + plain: (_) => result ? 'Stopped' : 'Failed to stop' + ); + return result ? 0 : 1; + + case 'next': + final result = await api.next(); + printFormatted( + {'success': result, 'action': 'next'}, + json: jsonOutput, xml: xmlOutput, + plain: (_) => result ? 'Next track' : 'Failed to skip' + ); + return result ? 0 : 1; + + case 'prev': + case 'previous': + final result = await api.previous(); + printFormatted( + {'success': result, 'action': 'previous'}, + json: jsonOutput, xml: xmlOutput, + plain: (_) => result ? 'Previous track' : 'Failed to go back' + ); + return result ? 0 : 1; + + case 'seek': + final values = commandArgs.where((a) => !a.startsWith('--')).toList(); + if (values.isEmpty) { + stderr.writeln('Error: seek requires offset (e.g., +30s, -10s)'); + return 1; + } + final offset = values[0]; + final result = await api.seek(offset); + printFormatted( + {'success': result, 'action': 'seek', 'offset': offset}, + json: jsonOutput, xml: xmlOutput, + plain: (_) => result ? 'Seeked $offset' : 'Failed to seek' + ); + return result ? 0 : 1; + + case 'playing': + final result = await api.getPlaying(); + printFormatted( + result, + json: jsonOutput, + xml: xmlOutput, + xmlRoot: 'media', + plain: (_) { + if (result['playing'] == true) { + final buffer = StringBuffer(); + buffer.writeln('${result['title']} - ${result['artist']}'); + if (result['album']?.isNotEmpty == true) { + buffer.writeln('Album: ${result['album']}'); + } + if (result['position']?.isNotEmpty == true) { + buffer.writeln('${result['position']} / ${result['duration']}'); + } + return buffer.toString().trimRight(); + } else { + return 'Not playing'; + } + } + ); + return 0; + + default: + stderr.writeln('Error: Unknown media subcommand: $subcommand'); + return 1; + } + } +} diff --git a/packages/crossbar_cli/lib/src/commands/network_command.dart b/packages/crossbar_cli/lib/src/commands/network_command.dart new file mode 100644 index 0000000..b56c9df --- /dev/null +++ b/packages/crossbar_cli/lib/src/commands/network_command.dart @@ -0,0 +1,71 @@ +// ignore_for_file: avoid_print +import 'dart:io'; + +import 'package:crossbar_core/crossbar_core.dart'; +import 'base_command.dart'; + +class NetworkCommand extends CliCommand { + @override + String get name => 'net'; + + @override + String get description => 'Network diagnostics (status, ip, ping)'; + + @override + Future<int> execute(List<String> args) async { + if (args.isEmpty) { + stderr.writeln('Error: net command requires a subcommand (status, ip, ping)'); + return 1; + } + + final subcommand = args[0]; + final commandArgs = args.sublist(1); + final jsonOutput = args.contains('--json'); + final xmlOutput = args.contains('--xml'); + + const api = NetworkApi(); + + switch (subcommand) { + case 'status': + final result = await api.getNetStatus(); + printFormatted( + {'status': result}, + json: jsonOutput, + xml: xmlOutput, + plain: (_) => result + ); + return 0; + + case 'ip': + final isPublic = commandArgs.contains('--public'); + final result = isPublic ? await api.getPublicIp() : await api.getLocalIp(); + printFormatted( + {'ip': result, 'type': isPublic ? 'public' : 'local'}, + json: jsonOutput, + xml: xmlOutput, + plain: (_) => result + ); + return 0; + + case 'ping': + final values = commandArgs.where((a) => !a.startsWith('--')).toList(); + if (values.isEmpty) { + stderr.writeln('Error: ping requires a host'); + return 1; + } + final host = values[0]; + final result = await api.ping(host); + printFormatted( + {'ping': result, 'host': host}, + json: jsonOutput, + xml: xmlOutput, + plain: (_) => result + ); + return 0; + + default: + stderr.writeln('Error: Unknown net subcommand: $subcommand'); + return 1; + } + } +} diff --git a/packages/crossbar_cli/lib/src/commands/plugin_commands.dart b/packages/crossbar_cli/lib/src/commands/plugin_commands.dart new file mode 100644 index 0000000..7b283f6 --- /dev/null +++ b/packages/crossbar_cli/lib/src/commands/plugin_commands.dart @@ -0,0 +1,227 @@ +// ignore_for_file: avoid_print + +import 'dart:io'; + +import '../core/plugin_manager_cli.dart'; +import '../plugin_scaffolding.dart'; +import 'base_command.dart'; + + +class InitCommand extends CliCommand { + @override + String get name => 'init'; + + @override + String get description => 'Create a new plugin from template'; + + @override + Future<int> execute(List<String> args) async { + // args: --lang bash --type custom --name my-plugin --output dir + // We need to parse flags. + + String? lang; + var type = 'custom'; + String? name; + String? outputDir; + + for (var i = 0; i < args.length; i++) { + if (args[i] == '--lang' && i + 1 < args.length) lang = args[i + 1]; + if (args[i] == '--type' && i + 1 < args.length) type = args[i + 1]; + if (args[i] == '--name' && i + 1 < args.length) name = args[i + 1]; + if (args[i] == '--output' && i + 1 < args.length) outputDir = args[i + 1]; + } + + final jsonOutput = args.contains('--json'); + final xmlOutput = args.contains('--xml'); + + if (lang == null) { + stderr.writeln('Error: --lang is required'); + stderr.writeln('Usage: crossbar init --lang <bash|python|node|dart|go|rust> --type <clock|monitor|status|api|custom> [--name <name>]'); + stderr.writeln(''); + stderr.writeln('Supported languages: ${PluginScaffolding.supportedLanguages.join(', ')}'); + stderr.writeln('Supported types: ${PluginScaffolding.supportedTypes.join(', ')}'); + return 1; + } + + if (!PluginScaffolding.supportedLanguages.contains(lang.toLowerCase())) { + stderr.writeln('Error: Unsupported language: $lang'); + stderr.writeln('Supported: ${PluginScaffolding.supportedLanguages.join(', ')}'); + return 1; + } + + if (!PluginScaffolding.supportedTypes.contains(type.toLowerCase())) { + stderr.writeln('Error: Unsupported type: $type'); + stderr.writeln('Supported: ${PluginScaffolding.supportedTypes.join(', ')}'); + return 1; + } + + const scaffolding = PluginScaffolding(); + final pluginPath = await scaffolding.createPlugin( + lang: lang, + type: type, + name: name, + outputDir: outputDir, + ); + + if (pluginPath != null) { + printFormatted( + { + 'success': true, + 'path': pluginPath, + 'config': '$pluginPath.schema.json', + 'instructions': [ + 'Edit the plugin file to add your logic', + 'Customize the config file for settings', + 'Test with: crossbar exec "${_getInterpreterCommand(lang)} $pluginPath"' + ] + }, + json: jsonOutput, + xml: xmlOutput, + plain: (_) { + final buffer = StringBuffer(); + buffer.writeln('Plugin created: $pluginPath'); + buffer.writeln('Config file: $pluginPath.schema.json'); + buffer.writeln(''); + buffer.writeln('Next steps:'); + buffer.writeln(' 1. Edit the plugin file to add your logic'); + buffer.writeln(' 2. Customize the config file for settings'); + buffer.writeln(' 3. Test with: crossbar exec "${_getInterpreterCommand(lang!)} $pluginPath"'); + return buffer.toString().trimRight(); + } + ); + } else { + stderr.writeln('Failed to create plugin'); + return 1; + } + return 0; + } + + String _getInterpreterCommand(String lang) { + switch (lang.toLowerCase()) { + case 'bash': + return 'bash'; + case 'python': + return 'python3'; + case 'node': + return 'node'; + case 'dart': + return 'dart'; + case 'go': + return 'go run'; + case 'rust': + return 'rustc -o /tmp/test && /tmp/test #'; + default: + return lang; + } + } +} + +class InstallCommand extends CliCommand { + @override + String get name => 'install'; + + @override + String get description => 'Install plugin from GitHub repository'; + + @override + Future<int> execute(List<String> args) async { + final values = args.where((a) => !a.startsWith('--')).toList(); + if (values.isEmpty) { + stderr.writeln('Error: URL is required'); + stderr.writeln('Usage: crossbar install <github-url>'); + stderr.writeln(''); + stderr.writeln('Example: crossbar install https://github.com/user/my-crossbar-plugin'); + return 1; + } + final url = values[0]; + final jsonOutput = args.contains('--json'); + final xmlOutput = args.contains('--xml'); + + if (!jsonOutput && !xmlOutput) { + print('Installing plugin from: $url'); + } + + const installer = PluginInstaller(); + final installedPath = await installer.installFromGitHub(url); + + if (installedPath != null) { + printFormatted( + {'success': true, 'path': installedPath}, + json: jsonOutput, + xml: xmlOutput, + plain: (_) => 'Plugin installed: $installedPath' + ); + } else { + stderr.writeln('Failed to install plugin'); + stderr.writeln('Make sure:'); + stderr.writeln(' - The URL is a valid GitHub repository'); + stderr.writeln(' - The repository contains plugin files (name.interval.ext)'); + stderr.writeln(' - git is installed and accessible'); + return 1; + } + return 0; + } +} + +class RunPluginCommand extends CliCommand { + @override + String get name => 'run'; + + @override + String get description => 'Run a specific plugin immediately'; + + @override + Future<int> execute(List<String> args) async { + final values = args.where((a) => !a.startsWith('--')).toList(); + + if (values.isEmpty) { + stderr.writeln('Error: Plugin ID is required'); + stderr.writeln('Usage: crossbar run <plugin-id>'); + return 1; + } + + final pluginId = values[0]; + final jsonOutput = args.contains('--json'); + final xmlOutput = args.contains('--xml'); + + // We need to initialize PluginManager and discover plugins + final manager = PluginManagerCli(); + await manager.discoverPlugins(); + + final plugin = manager.getPlugin(pluginId); + if (plugin == null) { + stderr.writeln('Error: Plugin not found: $pluginId'); + return 1; + } + + // We run it regardless of enabled state (manual run) + final output = await manager.runPlugin(pluginId); + + if (output == null) { + stderr.writeln('Error: Failed to run plugin'); + return 1; + } + + printFormatted( + { + 'success': !output.hasError, + 'plugin': pluginId, + 'output': { + 'text': output.text, + 'icon': output.icon, + 'error': output.errorMessage + } + }, + json: jsonOutput, + xml: xmlOutput, + plain: (_) { + if (output.hasError) { + return 'Error: ${output.errorMessage}'; + } + return '${output.icon} ${output.text ?? ""}'; + } + ); + + return output.hasError ? 1 : 0; + } +} diff --git a/packages/crossbar_cli/lib/src/commands/power_command.dart b/packages/crossbar_cli/lib/src/commands/power_command.dart new file mode 100644 index 0000000..969bb0e --- /dev/null +++ b/packages/crossbar_cli/lib/src/commands/power_command.dart @@ -0,0 +1,55 @@ +// ignore_for_file: avoid_print +import 'dart:io'; + +import 'package:crossbar_core/crossbar_core.dart'; +import 'base_command.dart'; + +class PowerCommand extends CliCommand { + @override + String get name => 'power'; + + @override + String get description => 'Power management (sleep, restart, shutdown)'; + + @override + Future<int> execute(List<String> args) async { + if (args.isEmpty) { + stderr.writeln('Error: power command requires a subcommand (sleep, restart, shutdown)'); + return 1; + } + + final subcommand = args[0]; + final commandArgs = args.sublist(1); // Keep flags like --confirm + + const api = UtilsApi(); + + switch (subcommand) { + case 'sleep': + final result = await api.sleep(); + print(result ? 'System going to sleep...' : 'Failed to sleep'); + return result ? 0 : 1; + + case 'restart': + if (!commandArgs.contains('--confirm')) { + stderr.writeln('Error: restart requires --confirm flag for safety'); + return 1; + } + final result = await api.restart(confirmed: true); + print(result ? 'System restarting...' : 'Failed to restart'); + return result ? 0 : 1; + + case 'shutdown': + if (!commandArgs.contains('--confirm')) { + stderr.writeln('Error: shutdown requires --confirm flag for safety'); + return 1; + } + final result = await api.shutdown(confirmed: true); + print(result ? 'System shutting down...' : 'Failed to shutdown'); + return result ? 0 : 1; + + default: + stderr.writeln('Error: Unknown power subcommand: $subcommand'); + return 1; + } + } +} diff --git a/packages/crossbar_cli/lib/src/commands/screen_command.dart b/packages/crossbar_cli/lib/src/commands/screen_command.dart new file mode 100644 index 0000000..e06414f --- /dev/null +++ b/packages/crossbar_cli/lib/src/commands/screen_command.dart @@ -0,0 +1,123 @@ +// ignore_for_file: avoid_print +import 'dart:io'; + +import 'package:crossbar_core/crossbar_core.dart'; +import 'base_command.dart'; + +class ScreenCommand extends CliCommand { + @override + String get name => 'screen'; + + @override + String get description => 'Screen brightness and resolution'; + + @override + Future<int> execute(List<String> args) async { + if (args.isEmpty) { + stderr.writeln('Error: screen command requires a subcommand (brightness, size)'); + return 1; + } + + final subcommand = args[0]; + final commandArgs = args.sublist(1); + final jsonOutput = args.contains('--json'); + final xmlOutput = args.contains('--xml'); + + switch (subcommand) { + case 'brightness': + return _handleBrightness(commandArgs, jsonOutput, xmlOutput); + case 'size': + return _handleSize(jsonOutput, xmlOutput); + default: + stderr.writeln('Error: Unknown screen subcommand: $subcommand'); + return 1; + } + } + + Future<int> _handleBrightness(List<String> args, bool json, bool xml) async { + const api = MediaApi(); + final values = args.where((a) => !a.startsWith('--')).toList(); + + if (values.isEmpty) { + // Get + final result = await api.getBrightness(); + printFormatted( + {'brightness': result}, + json: json, + xml: xml, + plain: (_) => '$result%' + ); + } else { + // Set + final level = int.tryParse(values[0]); + if (level == null) { + stderr.writeln('Error: brightness requires a number (0-100)'); + return 1; + } + final result = await api.setBrightness(level); + if (result) { + printFormatted( + {'success': true, 'brightness': level}, + json: json, + xml: xml, + plain: (_) => 'Brightness set to $level%' + ); + } else { + stderr.writeln('Failed to set brightness'); + return 1; + } + } + return 0; + } + + Future<int> _handleSize(bool json, bool xml) async { + var resultStr = 'unknown'; + + try { + if (Platform.isLinux) { + final result = await Process.run('xdpyinfo', []); + final output = result.stdout as String; + final match = RegExp(r'dimensions:\s+(\d+x\d+)').firstMatch(output); + resultStr = match?.group(1) ?? 'unknown'; + } else if (Platform.isMacOS) { + final result = await Process.run('system_profiler', ['SPDisplaysDataType']); + final output = result.stdout as String; + final match = RegExp(r'Resolution:\s+(\d+ x \d+)').firstMatch(output); + resultStr = match?.group(1)?.replaceAll(' ', '') ?? 'unknown'; + } else if (Platform.isWindows) { + final result = await Process.run('wmic', [ + 'path', 'Win32_VideoController', 'get', + 'CurrentHorizontalResolution,CurrentVerticalResolution', '/format:list' + ]); + final output = result.stdout as String; + final h = RegExp(r'CurrentHorizontalResolution=(\d+)').firstMatch(output); + final v = RegExp(r'CurrentVerticalResolution=(\d+)').firstMatch(output); + if (h != null && v != null) { + resultStr = '${h.group(1)}x${v.group(1)}'; + } + } + } catch (e) { + // ignore + } + + Map<String, dynamic> data; + final parts = resultStr.split('x'); + if (parts.length == 2) { + data = { + 'width': int.tryParse(parts[0]), + 'height': int.tryParse(parts[1]), + 'resolution': resultStr + }; + } else { + data = {'resolution': resultStr}; + } + + printFormatted( + data, + json: json, + xml: xml, + plain: (_) => resultStr + ); + return 0; + } +} diff --git a/packages/crossbar_cli/lib/src/commands/system_info_commands.dart b/packages/crossbar_cli/lib/src/commands/system_info_commands.dart new file mode 100644 index 0000000..0fc7daf --- /dev/null +++ b/packages/crossbar_cli/lib/src/commands/system_info_commands.dart @@ -0,0 +1,378 @@ +// ignore_for_file: avoid_print +import 'dart:io'; + +import 'package:crossbar_core/crossbar_core.dart'; +import 'base_command.dart'; + +class SystemInfoCommand extends CliCommand { + SystemInfoCommand(this._name, this._desc); + + final String _name; + final String _desc; + + @override + String get name => _name; + + @override + String get description => _desc; + + @override + Future<int> execute(List<String> args) async { + return 0; + } +} + +class CpuCommand extends CliCommand { + @override + String get name => 'cpu'; + @override + String get description => 'CPU usage percentage'; + + @override + Future<int> execute(List<String> args) async { + final api = SystemApi(); + final result = await api.getCpuUsage(); + final val = double.tryParse(result) ?? 0; + + printFormatted( + {'cpu': val}, + json: args.contains('--json'), + xml: args.contains('--xml'), + plain: (_) => result, + ); + return 0; + } +} + +class MemoryCommand extends CliCommand { + @override + String get name => 'memory'; + @override + String get description => 'RAM usage'; + + @override + Future<int> execute(List<String> args) async { + final api = SystemApi(); + final result = await api.getMemoryUsage(); + + var data = <String, dynamic>{'memory': result}; + final parts = result.split('/'); + if (parts.length == 2) { + final used = double.tryParse(parts[0].replaceAll(' GB', '')) ?? 0; + final total = double.tryParse(parts[1].replaceAll(' GB', '')) ?? 0; + data = { + 'used': used, + 'total': total, + 'unit': 'GB', + }; + } + + printFormatted( + data, + json: args.contains('--json'), + xml: args.contains('--xml'), + plain: (_) => result, + ); + return 0; + } +} + +class BatteryCommand extends CliCommand { + @override + String get name => 'battery'; + @override + String get description => 'Battery status'; + + @override + Future<int> execute(List<String> args) async { + final api = SystemApi(); + final result = await api.getBatteryStatus(); + + final match = RegExp(r'(\d+)%').firstMatch(result); + final isCharging = result.contains('⚡'); + final data = { + 'level': match != null ? int.parse(match.group(1)!) : null, + 'charging': isCharging, + 'status': result + }; + + printFormatted( + data, + json: args.contains('--json'), + xml: args.contains('--xml'), + plain: (_) => result, + ); + return 0; + } +} + +class UptimeCommand extends CliCommand { + @override + String get name => 'uptime'; + @override + String get description => 'System uptime'; + + @override + Future<int> execute(List<String> args) async { + final api = SystemApi(); + final result = await api.getUptime(); + printFormatted( + {'uptime': result}, + json: args.contains('--json'), + xml: args.contains('--xml'), + plain: (_) => result + ); + return 0; + } +} + +class DiskCommand extends CliCommand { + @override + String get name => 'disk'; + @override + String get description => 'Disk usage'; + + @override + Future<int> execute(List<String> args) async { + final api = SystemApi(); + final values = args.where((a) => !a.startsWith('--')).toList(); + final path = values.isNotEmpty ? values[0] : null; + final result = await api.getDiskUsage(path); + + // Attempt to parse result to structured data if possible, currently just string + // "Used: 50GB, Free: 100GB" typically + printFormatted( + {'disk': result}, // Ideally parse this better but sticking to string for now if format is complex + json: args.contains('--json'), + xml: args.contains('--xml'), + plain: (_) => result + ); + return 0; + } +} + +class OsCommand extends CliCommand { + @override + String get name => 'os'; + @override + String get description => 'Operating system info'; + + @override + Future<int> execute(List<String> args) async { + final api = SystemApi(); + printFormatted( + api.getOsDetails(), + json: args.contains('--json'), + xml: args.contains('--xml'), + plain: (_) => api.getOs() + ); + return 0; + } +} + +class KernelCommand extends CliCommand { + @override + String get name => 'kernel'; + @override + String get description => 'Kernel version'; + + @override + Future<int> execute(List<String> args) async { + String resultStr; + if (Platform.isLinux || Platform.isMacOS) { + final res = await Process.run('uname', ['-r']); + resultStr = (res.stdout as String).trim(); + } else if (Platform.isWindows) { + final res = await Process.run('ver', [], runInShell: true); + resultStr = (res.stdout as String).trim(); + } else { + resultStr = Platform.operatingSystemVersion; + } + + printFormatted( + {'kernel': resultStr}, + json: args.contains('--json'), + xml: args.contains('--xml'), + plain: (_) => resultStr + ); + return 0; + } +} + +class ArchCommand extends CliCommand { + @override + String get name => 'arch'; + @override + String get description => 'System architecture'; + + @override + Future<int> execute(List<String> args) async { + String resultStr; + if (Platform.isLinux || Platform.isMacOS) { + final res = await Process.run('uname', ['-m']); + resultStr = (res.stdout as String).trim(); + } else if (Platform.isWindows) { + resultStr = Platform.environment['PROCESSOR_ARCHITECTURE'] ?? 'unknown'; + } else { + resultStr = 'unknown'; + } + + printFormatted( + {'arch': resultStr}, + json: args.contains('--json'), + xml: args.contains('--xml'), + plain: (_) => resultStr + ); + return 0; + } +} + +class HostnameCommand extends CliCommand { + @override + String get name => 'hostname'; + @override + String get description => 'System hostname'; + + @override + Future<int> execute(List<String> args) async { + final result = Platform.localHostname; + printFormatted( + {'hostname': result}, + json: args.contains('--json'), + xml: args.contains('--xml'), + plain: (_) => result + ); + return 0; + } +} + +class UsernameCommand extends CliCommand { + @override + String get name => 'username'; + @override + String get description => 'Current username'; + + @override + Future<int> execute(List<String> args) async { + final result = Platform.environment['USER'] ?? Platform.environment['USERNAME'] ?? 'unknown'; + printFormatted( + {'username': result}, + json: args.contains('--json'), + xml: args.contains('--xml'), + plain: (_) => result + ); + return 0; + } +} + +class HomeCommand extends CliCommand { + @override + String get name => 'home'; + @override + String get description => 'Home directory'; + + @override + Future<int> execute(List<String> args) async { + final result = Platform.environment['HOME'] ?? Platform.environment['USERPROFILE'] ?? '~'; + printFormatted( + {'home': result}, + json: args.contains('--json'), + xml: args.contains('--xml'), + plain: (_) => result + ); + return 0; + } +} + +class TempCommand extends CliCommand { + @override + String get name => 'temp'; + @override + String get description => 'Temp directory'; + + @override + Future<int> execute(List<String> args) async { + final result = Directory.systemTemp.path; + printFormatted( + {'temp': result}, + json: args.contains('--json'), + xml: args.contains('--xml'), + plain: (_) => result + ); + return 0; + } +} + +class EnvCommand extends CliCommand { + @override + String get name => 'env'; + @override + String get description => 'Environment variables'; + + @override + Future<int> execute(List<String> args) async { + final values = args.where((a) => !a.startsWith('--')).toList(); + final name = values.isNotEmpty ? values[0] : null; + final json = args.contains('--json'); + final xml = args.contains('--xml'); + + if (name == null) { + printFormatted( + Platform.environment, + json: json, + xml: xml, + plain: (data) { + final map = data as Map<String, String>; + return map.entries.map((e) => '${e.key}=${e.value}').join('\n'); + } + ); + } else { + final value = Platform.environment[name] ?? ''; + printFormatted( + {name: value}, + json: json, + xml: xml, + plain: (_) => value, + ); + } + return 0; + } +} + +class LocaleCommand extends CliCommand { + @override + String get name => 'locale'; + @override + String get description => 'System locale'; + + @override + Future<int> execute(List<String> args) async { + final result = Platform.localeName; + printFormatted( + {'locale': result}, + json: args.contains('--json'), + xml: args.contains('--xml'), + plain: (_) => result + ); + return 0; + } +} + +class TimezoneCommand extends CliCommand { + @override + String get name => 'timezone'; + @override + String get description => 'System timezone'; + + @override + Future<int> execute(List<String> args) async { + final result = DateTime.now().timeZoneName; + printFormatted( + {'timezone': result}, + json: args.contains('--json'), + xml: args.contains('--xml'), + plain: (_) => result + ); + return 0; + } +} diff --git a/packages/crossbar_cli/lib/src/commands/utility_commands.dart b/packages/crossbar_cli/lib/src/commands/utility_commands.dart new file mode 100644 index 0000000..ec34063 --- /dev/null +++ b/packages/crossbar_cli/lib/src/commands/utility_commands.dart @@ -0,0 +1,394 @@ +// ignore_for_file: avoid_print +import 'dart:convert'; +import 'dart:io'; + +import 'package:crypto/crypto.dart' as crypto; +import 'package:crossbar_core/crossbar_core.dart'; +import 'base_command.dart'; + +class ExecCommand extends CliCommand { + @override + String get name => 'exec'; + + @override + String get description => 'Execute shell command'; + + @override + Future<int> execute(List<String> args) async { + final values = args.where((a) => !a.startsWith('--')).toList(); + if (values.isEmpty) { + stderr.writeln('Error: exec requires command'); + return 1; + } + + final cmd = values.join(' '); + final jsonOutput = args.contains('--json'); + final xmlOutput = args.contains('--xml'); + + final result = await Process.run( + Platform.isWindows ? 'cmd' : 'sh', + Platform.isWindows ? ['/c', cmd] : ['-c', cmd], + ); + + if (jsonOutput || xmlOutput) { + printFormatted( + { + 'stdout': result.stdout.toString(), + 'stderr': result.stderr.toString(), + 'exitCode': result.exitCode + }, + json: jsonOutput, + xml: xmlOutput, + plain: (_) => '' // Should not be reached logic-wise if I used separate check, but here we want to suppress standard output if json/xml + ); + } else { + stdout.write(result.stdout); + stderr.write(result.stderr); + } + return result.exitCode; + } +} + +class NotifyCommand extends CliCommand { + @override + String get name => 'notify'; + + @override + String get description => 'Send desktop notification'; + + @override + Future<int> execute(List<String> args) async { + String? title; + String? message; + + final values = args.where((a) => !a.startsWith('--')).toList(); + if (values.length >= 2) { + title = values[0]; + message = values[1]; + } else { + stderr.writeln('Error: notify requires title and message'); + return 1; + } + + String? icon; + var priority = 'normal'; + + for (var i = 0; i < args.length; i++) { + if (args[i] == '--icon' && i + 1 < args.length) icon = args[i + 1]; + if (args[i] == '--priority' && i + 1 < args.length) priority = args[i + 1]; + } + + final jsonOutput = args.contains('--json'); + final xmlOutput = args.contains('--xml'); + + const api = UtilsApi(); + final result = await api.sendNotification( + title: title, + message: message, + icon: icon, + priority: priority, + ); + + printFormatted( + {'success': result, 'action': 'notify'}, + json: jsonOutput, + xml: xmlOutput, + plain: (_) => result ? 'Notification sent' : 'Failed to send notification' + ); + return result ? 0 : 1; + } +} + +class OpenCommand extends CliCommand { + @override + String get name => 'open'; + + @override + String get description => 'Open URL, file, or application'; + + @override + Future<int> execute(List<String> args) async { + if (args.isEmpty) { + stderr.writeln('Error: open requires subcommand (url, file, app)'); + return 1; + } + + final subcommand = args[0]; + final values = args.sublist(1).where((a) => !a.startsWith('--')).toList(); + final jsonOutput = args.contains('--json'); + final xmlOutput = args.contains('--xml'); + + if (values.isEmpty) { + stderr.writeln('Error: open $subcommand requires a value'); + return 1; + } + final target = values[0]; + const api = UtilsApi(); + + switch (subcommand) { + case 'url': + final result = await api.openUrl(target); + printFormatted( + {'success': result, 'action': 'open-url', 'target': target}, + json: jsonOutput, xml: xmlOutput, + plain: (_) => result ? 'Opened: $target' : 'Failed to open URL' + ); + return result ? 0 : 1; + case 'file': + final result = await api.openFile(target); + printFormatted( + {'success': result, 'action': 'open-file', 'target': target}, + json: jsonOutput, xml: xmlOutput, + plain: (_) => result ? 'Opened: $target' : 'Failed to open file' + ); + return result ? 0 : 1; + case 'app': + final result = await api.openApp(target); + printFormatted( + {'success': result, 'action': 'open-app', 'target': target}, + json: jsonOutput, xml: xmlOutput, + plain: (_) => result ? 'Launched: $target' : 'Failed to launch app' + ); + return result ? 0 : 1; + default: + stderr.writeln('Error: Unknown open subcommand: $subcommand'); + return 1; + } + } +} + +class TimeCommand extends CliCommand { + @override + String get name => 'time'; + + @override + String get description => 'Current time'; + + @override + Future<int> execute(List<String> args) async { + var fmt = '24h'; + for (var i = 0; i < args.length; i++) { + if (args[i] == '--fmt' && i + 1 < args.length) fmt = args[i + 1]; + } + final jsonOutput = args.contains('--json'); + final xmlOutput = args.contains('--xml'); + + final now = DateTime.now(); + String result; + + if (fmt == '12h') { + final hour = now.hour > 12 ? now.hour - 12 : now.hour; + final period = now.hour >= 12 ? 'PM' : 'AM'; + result = '${hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')} $period'; + } else { + result = '${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}'; + } + + printFormatted( + {'time': result, 'fmt': fmt}, + json: jsonOutput, xml: xmlOutput, + plain: (_) => result + ); + return 0; + } +} + +class DateCommand extends CliCommand { + @override + String get name => 'date'; + + @override + String get description => 'Current date'; + + @override + Future<int> execute(List<String> args) async { + var fmt = 'iso'; + for (var i = 0; i < args.length; i++) { + if (args[i] == '--fmt' && i + 1 < args.length) fmt = args[i + 1]; + } + final jsonOutput = args.contains('--json'); + final xmlOutput = args.contains('--xml'); + + final now = DateTime.now(); + dynamic result; + + switch (fmt) { + case 'iso': + result = now.toIso8601String().split('T')[0]; + case 'us': + result = '${now.month}/${now.day}/${now.year}'; + case 'eu': + result = '${now.day}/${now.month}/${now.year}'; + case 'unix': + result = now.millisecondsSinceEpoch ~/ 1000; + default: + result = now.toIso8601String().split('T')[0]; + } + + printFormatted( + {'date': result, 'fmt': fmt}, + json: jsonOutput, xml: xmlOutput, + plain: (_) => result.toString() + ); + return 0; + } +} + +class HashCommand extends CliCommand { + @override + String get name => 'hash'; + + @override + String get description => 'Hash text'; + + @override + Future<int> execute(List<String> args) async { + final values = args.where((a) => !a.startsWith('--')).toList(); + if (values.isEmpty) { + stderr.writeln('Error: hash requires text'); + return 1; + } + + var algo = 'sha256'; + for (var i = 0; i < args.length; i++) { + if (args[i] == '--algo' && i + 1 < args.length) algo = args[i + 1]; + } + final jsonOutput = args.contains('--json'); + final xmlOutput = args.contains('--xml'); + + final text = values[0]; + final bytes = utf8.encode(text); + String result; + + switch (algo.toLowerCase()) { + case 'md5': + result = crypto.md5.convert(bytes).toString(); + case 'sha1': + result = crypto.sha1.convert(bytes).toString(); + case 'sha384': + result = crypto.sha384.convert(bytes).toString(); + case 'sha512': + result = crypto.sha512.convert(bytes).toString(); + case 'sha256': + default: + result = crypto.sha256.convert(bytes).toString(); + } + + printFormatted( + {'hash': result, 'algo': algo}, + json: jsonOutput, xml: xmlOutput, + plain: (_) => result + ); + return 0; + } +} + +class UuidCommand extends CliCommand { + @override + String get name => 'uuid'; + + @override + String get description => 'Generate UUID'; + + @override + Future<int> execute(List<String> args) async { + final jsonOutput = args.contains('--json'); + final xmlOutput = args.contains('--xml'); + + final now = DateTime.now().microsecondsSinceEpoch; + final random = now.hashCode; + + final hex = StringBuffer(); + for (var i = 0; i < 32; i++) { + final value = ((now >> (i * 2)) ^ (random >> i)) & 0xF; + hex.write(value.toRadixString(16)); + } + + final uuidRaw = hex.toString(); + final result = '${uuidRaw.substring(0, 8)}-${uuidRaw.substring(8, 12)}-4${uuidRaw.substring(13, 16)}-${uuidRaw.substring(16, 20)}-${uuidRaw.substring(20, 32)}'; + + printFormatted( + {'uuid': result}, + json: jsonOutput, xml: xmlOutput, + plain: (_) => result + ); + return 0; + } +} + +class RandomCommand extends CliCommand { + @override + String get name => 'random'; + + @override + String get description => 'Generate random number'; + + @override + Future<int> execute(List<String> args) async { + final values = args.where((a) => !a.startsWith('--')).toList(); + final min = values.isNotEmpty ? int.tryParse(values[0]) ?? 0 : 0; + final max = values.length > 1 ? int.tryParse(values[1]) ?? 100 : 100; + final jsonOutput = args.contains('--json'); + final xmlOutput = args.contains('--xml'); + + final result = min + (DateTime.now().microsecond % (max - min + 1)); + + printFormatted( + {'random': result, 'min': min, 'max': max}, + json: jsonOutput, xml: xmlOutput, + plain: (_) => result.toString() + ); + return 0; + } +} + +class Base64Command extends CliCommand { + @override + String get name => 'base64'; + + @override + String get description => 'Base64 encode/decode'; + + @override + Future<int> execute(List<String> args) async { + if (args.isEmpty) { + stderr.writeln('Error: base64 requires subcommand (encode, decode)'); + return 1; + } + final subcommand = args[0]; + final values = args.sublist(1).where((a) => !a.startsWith('--')).toList(); + final jsonOutput = args.contains('--json'); + final xmlOutput = args.contains('--xml'); + + if (values.isEmpty) { + stderr.writeln('Error: base64 requires text'); + return 1; + } + final text = values[0]; + + if (subcommand == 'encode') { + final result = base64Encode(utf8.encode(text)); + printFormatted( + {'encoded': result}, + json: jsonOutput, xml: xmlOutput, + plain: (_) => result + ); + } else if (subcommand == 'decode') { + try { + final result = utf8.decode(base64Decode(text)); + printFormatted( + {'decoded': result}, + json: jsonOutput, xml: xmlOutput, + plain: (_) => result + ); + } catch (e) { + stderr.writeln('Error decoding base64: $e'); + return 1; + } + } else { + stderr.writeln('Error: Unknown base64 subcommand: $subcommand'); + return 1; + } + return 0; + } +} diff --git a/packages/crossbar_cli/lib/src/commands/vpn_command.dart b/packages/crossbar_cli/lib/src/commands/vpn_command.dart new file mode 100644 index 0000000..8c15215 --- /dev/null +++ b/packages/crossbar_cli/lib/src/commands/vpn_command.dart @@ -0,0 +1,48 @@ +// ignore_for_file: avoid_print +import 'dart:io'; + +import 'package:crossbar_core/crossbar_core.dart'; +import 'base_command.dart'; + +class VpnCommand extends CliCommand { + @override + String get name => 'vpn'; + + @override + String get description => 'VPN status'; + + @override + Future<int> execute(List<String> args) async { + final subcommand = args.isNotEmpty ? args[0] : 'status'; + + if (subcommand == 'status') { + return _status(args); + } else { + stderr.writeln('Error: Unknown vpn subcommand: $subcommand'); + return 1; + } + } + + Future<int> _status(List<String> args) async { + const api = UtilsApi(); + final jsonOutput = args.contains('--json'); + final xmlOutput = args.contains('--xml'); + + final result = await api.getVpnStatus(); + printFormatted( + result, + json: jsonOutput, + xml: xmlOutput, + xmlRoot: 'vpn', + plain: (_) { + if (result['connected'] == true) { + final name = result['name'] ?? result['type'] ?? 'VPN'; + return 'VPN: Connected ($name)'; + } else { + return 'VPN: Disconnected'; + } + } + ); + return 0; + } +} diff --git a/packages/crossbar_cli/lib/src/commands/wallpaper_command.dart b/packages/crossbar_cli/lib/src/commands/wallpaper_command.dart new file mode 100644 index 0000000..369ad32 --- /dev/null +++ b/packages/crossbar_cli/lib/src/commands/wallpaper_command.dart @@ -0,0 +1,42 @@ +// ignore_for_file: avoid_print +import 'package:crossbar_core/crossbar_core.dart'; +import 'base_command.dart'; + +class WallpaperCommand extends CliCommand { + @override + String get name => 'wallpaper'; + + @override + String get description => 'Get or set desktop wallpaper'; + + @override + Future<int> execute(List<String> args) async { + const api = UtilsApi(); + final values = args.where((a) => !a.startsWith('--')).toList(); + final jsonOutput = args.contains('--json'); + final xmlOutput = args.contains('--xml'); + + if (values.isEmpty) { + // Get + final result = await api.getWallpaper(); + printFormatted( + {'wallpaper': result}, + json: jsonOutput, + xml: xmlOutput, + plain: (_) => result + ); + } else { + // Set + final path = values[0]; + final result = await api.setWallpaper(path); + printFormatted( + {'success': result, 'wallpaper': path}, + json: jsonOutput, + xml: xmlOutput, + plain: (_) => result ? 'Wallpaper set to $path' : 'Failed to set wallpaper' + ); + return result ? 0 : 1; + } + return 0; + } +} diff --git a/packages/crossbar_cli/lib/src/commands/web_command.dart b/packages/crossbar_cli/lib/src/commands/web_command.dart new file mode 100644 index 0000000..1747592 --- /dev/null +++ b/packages/crossbar_cli/lib/src/commands/web_command.dart @@ -0,0 +1,260 @@ +// ignore_for_file: avoid_print +import 'dart:convert'; +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:dio/io.dart'; + +import 'base_command.dart'; + +/// HTTP client command powered by Dio +/// +/// Usage: +/// `crossbar web <url> [options]` +/// +/// Options: +/// --method GET|POST|PUT|DELETE|PATCH|HEAD (default: GET) +/// --headers '{"key":"value"}' JSON headers +/// --body '{"key":"value"}' Request body +/// --body-file path.json Read body from file +/// --timeout 10 Timeout in seconds (default: 30) +/// --user-agent "Custom Agent" Custom User-Agent +/// --insecure Skip SSL verification +/// --follow-redirects Follow redirects (default: true) +/// --json Output as JSON +/// --xml Output as XML +class WebCommand extends CliCommand { + @override + String get name => 'web'; + + @override + String get description => 'HTTP client (GET, POST, etc.)'; + + @override + Future<int> execute(List<String> args) async { + // Show help + if (args.isEmpty || args.contains('--help') || args.contains('-h')) { + print('''Usage: crossbar web <url> [options] + +Options: + --method GET|POST|PUT|DELETE|PATCH|HEAD + --headers '{"key":"value"}' + --body '{"key":"value"}' + --body-file path.json + --timeout 10 + --user-agent "Custom Agent" + --insecure + --json / --xml + +Examples: + crossbar web api.github.com/users/octocat + crossbar web httpbin.org/post --method POST --body '{"test":1}' '''); + return args.isEmpty ? 1 : 0; + } + + // Parse arguments + final jsonOutput = args.contains('--json'); + final xmlOutput = args.contains('--xml'); + final insecure = args.contains('--insecure'); + final followRedirects = !args.contains('--no-redirects'); + + // Extract URL (first non-flag argument) + String? url; + for (final arg in args) { + if (!arg.startsWith('--')) { + url = arg; + break; + } + } + + if (url == null || url.isEmpty) { + stderr.writeln('Error: URL is required'); + return 1; + } + + // Auto-prefix https:// if no protocol specified + if (!url.startsWith('http://') && !url.startsWith('https://')) { + url = 'https://$url'; + } + + // Parse options + var method = 'GET'; + Map<String, dynamic>? headers; + dynamic body; + var timeout = 30; + String? userAgent; + + for (var i = 0; i < args.length; i++) { + final arg = args[i]; + final hasNext = i + 1 < args.length; + + switch (arg) { + case '--method': + if (hasNext) method = args[++i].toUpperCase(); + case '--headers': + if (hasNext) { + try { + headers = jsonDecode(args[++i]) as Map<String, dynamic>; + } catch (e) { + stderr.writeln('Error: Invalid JSON in --headers'); + return 1; + } + } + case '--body': + if (hasNext) { + final bodyStr = args[++i]; + // Try to parse as JSON, otherwise use as string + try { + body = jsonDecode(bodyStr); + } catch (_) { + body = bodyStr; + } + } + case '--body-file': + if (hasNext) { + final filePath = args[++i]; + try { + final file = File(filePath); + if (!file.existsSync()) { + stderr.writeln('Error: Body file not found: $filePath'); + return 1; + } + final content = file.readAsStringSync(); + try { + body = jsonDecode(content); + } catch (_) { + body = content; + } + } catch (e) { + stderr.writeln('Error reading body file: $e'); + return 1; + } + } + case '--timeout': + if (hasNext) timeout = int.tryParse(args[++i]) ?? 30; + case '--user-agent': + if (hasNext) userAgent = args[++i]; + } + } + + // Configure Dio + final dio = Dio(BaseOptions( + connectTimeout: Duration(seconds: timeout), + receiveTimeout: Duration(seconds: timeout), + sendTimeout: Duration(seconds: timeout), + followRedirects: followRedirects, + maxRedirects: 5, + validateStatus: (status) => true, // Accept all status codes + headers: { + 'User-Agent': userAgent ?? 'Crossbar/1.0', + if (headers != null) ...headers, + }, + )); + + // Handle insecure (skip SSL verification) + if (insecure) { + dio.httpClientAdapter = IOHttpClientAdapter( + createHttpClient: () { + final client = HttpClient(); + client.badCertificateCallback = (cert, host, port) => true; + return client; + }, + ); + } + + try { + final Response<dynamic> response; + + switch (method) { + case 'GET': + response = await dio.get<dynamic>(url); + case 'POST': + response = await dio.post<dynamic>(url, data: body); + case 'PUT': + response = await dio.put<dynamic>(url, data: body); + case 'DELETE': + response = await dio.delete<dynamic>(url, data: body); + case 'PATCH': + response = await dio.patch<dynamic>(url, data: body); + case 'HEAD': + response = await dio.head<dynamic>(url); + default: + stderr.writeln('Error: Unknown method: $method'); + return 1; + } + + // Build result + final result = <String, dynamic>{ + 'status': response.statusCode, + 'statusMessage': response.statusMessage, + }; + + // Add response body + if (response.data != null) { + if (response.data is Map || response.data is List) { + result['data'] = response.data; + } else { + // Try to parse as JSON + try { + result['data'] = jsonDecode(response.data.toString()); + } catch (_) { + result['data'] = response.data.toString(); + } + } + } + + // Add headers if JSON/XML output requested + if (jsonOutput || xmlOutput) { + result['headers'] = response.headers.map.map( + (key, value) => MapEntry(key, value.length == 1 ? value.first : value), + ); + } + + // Output + printFormatted( + result, + json: jsonOutput, + xml: xmlOutput, + xmlRoot: 'response', + plain: (_) { + // For plain output, just return the body + final data = result['data']; + if (data is String) return data; + if (data is Map || data is List) return jsonEncode(data); + return data?.toString() ?? ''; + }, + ); + + // Return non-zero for HTTP errors in non-JSON mode + final statusCode = response.statusCode ?? 0; + if (!jsonOutput && !xmlOutput && statusCode >= 400) { + return 1; + } + + return 0; + } on DioException catch (e) { + final errorData = <String, dynamic>{ + 'error': true, + 'type': e.type.toString(), + 'message': e.message ?? 'Unknown error', + }; + + if (e.response != null) { + errorData['status'] = e.response?.statusCode; + errorData['data'] = e.response?.data; + } + + printFormatted( + errorData, + json: jsonOutput, + xml: xmlOutput, + xmlRoot: 'error', + plain: (_) => 'Error: ${e.message}', + ); + return 1; + } catch (e) { + stderr.writeln('Error: $e'); + return 1; + } + } +} diff --git a/packages/crossbar_cli/lib/src/commands/wifi_command.dart b/packages/crossbar_cli/lib/src/commands/wifi_command.dart new file mode 100644 index 0000000..ccc3137 --- /dev/null +++ b/packages/crossbar_cli/lib/src/commands/wifi_command.dart @@ -0,0 +1,95 @@ +// ignore_for_file: avoid_print +import 'dart:io'; + +import 'package:crossbar_core/crossbar_core.dart'; +import 'base_command.dart'; + +class WifiCommand extends CliCommand { + @override + String get name => 'wifi'; + + @override + String get description => 'WiFi control (on, off, ssid)'; + + @override + Future<int> execute(List<String> args) async { + const api = NetworkApi(); + final values = args.where((a) => !a.startsWith('--')).toList(); + final jsonOutput = args.contains('--json'); + final xmlOutput = args.contains('--xml'); + + if (values.isEmpty) { + // Get Status + final status = await _getWifiStatus(); + printFormatted( + {'wifi': status}, + json: jsonOutput, + xml: xmlOutput, + plain: (_) => status ? 'WiFi: On' : 'WiFi: Off' + ); + } else { + final val = values[0].toLowerCase(); + + if (val == 'ssid') { + final ssid = await api.getWifiSsid(); + printFormatted( + {'ssid': ssid}, + json: jsonOutput, + xml: xmlOutput, + plain: (_) => ssid + ); + return 0; + } + + bool newState; + if (val == 'on' || val == 'true') { + newState = true; + } else if (val == 'off' || val == 'false') { + newState = false; + } else if (val == 'toggle') { + final current = await _getWifiStatus(); + newState = !current; + } else { + stderr.writeln('Error: wifi requires on|off|toggle|ssid'); + return 1; + } + + final result = await api.setWifi(newState); + if (result) { + printFormatted( + {'success': true, 'wifi': newState}, + json: jsonOutput, + xml: xmlOutput, + plain: (_) => 'WiFi set to ${newState ? 'on' : 'off'}' + ); + } else { + stderr.writeln('Failed to set WiFi'); + return 1; + } + } + return 0; + } + + Future<bool> _getWifiStatus() async { + try { + if (Platform.isLinux) { + final result = await Process.run('nmcli', ['radio', 'wifi']); + if (result.exitCode == 0) { + return (result.stdout as String).trim() == 'enabled'; + } + } else if (Platform.isMacOS) { + final result = await Process.run('networksetup', ['-getairportpower', 'en0']); + if (result.exitCode == 0) { + return (result.stdout as String).contains(': On'); + } + } else if (Platform.isWindows) { + final result = await Process.run('netsh', ['interface', 'show', 'interface', 'Wi-Fi']); + if (result.exitCode == 0) { + return (result.stdout as String).contains('Enabled'); + } + } + } catch (_) {} + // Fallback or unknown + return false; + } +} diff --git a/packages/crossbar_cli/lib/src/core/plugin_manager_cli.dart b/packages/crossbar_cli/lib/src/core/plugin_manager_cli.dart new file mode 100644 index 0000000..a0d5986 --- /dev/null +++ b/packages/crossbar_cli/lib/src/core/plugin_manager_cli.dart @@ -0,0 +1,255 @@ +// ignore_for_file: avoid_slow_async_io +import 'dart:convert'; +import 'dart:io'; + +import 'package:crossbar_core/crossbar_core.dart'; +import 'package:path/path.dart' as path; + +/// CLI-only PluginManager - No Flutter dependencies +/// +/// Simplified version that: +/// - Only works on desktop (Linux, macOS, Windows) +/// - Reads config from JSON files (not secure storage) +/// - Uses external interpreters for all plugins +class PluginManagerCli { + factory PluginManagerCli() => _instance; + PluginManagerCli._internal(); + + static final PluginManagerCli _instance = PluginManagerCli._internal(); + + final List<Plugin> _plugins = []; + + static const List<String> allowedExtensions = [ + '.sh', '.py', '.js', '.dart', '.go', '.rs', '.lua', '.yaml', '.yml', + ]; + + static const Map<String, String> extensionToInterpreter = { + '.sh': 'bash', + '.py': 'python3', + '.js': 'node', + '.dart': 'dart', + '.go': 'go', + '.rs': 'rust', + '.lua': 'lua', + '.yaml': 'yaml', + '.yml': 'yaml', + }; + + List<Plugin> get plugins => List.unmodifiable(_plugins); + + String get pluginsDirectory { + final homeDir = Platform.environment['HOME'] ?? + Platform.environment['USERPROFILE'] ?? + ''; + return path.join(homeDir, '.crossbar', 'plugins'); + } + + Future<void> discoverPlugins() async { + _plugins.clear(); + + final pluginsDir = Directory(pluginsDirectory); + if (!await pluginsDir.exists()) { + return; + } + + await for (final entity in pluginsDir.list()) { + if (entity is File && _isValidPluginFile(entity.path)) { + final plugin = await _createPluginFromFile(entity); + if (plugin != null) { + _plugins.add(plugin); + } + } else if (entity is Directory) { + // Check subdirectories (git repos) but only 1 level deep + await for (final subEntity in entity.list()) { + if (subEntity is File && _isValidPluginFile(subEntity.path)) { + final plugin = await _createPluginFromFile(subEntity); + if (plugin != null) { + _plugins.add(plugin); + } + } + } + } + } + } + + bool _isValidPluginFile(String filePath) { + final ext = path.extension(filePath).toLowerCase(); + return allowedExtensions.contains(ext); + } + + Future<Plugin?> _createPluginFromFile(File file) async { + final fileName = path.basename(file.path); + + final interpreter = _detectInterpreter(file); + if (interpreter == null) return null; + + final refreshInterval = _parseRefreshInterval(fileName); + + var isEnabled = true; + if (fileName.contains('.off.')) { + isEnabled = false; + } else if (Platform.isLinux || Platform.isMacOS) { + try { + final stat = await file.stat(); + if ((stat.mode & 0x49) == 0) { + isEnabled = false; + } + } catch (_) {} + } + + final config = await _loadPluginConfig(file.path); + + return Plugin( + id: fileName, + path: file.path, + interpreter: interpreter, + refreshInterval: refreshInterval, + enabled: isEnabled, + config: config, + ); + } + + Future<PluginConfig?> _loadPluginConfig(String pluginPath) async { + final configPath = '$pluginPath.schema.json'; + final configFile = File(configPath); + + if (!await configFile.exists()) { + return null; + } + + try { + final content = await configFile.readAsString(); + final json = jsonDecode(content) as Map<String, dynamic>; + return PluginConfig.fromJson(json); + } catch (e) { + return null; + } + } + + String? _detectInterpreter(File file) { + final ext = path.extension(file.path).toLowerCase(); + + try { + final content = file.readAsStringSync(); + final lines = content.split('\n'); + if (lines.isNotEmpty) { + final firstLine = lines.first; + if (firstLine.startsWith('#!')) { + if (firstLine.contains('python')) return 'python3'; + if (firstLine.contains('node')) return 'node'; + if (firstLine.contains('bash')) return 'bash'; + if (firstLine.contains('sh')) return 'sh'; + if (firstLine.contains('dart')) return 'dart'; + } + } + } catch (_) {} + + return extensionToInterpreter[ext]; + } + + Duration _parseRefreshInterval(String fileName) { + final match = RegExp(r'\.(\d+(?:\.\d+)?)([smh])\.').firstMatch(fileName); + + if (match != null) { + final value = double.parse(match.group(1)!); + final unit = match.group(2)!; + + Duration interval; + switch (unit) { + case 's': + interval = Duration(milliseconds: (value * 1000).round()); + case 'm': + interval = Duration(minutes: value.round()); + case 'h': + interval = Duration(hours: value.round()); + default: + interval = const Duration(minutes: 5); + } + + if (interval < const Duration(seconds: 1)) { + return const Duration(seconds: 1); + } + + return interval; + } + + return const Duration(minutes: 5); + } + + Plugin? getPlugin(String pluginId) { + return _plugins.where((p) => p.id == pluginId).firstOrNull; + } + + /// Run a plugin and return its output + Future<PluginOutput?> runPlugin(String pluginId) async { + final plugin = getPlugin(pluginId); + if (plugin == null) return null; + + try { + // Load config values from JSON file + final configEnv = await _loadConfigValues(pluginId); + + // Build environment + final env = <String, String>{ + ...Platform.environment, + ...configEnv, + 'CROSSBAR_PLUGIN_ID': pluginId, + 'CROSSBAR_PLUGIN_PATH': plugin.path, + }; + + // Run the plugin + final result = await _executePlugin(plugin, env); + + if (result.exitCode != 0 && result.stderr.toString().isNotEmpty) { + return PluginOutput.error(pluginId, result.stderr.toString()); + } + + final output = result.stdout.toString(); + return OutputParser.parse(output, pluginId); + } catch (e) { + return PluginOutput.error(pluginId, e.toString()); + } + } + + Future<ProcessResult> _executePlugin(Plugin plugin, Map<String, String> env) async { + final ext = path.extension(plugin.path).toLowerCase(); + + switch (ext) { + case '.sh': + return Process.run('bash', [plugin.path], environment: env); + case '.py': + return Process.run('python3', [plugin.path], environment: env); + case '.js': + return Process.run('node', [plugin.path], environment: env); + case '.dart': + return Process.run('dart', ['run', plugin.path], environment: env); + case '.go': + return Process.run('go', ['run', plugin.path], environment: env); + case '.lua': + return Process.run('lua', [plugin.path], environment: env); + default: + return Process.run(plugin.path, [], environment: env); + } + } + + /// Load config values from JSON file + Future<Map<String, String>> _loadConfigValues(String pluginId) async { + final homeDir = Platform.environment['HOME'] ?? + Platform.environment['USERPROFILE'] ?? + ''; + final configPath = path.join(homeDir, '.crossbar', 'config', '$pluginId.json'); + final configFile = File(configPath); + + if (!await configFile.exists()) { + return {}; + } + + try { + final content = await configFile.readAsString(); + final json = jsonDecode(content) as Map<String, dynamic>; + return json.map((k, v) => MapEntry(k, v.toString())); + } catch (_) { + return {}; + } + } +} diff --git a/packages/crossbar_cli/lib/src/plugin_scaffolding.dart b/packages/crossbar_cli/lib/src/plugin_scaffolding.dart new file mode 100644 index 0000000..b39e209 --- /dev/null +++ b/packages/crossbar_cli/lib/src/plugin_scaffolding.dart @@ -0,0 +1,380 @@ +// ignore_for_file: avoid_slow_async_io +import 'dart:io'; + +import 'package:path/path.dart' as path; + +/// Service for scaffolding new plugins +class PluginScaffolding { + const PluginScaffolding(); + + /// Supported languages for plugin scaffolding + static const List<String> supportedLanguages = [ + 'bash', + 'python', + 'node', + 'dart', + 'go', + 'rust', + ]; + + /// Supported plugin types + static const List<String> supportedTypes = [ + 'clock', + 'monitor', + 'status', + 'api', + 'custom', + ]; + + /// Default refresh intervals for each type + static const Map<String, String> typeIntervals = { + 'clock': '1s', + 'monitor': '10s', + 'status': '30s', + 'api': '5m', + 'custom': '1m', + }; + + /// File extensions for each language + static const Map<String, String> langExtensions = { + 'bash': 'sh', + 'python': 'py', + 'node': 'js', + 'dart': 'dart', + 'go': 'go', + 'rust': 'rs', + }; + + /// Create a new plugin from template + Future<String?> createPlugin({ + required String lang, + required String type, + String? name, + String? outputDir, + }) async { + // Validate language + final normalizedLang = lang.toLowerCase(); + if (!supportedLanguages.contains(normalizedLang)) { + return null; + } + + // Validate type + final normalizedType = type.toLowerCase(); + if (!supportedTypes.contains(normalizedType)) { + return null; + } + + // Generate plugin name + final pluginName = name ?? 'my-$normalizedType'; + final interval = typeIntervals[normalizedType] ?? '1m'; + final ext = langExtensions[normalizedLang]!; + final fileName = '$pluginName.$interval.$ext'; + + // Determine output directory + final homeDir = Platform.environment['HOME'] ?? + Platform.environment['USERPROFILE'] ?? + '.'; + final pluginsDir = outputDir ?? path.join(homeDir, '.crossbar', 'plugins'); + + // Create directory if needed + await Directory(pluginsDir).create(recursive: true); + + // Generate plugin content + final content = _generateTemplate(normalizedLang, normalizedType, pluginName); + final filePath = path.join(pluginsDir, fileName); + + // Write plugin file + await File(filePath).writeAsString(content); + + // Make executable on Unix + if (Platform.isLinux || Platform.isMacOS) { + await Process.run('chmod', ['+x', filePath]); + } + + // Generate config file + final configContent = _generateConfig(pluginName, normalizedType); + final configPath = '$filePath.schema.json'; + await File(configPath).writeAsString(configContent); + + return filePath; + } + + /// Generate template content for a plugin + String _generateTemplate(String lang, String type, String name) { + switch (lang) { + case 'bash': + return _bashTemplate(type, name); + case 'python': + return _pythonTemplate(type, name); + case 'node': + return _nodeTemplate(type, name); + case 'dart': + return _dartTemplate(type, name); + case 'go': + return _goTemplate(type, name); + case 'rust': + return _rustTemplate(type, name); + default: + return _bashTemplate(type, name); + } + } + + String _bashTemplate(String type, String name) { + return '''#!/bin/bash +# $name - $type plugin +# Generated by Crossbar + +# Your plugin logic here +value="OK" + +# Determine color based on status +if [ "\$value" = "OK" ]; then + color="green" +else + color="red" +fi + +# Output in BitBar format +echo "✓ \$value | color=\$color" +echo "---" +echo "Plugin: $name" +echo "Type: $type" +echo "---" +echo "Refresh | refresh=true" +'''; + } + + String _pythonTemplate(String type, String name) { + return '''#!/usr/bin/env python3 +# $name - $type plugin +# Generated by Crossbar + +import json +import sys + +def main(): + # Your plugin logic here + value = "OK" + + # Simple text output + if value == "OK": + color = "green" + else: + color = "red" + + print(f"✓ {value} | color={color}") + print("---") + print(f"Plugin: $name") + print(f"Type: $type") + print("---") + print("Refresh | refresh=true") + +if __name__ == "__main__": + main() +'''; + } + + String _nodeTemplate(String type, String name) { + return '''#!/usr/bin/env node +// $name - $type plugin +// Generated by Crossbar + +function main() { + // Your plugin logic here + const value = "OK"; + + // Determine color + const color = value === "OK" ? "green" : "red"; + + // Output in BitBar format + console.log(`✓ \${value} | color=\${color}`); + console.log("---"); + console.log("Plugin: $name"); + console.log("Type: $type"); + console.log("---"); + console.log("Refresh | refresh=true"); +} + +main(); +'''; + } + + String _dartTemplate(String type, String name) { + return '''#!/usr/bin/env dart +// $name - $type plugin +// Generated by Crossbar + +void main() { + // Your plugin logic here + final value = 'OK'; + + // Determine color + final color = value == 'OK' ? 'green' : 'red'; + + // Output in BitBar format + print('✓ \$value | color=\$color'); + print('---'); + print('Plugin: $name'); + print('Type: $type'); + print('---'); + print('Refresh | refresh=true'); +} +'''; + } + + String _goTemplate(String type, String name) { + return '''// +build ignore + +package main + +// $name - $type plugin +// Generated by Crossbar + +import "fmt" + +func main() { + // Your plugin logic here + value := "OK" + + // Determine color + color := "green" + if value != "OK" { + color = "red" + } + + // Output in BitBar format + fmt.Printf("✓ %s | color=%s\\n", value, color) + fmt.Println("---") + fmt.Println("Plugin: $name") + fmt.Println("Type: $type") + fmt.Println("---") + fmt.Println("Refresh | refresh=true") +} +'''; + } + + String _rustTemplate(String type, String name) { + return '''// $name - $type plugin +// Generated by Crossbar + +fn main() { + // Your plugin logic here + let value = "OK"; + + // Determine color + let color = if value == "OK" { "green" } else { "red" }; + + // Output in BitBar format + println!("✓ {} | color={}", value, color); + println!("---"); + println!("Plugin: $name"); + println!("Type: $type"); + println!("---"); + println!("Refresh | refresh=true"); +} +'''; + } + + String _generateConfig(String name, String type) { + return '''{ + "name": "$name", + "version": "1.0.0", + "description": "A $type plugin generated by Crossbar", + "author": "", + "settings": [ + { + "key": "EXAMPLE_SETTING", + "type": "text", + "label": "Example Setting", + "default": "", + "required": false + } + ] +} +'''; + } +} + +/// Service for installing plugins from GitHub +class PluginInstaller { + const PluginInstaller(); + + /// Install a plugin from a GitHub URL + Future<String?> installFromGitHub(String url) async { + try { + // Parse GitHub URL + final uri = Uri.parse(url); + if (!uri.host.contains('github.com')) { + return null; + } + + // Extract owner/repo from path + final pathSegments = uri.pathSegments; + if (pathSegments.length < 2) { + return null; + } + + final repo = pathSegments[1].replaceAll('.git', ''); + + // Determine plugins directory + final homeDir = Platform.environment['HOME'] ?? + Platform.environment['USERPROFILE'] ?? + '.'; + final pluginsDir = path.join(homeDir, '.crossbar', 'plugins'); + + // Ensure plugins dir exists + await Directory(pluginsDir).create(recursive: true); + + // Target directory for the repo + final targetDir = path.join(pluginsDir, repo); + + // Remove existing clone if any + if (await Directory(targetDir).exists()) { + await Directory(targetDir).delete(recursive: true); + } + + final cloneResult = await Process.run( + 'git', + ['clone', '--depth', '1', url, targetDir], + ); + + if (cloneResult.exitCode != 0) { + return null; + } + + // Find plugin files in cloned repo to validate and chmod + final cloneDir = Directory(targetDir); + final pluginFiles = <File>[]; + + await for (final entity in cloneDir.list(recursive: true)) { + if (entity is File) { + final ext = path.extension(entity.path).toLowerCase(); + if (['.sh', '.py', '.js', '.dart', '.go', '.rs'].contains(ext)) { + // Check if filename matches plugin pattern (name.interval.ext) + final fileName = path.basename(entity.path); + if (RegExp(r'\.\d+[smh]\.').hasMatch(fileName)) { + pluginFiles.add(entity); + } + } + } + } + + if (pluginFiles.isEmpty) { + // Clean up - no valid plugins found + await Directory(targetDir).delete(recursive: true); + return null; + } + + // Make executable + if (Platform.isLinux || Platform.isMacOS) { + for (final file in pluginFiles) { + await Process.run('chmod', ['+x', file.path]); + } + } + + return targetDir; + } catch (e) { + return null; + } + } +} diff --git a/packages/crossbar_cli/pubspec.lock b/packages/crossbar_cli/pubspec.lock new file mode 100644 index 0000000..59c2ef1 --- /dev/null +++ b/packages/crossbar_cli/pubspec.lock @@ -0,0 +1,412 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: "5b7468c326d2f8a4f630056404ca0d291ade42918f4a3c6233618e724f39da8e" + url: "https://pub.dev" + source: hosted + version: "92.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "70e4b1ef8003c64793a9e268a551a82869a8a96f39deb73dea28084b0e8bf75e" + url: "https://pub.dev" + source: hosted + version: "9.0.0" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + coverage: + dependency: transitive + description: + name: coverage + sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d" + url: "https://pub.dev" + source: hosted + version: "1.15.0" + crossbar_core: + dependency: "direct main" + description: + path: "../crossbar_core" + relative: true + source: path + version: "1.0.0" + crypto: + dependency: "direct main" + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + dio: + dependency: "direct main" + description: + name: dio + sha256: d90ee57923d1828ac14e492ca49440f65477f4bb1263575900be731a3dac66a9 + url: "https://pub.dev" + source: hosted + version: "5.9.0" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + lints: + dependency: "direct dev" + description: + name: lints + sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 + url: "https://pub.dev" + source: hosted + version: "5.1.1" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" + url: "https://pub.dev" + source: hosted + version: "0.12.18" + meta: + dependency: transitive + description: + name: meta + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + url: "https://pub.dev" + source: hosted + version: "1.17.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + path: + dependency: "direct main" + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" + source: hosted + version: "1.1.3" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" + url: "https://pub.dev" + source: hosted + version: "0.10.13" + source_span: + dependency: transitive + description: + name: source_span + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + url: "https://pub.dev" + source: hosted + version: "1.10.1" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test: + dependency: "direct dev" + description: + name: test + sha256: "77cc98ea27006c84e71a7356cf3daf9ddbde2d91d84f77dbfe64cf0e4d9611ae" + url: "https://pub.dev" + source: hosted + version: "1.28.0" + test_api: + dependency: transitive + description: + name: test_api + sha256: "19a78f63e83d3a61f00826d09bc2f60e191bf3504183c001262be6ac75589fb8" + url: "https://pub.dev" + source: hosted + version: "0.7.8" + test_core: + dependency: transitive + description: + name: test_core + sha256: f1072617a6657e5fc09662e721307f7fb009b4ed89b19f47175d11d5254a62d4 + url: "https://pub.dev" + source: hosted + version: "0.6.14" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + url: "https://pub.dev" + source: hosted + version: "15.0.2" + watcher: + dependency: transitive + description: + name: watcher + sha256: f52385d4f73589977c80797e60fe51014f7f2b957b5e9a62c3f6ada439889249 + url: "https://pub.dev" + source: hosted + version: "1.2.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.9.0 <4.0.0" diff --git a/packages/crossbar_cli/pubspec.yaml b/packages/crossbar_cli/pubspec.yaml new file mode 100644 index 0000000..980379d --- /dev/null +++ b/packages/crossbar_cli/pubspec.yaml @@ -0,0 +1,21 @@ +name: crossbar_cli +description: Crossbar CLI - Command line interface for Crossbar +version: 1.0.0 +publish_to: none + +environment: + sdk: ^3.5.0 + +dependencies: + crossbar_core: + path: ../crossbar_core + path: ^1.9.0 + dio: ^5.7.0 + crypto: ^3.0.6 + +dev_dependencies: + lints: ^5.0.0 + test: ^1.25.8 + +executables: + crossbar: crossbar diff --git a/packages/crossbar_core/lib/crossbar_core.dart b/packages/crossbar_core/lib/crossbar_core.dart new file mode 100644 index 0000000..6b3595a --- /dev/null +++ b/packages/crossbar_core/lib/crossbar_core.dart @@ -0,0 +1,19 @@ +/// Crossbar Core - Shared APIs and models +/// +/// This package contains pure Dart implementations that work +/// both in CLI and Flutter contexts. +library crossbar_core; + +// APIs +export 'src/api/media_api.dart'; +export 'src/api/network_api.dart'; +export 'src/api/system_api.dart'; +export 'src/api/utils_api.dart'; + +// Models +export 'src/models/plugin.dart'; +export 'src/models/plugin_config.dart'; +export 'src/models/plugin_output.dart'; + +// Core +export 'src/core/output_parser.dart'; diff --git a/packages/crossbar_core/lib/src/api/media_api.dart b/packages/crossbar_core/lib/src/api/media_api.dart new file mode 100644 index 0000000..14aa05a --- /dev/null +++ b/packages/crossbar_core/lib/src/api/media_api.dart @@ -0,0 +1,672 @@ +import 'dart:convert'; +import 'dart:io'; + +/// Media control API for playback, volume, and brightness controls. +/// Uses platform-specific implementations: +/// - Linux: playerctl (MPRIS D-Bus), PulseAudio, sysfs +/// - macOS: AppleScript, MediaRemote +/// - Windows: PowerShell, MediaPlayer +class MediaApi { + const MediaApi(); + + // ============================================================ + // MEDIA PLAYBACK CONTROLS + // ============================================================ + + /// Resume media playback + Future<bool> play() async { + try { + if (Platform.isLinux) { + final result = await Process.run('playerctl', ['play']); + return result.exitCode == 0; + } + if (Platform.isMacOS) { + final result = await Process.run('osascript', [ + '-e', + 'tell application "System Events" to key code 16 using control down', + ]); + return result.exitCode == 0; + } + if (Platform.isWindows) { + final result = await Process.run('powershell', [ + '-command', + '(New-Object -ComObject WScript.Shell).SendKeys([char]0xB3)', + ]); + return result.exitCode == 0; + } + return false; + } catch (e) { + return false; + } + } + + /// Pause media playback + Future<bool> pause() async { + try { + if (Platform.isLinux) { + final result = await Process.run('playerctl', ['pause']); + return result.exitCode == 0; + } + if (Platform.isMacOS) { + final result = await Process.run('osascript', [ + '-e', + 'tell application "System Events" to key code 16 using control down', + ]); + return result.exitCode == 0; + } + if (Platform.isWindows) { + final result = await Process.run('powershell', [ + '-command', + '(New-Object -ComObject WScript.Shell).SendKeys([char]0xB3)', + ]); + return result.exitCode == 0; + } + return false; + } catch (e) { + return false; + } + } + + /// Toggle play/pause + Future<bool> playPause() async { + try { + if (Platform.isLinux) { + final result = await Process.run('playerctl', ['play-pause']); + return result.exitCode == 0; + } + if (Platform.isMacOS) { + final result = await Process.run('osascript', [ + '-e', + 'tell application "System Events" to key code 16 using control down', + ]); + return result.exitCode == 0; + } + if (Platform.isWindows) { + final result = await Process.run('powershell', [ + '-command', + '(New-Object -ComObject WScript.Shell).SendKeys([char]0xB3)', + ]); + return result.exitCode == 0; + } + return false; + } catch (e) { + return false; + } + } + + /// Stop media playback + Future<bool> stop() async { + try { + if (Platform.isLinux) { + final result = await Process.run('playerctl', ['stop']); + return result.exitCode == 0; + } + if (Platform.isMacOS) { + final result = await Process.run('osascript', [ + '-e', + 'tell application "Music" to stop', + ]); + return result.exitCode == 0; + } + if (Platform.isWindows) { + final result = await Process.run('powershell', [ + '-command', + '(New-Object -ComObject WScript.Shell).SendKeys([char]0xB2)', + ]); + return result.exitCode == 0; + } + return false; + } catch (e) { + return false; + } + } + + /// Skip to next track + Future<bool> next() async { + try { + if (Platform.isLinux) { + final result = await Process.run('playerctl', ['next']); + return result.exitCode == 0; + } + if (Platform.isMacOS) { + final result = await Process.run('osascript', [ + '-e', + 'tell application "System Events" to key code 17 using control down', + ]); + return result.exitCode == 0; + } + if (Platform.isWindows) { + final result = await Process.run('powershell', [ + '-command', + '(New-Object -ComObject WScript.Shell).SendKeys([char]0xB0)', + ]); + return result.exitCode == 0; + } + return false; + } catch (e) { + return false; + } + } + + /// Go to previous track + Future<bool> previous() async { + try { + if (Platform.isLinux) { + final result = await Process.run('playerctl', ['previous']); + return result.exitCode == 0; + } + if (Platform.isMacOS) { + final result = await Process.run('osascript', [ + '-e', + 'tell application "System Events" to key code 18 using control down', + ]); + return result.exitCode == 0; + } + if (Platform.isWindows) { + final result = await Process.run('powershell', [ + '-command', + '(New-Object -ComObject WScript.Shell).SendKeys([char]0xB1)', + ]); + return result.exitCode == 0; + } + return false; + } catch (e) { + return false; + } + } + + /// Seek relative position (e.g., "+30", "-10" in seconds) + Future<bool> seek(String offset) async { + try { + // Parse offset string like "+30s", "-10s", "+30", "-10" + final cleanOffset = offset.replaceAll('s', ''); + final seconds = int.tryParse(cleanOffset) ?? 0; + + if (Platform.isLinux) { + // playerctl position expects offset in seconds with +/- prefix + final result = await Process.run('playerctl', [ + 'position', + '${seconds >= 0 ? '+' : ''}$seconds', + ]); + return result.exitCode == 0; + } + if (Platform.isMacOS) { + final result = await Process.run('osascript', [ + '-e', + ''' + tell application "Music" + set player position to (player position + $seconds) + end tell + ''', + ]); + return result.exitCode == 0; + } + if (Platform.isWindows) { + // Windows media keys don't support seek directly + // Would require specific player control + return false; + } + return false; + } catch (e) { + return false; + } + } + + /// Get current playing track info + Future<Map<String, dynamic>> getPlaying() async { + try { + if (Platform.isLinux) { + return _getLinuxPlaying(); + } + if (Platform.isMacOS) { + return _getMacOsPlaying(); + } + if (Platform.isWindows) { + return _getWindowsPlaying(); + } + return {'status': 'unsupported'}; + } catch (e) { + return {'status': 'error', 'error': e.toString()}; + } + } + + Future<Map<String, dynamic>> _getLinuxPlaying() async { + try { + final statusResult = await Process.run('playerctl', ['status']); + final status = (statusResult.stdout as String).trim().toLowerCase(); + + if (status == 'no players found' || statusResult.exitCode != 0) { + return {'status': 'stopped', 'playing': false}; + } + + final metadataResult = await Process.run('playerctl', [ + 'metadata', + '--format', + '{{artist}}|||{{title}}|||{{album}}|||{{duration(position)}}|||{{duration(mpris:length)}}', + ]); + + final parts = (metadataResult.stdout as String).trim().split('|||'); + + return { + 'status': status, + 'playing': status == 'playing', + 'artist': parts.isNotEmpty ? parts[0] : '', + 'title': parts.length > 1 ? parts[1] : '', + 'album': parts.length > 2 ? parts[2] : '', + 'position': parts.length > 3 ? parts[3] : '', + 'duration': parts.length > 4 ? parts[4] : '', + }; + } catch (e) { + // playerctl not installed or other error + return {'status': 'unavailable', 'playing': false}; + } + } + + Future<Map<String, dynamic>> _getMacOsPlaying() async { + final result = await Process.run('osascript', [ + '-e', + ''' + tell application "Music" + if player state is playing then + set trackName to name of current track + set trackArtist to artist of current track + set trackAlbum to album of current track + set trackDuration to duration of current track + set trackPosition to player position + return "playing|||" & trackArtist & "|||" & trackName & "|||" & trackAlbum & "|||" & trackPosition & "|||" & trackDuration + else + return "stopped" + end if + end tell + ''', + ]); + + final output = (result.stdout as String).trim(); + if (output == 'stopped' || result.exitCode != 0) { + return {'status': 'stopped', 'playing': false}; + } + + final parts = output.split('|||'); + return { + 'status': parts[0], + 'playing': parts[0] == 'playing', + 'artist': parts.length > 1 ? parts[1] : '', + 'title': parts.length > 2 ? parts[2] : '', + 'album': parts.length > 3 ? parts[3] : '', + 'position': parts.length > 4 ? parts[4] : '', + 'duration': parts.length > 5 ? parts[5] : '', + }; + } + + Future<Map<String, dynamic>> _getWindowsPlaying() async { + // Windows doesn't have a universal way to get media info + // This would require specific player integration + return {'status': 'unsupported', 'playing': false}; + } + + // ============================================================ + // AUDIO VOLUME CONTROLS + // ============================================================ + + /// Get current volume (0-100) + Future<int> getVolume() async { + try { + if (Platform.isLinux) { + final result = await Process.run( + 'pactl', + ['get-sink-volume', '@DEFAULT_SINK@'], + ); + final output = result.stdout as String; + final match = RegExp(r'(\d+)%').firstMatch(output); + return match != null ? int.parse(match.group(1)!) : 0; + } + if (Platform.isMacOS) { + final result = await Process.run('osascript', [ + '-e', + 'output volume of (get volume settings)', + ]); + return int.tryParse((result.stdout as String).trim()) ?? 0; + } + if (Platform.isWindows) { + final result = await Process.run('powershell', [ + '-command', + '[Audio]::Volume * 100', + ]); + return int.tryParse((result.stdout as String).trim()) ?? 0; + } + return 0; + } catch (e) { + return 0; + } + } + + /// Set volume (0-100) + Future<bool> setVolume(int level) async { + final clampedLevel = level.clamp(0, 100); + try { + if (Platform.isLinux) { + final result = await Process.run('pactl', [ + 'set-sink-volume', + '@DEFAULT_SINK@', + '$clampedLevel%', + ]); + return result.exitCode == 0; + } + if (Platform.isMacOS) { + final result = await Process.run('osascript', [ + '-e', + 'set volume output volume $clampedLevel', + ]); + return result.exitCode == 0; + } + if (Platform.isWindows) { + // Using nircmd if available, otherwise PowerShell + final result = await Process.run('powershell', [ + '-command', + ''' + \$wshShell = New-Object -ComObject WScript.Shell + for (\$i = 0; \$i -lt 50; \$i++) { \$wshShell.SendKeys([char]174) } + for (\$i = 0; \$i -lt $clampedLevel / 2; \$i++) { \$wshShell.SendKeys([char]175) } + ''', + ]); + return result.exitCode == 0; + } + return false; + } catch (e) { + return false; + } + } + + /// Get mute status + Future<bool> isMuted() async { + try { + if (Platform.isLinux) { + final result = await Process.run( + 'pactl', + ['get-sink-mute', '@DEFAULT_SINK@'], + ); + return (result.stdout as String).contains('yes'); + } + if (Platform.isMacOS) { + final result = await Process.run('osascript', [ + '-e', + 'output muted of (get volume settings)', + ]); + return (result.stdout as String).trim() == 'true'; + } + if (Platform.isWindows) { + final result = await Process.run('powershell', [ + '-command', + '[Audio]::Mute', + ]); + return (result.stdout as String).trim().toLowerCase() == 'true'; + } + return false; + } catch (e) { + return false; + } + } + + /// Toggle mute + Future<bool> toggleMute() async { + try { + if (Platform.isLinux) { + final result = await Process.run('pactl', [ + 'set-sink-mute', + '@DEFAULT_SINK@', + 'toggle', + ]); + return result.exitCode == 0; + } + if (Platform.isMacOS) { + final result = await Process.run('osascript', [ + '-e', + ''' + set currentMute to output muted of (get volume settings) + set volume with output muted (not currentMute) + ''', + ]); + return result.exitCode == 0; + } + if (Platform.isWindows) { + final result = await Process.run('powershell', [ + '-command', + '(New-Object -ComObject WScript.Shell).SendKeys([char]173)', + ]); + return result.exitCode == 0; + } + return false; + } catch (e) { + return false; + } + } + + /// Set mute state explicitly + Future<bool> setMute(bool muted) async { + try { + if (Platform.isLinux) { + final result = await Process.run('pactl', [ + 'set-sink-mute', + '@DEFAULT_SINK@', + muted ? '1' : '0', + ]); + return result.exitCode == 0; + } + if (Platform.isMacOS) { + final result = await Process.run('osascript', [ + '-e', + 'set volume with output muted $muted', + ]); + return result.exitCode == 0; + } + if (Platform.isWindows) { + // Toggle approach if current state differs + final currentMute = await isMuted(); + if (currentMute != muted) { + return toggleMute(); + } + return true; + } + return false; + } catch (e) { + return false; + } + } + + /// Get current audio output device + Future<String> getAudioOutput() async { + try { + if (Platform.isLinux) { + final result = await Process.run('pactl', ['get-default-sink']); + return (result.stdout as String).trim(); + } + if (Platform.isMacOS) { + final result = await Process.run('sh', [ + '-c', + "system_profiler SPAudioDataType | grep 'Default Output Device' | cut -d':' -f2", + ]); + return (result.stdout as String).trim(); + } + if (Platform.isWindows) { + final result = await Process.run('powershell', [ + '-command', + 'Get-AudioDevice -Playback | Select-Object -ExpandProperty Name', + ]); + return (result.stdout as String).trim(); + } + return 'unknown'; + } catch (e) { + return 'unknown'; + } + } + + /// List available audio output devices + Future<List<Map<String, String>>> listAudioOutputs() async { + try { + if (Platform.isLinux) { + final result = await Process.run( + 'pactl', + ['list', 'sinks', 'short'], + ); + final lines = (result.stdout as String).trim().split('\n'); + return lines.where((l) => l.isNotEmpty).map((line) { + final parts = line.split('\t'); + return { + 'id': parts.isNotEmpty ? parts[0] : '', + 'name': parts.length > 1 ? parts[1] : '', + 'driver': parts.length > 2 ? parts[2] : '', + }; + }).toList(); + } + if (Platform.isMacOS) { + final result = await Process.run('sh', [ + '-c', + "system_profiler SPAudioDataType | grep -A1 'Output:' | grep 'Name'", + ]); + final lines = (result.stdout as String).trim().split('\n'); + return lines.where((l) => l.isNotEmpty).map((line) { + final name = line.replaceAll('Name:', '').trim(); + return {'id': name, 'name': name}; + }).toList(); + } + if (Platform.isWindows) { + final result = await Process.run('powershell', [ + '-command', + 'Get-AudioDevice -List | Where-Object Type -eq "Playback" | ConvertTo-Json', + ]); + try { + final json = jsonDecode(result.stdout as String); + if (json is List) { + return json.map<Map<String, String>>((item) { + return { + 'id': item['ID']?.toString() ?? '', + 'name': item['Name']?.toString() ?? '', + }; + }).toList(); + } + } catch (_) {} + return []; + } + return []; + } catch (e) { + return []; + } + } + + /// Set audio output device + Future<bool> setAudioOutput(String deviceId) async { + try { + if (Platform.isLinux) { + final result = await Process.run('pactl', [ + 'set-default-sink', + deviceId, + ]); + return result.exitCode == 0; + } + if (Platform.isMacOS) { + // Requires SwitchAudioSource or similar tool + final result = await Process.run('SwitchAudioSource', ['-s', deviceId]); + return result.exitCode == 0; + } + if (Platform.isWindows) { + final result = await Process.run('powershell', [ + '-command', + 'Set-AudioDevice -ID "$deviceId"', + ]); + return result.exitCode == 0; + } + return false; + } catch (e) { + return false; + } + } + + // ============================================================ + // SCREEN BRIGHTNESS CONTROLS + // ============================================================ + + /// Get current screen brightness (0-100) + Future<int> getBrightness() async { + try { + if (Platform.isLinux) { + // Try brightnessctl first + var result = await Process.run('brightnessctl', ['get']); + if (result.exitCode == 0) { + final current = int.tryParse((result.stdout as String).trim()) ?? 0; + result = await Process.run('brightnessctl', ['max']); + final max = int.tryParse((result.stdout as String).trim()) ?? 100; + return (current * 100 / max).round(); + } + + // Fallback to sysfs + result = await Process.run('sh', [ + '-c', + 'cat /sys/class/backlight/*/brightness /sys/class/backlight/*/max_brightness 2>/dev/null | head -2', + ]); + final lines = (result.stdout as String).trim().split('\n'); + if (lines.length >= 2) { + final current = int.tryParse(lines[0]) ?? 0; + final max = int.tryParse(lines[1]) ?? 100; + return (current * 100 / max).round(); + } + return 0; + } + if (Platform.isMacOS) { + final result = await Process.run('brightness', ['-l']); + final output = result.stdout as String; + final match = + RegExp(r'display 0: brightness ([\d.]+)').firstMatch(output); + if (match != null) { + return (double.parse(match.group(1)!) * 100).round(); + } + return 0; + } + if (Platform.isWindows) { + final result = await Process.run('powershell', [ + '-command', + '(Get-WmiObject -Namespace root/WMI -Class WmiMonitorBrightness).CurrentBrightness', + ]); + return int.tryParse((result.stdout as String).trim()) ?? 0; + } + return 0; + } catch (e) { + return 0; + } + } + + /// Set screen brightness (0-100) + Future<bool> setBrightness(int level) async { + final clampedLevel = level.clamp(0, 100); + try { + if (Platform.isLinux) { + // Try brightnessctl first + var result = + await Process.run('brightnessctl', ['set', '$clampedLevel%']); + if (result.exitCode == 0) return true; + + // Fallback to xrandr (requires output name) + final brightness = clampedLevel / 100.0; + result = await Process.run('sh', [ + '-c', + "xrandr --output \$(xrandr | grep ' connected' | head -1 | cut -d' ' -f1) --brightness $brightness", + ]); + return result.exitCode == 0; + } + if (Platform.isMacOS) { + final brightness = clampedLevel / 100.0; + final result = await Process.run('brightness', ['$brightness']); + return result.exitCode == 0; + } + if (Platform.isWindows) { + final result = await Process.run('powershell', [ + '-command', + '(Get-WmiObject -Namespace root/WMI -Class WmiMonitorBrightnessMethods).WmiSetBrightness(1, $clampedLevel)', + ]); + return result.exitCode == 0; + } + return false; + } catch (e) { + return false; + } + } +} diff --git a/packages/crossbar_core/lib/src/api/network_api.dart b/packages/crossbar_core/lib/src/api/network_api.dart new file mode 100644 index 0000000..f3ec1e1 --- /dev/null +++ b/packages/crossbar_core/lib/src/api/network_api.dart @@ -0,0 +1,280 @@ +import 'dart:convert'; +import 'dart:io'; + +class NetworkApi { + const NetworkApi(); + + Future<String> getNetStatus() async { + try { + final result = await InternetAddress.lookup('google.com'); + if (result.isNotEmpty && result[0].rawAddress.isNotEmpty) { + return 'online'; + } + return 'offline'; + } catch (_) { + return 'offline'; + } + } + + Future<String> getLocalIp() async { + try { + final interfaces = await NetworkInterface.list( + type: InternetAddressType.IPv4, + ); + + for (final interface in interfaces) { + for (final addr in interface.addresses) { + if (!addr.isLoopback) { + return addr.address; + } + } + } + + return '127.0.0.1'; + } catch (e) { + return '127.0.0.1'; + } + } + + Future<String> getPublicIp() async { + try { + final client = HttpClient(); + final request = await client.getUrl( + Uri.parse('https://api.ipify.org?format=text'), + ); + final response = await request.close(); + + if (response.statusCode == 200) { + final body = await response.transform(utf8.decoder).join(); + return body.trim(); + } + + return 'Unknown'; + } catch (e) { + return 'Unknown'; + } + } + + Future<String> getWifiSsid() async { + try { + if (Platform.isLinux) { + final result = await Process.run('iwgetid', ['-r']); + if (result.exitCode == 0) { + return (result.stdout as String).trim(); + } + + final nmResult = await Process.run('nmcli', [ + '-t', + '-f', + 'active,ssid', + 'dev', + 'wifi', + ]); + if (nmResult.exitCode == 0) { + final lines = (nmResult.stdout as String).split('\n'); + for (final line in lines) { + if (line.startsWith('yes:')) { + return line.substring(4); + } + } + } + } + + if (Platform.isMacOS) { + final result = await Process.run( + '/System/Library/PrivateFrameworks/Apple80211.framework/Versions/Current/Resources/airport', + ['-I'], + ); + if (result.exitCode == 0) { + final match = + RegExp(r'SSID:\s*(.+)').firstMatch(result.stdout as String); + if (match != null) { + return match.group(1)!.trim(); + } + } + } + + if (Platform.isWindows) { + final result = await Process.run('netsh', [ + 'wlan', + 'show', + 'interfaces', + ]); + if (result.exitCode == 0) { + final match = + RegExp(r'SSID\s*:\s*(.+)').firstMatch(result.stdout as String); + if (match != null) { + return match.group(1)!.trim(); + } + } + } + + return 'Unknown'; + } catch (e) { + return 'Unknown'; + } + } + + Future<String> ping(String host) async { + try { + final flag = Platform.isWindows ? '-n' : '-c'; + final result = await Process.run('ping', [flag, '1', host]); + + if (result.exitCode == 0) { + final output = result.stdout as String; + + RegExp pattern; + if (Platform.isWindows) { + pattern = RegExp(r'Average = (\d+)ms'); + } else { + pattern = RegExp(r'time[=<](\d+\.?\d*)\s*ms'); + } + + final match = pattern.firstMatch(output); + if (match != null) { + return '${match.group(1)}ms'; + } + } + + return 'timeout'; + } catch (e) { + return 'error'; + } + } + + Future<String> makeRequest( + String url, { + String method = 'GET', + Map<String, String>? headers, + String? body, + Duration timeout = const Duration(seconds: 30), + }) async { + try { + final client = HttpClient(); + client.connectionTimeout = timeout; + + final uri = Uri.parse(url); + HttpClientRequest request; + + switch (method.toUpperCase()) { + case 'POST': + request = await client.postUrl(uri); + case 'PUT': + request = await client.putUrl(uri); + case 'DELETE': + request = await client.deleteUrl(uri); + case 'HEAD': + request = await client.headUrl(uri); + default: + request = await client.getUrl(uri); + } + + headers?.forEach((key, value) { + request.headers.add(key, value); + }); + + if (body != null) { + request.headers.contentType = ContentType.json; + request.write(body); + } + + final response = await request.close(); + final responseBody = await response.transform(utf8.decoder).join(); + + return responseBody; + } catch (e) { + throw Exception('Request failed: $e'); + } + } + + Future<Map<String, dynamic>> makeRequestJson( + String url, { + String method = 'GET', + Map<String, String>? headers, + String? body, + Duration timeout = const Duration(seconds: 30), + }) async { + final response = await makeRequest( + url, + method: method, + headers: headers, + body: body, + timeout: timeout, + ); + + return jsonDecode(response) as Map<String, dynamic>; + } + + Future<bool> setWifi(bool enabled) async { + try { + if (Platform.isLinux) { + final action = enabled ? 'on' : 'off'; + final result = await Process.run('nmcli', ['radio', 'wifi', action]); + return result.exitCode == 0; + } + + if (Platform.isMacOS) { + final action = enabled ? 'on' : 'off'; + final result = await Process.run('networksetup', [ + '-setairportpower', + 'en0', + action, + ]); + return result.exitCode == 0; + } + + if (Platform.isWindows) { + final action = enabled ? 'enable' : 'disable'; + final result = await Process.run('netsh', [ + 'interface', + 'set', + 'interface', + 'Wi-Fi', + action, + ]); + return result.exitCode == 0; + } + + return false; + } catch (e) { + return false; + } + } + + Future<String> getBluetoothStatus() async { + try { + if (Platform.isLinux) { + final result = await Process.run('bluetoothctl', ['show']); + if (result.exitCode == 0) { + final output = result.stdout as String; + if (output.contains('Powered: yes')) { + final devicesResult = await Process.run('bluetoothctl', ['devices']); + final devices = (devicesResult.stdout as String) + .split('\n') + .where((l) => l.trim().isNotEmpty) + .length; + return devices > 0 ? 'on:$devices' : 'on'; + } + return 'off'; + } + } + + if (Platform.isMacOS) { + final result = await Process.run('system_profiler', [ + 'SPBluetoothDataType', + ]); + if (result.exitCode == 0) { + final output = result.stdout as String; + if (output.contains('State: On')) { + return 'on'; + } + return 'off'; + } + } + + return 'unknown'; + } catch (e) { + return 'unknown'; + } + } +} diff --git a/packages/crossbar_core/lib/src/api/system_api.dart b/packages/crossbar_core/lib/src/api/system_api.dart new file mode 100644 index 0000000..bfc096c --- /dev/null +++ b/packages/crossbar_core/lib/src/api/system_api.dart @@ -0,0 +1,598 @@ +// ignore_for_file: avoid_slow_async_io +import 'dart:io'; + +class SystemApi { + SystemApi(); + + // State for CPU calculation (Linux) + List<int>? _lastLinuxCpuValues; + + Future<String> getCpuUsage() async { + try { + if (Platform.isLinux) { + return _getLinuxCpuUsage(); + } + + if (Platform.isMacOS) { + return _getMacOsCpuUsage(); + } + + if (Platform.isWindows) { + return _getWindowsCpuUsage(); + } + + if (Platform.isAndroid) { + return _getAndroidCpuUsage(); + } + + return '0.0'; + } catch (e) { + return '0.0'; + } + } + + /// Synchronous CPU usage (Stateful for Linux/Android) + String getCpuUsageSync() { + try { + if (Platform.isLinux || Platform.isAndroid) { + return _getLinuxCpuUsageSync(); + } + return '0.0'; + } catch (e) { + return '0.0'; + } + } + + Future<String> _getLinuxCpuUsage() async { + // Current Async implementation does sleep 100ms. + // We can keep it or switch to stateful too. Keeping it stateless for async is fine for now. + final stat1 = await File('/proc/stat').readAsString(); + await Future<void>.delayed(const Duration(milliseconds: 100)); + final stat2 = await File('/proc/stat').readAsString(); + + final values1 = _parseProcStat(stat1); + final values2 = _parseProcStat(stat2); + + if (values1 == null || values2 == null) return '0.0'; + + final idle1 = values1[3]; + final idle2 = values2[3]; + final total1 = values1.reduce((a, b) => a + b); + final total2 = values2.reduce((a, b) => a + b); + + final idleDelta = idle2 - idle1; + final totalDelta = total2 - total1; + + if (totalDelta == 0) return '0.0'; + + final usage = (totalDelta - idleDelta) / totalDelta * 100; + return usage.toStringAsFixed(1); + } + + String _getLinuxCpuUsageSync() { + try { + final content = File('/proc/stat').readAsStringSync(); + final currentValues = _parseProcStat(content); + + if (currentValues == null) return '0.0'; + + if (_lastLinuxCpuValues == null) { + _lastLinuxCpuValues = currentValues; + return '...'; // Initializing + } + + final values1 = _lastLinuxCpuValues!; + final values2 = currentValues; + + // Update state for next call (continuous measurement) + _lastLinuxCpuValues = currentValues; + + final idle1 = values1[3]; + final idle2 = values2[3]; + final total1 = values1.reduce((a, b) => a + b); + final total2 = values2.reduce((a, b) => a + b); + + final idleDelta = idle2 - idle1; + final totalDelta = total2 - total1; + + if (totalDelta == 0) return '0.0'; + + final usage = (totalDelta - idleDelta) / totalDelta * 100; + return usage.toStringAsFixed(1); + } catch (e) { + return '0.0'; + } + } + + List<int>? _parseProcStat(String content) { + final lines = content.split('\n'); + for (final line in lines) { + if (line.startsWith('cpu ')) { + final parts = line.split(RegExp(r'\s+')).skip(1).toList(); + if (parts.length >= 4) { + return parts.take(7).map(int.parse).toList(); + } + } + } + return null; + } + + Future<String> _getMacOsCpuUsage() async { + final result = await Process.run( + 'sh', + ['-c', "top -l1 | grep 'CPU usage'"], + ); + + final match = RegExp(r'(\d+\.\d+)%\s+user').firstMatch(result.stdout as String); + if (match != null) { + final userPercent = double.parse(match.group(1)!); + final sysMatch = RegExp(r'(\d+\.\d+)%\s+sys').firstMatch(result.stdout as String); + if (sysMatch != null) { + final sysPercent = double.parse(sysMatch.group(1)!); + return (userPercent + sysPercent).toStringAsFixed(1); + } + return userPercent.toStringAsFixed(1); + } + return '0.0'; + } + + Future<String> _getWindowsCpuUsage() async { + final result = await Process.run( + 'wmic', + ['cpu', 'get', 'loadpercentage'], + ); + + final match = RegExp(r'(\d+)').firstMatch(result.stdout as String); + if (match != null) { + return '${match.group(1)}.0'; + } + return '0.0'; + } + + /// Android CPU usage via /proc/stat + /// Note: May be blocked on Android 8+ due to security + Future<String> _getAndroidCpuUsage() async { + try { + // Use /proc/stat (may be blocked on Android 8+) + final stat1 = await File('/proc/stat').readAsString(); + await Future<void>.delayed(const Duration(milliseconds: 100)); + final stat2 = await File('/proc/stat').readAsString(); + + final values1 = _parseProcStat(stat1); + final values2 = _parseProcStat(stat2); + + if (values1 == null || values2 == null) return '0.0'; + + final idle1 = values1[3]; + final idle2 = values2[3]; + final total1 = values1.reduce((a, b) => a + b); + final total2 = values2.reduce((a, b) => a + b); + + final idleDelta = idle2 - idle1; + final totalDelta = total2 - total1; + + if (totalDelta == 0) return '0.0'; + + final usage = (totalDelta - idleDelta) / totalDelta * 100; + return usage.toStringAsFixed(1); + } catch (e) { + return '0.0'; + } + } + + // MEMORY + + Future<String> getMemoryUsage() async { + try { + if (Platform.isLinux || Platform.isAndroid) { + // Android can also read /proc/meminfo + return _getLinuxMemoryUsage(); + } + if (Platform.isMacOS) { + return _getMacOsMemoryUsage(); + } + if (Platform.isWindows) { + return _getWindowsMemoryUsage(); + } + return 'Unknown'; + } catch (e) { + return 'Unknown'; + } + } + + String getMemoryUsageSync() { + try { + if (Platform.isLinux || Platform.isAndroid) { + return _getLinuxMemoryUsageSync(); + } + // TODO: Implement sync for macOS/Windows if needed + return 'Unknown'; + } catch (e) { + return 'Unknown'; + } + } + + Future<String> _getLinuxMemoryUsage() async { + final memInfo = await File('/proc/meminfo').readAsString(); + return _parseLinuxMemory(memInfo); + } + + String _getLinuxMemoryUsageSync() { + final memInfo = File('/proc/meminfo').readAsStringSync(); + return _parseLinuxMemory(memInfo); + } + + String _parseLinuxMemory(String memInfo) { + final total = _parseMemValue(memInfo, 'MemTotal:'); + final available = _parseMemValue(memInfo, 'MemAvailable:'); + final used = total - available; + final usedGB = (used / 1024 / 1024).toStringAsFixed(1); + final totalGB = (total / 1024 / 1024).toStringAsFixed(1); + return '$usedGB/$totalGB GB'; + } + + int _parseMemValue(String content, String key) { + final match = RegExp('$key\\s+(\\d+)').firstMatch(content); + return match != null ? int.parse(match.group(1)!) : 0; + } + + Future<String> _getMacOsMemoryUsage() async { + final result = await Process.run('vm_stat', []); + final output = result.stdout as String; + + const pageSize = 4096; + final freeMatch = RegExp(r'Pages free:\s+(\d+)').firstMatch(output); + final activeMatch = RegExp(r'Pages active:\s+(\d+)').firstMatch(output); + final inactiveMatch = RegExp(r'Pages inactive:\s+(\d+)').firstMatch(output); + final wiredMatch = RegExp(r'Pages wired down:\s+(\d+)').firstMatch(output); + + if (freeMatch != null && activeMatch != null) { + final free = int.parse(freeMatch.group(1)!) * pageSize; + final active = int.parse(activeMatch.group(1)!) * pageSize; + final inactive = int.parse(inactiveMatch?.group(1) ?? '0') * pageSize; + final wired = int.parse(wiredMatch?.group(1) ?? '0') * pageSize; + + final used = active + inactive + wired; + final total = used + free; + + final usedGB = (used / 1024 / 1024 / 1024).toStringAsFixed(1); + final totalGB = (total / 1024 / 1024 / 1024).toStringAsFixed(1); + return '$usedGB/$totalGB GB'; + } + + return 'Unknown'; + } + + Future<String> _getWindowsMemoryUsage() async { + final result = await Process.run( + 'wmic', + ['OS', 'get', 'FreePhysicalMemory,TotalVisibleMemorySize'], + ); + + final lines = (result.stdout as String).split('\n'); + for (final line in lines) { + final parts = line.trim().split(RegExp(r'\s+')); + if (parts.length >= 2) { + final free = int.tryParse(parts[0]); + final total = int.tryParse(parts[1]); + if (free != null && total != null && total > 0) { + final used = total - free; + final usedGB = (used / 1024 / 1024).toStringAsFixed(1); + final totalGB = (total / 1024 / 1024).toStringAsFixed(1); + return '$usedGB/$totalGB GB'; + } + } + } + return 'Unknown'; + } + + // BATTERY + + Future<String> getBatteryStatus() async { + try { + if (Platform.isLinux || Platform.isAndroid) { + // Android can also read /sys/class/power_supply + return _getLinuxBatteryStatus(); + } + if (Platform.isMacOS) { + return _getMacOsBatteryStatus(); + } + if (Platform.isWindows) { + return _getWindowsBatteryStatus(); + } + return 'N/A'; + } catch (e) { + return 'N/A'; + } + } + + String getBatteryStatusSync() { + try { + if (Platform.isLinux || Platform.isAndroid) { + return _getLinuxBatteryStatusSync(); + } + return 'N/A'; + } catch (e) { + return 'N/A'; + } + } + + Future<String> _getLinuxBatteryStatus() async { + // Find battery path dynamically (works on Linux and Android) + final batteryPath = await _findBatteryPath(); + if (batteryPath == null) return 'N/A'; + return _readLinuxBattery(batteryPath); + } + + /// Finds the battery path in /sys/class/power_supply/ + /// Returns the first directory that has a 'capacity' file + Future<String?> _findBatteryPath() async { + const basePath = '/sys/class/power_supply'; + try { + final dir = Directory(basePath); + if (!await dir.exists()) return null; + + await for (final entity in dir.list()) { + if (entity is Directory) { + final capacityFile = File('${entity.path}/capacity'); + if (await capacityFile.exists()) { + return entity.path; + } + } + } + } catch (_) {} + return null; + } + + String _getLinuxBatteryStatusSync() { + final batteryPath = _findBatteryPathSync(); + if (batteryPath == null) return 'N/A'; + return _readLinuxBatterySync(batteryPath); + } + + /// Finds the battery path synchronously in /sys/class/power_supply/ + /// Returns the first directory that has a 'capacity' file + String? _findBatteryPathSync() { + const basePath = '/sys/class/power_supply'; + try { + final dir = Directory(basePath); + if (!dir.existsSync()) return null; + + for (final entity in dir.listSync()) { + if (entity is Directory) { + final capacityFile = File('${entity.path}/capacity'); + if (capacityFile.existsSync()) { + return entity.path; + } + } + } + } catch (_) {} + return null; + } + + Future<String> _readLinuxBattery(String batteryPath) async { + final capacityFile = File('$batteryPath/capacity'); + final statusFile = File('$batteryPath/status'); + + if (!await capacityFile.exists()) return 'N/A'; + + final capacity = (await capacityFile.readAsString()).trim(); + var status = ''; + + if (await statusFile.exists()) { + final statusValue = (await statusFile.readAsString()).trim().toLowerCase(); + if (statusValue == 'charging') { + status = ' ⚡'; + } else if (statusValue == 'full') { + status = ' ✓'; + } + } + + return '$capacity%$status'; + } + + String _readLinuxBatterySync(String batteryPath) { + final capacityFile = File('$batteryPath/capacity'); + final statusFile = File('$batteryPath/status'); + + if (!capacityFile.existsSync()) return 'N/A'; + + final capacity = capacityFile.readAsStringSync().trim(); + var status = ''; + + if (statusFile.existsSync()) { + final statusValue = statusFile.readAsStringSync().trim().toLowerCase(); + if (statusValue == 'charging') { + status = ' ⚡'; + } else if (statusValue == 'full') { + status = ' ✓'; + } + } + + return '$capacity%$status'; + } + + Future<String> _getMacOsBatteryStatus() async { + final result = await Process.run( + 'pmset', + ['-g', 'batt'], + ); + + final output = result.stdout as String; + final match = RegExp(r'(\d+)%').firstMatch(output); + if (match != null) { + final percent = match.group(1); + final isCharging = output.contains('charging') || output.contains('AC Power'); + return '$percent%${isCharging ? " ⚡" : ""}'; + } + return 'N/A'; + } + + Future<String> _getWindowsBatteryStatus() async { + final result = await Process.run( + 'wmic', + ['path', 'Win32_Battery', 'get', 'EstimatedChargeRemaining,BatteryStatus'], + ); + + final lines = (result.stdout as String).split('\n'); + for (final line in lines) { + final parts = line.trim().split(RegExp(r'\s+')); + if (parts.length >= 2) { + final status = int.tryParse(parts[0]); + final charge = int.tryParse(parts[1]); + if (charge != null) { + final isCharging = status == 2 || status == 6; + return '$charge%${isCharging ? " ⚡" : ""}'; + } + } + } + return 'N/A'; + } + + Future<String> getUptime() async { + try { + if (Platform.isLinux) { + return _getLinuxUptime(); + } + if (Platform.isMacOS) { + return _getMacOsUptime(); + } + if (Platform.isWindows) { + return _getWindowsUptime(); + } + return 'Unknown'; + } catch (e) { + return 'Unknown'; + } + } + + /// Synchronous uptime getter (Linux/Android only) + String getUptimeSync() { + try { + if (Platform.isLinux || Platform.isAndroid) { + return _getLinuxUptimeSync(); + } + return 'Unknown'; + } catch (e) { + return 'Unknown'; + } + } + + Future<String> _getLinuxUptime() async { + final uptimeFile = File('/proc/uptime'); + if (!await uptimeFile.exists()) return 'Unknown'; + + final content = await uptimeFile.readAsString(); + final seconds = double.parse(content.split(' ')[0]); + return _formatUptime(Duration(seconds: seconds.round())); + } + + String _getLinuxUptimeSync() { + final uptimeFile = File('/proc/uptime'); + if (!uptimeFile.existsSync()) return 'Unknown'; + + final content = uptimeFile.readAsStringSync(); + final seconds = double.parse(content.split(' ')[0]); + return _formatUptime(Duration(seconds: seconds.round())); + } + + Future<String> _getMacOsUptime() async { + final result = await Process.run('uptime', []); + final output = result.stdout as String; + + final match = RegExp(r'up\s+(\d+)\s+days?,?\s+(\d+):(\d+)').firstMatch(output); + if (match != null) { + final days = int.parse(match.group(1)!); + final hours = int.parse(match.group(2)!); + final minutes = int.parse(match.group(3)!); + return _formatUptime(Duration(days: days, hours: hours, minutes: minutes)); + } + + final shortMatch = RegExp(r'up\s+(\d+):(\d+)').firstMatch(output); + if (shortMatch != null) { + final hours = int.parse(shortMatch.group(1)!); + final minutes = int.parse(shortMatch.group(2)!); + return _formatUptime(Duration(hours: hours, minutes: minutes)); + } + + return 'Unknown'; + } + + Future<String> _getWindowsUptime() async { + final result = await Process.run( + 'wmic', + ['os', 'get', 'lastbootuptime'], + ); + + final output = result.stdout as String; + final match = RegExp(r'(\d{14})').firstMatch(output); + if (match != null) { + final dateStr = match.group(1)!; + final year = int.parse(dateStr.substring(0, 4)); + final month = int.parse(dateStr.substring(4, 6)); + final day = int.parse(dateStr.substring(6, 8)); + final hour = int.parse(dateStr.substring(8, 10)); + final minute = int.parse(dateStr.substring(10, 12)); + final second = int.parse(dateStr.substring(12, 14)); + + final bootTime = DateTime(year, month, day, hour, minute, second); + final uptime = DateTime.now().difference(bootTime); + return _formatUptime(uptime); + } + + return 'Unknown'; + } + + String _formatUptime(Duration duration) { + final days = duration.inDays; + final hours = duration.inHours % 24; + final minutes = duration.inMinutes % 60; + + final parts = <String>[]; + if (days > 0) parts.add('${days}d'); + if (hours > 0) parts.add('${hours}h'); + if (minutes > 0 || parts.isEmpty) parts.add('${minutes}m'); + + return parts.join(' '); + } + + Future<String> getDiskUsage([String? path]) async { + try { + final targetPath = path ?? '/'; + + if (Platform.isLinux || Platform.isMacOS) { + final result = await Process.run('df', ['-h', targetPath]); + final lines = (result.stdout as String).split('\n'); + if (lines.length > 1) { + final parts = lines[1].split(RegExp(r'\s+')); + if (parts.length >= 4) { + return '${parts[2]}/${parts[1]}'; + } + } + } + + if (Platform.isWindows) { + final result = await Process.run( + 'wmic', + ['logicaldisk', 'get', 'size,freespace,caption'], + ); + return result.stdout.toString().trim(); + } + + return 'Unknown'; + } catch (e) { + return 'Unknown'; + } + } + + String getOs() { + return Platform.operatingSystem; + } + + Map<String, String> getOsDetails() { + return { + 'short': Platform.operatingSystem, + 'version': Platform.operatingSystemVersion, + }; + } +} diff --git a/packages/crossbar_core/lib/src/api/utils_api.dart b/packages/crossbar_core/lib/src/api/utils_api.dart new file mode 100644 index 0000000..a158a12 --- /dev/null +++ b/packages/crossbar_core/lib/src/api/utils_api.dart @@ -0,0 +1,790 @@ +// ignore_for_file: avoid_slow_async_io +import 'dart:io'; + +/// Utility API for system controls: screenshot, wallpaper, power, notifications, DND. +/// Uses platform-specific implementations: +/// - Linux: gnome-screenshot/scrot, gsettings, systemctl, notify-send +/// - macOS: screencapture, osascript, pmset +/// - Windows: PowerShell, registry +class UtilsApi { + const UtilsApi(); + + // ============================================================ + // BLUETOOTH + // ============================================================ + + /// Get Bluetooth status (on/off/unavailable) + Future<String> getBluetoothStatus() async { + try { + if (Platform.isLinux) { + // Try bluetoothctl first + var result = await Process.run('bluetoothctl', ['show']); + if (result.exitCode == 0) { + final output = result.stdout as String; + if (output.contains('Powered: yes')) return 'on'; + if (output.contains('Powered: no')) return 'off'; + } + // Try rfkill + result = await Process.run('rfkill', ['list', 'bluetooth']); + if (result.exitCode == 0) { + final output = result.stdout as String; + if (output.contains('Soft blocked: yes') || output.contains('Hard blocked: yes')) { + return 'off'; + } + return 'on'; + } + return 'unavailable'; + } + if (Platform.isMacOS) { + // Try blueutil if available + var result = await Process.run('blueutil', ['--power']); + if (result.exitCode == 0) { + return (result.stdout as String).trim() == '1' ? 'on' : 'off'; + } + // Fallback to system_profiler + result = await Process.run('system_profiler', ['SPBluetoothDataType']); + if (result.exitCode == 0) { + final output = result.stdout as String; + if (output.contains('State: On')) return 'on'; + if (output.contains('State: Off')) return 'off'; + } + return 'unavailable'; + } + if (Platform.isWindows) { + final result = await Process.run('powershell', [ + '-command', + 'Get-PnpDevice -Class Bluetooth | Where-Object Status -eq "OK" | Measure-Object | Select-Object -ExpandProperty Count', + ]); + if (result.exitCode == 0) { + final count = int.tryParse((result.stdout as String).trim()) ?? 0; + return count > 0 ? 'on' : 'off'; + } + return 'unavailable'; + } + return 'unavailable'; + } catch (e) { + return 'unavailable'; + } + } + + /// Enable Bluetooth + Future<bool> enableBluetooth() async { + try { + if (Platform.isLinux) { + // Try rfkill first + var result = await Process.run('rfkill', ['unblock', 'bluetooth']); + if (result.exitCode == 0) { + // Then power on via bluetoothctl + result = await Process.run('bluetoothctl', ['power', 'on']); + return result.exitCode == 0; + } + return false; + } + if (Platform.isMacOS) { + final result = await Process.run('blueutil', ['--power', '1']); + return result.exitCode == 0; + } + if (Platform.isWindows) { + final result = await Process.run('powershell', [ + '-command', + r'Get-PnpDevice -Class Bluetooth | Enable-PnpDevice -Confirm:$false', + ]); + return result.exitCode == 0; + } + return false; + } catch (e) { + return false; + } + } + + /// Disable Bluetooth + Future<bool> disableBluetooth() async { + try { + if (Platform.isLinux) { + // Power off via bluetoothctl first + var result = await Process.run('bluetoothctl', ['power', 'off']); + // Then block via rfkill + result = await Process.run('rfkill', ['block', 'bluetooth']); + return result.exitCode == 0; + } + if (Platform.isMacOS) { + final result = await Process.run('blueutil', ['--power', '0']); + return result.exitCode == 0; + } + if (Platform.isWindows) { + final result = await Process.run('powershell', [ + '-command', + r'Get-PnpDevice -Class Bluetooth | Disable-PnpDevice -Confirm:$false', + ]); + return result.exitCode == 0; + } + return false; + } catch (e) { + return false; + } + } + + /// List paired Bluetooth devices + Future<List<Map<String, String>>> listBluetoothDevices() async { + try { + if (Platform.isLinux) { + final result = await Process.run('bluetoothctl', ['devices']); + if (result.exitCode == 0) { + final lines = (result.stdout as String).trim().split('\n'); + return lines.where((l) => l.startsWith('Device')).map((line) { + final parts = line.split(' '); + final mac = parts.length > 1 ? parts[1] : ''; + final name = parts.length > 2 ? parts.sublist(2).join(' ') : ''; + return {'mac': mac, 'name': name}; + }).toList(); + } + return []; + } + if (Platform.isMacOS) { + final result = await Process.run('system_profiler', ['SPBluetoothDataType', '-json']); + if (result.exitCode == 0) { + // Parse JSON output for devices + // This is simplified - full parsing would be more complex + return []; + } + return []; + } + if (Platform.isWindows) { + final result = await Process.run('powershell', [ + '-command', + 'Get-PnpDevice -Class Bluetooth | Select-Object FriendlyName, InstanceId | ConvertTo-Json', + ]); + if (result.exitCode == 0) { + // Parse JSON output + return []; + } + return []; + } + return []; + } catch (e) { + return []; + } + } + + // ============================================================ + // VPN + // ============================================================ + + /// Get VPN connection status + Future<Map<String, dynamic>> getVpnStatus() async { + try { + if (Platform.isLinux) { + // Try nmcli for NetworkManager-based VPNs + final result = await Process.run('nmcli', ['-t', '-f', 'TYPE,STATE,NAME', 'connection', 'show', '--active']); + if (result.exitCode == 0) { + final lines = (result.stdout as String).trim().split('\n'); + for (final line in lines) { + if (line.contains('vpn') || line.contains('wireguard') || line.contains('tun')) { + final parts = line.split(':'); + return { + 'connected': true, + 'type': parts.isNotEmpty ? parts[0] : 'vpn', + 'name': parts.length > 2 ? parts[2] : 'unknown', + }; + } + } + } + // Check for WireGuard + final wgResult = await Process.run('wg', ['show']); + if (wgResult.exitCode == 0 && (wgResult.stdout as String).isNotEmpty) { + return {'connected': true, 'type': 'wireguard', 'name': 'WireGuard'}; + } + return {'connected': false}; + } + if (Platform.isMacOS) { + final result = await Process.run('scutil', ['--nc', 'list']); + if (result.exitCode == 0) { + final output = result.stdout as String; + if (output.contains('(Connected)')) { + final match = RegExp(r'"([^"]+)".*\(Connected\)').firstMatch(output); + return { + 'connected': true, + 'name': match?.group(1) ?? 'unknown', + }; + } + } + return {'connected': false}; + } + if (Platform.isWindows) { + final result = await Process.run('powershell', [ + '-command', + 'Get-VpnConnection | Where-Object ConnectionStatus -eq "Connected" | Select-Object Name, ServerAddress | ConvertTo-Json', + ]); + if (result.exitCode == 0) { + final output = (result.stdout as String).trim(); + if (output.isNotEmpty && output != '[]') { + return {'connected': true, 'raw': output}; + } + } + return {'connected': false}; + } + return {'connected': false}; + } catch (e) { + return {'connected': false, 'error': e.toString()}; + } + } + + // ============================================================ + // SCREENSHOT + // ============================================================ + + /// Take a screenshot and save to path (or default location) + /// Returns the path where screenshot was saved, or null on failure + Future<String?> takeScreenshot({String? path, bool toClipboard = false}) async { + try { + final timestamp = DateTime.now().toIso8601String().replaceAll(':', '-'); + final defaultPath = path ?? '${Platform.environment['HOME']}/screenshot_$timestamp.png'; + + if (Platform.isLinux) { + return _takeScreenshotLinux(defaultPath, toClipboard); + } + if (Platform.isMacOS) { + return _takeScreenshotMacOS(defaultPath, toClipboard); + } + if (Platform.isWindows) { + return _takeScreenshotWindows(defaultPath, toClipboard); + } + return null; + } catch (e) { + return null; + } + } + + Future<String?> _takeScreenshotLinux(String path, bool toClipboard) async { + // Try gnome-screenshot first, then scrot, then spectacle + if (toClipboard) { + // Try gnome-screenshot with clipboard + try { + final result = await Process.run('gnome-screenshot', ['-c']); + if (result.exitCode == 0) return 'clipboard'; + } catch (_) {} + + // Try spectacle (KDE) + try { + final result = await Process.run('spectacle', ['-b', '-c']); + if (result.exitCode == 0) return 'clipboard'; + } catch (_) {} + + // Try scrot with xclip + try { + final result = await Process.run('sh', ['-c', 'scrot -o /tmp/screenshot.png && xclip -selection clipboard -t image/png /tmp/screenshot.png']); + if (result.exitCode == 0) return 'clipboard'; + } catch (_) {} + + return null; + } + + // Save to file + try { + final result = await Process.run('gnome-screenshot', ['-f', path]); + if (result.exitCode == 0) return path; + } catch (_) {} + + try { + final result = await Process.run('spectacle', ['-b', '-n', '-o', path]); + if (result.exitCode == 0) return path; + } catch (_) {} + + try { + final result = await Process.run('scrot', ['-o', path]); + if (result.exitCode == 0) return path; + } catch (_) {} + + return null; + } + + Future<String?> _takeScreenshotMacOS(String path, bool toClipboard) async { + if (toClipboard) { + final result = await Process.run('screencapture', ['-c']); + return result.exitCode == 0 ? 'clipboard' : null; + } + final result = await Process.run('screencapture', [path]); + return result.exitCode == 0 ? path : null; + } + + Future<String?> _takeScreenshotWindows(String path, bool toClipboard) async { + if (toClipboard) { + final result = await Process.run('powershell', [ + '-command', + r'Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.Screen]::PrimaryScreen | ForEach-Object { $bitmap = New-Object System.Drawing.Bitmap($_.Bounds.Width, $_.Bounds.Height); $graphics = [System.Drawing.Graphics]::FromImage($bitmap); $graphics.CopyFromScreen($_.Bounds.Location, [System.Drawing.Point]::Empty, $_.Bounds.Size); [System.Windows.Forms.Clipboard]::SetImage($bitmap) }', + ]); + return result.exitCode == 0 ? 'clipboard' : null; + } + final result = await Process.run('powershell', [ + '-command', + ''' + Add-Type -AssemblyName System.Windows.Forms + \$screen = [System.Windows.Forms.Screen]::PrimaryScreen + \$bitmap = New-Object System.Drawing.Bitmap(\$screen.Bounds.Width, \$screen.Bounds.Height) + \$graphics = [System.Drawing.Graphics]::FromImage(\$bitmap) + \$graphics.CopyFromScreen(\$screen.Bounds.Location, [System.Drawing.Point]::Empty, \$screen.Bounds.Size) + \$bitmap.Save("$path") + ''', + ]); + return result.exitCode == 0 ? path : null; + } + + // ============================================================ + // WALLPAPER + // ============================================================ + + /// Get current wallpaper path + Future<String> getWallpaper() async { + try { + if (Platform.isLinux) { + // Try GNOME first + var result = await Process.run('gsettings', [ + 'get', + 'org.gnome.desktop.background', + 'picture-uri', + ]); + if (result.exitCode == 0) { + var path = (result.stdout as String).trim(); + // Remove quotes and file:// prefix + path = path.replaceAll("'", '').replaceAll('file://', ''); + if (path.isNotEmpty && path != 'none') return path; + } + + // Try dark mode variant + result = await Process.run('gsettings', [ + 'get', + 'org.gnome.desktop.background', + 'picture-uri-dark', + ]); + if (result.exitCode == 0) { + var path = (result.stdout as String).trim(); + path = path.replaceAll("'", '').replaceAll('file://', ''); + if (path.isNotEmpty && path != 'none') return path; + } + + // Try Cinnamon + result = await Process.run('gsettings', [ + 'get', + 'org.cinnamon.desktop.background', + 'picture-uri', + ]); + if (result.exitCode == 0) { + var path = (result.stdout as String).trim(); + path = path.replaceAll("'", '').replaceAll('file://', ''); + if (path.isNotEmpty) return path; + } + + return 'unknown'; + } + if (Platform.isMacOS) { + final result = await Process.run('osascript', [ + '-e', + 'tell application "Finder" to get POSIX path of (get desktop picture as alias)', + ]); + return result.exitCode == 0 + ? (result.stdout as String).trim() + : 'unknown'; + } + if (Platform.isWindows) { + final result = await Process.run('powershell', [ + '-command', + r'(Get-ItemProperty -Path "HKCU:\Control Panel\Desktop" -Name Wallpaper).Wallpaper', + ]); + return result.exitCode == 0 + ? (result.stdout as String).trim() + : 'unknown'; + } + return 'unknown'; + } catch (e) { + return 'unknown'; + } + } + + /// Set wallpaper from path + Future<bool> setWallpaper(String path) async { + try { + // Check if file exists + if (!await File(path).exists()) { + return false; + } + + if (Platform.isLinux) { + // Try GNOME + var result = await Process.run('gsettings', [ + 'set', + 'org.gnome.desktop.background', + 'picture-uri', + 'file://$path', + ]); + if (result.exitCode == 0) { + // Also set dark mode wallpaper + await Process.run('gsettings', [ + 'set', + 'org.gnome.desktop.background', + 'picture-uri-dark', + 'file://$path', + ]); + return true; + } + + // Try Cinnamon + result = await Process.run('gsettings', [ + 'set', + 'org.cinnamon.desktop.background', + 'picture-uri', + 'file://$path', + ]); + if (result.exitCode == 0) return true; + + // Try feh for minimal WMs + result = await Process.run('feh', ['--bg-scale', path]); + if (result.exitCode == 0) return true; + + return false; + } + if (Platform.isMacOS) { + final result = await Process.run('osascript', [ + '-e', + 'tell application "Finder" to set desktop picture to POSIX file "$path"', + ]); + return result.exitCode == 0; + } + if (Platform.isWindows) { + final result = await Process.run('powershell', [ + '-command', + ''' + Add-Type @" + using System; + using System.Runtime.InteropServices; + public class Wallpaper { + [DllImport("user32.dll", CharSet = CharSet.Auto)] + public static extern int SystemParametersInfo(int uAction, int uParam, string lpvParam, int fuWinIni); + } +"@ + [Wallpaper]::SystemParametersInfo(0x0014, 0, "$path", 0x0001 -bor 0x0002) + ''', + ]); + return result.exitCode == 0; + } + return false; + } catch (e) { + return false; + } + } + + // ============================================================ + // POWER MANAGEMENT + // ============================================================ + + /// Suspend/sleep the system + Future<bool> sleep() async { + try { + if (Platform.isLinux) { + // Try systemctl first + var result = await Process.run('systemctl', ['suspend']); + if (result.exitCode == 0) return true; + + // Try pm-suspend + result = await Process.run('pm-suspend', []); + return result.exitCode == 0; + } + if (Platform.isMacOS) { + final result = await Process.run('pmset', ['sleepnow']); + return result.exitCode == 0; + } + if (Platform.isWindows) { + final result = await Process.run('powershell', [ + '-command', + r'Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.Application]::SetSuspendState("Suspend", $false, $false)', + ]); + return result.exitCode == 0; + } + return false; + } catch (e) { + return false; + } + } + + /// Restart the system (requires confirmation parameter for safety) + Future<bool> restart({bool confirmed = false}) async { + if (!confirmed) { + return false; // Safety: require explicit confirmation + } + try { + if (Platform.isLinux) { + final result = await Process.run('systemctl', ['reboot']); + return result.exitCode == 0; + } + if (Platform.isMacOS) { + final result = await Process.run('osascript', [ + '-e', + 'tell application "System Events" to restart', + ]); + return result.exitCode == 0; + } + if (Platform.isWindows) { + final result = await Process.run('shutdown', ['/r', '/t', '0']); + return result.exitCode == 0; + } + return false; + } catch (e) { + return false; + } + } + + /// Shutdown the system (requires confirmation parameter for safety) + Future<bool> shutdown({bool confirmed = false}) async { + if (!confirmed) { + return false; // Safety: require explicit confirmation + } + try { + if (Platform.isLinux) { + final result = await Process.run('systemctl', ['poweroff']); + return result.exitCode == 0; + } + if (Platform.isMacOS) { + final result = await Process.run('osascript', [ + '-e', + 'tell application "System Events" to shut down', + ]); + return result.exitCode == 0; + } + if (Platform.isWindows) { + final result = await Process.run('shutdown', ['/s', '/t', '0']); + return result.exitCode == 0; + } + return false; + } catch (e) { + return false; + } + } + + // ============================================================ + // NOTIFICATIONS + // ============================================================ + + /// Send a desktop notification + Future<bool> sendNotification({ + required String title, + required String message, + String? icon, + String? sound, + String? action, + String priority = 'normal', // low, normal, critical + }) async { + try { + if (Platform.isLinux) { + final args = <String>[title, message]; + if (icon != null) { + args.addAll(['-i', icon]); + } + if (priority == 'critical') { + args.addAll(['-u', 'critical']); + } else if (priority == 'low') { + args.addAll(['-u', 'low']); + } + final result = await Process.run('notify-send', args); + return result.exitCode == 0; + } + if (Platform.isMacOS) { + var script = 'display notification "$message" with title "$title"'; + if (sound != null) { + script += ' sound name "$sound"'; + } + final result = await Process.run('osascript', ['-e', script]); + return result.exitCode == 0; + } + if (Platform.isWindows) { + final result = await Process.run('powershell', [ + '-command', + ''' + [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null + \$template = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02) + \$textNodes = \$template.GetElementsByTagName("text") + \$textNodes.Item(0).AppendChild(\$template.CreateTextNode("$title")) | Out-Null + \$textNodes.Item(1).AppendChild(\$template.CreateTextNode("$message")) | Out-Null + \$notifier = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier("Crossbar") + \$notification = [Windows.UI.Notifications.ToastNotification]::new(\$template) + \$notifier.Show(\$notification) + ''', + ]); + return result.exitCode == 0; + } + return false; + } catch (e) { + return false; + } + } + + // ============================================================ + // DO NOT DISTURB (DND) + // ============================================================ + + /// Get Do Not Disturb status + Future<bool> getDndStatus() async { + try { + if (Platform.isLinux) { + // GNOME + final result = await Process.run('gsettings', [ + 'get', + 'org.gnome.desktop.notifications', + 'show-banners', + ]); + if (result.exitCode == 0) { + final value = (result.stdout as String).trim(); + // show-banners=false means DND is ON + return value == 'false'; + } + return false; + } + if (Platform.isMacOS) { + final result = await Process.run('defaults', [ + '-currentHost', + 'read', + 'com.apple.notificationcenterui', + 'doNotDisturb', + ]); + if (result.exitCode == 0) { + return (result.stdout as String).trim() == '1'; + } + return false; + } + if (Platform.isWindows) { + final result = await Process.run('powershell', [ + '-command', + r'Get-ItemProperty -Path "HKCU:\Software\Microsoft\Windows\CurrentVersion\Notifications\Settings" -Name "NOC_GLOBAL_SETTING_TOASTS_ENABLED" -ErrorAction SilentlyContinue | Select-Object -ExpandProperty NOC_GLOBAL_SETTING_TOASTS_ENABLED', + ]); + if (result.exitCode == 0) { + // 0 means DND is ON (toasts disabled) + return (result.stdout as String).trim() == '0'; + } + return false; + } + return false; + } catch (e) { + return false; + } + } + + /// Set Do Not Disturb status + Future<bool> setDnd(bool enabled) async { + try { + if (Platform.isLinux) { + // GNOME - show-banners=false means DND is ON + final result = await Process.run('gsettings', [ + 'set', + 'org.gnome.desktop.notifications', + 'show-banners', + enabled ? 'false' : 'true', + ]); + return result.exitCode == 0; + } + if (Platform.isMacOS) { + final value = enabled ? '1' : '0'; + final result = await Process.run('defaults', [ + '-currentHost', + 'write', + 'com.apple.notificationcenterui', + 'doNotDisturb', + '-bool', + value, + ]); + if (result.exitCode == 0) { + // Restart notification center to apply + await Process.run('killall', ['NotificationCenter']); + return true; + } + return false; + } + if (Platform.isWindows) { + final value = enabled ? '0' : '1'; // 0 = DND ON, 1 = DND OFF + final result = await Process.run('powershell', [ + '-command', + 'Set-ItemProperty -Path "HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Notifications\\Settings" -Name "NOC_GLOBAL_SETTING_TOASTS_ENABLED" -Value $value', + ]); + return result.exitCode == 0; + } + return false; + } catch (e) { + return false; + } + } + + // ============================================================ + // OPEN/LAUNCH UTILITIES + // ============================================================ + + /// Open URL in default browser + Future<bool> openUrl(String url) async { + try { + if (Platform.isLinux) { + final result = await Process.run('xdg-open', [url]); + return result.exitCode == 0; + } + if (Platform.isMacOS) { + final result = await Process.run('open', [url]); + return result.exitCode == 0; + } + if (Platform.isWindows) { + final result = await Process.run('start', [url], runInShell: true); + return result.exitCode == 0; + } + return false; + } catch (e) { + return false; + } + } + + /// Open file with default application + Future<bool> openFile(String path) async { + try { + if (!await File(path).exists() && !await Directory(path).exists()) { + return false; + } + if (Platform.isLinux) { + final result = await Process.run('xdg-open', [path]); + return result.exitCode == 0; + } + if (Platform.isMacOS) { + final result = await Process.run('open', [path]); + return result.exitCode == 0; + } + if (Platform.isWindows) { + final result = await Process.run('start', ['', path], runInShell: true); + return result.exitCode == 0; + } + return false; + } catch (e) { + return false; + } + } + + /// Launch application by name + Future<bool> openApp(String appName) async { + try { + if (Platform.isLinux) { + // Try direct execution first + var result = await Process.run('which', [appName]); + if (result.exitCode == 0) { + final appPath = (result.stdout as String).trim(); + await Process.start(appPath, [], mode: ProcessStartMode.detached); + return true; + } + // Try gtk-launch for .desktop files + result = await Process.run('gtk-launch', [appName]); + return result.exitCode == 0; + } + if (Platform.isMacOS) { + final result = await Process.run('open', ['-a', appName]); + return result.exitCode == 0; + } + if (Platform.isWindows) { + final result = await Process.run('start', [appName], runInShell: true); + return result.exitCode == 0; + } + return false; + } catch (e) { + return false; + } + } +} diff --git a/packages/crossbar_core/lib/src/core/output_parser.dart b/packages/crossbar_core/lib/src/core/output_parser.dart new file mode 100644 index 0000000..70ef756 --- /dev/null +++ b/packages/crossbar_core/lib/src/core/output_parser.dart @@ -0,0 +1,346 @@ +import 'dart:convert'; + +import '../models/plugin_output.dart'; + +class OutputParser { + static bool isJson(String output) { + final trimmed = output.trim(); + return trimmed.startsWith('{') && trimmed.endsWith('}'); + } + + static PluginOutput parse(String output, String pluginId) { + try { + final trimmedOutput = output.trim(); + if (trimmedOutput.isEmpty) { + return PluginOutput.empty(pluginId); + } + + if (isJson(trimmedOutput)) { + return _parseJson(trimmedOutput, pluginId); + } else { + return _parseBitBar(trimmedOutput, pluginId); + } + } catch (e) { + return PluginOutput.error(pluginId, 'Failed to parse output: $e'); + } + } + + static PluginOutput _parseJson(String jsonString, String pluginId) { + final data = jsonDecode(jsonString) as Map<String, dynamic>; + + return PluginOutput( + pluginId: pluginId, + icon: data['icon'] as String? ?? '', + text: data['text'] as String?, + color: data['color'] != null + ? _parseColor(data['color'] as String) + : null, + trayTooltip: data['tray_tooltip'] as String?, + menu: _parseMenuItems(data['menu'] as List<dynamic>? ?? []), + ); + } + + static PluginOutput _parseBitBar(String text, String pluginId) { + final lines = text.split('\n').where((l) => l.isNotEmpty).toList(); + + if (lines.isEmpty) { + return PluginOutput(pluginId: pluginId, icon: '', text: ''); + } + + final firstLine = lines.first; + var icon = ''; + String? displayText; + String? colorStr; + + if (firstLine.contains('|')) { + final parts = firstLine.split('|'); + final mainText = parts[0].trim(); + + final parsed = _parseIconAndText(mainText); + icon = parsed.icon; + displayText = parsed.text; + + for (var i = 1; i < parts.length; i++) { + final attr = parts[i].trim(); + if (attr.startsWith('color=')) { + colorStr = attr.substring(6); + } + } + } else { + final parsed = _parseIconAndText(firstLine); + icon = parsed.icon; + displayText = parsed.text; + } + + final menu = <MenuItem>[]; + var inMenu = false; + + // Stack to track parent items at each depth level + // Index 0 = root menu, Index 1 = first submenu level, etc. + final parentStack = <List<MenuItem>>[menu]; + + for (var i = 1; i < lines.length; i++) { + final line = lines[i]; + + // Check for separator (exactly ---) - but not submenu indicator (--) + if (line.trim() == '---') { + if (!inMenu) { + // First --- marks the start of the menu section + inMenu = true; + } else { + // Subsequent --- add visual separators to current level + parentStack.last.add(MenuItem.separator()); + } + continue; + } + + if (!inMenu) continue; + + // Parse indent level and get actual content + final indentInfo = _parseIndentedLine(line); + final depth = indentInfo.depth; + final content = indentInfo.content; + + if (content.isEmpty) continue; + + // Parse menu item from content + final item = _parseMenuItemFromLine(content); + + // Adjust parent stack to correct depth + // depth 0 = root menu, depth 1 = submenu of last root item, etc. + while (parentStack.length > depth + 1) { + parentStack.removeLast(); + } + + // If we need to go deeper, ensure parent has submenu + while (parentStack.length < depth + 1) { + final lastList = parentStack.last; + if (lastList.isNotEmpty) { + final lastItem = lastList.last; + // Initialize submenu if needed + if (lastItem.submenu == null) { + // Create a new item with submenu + final newItem = lastItem.copyWith(submenu: <MenuItem>[]); + lastList[lastList.length - 1] = newItem; + parentStack.add(newItem.submenu!); + } else { + parentStack.add(lastItem.submenu!); + } + } else { + // Cannot add child to empty parent, add to root + break; + } + } + + // Add item to current level + parentStack.last.add(item); + } + + return PluginOutput( + pluginId: pluginId, + icon: icon, + text: displayText, + color: colorStr != null ? _parseColor(colorStr) : null, + menu: menu, + ); + } + + /// Parses BitBar-style indented line (-- prefix indicates submenu level) + /// Returns the depth level and the actual content without the prefix. + static ({int depth, String content}) _parseIndentedLine(String line) { + var depth = 0; + var current = line; + + // Count leading -- pairs (each -- is one level) + while (current.startsWith('--')) { + depth++; + current = current.substring(2); + } + + return (depth: depth, content: current.trim()); + } + + /// Parses a single menu item line (already stripped of -- prefix) + static MenuItem _parseMenuItemFromLine(String line) { + if (line.contains('|')) { + final parts = line.split('|'); + final itemText = parts[0].trim(); + String? bash; + String? href; + String? itemColor; + + for (var k = 1; k < parts.length; k++) { + final attr = parts[k].trim(); + if (attr.startsWith('bash=')) { + bash = attr.substring(5); + } else if (attr.startsWith('href=')) { + href = attr.substring(5); + } else if (attr.startsWith('color=')) { + itemColor = attr.substring(6); + } + } + + return MenuItem( + text: itemText, + bash: bash, + href: href, + color: itemColor, + ); + } else { + return MenuItem(text: line); + } + } + + static ({String icon, String? text}) _parseIconAndText(String input) { + if (input.isEmpty) { + return (icon: '', text: null); + } + + final runes = input.runes.toList(); + if (runes.isEmpty) { + return (icon: '', text: null); + } + + final firstCodePoint = runes.first; + + if (_isEmoji(firstCodePoint) || firstCodePoint > 127) { + final firstChar = String.fromCharCode(firstCodePoint); + final remaining = input.substring(firstChar.length).trim(); + return ( + icon: firstChar, + text: remaining.isNotEmpty ? remaining : null, + ); + } + + return (icon: '', text: input); + } + + static bool _isEmoji(int codePoint) { + return (codePoint >= 0x1F600 && codePoint <= 0x1F64F) || + (codePoint >= 0x1F300 && codePoint <= 0x1F5FF) || + (codePoint >= 0x1F680 && codePoint <= 0x1F6FF) || + (codePoint >= 0x1F700 && codePoint <= 0x1F77F) || + (codePoint >= 0x1F780 && codePoint <= 0x1F7FF) || + (codePoint >= 0x1F800 && codePoint <= 0x1F8FF) || + (codePoint >= 0x1F900 && codePoint <= 0x1F9FF) || + (codePoint >= 0x1FA00 && codePoint <= 0x1FA6F) || + (codePoint >= 0x1FA70 && codePoint <= 0x1FAFF) || + (codePoint >= 0x2600 && codePoint <= 0x26FF) || + (codePoint >= 0x2700 && codePoint <= 0x27BF) || + (codePoint >= 0x231A && codePoint <= 0x231B) || + (codePoint >= 0x23E9 && codePoint <= 0x23F3) || + (codePoint >= 0x23F8 && codePoint <= 0x23FA) || + codePoint == 0x2614 || + codePoint == 0x2615 || + codePoint == 0x2648 || + codePoint == 0x267F || + codePoint == 0x2693 || + codePoint == 0x26A1 || + codePoint == 0x26AA || + codePoint == 0x26AB || + codePoint == 0x26BD || + codePoint == 0x26BE || + codePoint == 0x26C4 || + codePoint == 0x26C5 || + codePoint == 0x26CE || + codePoint == 0x26D4 || + codePoint == 0x26EA || + codePoint == 0x26F2 || + codePoint == 0x26F3 || + codePoint == 0x26F5 || + codePoint == 0x26FA || + codePoint == 0x26FD || + codePoint == 0x2702 || + codePoint == 0x2705 || + codePoint == 0x2708 || + codePoint == 0x2709 || + codePoint == 0x270A || + codePoint == 0x270B || + codePoint == 0x270C || + codePoint == 0x270D || + codePoint == 0x270F || + codePoint == 0x2712 || + codePoint == 0x2714 || + codePoint == 0x2716 || + codePoint == 0x271D || + codePoint == 0x2721 || + codePoint == 0x2728 || + codePoint == 0x2733 || + codePoint == 0x2734 || + codePoint == 0x2744 || + codePoint == 0x2747 || + codePoint == 0x274C || + codePoint == 0x274E || + codePoint == 0x2753 || + codePoint == 0x2754 || + codePoint == 0x2755 || + codePoint == 0x2757 || + codePoint == 0x2763 || + codePoint == 0x2764 || + codePoint == 0x2795 || + codePoint == 0x2796 || + codePoint == 0x2797 || + codePoint == 0x27A1 || + codePoint == 0x27B0 || + codePoint == 0x27BF; + } + + static List<MenuItem> _parseMenuItems(List<dynamic> items) { + return items.map((item) { + final map = item as Map<String, dynamic>; + if (map['separator'] == true) { + return MenuItem.separator(); + } + return MenuItem( + text: map['text'] as String?, + bash: map['bash'] as String?, + href: map['href'] as String?, + submenu: map['submenu'] != null + ? _parseMenuItems(map['submenu'] as List<dynamic>) + : null, + color: map['color'] as String?, + ); + }).toList(); + } + + static int? _parseColor(String? colorString) { + if (colorString == null || colorString.isEmpty) return null; + + final colors = <String, int>{ + 'red': 0xFFFF0000, + 'green': 0xFF00FF00, + 'blue': 0xFF0000FF, + 'yellow': 0xFFFFFF00, + 'orange': 0xFFFFA500, + 'purple': 0xFF800080, + 'pink': 0xFFFFC0CB, + 'cyan': 0xFF00FFFF, + 'white': 0xFFFFFFFF, + 'black': 0xFF000000, + 'grey': 0xFF808080, + 'gray': 0xFF808080, + }; + + final lowerColor = colorString.toLowerCase(); + if (colors.containsKey(lowerColor)) { + return colors[lowerColor]; + } + + if (colorString.startsWith('#')) { + try { + var hex = colorString.substring(1); + if (hex.length == 3) { + hex = hex.split('').map((c) => '$c$c').join(); + } + if (hex.length == 6) { + hex = 'FF$hex'; + } + return int.parse(hex, radix: 16); + } catch (_) { + return null; + } + } + + return null; + } +} diff --git a/packages/crossbar_core/lib/src/models/plugin.dart b/packages/crossbar_core/lib/src/models/plugin.dart new file mode 100644 index 0000000..9bd7d62 --- /dev/null +++ b/packages/crossbar_core/lib/src/models/plugin.dart @@ -0,0 +1,120 @@ +import 'plugin_config.dart'; + +class Plugin { + + const Plugin({ + required this.id, + required this.path, + required this.interpreter, + required this.refreshInterval, + this.enabled = true, + this.lastRun, + this.lastError, + this.config, + }); + + factory Plugin.mock({ + String id = 'mock.10s.sh', + String path = '/path/to/mock.10s.sh', + String interpreter = 'bash', + Duration refreshInterval = const Duration(seconds: 10), + PluginConfig? config, + }) { + return Plugin( + id: id, + path: path, + interpreter: interpreter, + refreshInterval: refreshInterval, + config: config, + ); + } + + factory Plugin.fromJson(Map<String, dynamic> json) { + return Plugin( + id: json['id'] as String, + path: json['path'] as String, + interpreter: json['interpreter'] as String, + refreshInterval: + Duration(milliseconds: json['refreshInterval'] as int), + enabled: json['enabled'] as bool? ?? true, + lastRun: json['lastRun'] != null + ? DateTime.parse(json['lastRun'] as String) + : null, + lastError: json['lastError'] as String?, + config: json['config'] != null + ? PluginConfig.fromJson(json['config'] as Map<String, dynamic>) + : null, + ); + } + final String id; + final String path; + final String interpreter; + final Duration refreshInterval; + final bool enabled; + final DateTime? lastRun; + final String? lastError; + final PluginConfig? config; + + /// Returns true if the plugin has a configuration schema defined. + bool get hasConfig => config != null && config!.settings.isNotEmpty; + + /// Returns true if the plugin requires configuration before running. + bool get requiresConfig => config?.configRequired == 'required'; + + Map<String, dynamic> toJson() { + return { + 'id': id, + 'path': path, + 'interpreter': interpreter, + 'refreshInterval': refreshInterval.inMilliseconds, + 'enabled': enabled, + 'lastRun': lastRun?.toIso8601String(), + 'lastError': lastError, + if (config != null) 'config': config!.toJson(), + }; + } + + Plugin copyWith({ + String? id, + String? path, + String? interpreter, + Duration? refreshInterval, + bool? enabled, + DateTime? lastRun, + String? lastError, + PluginConfig? config, + }) { + return Plugin( + id: id ?? this.id, + path: path ?? this.path, + interpreter: interpreter ?? this.interpreter, + refreshInterval: refreshInterval ?? this.refreshInterval, + enabled: enabled ?? this.enabled, + lastRun: lastRun ?? this.lastRun, + lastError: lastError ?? this.lastError, + config: config ?? this.config, + ); + } + + @override + String toString() { + return 'Plugin(id: $id, interpreter: $interpreter, enabled: $enabled)'; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is Plugin && + other.id == id && + other.path == path && + other.interpreter == interpreter && + other.refreshInterval == refreshInterval && + other.enabled == enabled && + other.config == config; + } + + @override + int get hashCode { + return Object.hash(id, path, interpreter, refreshInterval, enabled, config); + } +} diff --git a/packages/crossbar_core/lib/src/models/plugin_config.dart b/packages/crossbar_core/lib/src/models/plugin_config.dart new file mode 100644 index 0000000..7102969 --- /dev/null +++ b/packages/crossbar_core/lib/src/models/plugin_config.dart @@ -0,0 +1,174 @@ +class PluginConfig { + + const PluginConfig({ + required this.name, + required this.description, + required this.icon, + required this.configRequired, + required this.settings, + }); + + factory PluginConfig.fromJson(Map<String, dynamic> json) { + return PluginConfig( + name: json['name'] as String? ?? '', + description: json['description'] as String? ?? '', + icon: json['icon'] as String? ?? '', + configRequired: json['config_required'] as String? ?? 'optional', + settings: (json['settings'] as List<dynamic>?) + ?.map((s) => Setting.fromJson(s as Map<String, dynamic>)) + .toList() ?? + [], + ); + } + final String name; + final String description; + final String icon; + final String configRequired; + final List<Setting> settings; + + Map<String, dynamic> toJson() { + return { + 'name': name, + 'description': description, + 'icon': icon, + 'config_required': configRequired, + 'settings': settings.map((s) => s.toJson()).toList(), + }; + } + + PluginConfig copyWith({ + String? name, + String? description, + String? icon, + String? configRequired, + List<Setting>? settings, + }) { + return PluginConfig( + name: name ?? this.name, + description: description ?? this.description, + icon: icon ?? this.icon, + configRequired: configRequired ?? this.configRequired, + settings: settings ?? this.settings, + ); + } + + @override + String toString() { + return 'PluginConfig(name: $name, settings: ${settings.length})'; + } +} + +/// Represents a single configuration option for select fields. +class SelectOption { + const SelectOption({ + required this.value, + required this.label, + }); + + factory SelectOption.fromJson(Map<String, dynamic> json) { + return SelectOption( + value: json['value'] as String, + label: json['label'] as String, + ); + } + + final String value; + final String label; + + Map<String, dynamic> toJson() => {'value': value, 'label': label}; +} + +class Setting { + + const Setting({ + required this.key, + required this.label, + required this.type, + this.defaultValue, + this.description, + this.required = false, + this.options, + this.width, + this.placeholder, + this.help, + }); + + factory Setting.fromJson(Map<String, dynamic> json) { + List<SelectOption>? options; + if (json['options'] != null) { + options = (json['options'] as List<dynamic>) + .map((o) => SelectOption.fromJson(o as Map<String, dynamic>)) + .toList(); + } + + return Setting( + key: json['key'] as String, + label: json['label'] as String, + type: json['type'] as String, + defaultValue: json['default'] as String?, + description: json['description'] as String?, + required: json['required'] as bool? ?? false, + options: options, + width: json['width'] as int?, + placeholder: json['placeholder'] as String?, + help: json['help'] as String?, + ); + } + final String key; + final String label; + final String type; + final String? defaultValue; + final String? description; + final bool required; + final List<SelectOption>? options; + final int? width; + final String? placeholder; + final String? help; + + Map<String, dynamic> toJson() { + return { + 'key': key, + 'label': label, + 'type': type, + if (defaultValue != null) 'default': defaultValue, + if (description != null) 'description': description, + 'required': required, + if (options != null) 'options': options!.map((o) => o.toJson()).toList(), + if (width != null) 'width': width, + if (placeholder != null) 'placeholder': placeholder, + if (help != null) 'help': help, + }; + } + + Setting copyWith({ + String? key, + String? label, + String? type, + String? defaultValue, + String? description, + bool? required, + List<SelectOption>? options, + int? width, + String? placeholder, + String? help, + }) { + return Setting( + key: key ?? this.key, + label: label ?? this.label, + type: type ?? this.type, + defaultValue: defaultValue ?? this.defaultValue, + description: description ?? this.description, + required: required ?? this.required, + options: options ?? this.options, + width: width ?? this.width, + placeholder: placeholder ?? this.placeholder, + help: help ?? this.help, + ); + } + + @override + String toString() { + return 'Setting(key: $key, type: $type, required: $required)'; + } +} + diff --git a/packages/crossbar_core/lib/src/models/plugin_output.dart b/packages/crossbar_core/lib/src/models/plugin_output.dart new file mode 100644 index 0000000..6c84882 --- /dev/null +++ b/packages/crossbar_core/lib/src/models/plugin_output.dart @@ -0,0 +1,149 @@ +class PluginOutput { + + const PluginOutput({ + required this.pluginId, + required this.icon, + this.text, + this.color, + this.trayTooltip, + this.menu = const [], + this.hasError = false, + this.errorMessage, + }); + + factory PluginOutput.error(String pluginId, String message) { + return PluginOutput( + pluginId: pluginId, + icon: '', + text: 'Error', + hasError: true, + errorMessage: message, + ); + } + + factory PluginOutput.empty(String pluginId) { + return PluginOutput( + pluginId: pluginId, + icon: '', + text: '', + ); + } + final String pluginId; + final String icon; + final String? text; + final int? color; + final String? trayTooltip; + final List<MenuItem> menu; + final bool hasError; + final String? errorMessage; + + PluginOutput copyWith({ + String? pluginId, + String? icon, + String? text, + int? color, + String? trayTooltip, + List<MenuItem>? menu, + bool? hasError, + String? errorMessage, + }) { + return PluginOutput( + pluginId: pluginId ?? this.pluginId, + icon: icon ?? this.icon, + text: text ?? this.text, + color: color ?? this.color, + trayTooltip: trayTooltip ?? this.trayTooltip, + menu: menu ?? this.menu, + hasError: hasError ?? this.hasError, + errorMessage: errorMessage ?? this.errorMessage, + ); + } + + Map<String, dynamic> toJson() { + return { + 'pluginId': pluginId, + 'icon': icon, + 'text': text, + 'color': color, + 'trayTooltip': trayTooltip, + 'menu': menu.map((m) => m.toJson()).toList(), + 'hasError': hasError, + 'errorMessage': errorMessage, + }; + } + + @override + String toString() { + return 'PluginOutput(pluginId: $pluginId, icon: $icon, text: $text, hasError: $hasError)'; + } +} + +class MenuItem { + + const MenuItem({ + this.text, + this.separator = false, + this.bash, + this.href, + this.color, + this.submenu, + }); + + factory MenuItem.separator() { + return const MenuItem(separator: true); + } + + factory MenuItem.fromJson(Map<String, dynamic> json) { + return MenuItem( + text: json['text'] as String?, + separator: json['separator'] as bool? ?? false, + bash: json['bash'] as String?, + href: json['href'] as String?, + color: json['color'] as String?, + submenu: (json['submenu'] as List<dynamic>?) + ?.map((s) => MenuItem.fromJson(s as Map<String, dynamic>)) + .toList(), + ); + } + final String? text; + final bool separator; + final String? bash; + final String? href; + final String? color; + final List<MenuItem>? submenu; + + Map<String, dynamic> toJson() { + return { + if (text != null) 'text': text, + 'separator': separator, + if (bash != null) 'bash': bash, + if (href != null) 'href': href, + if (color != null) 'color': color, + if (submenu != null) 'submenu': submenu!.map((s) => s.toJson()).toList(), + }; + } + + MenuItem copyWith({ + String? text, + bool? separator, + String? bash, + String? href, + String? color, + List<MenuItem>? submenu, + }) { + return MenuItem( + text: text ?? this.text, + separator: separator ?? this.separator, + bash: bash ?? this.bash, + href: href ?? this.href, + color: color ?? this.color, + submenu: submenu ?? this.submenu, + ); + } + + @override + String toString() { + if (separator) return 'MenuItem(separator)'; + return 'MenuItem(text: $text)'; + } +} diff --git a/packages/crossbar_core/pubspec.lock b/packages/crossbar_core/pubspec.lock new file mode 100644 index 0000000..4289812 --- /dev/null +++ b/packages/crossbar_core/pubspec.lock @@ -0,0 +1,389 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: "5b7468c326d2f8a4f630056404ca0d291ade42918f4a3c6233618e724f39da8e" + url: "https://pub.dev" + source: hosted + version: "92.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "70e4b1ef8003c64793a9e268a551a82869a8a96f39deb73dea28084b0e8bf75e" + url: "https://pub.dev" + source: hosted + version: "9.0.0" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + coverage: + dependency: transitive + description: + name: coverage + sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d" + url: "https://pub.dev" + source: hosted + version: "1.15.0" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + lints: + dependency: "direct dev" + description: + name: lints + sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 + url: "https://pub.dev" + source: hosted + version: "5.1.1" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" + url: "https://pub.dev" + source: hosted + version: "0.12.18" + meta: + dependency: "direct main" + description: + name: meta + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + url: "https://pub.dev" + source: hosted + version: "1.17.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + path: + dependency: "direct main" + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" + source: hosted + version: "1.1.3" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" + url: "https://pub.dev" + source: hosted + version: "0.10.13" + source_span: + dependency: transitive + description: + name: source_span + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + url: "https://pub.dev" + source: hosted + version: "1.10.1" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test: + dependency: "direct dev" + description: + name: test + sha256: "77cc98ea27006c84e71a7356cf3daf9ddbde2d91d84f77dbfe64cf0e4d9611ae" + url: "https://pub.dev" + source: hosted + version: "1.28.0" + test_api: + dependency: transitive + description: + name: test_api + sha256: "19a78f63e83d3a61f00826d09bc2f60e191bf3504183c001262be6ac75589fb8" + url: "https://pub.dev" + source: hosted + version: "0.7.8" + test_core: + dependency: transitive + description: + name: test_core + sha256: f1072617a6657e5fc09662e721307f7fb009b4ed89b19f47175d11d5254a62d4 + url: "https://pub.dev" + source: hosted + version: "0.6.14" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + url: "https://pub.dev" + source: hosted + version: "15.0.2" + watcher: + dependency: transitive + description: + name: watcher + sha256: f52385d4f73589977c80797e60fe51014f7f2b957b5e9a62c3f6ada439889249 + url: "https://pub.dev" + source: hosted + version: "1.2.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.9.0 <4.0.0" diff --git a/packages/crossbar_core/pubspec.yaml b/packages/crossbar_core/pubspec.yaml new file mode 100644 index 0000000..a91aa03 --- /dev/null +++ b/packages/crossbar_core/pubspec.yaml @@ -0,0 +1,15 @@ +name: crossbar_core +description: Shared APIs and models for Crossbar - pure Dart implementation +version: 1.0.0 +publish_to: none + +environment: + sdk: ^3.5.0 + +dependencies: + path: ^1.9.0 + meta: ^1.12.0 + +dev_dependencies: + lints: ^5.0.0 + test: ^1.25.8 diff --git a/plugins/examples/submenu_demo.30s.sh b/plugins/examples/submenu_demo.30s.sh new file mode 100755 index 0000000..41011c5 --- /dev/null +++ b/plugins/examples/submenu_demo.30s.sh @@ -0,0 +1,29 @@ +#!/bin/bash +# Crossbar submenu demo plugin +# Demonstrates nested menus using BitBar format + +echo "📊 System | color=blue" +echo "---" +echo "Hardware" +echo "--CPU" +echo "----Model: $(lscpu | grep 'Model name' | cut -d: -f2 | xargs)" +echo "----Cores: $(nproc)" +echo "----Usage: $(top -bn1 | grep 'Cpu(s)' | awk '{print $2}')%" +echo "--Memory" +echo "----Total: $(free -h | awk '/Mem:/ {print $2}')" +echo "----Used: $(free -h | awk '/Mem:/ {print $3}')" +echo "----Free: $(free -h | awk '/Mem:/ {print $4}')" +echo "--Storage" +echo "----Root: $(df -h / | awk 'NR==2 {print $3 "/" $2}')" +echo "----Home: $(df -h /home | awk 'NR==2 {print $3 "/" $2}')" +echo "---" +echo "Quick Actions" +echo "--Open Terminal | bash=gnome-terminal" +echo "--Open File Manager | bash=nautilus" +echo "---" +echo "Network" +echo "--IP Address" +echo "----Local: $(hostname -I | cut -d' ' -f1)" +echo "----Public: $(curl -s ifconfig.me 2>/dev/null || echo 'N/A') | href=https://ifconfig.me" +echo "--Status" +echo "----$(ping -c1 -W1 8.8.8.8 &>/dev/null && echo '🟢 Online' || echo '🔴 Offline')" diff --git a/test/unit/core/output_parser_test.dart b/test/unit/core/output_parser_test.dart index 2118e51..b069038 100644 --- a/test/unit/core/output_parser_test.dart +++ b/test/unit/core/output_parser_test.dart @@ -105,6 +105,161 @@ Menu Item expect(output.menu.length, 1); expect(output.menu[0].text, 'Menu Item'); }); + + test('parses single level submenu (-- prefix)', () { + const input = ''' +System Info +--- +CPU +--CPU Core 1: 45% +--CPU Core 2: 52% +Memory +--Used: 8GB +--Free: 24GB +'''; + + final output = OutputParser.parse(input, 'test.sh'); + + expect(output.text, 'System Info'); + expect(output.menu.length, 2); // CPU and Memory + expect(output.menu[0].text, 'CPU'); + expect(output.menu[0].submenu, isNotNull); + expect(output.menu[0].submenu!.length, 2); + expect(output.menu[0].submenu![0].text, 'CPU Core 1: 45%'); + expect(output.menu[0].submenu![1].text, 'CPU Core 2: 52%'); + expect(output.menu[1].text, 'Memory'); + expect(output.menu[1].submenu!.length, 2); + expect(output.menu[1].submenu![0].text, 'Used: 8GB'); + expect(output.menu[1].submenu![1].text, 'Free: 24GB'); + }); + + test('parses two level submenu (---- prefix)', () { + const input = ''' +System +--- +Hardware +--CPU +----Intel i9 +----12 Cores +--GPU +----NVIDIA RTX 4090 +'''; + + final output = OutputParser.parse(input, 'test.sh'); + + expect(output.menu.length, 1); // Hardware + expect(output.menu[0].text, 'Hardware'); + expect(output.menu[0].submenu!.length, 2); // CPU and GPU + + final cpu = output.menu[0].submenu![0]; + expect(cpu.text, 'CPU'); + expect(cpu.submenu!.length, 2); + expect(cpu.submenu![0].text, 'Intel i9'); + expect(cpu.submenu![1].text, '12 Cores'); + + final gpu = output.menu[0].submenu![1]; + expect(gpu.text, 'GPU'); + expect(gpu.submenu!.length, 1); + expect(gpu.submenu![0].text, 'NVIDIA RTX 4090'); + }); + + test('parses submenu with attributes', () { + const input = ''' +Actions +--- +Open Files +--Open Home | bash=xdg-open ~ +--Open Documents | href=file://~/Documents | color=blue +'''; + + final output = OutputParser.parse(input, 'test.sh'); + + expect(output.menu[0].text, 'Open Files'); + final submenu = output.menu[0].submenu!; + expect(submenu.length, 2); + expect(submenu[0].text, 'Open Home'); + expect(submenu[0].bash, 'xdg-open ~'); + expect(submenu[1].text, 'Open Documents'); + expect(submenu[1].href, 'file://~/Documents'); + expect(submenu[1].color, 'blue'); + }); + + test('handles mixed depth levels correctly', () { + const input = ''' +Mixed +--- +Level 0 Item A +--Level 1 Item +----Level 2 Item +Level 0 Item B +--Another Level 1 +'''; + + final output = OutputParser.parse(input, 'test.sh'); + + expect(output.menu.length, 2); // Level 0 Item A and B + expect(output.menu[0].text, 'Level 0 Item A'); + expect(output.menu[0].submenu!.length, 1); + expect(output.menu[0].submenu![0].text, 'Level 1 Item'); + expect(output.menu[0].submenu![0].submenu!.length, 1); + expect(output.menu[0].submenu![0].submenu![0].text, 'Level 2 Item'); + + expect(output.menu[1].text, 'Level 0 Item B'); + expect(output.menu[1].submenu!.length, 1); + expect(output.menu[1].submenu![0].text, 'Another Level 1'); + }); + + test('handles separator correctly within menu and submenus', () { + const input = ''' +Test +--- +Item 1 +--- +Item 2 +--Sub 1 +--- +--Sub 2 +'''; + + final output = OutputParser.parse(input, 'test.sh'); + + // After first ---, separator creates a graphical separator + // The structure should have separators in the right places + expect(output.menu.length, greaterThanOrEqualTo(2)); + }); + + test('parses 5 levels of nesting', () { + const input = ''' +Deep Nesting +--- +L0 +--L1 +----L2 +------L3 +--------L4 +----------L5 +'''; + + final output = OutputParser.parse(input, 'test.sh'); + + var current = output.menu[0]; + expect(current.text, 'L0'); + + current = current.submenu![0]; + expect(current.text, 'L1'); + + current = current.submenu![0]; + expect(current.text, 'L2'); + + current = current.submenu![0]; + expect(current.text, 'L3'); + + current = current.submenu![0]; + expect(current.text, 'L4'); + + current = current.submenu![0]; + expect(current.text, 'L5'); + }); }); group('parse - JSON format', () {