@testdriverai/runner 7.10.0-canary.0 → 7.10.1-canary
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/ably-service.js +74 -11
- package/lib/automation.js +67 -18
- package/package.json +1 -1
- package/sandbox-agent.js +38 -1
- package/scripts-desktop/start-desktop.sh +6 -3
package/lib/ably-service.js
CHANGED
|
@@ -40,16 +40,16 @@ function getLocalVersion() {
|
|
|
40
40
|
|
|
41
41
|
/**
|
|
42
42
|
* Upload a screenshot to S3 via presigned URL from the API
|
|
43
|
-
*
|
|
43
|
+
*
|
|
44
44
|
* @param {string} apiRoot - API base URL
|
|
45
45
|
* @param {string} apiKey - Team API key
|
|
46
46
|
* @param {string} sandboxId - Sandbox ID
|
|
47
|
-
* @param {string}
|
|
47
|
+
* @param {string|Buffer} payload - PNG bytes as a Buffer (preferred) or base64 string
|
|
48
48
|
* @param {string} label - Label for the screenshot (e.g., 'screenshot', 'before', 'after')
|
|
49
49
|
* @returns {Promise<{ s3Key: string, fileName: string } | null>}
|
|
50
50
|
*/
|
|
51
|
-
async function uploadToS3(apiRoot, apiKey, sandboxId,
|
|
52
|
-
if (!
|
|
51
|
+
async function uploadToS3(apiRoot, apiKey, sandboxId, payload, label = 'screenshot') {
|
|
52
|
+
if (!payload || !apiRoot || !apiKey) return null;
|
|
53
53
|
|
|
54
54
|
try {
|
|
55
55
|
const fileName = `${label}-${Date.now()}.png`;
|
|
@@ -68,8 +68,10 @@ async function uploadToS3(apiRoot, apiKey, sandboxId, base64, label = 'screensho
|
|
|
68
68
|
return null;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
// Upload to S3 via presigned URL
|
|
72
|
-
|
|
71
|
+
// Upload to S3 via presigned URL. Accept either a Buffer (no decode) or
|
|
72
|
+
// a base64 string (legacy callers) — Buffer is preferred to avoid the
|
|
73
|
+
// wasteful PNG → base64 → PNG round-trip.
|
|
74
|
+
const pngBuffer = Buffer.isBuffer(payload) ? payload : Buffer.from(payload, 'base64');
|
|
73
75
|
const url = new URL(uploadUrlResponse.uploadUrl);
|
|
74
76
|
const transport = url.protocol === 'https:' ? https : http;
|
|
75
77
|
|
|
@@ -136,6 +138,41 @@ async function httpPost(apiRoot, path, body, extraHeaders = {}) {
|
|
|
136
138
|
});
|
|
137
139
|
}
|
|
138
140
|
|
|
141
|
+
// ─── EC2 Metadata Helpers ────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Fetch the public IPv4 address from the EC2 Instance Metadata Service (IMDSv1).
|
|
145
|
+
* Returns null if not running on EC2, instance has no public IP, or request times out.
|
|
146
|
+
* Non-blocking: uses a 2-second timeout so it never delays runner startup.
|
|
147
|
+
*
|
|
148
|
+
* @returns {Promise<string|null>}
|
|
149
|
+
*/
|
|
150
|
+
function getEc2PublicIp() {
|
|
151
|
+
return new Promise((resolve) => {
|
|
152
|
+
const req = http.request(
|
|
153
|
+
{
|
|
154
|
+
hostname: '169.254.169.254',
|
|
155
|
+
port: 80,
|
|
156
|
+
path: '/latest/meta-data/public-ipv4',
|
|
157
|
+
method: 'GET',
|
|
158
|
+
timeout: 2000,
|
|
159
|
+
},
|
|
160
|
+
(res) => {
|
|
161
|
+
if (res.statusCode !== 200) {
|
|
162
|
+
resolve(null);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
let data = '';
|
|
166
|
+
res.on('data', (chunk) => { data += chunk; });
|
|
167
|
+
res.on('end', () => resolve(data.trim() || null));
|
|
168
|
+
}
|
|
169
|
+
);
|
|
170
|
+
req.on('error', () => resolve(null));
|
|
171
|
+
req.on('timeout', () => { req.destroy(); resolve(null); });
|
|
172
|
+
req.end();
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
139
176
|
// ─── Ably Service Class ──────────────────────────────────────────────────────
|
|
140
177
|
|
|
141
178
|
class AblyService extends EventEmitter {
|
|
@@ -164,6 +201,7 @@ class AblyService extends EventEmitter {
|
|
|
164
201
|
this._sessionChannel = null;
|
|
165
202
|
this._connected = false;
|
|
166
203
|
this._statsInterval = null;
|
|
204
|
+
this._closed = false; // set to true in close() to suppress post-close events
|
|
167
205
|
}
|
|
168
206
|
|
|
169
207
|
/**
|
|
@@ -185,12 +223,12 @@ class AblyService extends EventEmitter {
|
|
|
185
223
|
const text = typeof msg === 'string' ? msg : String(msg);
|
|
186
224
|
const isError = /error/i.test(text) || /\[Level 1\]/i.test(text);
|
|
187
225
|
if (isError) {
|
|
188
|
-
console.error(
|
|
226
|
+
console.error('[Ably runner] Ably SDK error', { sandboxId: self._sandboxId, raw: text });
|
|
189
227
|
Sentry.withScope((scope) => {
|
|
190
228
|
scope.setTag('ably.client', 'runner');
|
|
191
229
|
scope.setTag('sandbox.id', self._sandboxId);
|
|
192
230
|
scope.setContext('ably_log', { raw: text });
|
|
193
|
-
Sentry.captureMessage(
|
|
231
|
+
Sentry.captureMessage('Ably SDK error (runner)', 'error');
|
|
194
232
|
});
|
|
195
233
|
}
|
|
196
234
|
Sentry.addBreadcrumb({
|
|
@@ -367,7 +405,10 @@ class AblyService extends EventEmitter {
|
|
|
367
405
|
if (current === 'disconnected') {
|
|
368
406
|
this._connected = false;
|
|
369
407
|
this.emit('log', `Realtime connection: ${previous} → ${current}${reasonMsg ? ' — ' + reasonMsg : ''}${retryIn ? ' (retryIn=' + retryIn + 'ms)' : ''}`);
|
|
370
|
-
|
|
408
|
+
// Suppress auto-reconnect message when we've already initiated a clean close
|
|
409
|
+
if (!this._closed) {
|
|
410
|
+
this.emit('log', 'Ably disconnected — will auto-reconnect');
|
|
411
|
+
}
|
|
371
412
|
} else if (current === 'connected' && previous !== 'initialized') {
|
|
372
413
|
if (!this._connected) {
|
|
373
414
|
this._connected = true;
|
|
@@ -390,8 +431,8 @@ class AblyService extends EventEmitter {
|
|
|
390
431
|
this.emit('log', `Realtime connection: ${previous} → ${current}${reasonMsg ? ' — ' + reasonMsg : ''}`);
|
|
391
432
|
}
|
|
392
433
|
|
|
393
|
-
// Capture exceptions for bad states
|
|
394
|
-
if (current === 'failed' || current === 'suspended' || (current === 'disconnected' && reason)) {
|
|
434
|
+
// Capture exceptions for bad states — skip if we're in a clean close sequence
|
|
435
|
+
if (!this._closed && (current === 'failed' || current === 'suspended' || (current === 'disconnected' && reason))) {
|
|
395
436
|
Sentry.withScope((scope) => {
|
|
396
437
|
scope.setTag('ably.client', 'runner');
|
|
397
438
|
scope.setTag('ably.state', current);
|
|
@@ -477,12 +518,22 @@ class AblyService extends EventEmitter {
|
|
|
477
518
|
this.emit('log', 'Listening for commands on Ably');
|
|
478
519
|
|
|
479
520
|
// Signal readiness to SDK — commands sent before this would be lost
|
|
521
|
+
|
|
522
|
+
// Best-effort: fetch the public IP from EC2 instance metadata so the SDK can
|
|
523
|
+
// construct the VNC preview URL without relying solely on API-side IP lookup.
|
|
524
|
+
// Times out after 2s and resolves null if not on EC2 or instance has no public IP.
|
|
525
|
+
const ec2PublicIp = await getEc2PublicIp();
|
|
526
|
+
if (ec2PublicIp) {
|
|
527
|
+
this.emit('log', `EC2 public IP: ${ec2PublicIp}`);
|
|
528
|
+
}
|
|
529
|
+
|
|
480
530
|
const readyPayload = {
|
|
481
531
|
type: 'runner.ready',
|
|
482
532
|
os: process.platform === 'win32' ? 'windows' : 'linux',
|
|
483
533
|
sandboxId: this._sandboxId,
|
|
484
534
|
runnerVersion: getLocalVersion() || 'unknown',
|
|
485
535
|
timestamp: Date.now(),
|
|
536
|
+
ip: ec2PublicIp || undefined,
|
|
486
537
|
};
|
|
487
538
|
if (this._updateInfo) {
|
|
488
539
|
readyPayload.update = {
|
|
@@ -579,6 +630,10 @@ class AblyService extends EventEmitter {
|
|
|
579
630
|
* so that missed commands are actually executed.
|
|
580
631
|
*/
|
|
581
632
|
async _recoverFromDiscontinuity() {
|
|
633
|
+
// Don't attempt recovery after an intentional close — subscriptions are
|
|
634
|
+
// nulled out in close() and the channel is being torn down
|
|
635
|
+
if (this._closed) return;
|
|
636
|
+
|
|
582
637
|
const subs = [
|
|
583
638
|
{ name: 'command', sub: this._commandSubscription, handler: this._onCommandMsg },
|
|
584
639
|
{ name: 'control', sub: this._controlSubscription, handler: this._onControlMsg },
|
|
@@ -668,6 +723,9 @@ class AblyService extends EventEmitter {
|
|
|
668
723
|
* Disconnect from Ably and clean up.
|
|
669
724
|
*/
|
|
670
725
|
async close() {
|
|
726
|
+
if (this._closed) return; // Prevent double-close
|
|
727
|
+
this._closed = true;
|
|
728
|
+
|
|
671
729
|
this.emit('log', 'Closing realtime service...');
|
|
672
730
|
|
|
673
731
|
this._stopReadySignal();
|
|
@@ -677,6 +735,11 @@ class AblyService extends EventEmitter {
|
|
|
677
735
|
this._statsInterval = null;
|
|
678
736
|
}
|
|
679
737
|
|
|
738
|
+
// Null out subscription refs so _recoverFromDiscontinuity won't replay
|
|
739
|
+
// messages on a channel that is about to be torn down
|
|
740
|
+
this._commandSubscription = null;
|
|
741
|
+
this._controlSubscription = null;
|
|
742
|
+
|
|
680
743
|
try {
|
|
681
744
|
if (this._sessionChannel) this._sessionChannel.detach();
|
|
682
745
|
} catch { }
|
package/lib/automation.js
CHANGED
|
@@ -52,6 +52,26 @@ const PY_IMPORT = IS_LINUX
|
|
|
52
52
|
? "import os; os.environ.setdefault('DISPLAY', ':0'); import pyautogui, sys; pyautogui.FAILSAFE = False; "
|
|
53
53
|
: 'import pyautogui, sys; pyautogui.FAILSAFE = False; ';
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Read width/height from a PNG buffer's IHDR chunk without decoding the image.
|
|
57
|
+
* PNG layout: 8-byte signature, then the IHDR chunk whose 4-byte width and
|
|
58
|
+
* height live at byte offsets 16 and 20 (big-endian). Returns {} if the buffer
|
|
59
|
+
* doesn't look like a PNG so callers degrade to the resize fallback.
|
|
60
|
+
* @param {Buffer} buffer
|
|
61
|
+
* @returns {{ width?: number, height?: number }}
|
|
62
|
+
*/
|
|
63
|
+
function readPngDimensions(buffer) {
|
|
64
|
+
const PNG_SIGNATURE = '89504e470d0a1a0a';
|
|
65
|
+
if (!buffer || buffer.length < 24 ||
|
|
66
|
+
buffer.slice(0, 8).toString('hex') !== PNG_SIGNATURE) {
|
|
67
|
+
return {};
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
width: buffer.readUInt32BE(16),
|
|
71
|
+
height: buffer.readUInt32BE(20),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
55
75
|
/**
|
|
56
76
|
* Run a pyautogui Python script via subprocess.
|
|
57
77
|
* @param {string} script — Python code (pyautogui + sys are already imported via PY_IMPORT prefix)
|
|
@@ -150,6 +170,10 @@ class ShellSession {
|
|
|
150
170
|
// Clear stderr buffer
|
|
151
171
|
this._stderrLines = [];
|
|
152
172
|
|
|
173
|
+
// Capture the process reference now — the exit handler may set
|
|
174
|
+
// this._process to null before the Promise resolves/times out.
|
|
175
|
+
const proc = this._process;
|
|
176
|
+
|
|
153
177
|
return await new Promise((resolve, reject) => {
|
|
154
178
|
const timeoutMs = timeout * 1000;
|
|
155
179
|
const timer = setTimeout(() => {
|
|
@@ -208,11 +232,11 @@ class ShellSession {
|
|
|
208
232
|
|
|
209
233
|
const cleanup = () => {
|
|
210
234
|
clearTimeout(timer);
|
|
211
|
-
|
|
235
|
+
proc.stdout.removeListener('data', onData);
|
|
212
236
|
};
|
|
213
237
|
|
|
214
|
-
|
|
215
|
-
|
|
238
|
+
proc.stdout.on('data', onData);
|
|
239
|
+
proc.stdin.write(fullCommand);
|
|
216
240
|
});
|
|
217
241
|
} catch (err) {
|
|
218
242
|
return {
|
|
@@ -587,20 +611,26 @@ class Automation extends EventEmitter {
|
|
|
587
611
|
// For extract/remember: capture screenshot, upload to S3, return s3Key
|
|
588
612
|
// SDK will then call API with the s3Key
|
|
589
613
|
// For system.screenshot (normalized to 'screenshot'): same flow (return s3Key instead of base64)
|
|
590
|
-
|
|
591
|
-
// Capture screenshot locally
|
|
614
|
+
|
|
615
|
+
// Capture screenshot locally — buffer variant avoids an unnecessary
|
|
616
|
+
// round-trip through base64 (PNG buffer → base64 → PNG buffer) that
|
|
617
|
+
// doubles peak memory and adds ~10–30 ms of pure encoding cost on
|
|
618
|
+
// large screenshots.
|
|
592
619
|
console.log('[automation] Step 1: Capturing screenshot...');
|
|
593
|
-
const
|
|
594
|
-
console.log(`[automation] Step 2: Screenshot captured, size: ${
|
|
595
|
-
|
|
596
|
-
|
|
620
|
+
const buffer = await this._captureScreenshotBuffer();
|
|
621
|
+
console.log(`[automation] Step 2: Screenshot captured, size: ${buffer.length} bytes`);
|
|
622
|
+
|
|
623
|
+
// Read the PNG dimensions from the IHDR header (bytes 16–24) without
|
|
624
|
+
// decoding. The SDK uses these to decide whether it can pass the s3Key
|
|
625
|
+
// straight to the API (no resize needed) or must download + resize.
|
|
626
|
+
const dimensions = readPngDimensions(buffer);
|
|
597
627
|
|
|
598
628
|
// Upload screenshot to S3 and return key
|
|
599
|
-
console.log('[automation] Step
|
|
629
|
+
console.log('[automation] Step 3: Uploading to S3...');
|
|
600
630
|
const s3Key = await this._uploadToS3(buffer, this._sandboxId, 'image/png');
|
|
601
|
-
console.log(`[automation] Step
|
|
602
|
-
|
|
603
|
-
return { s3Key };
|
|
631
|
+
console.log(`[automation] Step 4: Upload complete, s3Key: ${s3Key}`);
|
|
632
|
+
|
|
633
|
+
return { s3Key, ...dimensions };
|
|
604
634
|
}
|
|
605
635
|
|
|
606
636
|
case 'ping':
|
|
@@ -664,7 +694,15 @@ class Automation extends EventEmitter {
|
|
|
664
694
|
|
|
665
695
|
// ── Screenshot (highest quality PNG, via pyautogui → temp file → sharp) ──
|
|
666
696
|
|
|
667
|
-
|
|
697
|
+
/**
|
|
698
|
+
* Capture a screenshot and return the raw PNG buffer.
|
|
699
|
+
*
|
|
700
|
+
* Prefer this when the next consumer of the bytes is binary (e.g. an HTTP
|
|
701
|
+
* PUT to S3): it avoids the wasteful PNG → base64 → PNG round-trip that
|
|
702
|
+
* `_captureScreenshot()` does for backwards compatibility with callers
|
|
703
|
+
* that still expect a base64 string.
|
|
704
|
+
*/
|
|
705
|
+
async _captureScreenshotBuffer() {
|
|
668
706
|
const sharp = require('sharp');
|
|
669
707
|
const maxAttempts = 3;
|
|
670
708
|
|
|
@@ -702,11 +740,15 @@ class Automation extends EventEmitter {
|
|
|
702
740
|
if (maxPixel <= 1) {
|
|
703
741
|
console.warn(`[automation] Screenshot attempt ${attempt}/${maxAttempts}: image is all black (max pixel=${maxPixel})`);
|
|
704
742
|
if (attempt < maxAttempts) {
|
|
705
|
-
// Try to heal: poke the display to trigger a redraw
|
|
743
|
+
// Try to heal: poke the display to trigger a redraw.
|
|
744
|
+
// NOTE: do NOT send the `super` key here — on Linux desktops it's
|
|
745
|
+
// bound to the Activities overview / app launcher, which steals
|
|
746
|
+
// focus from (and visually hides) the active window. That made
|
|
747
|
+
// Chrome appear to "go missing" right after a flaky black capture.
|
|
748
|
+
// The xset display-wake calls are what actually recover the screen.
|
|
706
749
|
try {
|
|
707
750
|
await runPyAutoGUI(
|
|
708
751
|
"import subprocess; " +
|
|
709
|
-
"subprocess.run(['xdotool', 'key', '--clearmodifiers', 'super'], timeout=5); " +
|
|
710
752
|
"subprocess.run(['xset', 's', 'off'], timeout=5); " +
|
|
711
753
|
"subprocess.run(['xset', 's', 'noblank'], timeout=5); " +
|
|
712
754
|
"subprocess.run(['xset', '-dpms'], timeout=5)",
|
|
@@ -722,8 +764,7 @@ class Automation extends EventEmitter {
|
|
|
722
764
|
}
|
|
723
765
|
}
|
|
724
766
|
|
|
725
|
-
|
|
726
|
-
return buffer.toString('base64');
|
|
767
|
+
return await image.png({ compressionLevel: 0 }).toBuffer();
|
|
727
768
|
} finally {
|
|
728
769
|
// Clean up temp file
|
|
729
770
|
try { fs.unlinkSync(tmpFile); } catch {}
|
|
@@ -731,6 +772,14 @@ class Automation extends EventEmitter {
|
|
|
731
772
|
}
|
|
732
773
|
}
|
|
733
774
|
|
|
775
|
+
async _captureScreenshot() {
|
|
776
|
+
// Kept for backwards compatibility with callers (e.g. Ably channel
|
|
777
|
+
// messages) that still want a base64 string. New code paths should
|
|
778
|
+
// prefer `_captureScreenshotBuffer()` to avoid the round-trip.
|
|
779
|
+
const buffer = await this._captureScreenshotBuffer();
|
|
780
|
+
return buffer.toString('base64');
|
|
781
|
+
}
|
|
782
|
+
|
|
734
783
|
// ── Focus window (platform-specific) ───────────────────────────────
|
|
735
784
|
|
|
736
785
|
async _focusWindow(data) {
|
package/package.json
CHANGED
package/sandbox-agent.js
CHANGED
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
*/
|
|
34
34
|
require('dotenv').config();
|
|
35
35
|
const Sentry = require('@sentry/node');
|
|
36
|
+
const crypto = require('crypto');
|
|
36
37
|
const fs = require('fs');
|
|
37
38
|
const os = require('os');
|
|
38
39
|
const path = require('path');
|
|
@@ -181,6 +182,7 @@ function log(msg) {
|
|
|
181
182
|
// ─── Main ────────────────────────────────────────────────────────────────────
|
|
182
183
|
|
|
183
184
|
async function main() {
|
|
185
|
+
let rootSpan = null;
|
|
184
186
|
log('TestDriver Sandbox Agent starting...');
|
|
185
187
|
log(`Runner version: ${getLocalVersion() || 'unknown'}`);
|
|
186
188
|
|
|
@@ -207,13 +209,45 @@ async function main() {
|
|
|
207
209
|
// Sample all runner-initiated traces (shouldn't happen often)
|
|
208
210
|
return 1.0;
|
|
209
211
|
},
|
|
210
|
-
environment: config.
|
|
212
|
+
environment: config.sentryChannel || process.env.TD_CHANNEL || 'stable',
|
|
211
213
|
release: version,
|
|
212
214
|
serverName: os.hostname(),
|
|
213
215
|
});
|
|
214
216
|
if (config.sentryChannel || process.env.TD_CHANNEL) {
|
|
215
217
|
Sentry.setTag('channel', config.sentryChannel || process.env.TD_CHANNEL);
|
|
216
218
|
}
|
|
219
|
+
|
|
220
|
+
// Create a session-level root span for distributed tracing.
|
|
221
|
+
// Uses deterministic traceId = MD5(sessionId) so all components
|
|
222
|
+
// (SDK, API, Runner, Worker) share the same trace.
|
|
223
|
+
const sessionId = config.sessionId;
|
|
224
|
+
if (sessionId) {
|
|
225
|
+
const traceId = crypto.createHash('md5').update(sessionId).digest('hex');
|
|
226
|
+
const spanId = crypto.randomBytes(8).toString('hex');
|
|
227
|
+
const sentryTraceHeader = `${traceId}-${spanId}-1`;
|
|
228
|
+
const baggageHeader = `sentry-trace_id=${traceId},sentry-sampled=true`;
|
|
229
|
+
|
|
230
|
+
Sentry.continueTrace(
|
|
231
|
+
{ sentryTrace: sentryTraceHeader, baggage: baggageHeader },
|
|
232
|
+
() => {
|
|
233
|
+
rootSpan = Sentry.startInactiveSpan({
|
|
234
|
+
name: 'runner.session',
|
|
235
|
+
op: 'session',
|
|
236
|
+
forceTransaction: true,
|
|
237
|
+
attributes: {
|
|
238
|
+
'sandbox.id': config.sandboxId,
|
|
239
|
+
'session.id': sessionId,
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
},
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
Sentry.setTag('session', sessionId);
|
|
246
|
+
Sentry.setTag('trace_id', traceId);
|
|
247
|
+
log(`Distributed tracing: session=${sessionId} traceId=${traceId}`);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
Sentry.setTag('sandbox', config.sandboxId);
|
|
217
251
|
log('Sentry initialized');
|
|
218
252
|
}
|
|
219
253
|
log(`Sandbox ID: ${config.sandboxId}`);
|
|
@@ -251,6 +285,9 @@ async function main() {
|
|
|
251
285
|
|
|
252
286
|
const shutdown = async () => {
|
|
253
287
|
log('Shutting down...');
|
|
288
|
+
if (rootSpan) {
|
|
289
|
+
try { rootSpan.end(); } catch (e) { /* ignore */ }
|
|
290
|
+
}
|
|
254
291
|
await ablyService.close();
|
|
255
292
|
automation.cleanup();
|
|
256
293
|
await Sentry.close(2000);
|
|
@@ -168,9 +168,12 @@ else
|
|
|
168
168
|
fi
|
|
169
169
|
|
|
170
170
|
# ─── Force a screen redraw ────────────────────────────────────────────────────
|
|
171
|
-
#
|
|
172
|
-
#
|
|
173
|
-
|
|
171
|
+
# Wake the display and reset any blanking timers WITHOUT synthetic input.
|
|
172
|
+
# Do NOT send the `super` key here — on Linux desktops it's bound to the
|
|
173
|
+
# Activities overview / app launcher, which steals focus from (and visually
|
|
174
|
+
# hides) the active window. Same reasoning as the black-screenshot recovery
|
|
175
|
+
# in lib/automation.js.
|
|
176
|
+
xset s reset 2>/dev/null || true
|
|
174
177
|
sleep 1
|
|
175
178
|
|
|
176
179
|
echo "[start-desktop] Desktop environment ready"
|