Skip to content

Commit 4604b20

Browse files
committed
feat(cli): add screenshot command
- Add `crossbar screenshot [path]` to save screenshot to file - Add `--clipboard` / `-c` flag to copy to clipboard - Support `--json`, `--xml`, `--help` output flags - Uses existing UtilsApi (gnome-screenshot/scrot/spectacle/screencapture/PowerShell) - Add command to both lib/cli and crossbar_cli package - Add 12 unit tests with mock API - Mark roadmap item as completed
1 parent 5ede1b4 commit 4604b20

File tree

6 files changed

+300
-2
lines changed

6 files changed

+300
-2
lines changed

ROADMAP.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ Antes de avançar, reconhecemos o que existe e o que falta para atingir a promes
3030
### 🚧 O que é "Fachada" (Precisa de Implementação)
3131

3232
- **Tray Avançado:** Modos "Smart Collapse" e "Overflow" são apenas enums sem lógica.
33-
- **API Gaps:** Comando `--location` (geocoding) não tem lógica implementada. Screenshot tem API mas sem comando CLI exposto.
33+
- **API Gaps:** Comando `--location` (geocoding) não tem lógica implementada.
3434

3535
---
3636

@@ -150,7 +150,7 @@ Antes de avançar, reconhecemos o que existe e o que falta para atingir a promes
150150
- [x] Linux: `gnome-screenshot`, `scrot`, `spectacle` (fallback chain).
151151
- [x] Windows: PowerShell snippet para captura.
152152
- [x] macOS: `screencapture`.
153-
- [ ] Expor como comando CLI (API existe, falta wiring no CLI handler).
153+
- [x] Expor como comando CLI (`crossbar screenshot [path] [--clipboard]`).
154154

155155
### Fase 2: Marketplace Engine
156156

lib/cli/cli_handler.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import 'commands/plugin_commands.dart';
1414
import 'commands/power_command.dart';
1515
import 'commands/qr_command.dart';
1616
import 'commands/screen_command.dart';
17+
import 'commands/screenshot_command.dart';
1718
import 'commands/system_info_commands.dart';
1819
import 'commands/utility_commands.dart';
1920
import 'commands/vpn_command.dart';
@@ -54,6 +55,7 @@ void _registerCommands() {
5455
_register(NotifyCommand());
5556
_register(OpenCommand());
5657
_register(QrCommand());
58+
_register(ScreenshotCommand());
5759

5860
// Misc
5961
_register(TimeCommand());
@@ -224,6 +226,7 @@ Utilities:
224226
open file <path> Open file
225227
open app <name> Launch application
226228
qr <text> Generate QR Code
229+
screenshot [path] Take screenshot (or --clipboard)
227230
228231
time Current time
229232
date Current date
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// ignore_for_file: avoid_print
2+
import 'dart:io';
3+
4+
import '../../core/api/utils_api.dart';
5+
import 'base_command.dart';
6+
7+
/// CLI command to take screenshots.
8+
///
9+
/// Supports saving to file or copying to clipboard.
10+
/// Uses platform-specific tools: gnome-screenshot/scrot/spectacle (Linux),
11+
/// screencapture (macOS), PowerShell (Windows).
12+
class ScreenshotCommand extends CliCommand {
13+
ScreenshotCommand({UtilsApi? api}) : _api = api ?? const UtilsApi();
14+
15+
final UtilsApi _api;
16+
17+
@override
18+
String get name => 'screenshot';
19+
20+
@override
21+
String get description => 'Take a screenshot';
22+
23+
@override
24+
Future<int> execute(List<String> args) async {
25+
if (args.contains('--help') || args.contains('-h')) {
26+
_printUsage();
27+
return 0;
28+
}
29+
30+
final toClipboard =
31+
args.contains('--clipboard') || args.contains('-c');
32+
final jsonOutput = args.contains('--json');
33+
final xmlOutput = args.contains('--xml');
34+
35+
// First non-flag argument is the file path
36+
final positional = args.where((a) => !a.startsWith('-')).toList();
37+
final path = positional.isNotEmpty ? positional[0] : null;
38+
39+
try {
40+
final result =
41+
await _api.takeScreenshot(path: path, toClipboard: toClipboard);
42+
43+
if (result == null) {
44+
stderr.writeln('Error: No screenshot tool available on this platform');
45+
return 1;
46+
}
47+
48+
printFormatted(
49+
{'success': true, 'path': result, 'clipboard': toClipboard},
50+
json: jsonOutput,
51+
xml: xmlOutput,
52+
xmlRoot: 'screenshot',
53+
plain: (_) => toClipboard
54+
? 'Screenshot copied to clipboard'
55+
: 'Screenshot saved to $result',
56+
);
57+
return 0;
58+
} catch (e) {
59+
stderr.writeln('Error: Failed to take screenshot: $e');
60+
return 1;
61+
}
62+
}
63+
64+
void _printUsage() {
65+
print('''
66+
Usage: crossbar screenshot [path] [options]
67+
68+
Take a screenshot and save to file or copy to clipboard.
69+
70+
Options:
71+
[path] Save screenshot to specific path (default: ~/screenshot_<timestamp>.png)
72+
--clipboard, -c Copy screenshot to clipboard instead of saving
73+
--json Output in JSON format
74+
--xml Output in XML format
75+
76+
Examples:
77+
crossbar screenshot
78+
crossbar screenshot /tmp/my-screenshot.png
79+
crossbar screenshot --clipboard
80+
crossbar screenshot --json
81+
''');
82+
}
83+
}

packages/crossbar_cli/lib/src/cli_handler.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import 'commands/plugin_commands.dart';
1414
import 'commands/power_command.dart';
1515
import 'commands/qr_command.dart';
1616
import 'commands/screen_command.dart';
17+
import 'commands/screenshot_command.dart';
1718
import 'commands/system_info_commands.dart';
1819
import 'commands/utility_commands.dart';
1920
import 'commands/vpn_command.dart';
@@ -54,6 +55,7 @@ void _registerCommands() {
5455
_register(NotifyCommand());
5556
_register(OpenCommand());
5657
_register(QrCommand());
58+
_register(ScreenshotCommand());
5759

5860
// Misc
5961
_register(TimeCommand());
@@ -224,6 +226,7 @@ Utilities:
224226
open file <path> Open file
225227
open app <name> Launch application
226228
qr <text> Generate QR Code
229+
screenshot [path] Take screenshot (or --clipboard)
227230
228231
time Current time
229232
date Current date
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// ignore_for_file: avoid_print
2+
import 'dart:io';
3+
4+
import 'package:crossbar_core/crossbar_core.dart';
5+
import 'base_command.dart';
6+
7+
/// CLI command to take screenshots.
8+
///
9+
/// Supports saving to file or copying to clipboard.
10+
/// Uses platform-specific tools: gnome-screenshot/scrot/spectacle (Linux),
11+
/// screencapture (macOS), PowerShell (Windows).
12+
class ScreenshotCommand extends CliCommand {
13+
ScreenshotCommand({UtilsApi? api}) : _api = api ?? const UtilsApi();
14+
15+
final UtilsApi _api;
16+
17+
@override
18+
String get name => 'screenshot';
19+
20+
@override
21+
String get description => 'Take a screenshot';
22+
23+
@override
24+
Future<int> execute(List<String> args) async {
25+
if (args.contains('--help') || args.contains('-h')) {
26+
_printUsage();
27+
return 0;
28+
}
29+
30+
final toClipboard =
31+
args.contains('--clipboard') || args.contains('-c');
32+
final jsonOutput = args.contains('--json');
33+
final xmlOutput = args.contains('--xml');
34+
35+
// First non-flag argument is the file path
36+
final positional = args.where((a) => !a.startsWith('-')).toList();
37+
final path = positional.isNotEmpty ? positional[0] : null;
38+
39+
try {
40+
final result =
41+
await _api.takeScreenshot(path: path, toClipboard: toClipboard);
42+
43+
if (result == null) {
44+
stderr.writeln('Error: No screenshot tool available on this platform');
45+
return 1;
46+
}
47+
48+
printFormatted(
49+
{'success': true, 'path': result, 'clipboard': toClipboard},
50+
json: jsonOutput,
51+
xml: xmlOutput,
52+
xmlRoot: 'screenshot',
53+
plain: (_) => toClipboard
54+
? 'Screenshot copied to clipboard'
55+
: 'Screenshot saved to $result',
56+
);
57+
return 0;
58+
} catch (e) {
59+
stderr.writeln('Error: Failed to take screenshot: $e');
60+
return 1;
61+
}
62+
}
63+
64+
void _printUsage() {
65+
print('''
66+
Usage: crossbar screenshot [path] [options]
67+
68+
Take a screenshot and save to file or copy to clipboard.
69+
70+
Options:
71+
[path] Save screenshot to specific path (default: ~/screenshot_<timestamp>.png)
72+
--clipboard, -c Copy screenshot to clipboard instead of saving
73+
--json Output in JSON format
74+
--xml Output in XML format
75+
76+
Examples:
77+
crossbar screenshot
78+
crossbar screenshot /tmp/my-screenshot.png
79+
crossbar screenshot --clipboard
80+
crossbar screenshot --json
81+
''');
82+
}
83+
}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import 'package:crossbar/cli/commands/screenshot_command.dart';
2+
import 'package:crossbar/core/api/utils_api.dart';
3+
import 'package:flutter_test/flutter_test.dart';
4+
5+
class _MockUtilsApi extends UtilsApi {
6+
String? result = '/tmp/screenshot.png';
7+
String? capturedPath;
8+
bool? capturedClipboard;
9+
10+
@override
11+
Future<String?> takeScreenshot(
12+
{String? path, bool toClipboard = false}) async {
13+
capturedPath = path;
14+
capturedClipboard = toClipboard;
15+
return result;
16+
}
17+
}
18+
19+
class _ThrowingUtilsApi extends UtilsApi {
20+
@override
21+
Future<String?> takeScreenshot(
22+
{String? path, bool toClipboard = false}) async {
23+
throw Exception('gnome-screenshot not found');
24+
}
25+
}
26+
27+
void main() {
28+
group('ScreenshotCommand', () {
29+
late ScreenshotCommand command;
30+
late _MockUtilsApi mockApi;
31+
32+
setUp(() {
33+
mockApi = _MockUtilsApi();
34+
command = ScreenshotCommand(api: mockApi);
35+
});
36+
37+
test('name is screenshot', () {
38+
expect(command.name, 'screenshot');
39+
});
40+
41+
test('description is not empty', () {
42+
expect(command.description, isNotEmpty);
43+
});
44+
45+
test('--help returns 0', () async {
46+
final exitCode = await command.execute(['--help']);
47+
expect(exitCode, 0);
48+
});
49+
50+
test('-h returns 0', () async {
51+
final exitCode = await command.execute(['-h']);
52+
expect(exitCode, 0);
53+
});
54+
55+
test('saves screenshot to specified path', () async {
56+
mockApi.result = '/tmp/my-screenshot.png';
57+
58+
final exitCode = await command.execute(['/tmp/my-screenshot.png']);
59+
60+
expect(exitCode, 0);
61+
expect(mockApi.capturedPath, '/tmp/my-screenshot.png');
62+
expect(mockApi.capturedClipboard, false);
63+
});
64+
65+
test('saves screenshot to default path when no args', () async {
66+
mockApi.result = '/home/user/screenshot_2026.png';
67+
68+
final exitCode = await command.execute([]);
69+
70+
expect(exitCode, 0);
71+
expect(mockApi.capturedPath, isNull);
72+
expect(mockApi.capturedClipboard, false);
73+
});
74+
75+
test('handles --clipboard flag', () async {
76+
mockApi.result = 'clipboard';
77+
78+
final exitCode = await command.execute(['--clipboard']);
79+
80+
expect(exitCode, 0);
81+
expect(mockApi.capturedPath, isNull);
82+
expect(mockApi.capturedClipboard, true);
83+
});
84+
85+
test('handles -c shorthand flag', () async {
86+
mockApi.result = 'clipboard';
87+
88+
final exitCode = await command.execute(['-c']);
89+
90+
expect(exitCode, 0);
91+
expect(mockApi.capturedClipboard, true);
92+
});
93+
94+
test('returns 1 when screenshot tool not available', () async {
95+
mockApi.result = null;
96+
97+
final exitCode = await command.execute([]);
98+
99+
expect(exitCode, 1);
100+
});
101+
102+
test('returns 1 when exception is thrown', () async {
103+
final throwingCommand = ScreenshotCommand(api: _ThrowingUtilsApi());
104+
105+
final exitCode = await throwingCommand.execute([]);
106+
107+
expect(exitCode, 1);
108+
});
109+
110+
test('supports --json flag', () async {
111+
mockApi.result = '/tmp/screenshot.png';
112+
113+
final exitCode = await command.execute(['--json']);
114+
115+
expect(exitCode, 0);
116+
});
117+
118+
test('supports --xml flag', () async {
119+
mockApi.result = '/tmp/screenshot.png';
120+
121+
final exitCode = await command.execute(['--xml']);
122+
123+
expect(exitCode, 0);
124+
});
125+
});
126+
}

0 commit comments

Comments
 (0)