browser-commander 0.2.0

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.
Files changed (82) hide show
  1. package/.changeset/README.md +8 -0
  2. package/.changeset/config.json +11 -0
  3. package/.github/workflows/release.yml +296 -0
  4. package/.husky/pre-commit +1 -0
  5. package/.jscpd.json +20 -0
  6. package/.prettierignore +7 -0
  7. package/.prettierrc +10 -0
  8. package/CHANGELOG.md +32 -0
  9. package/LICENSE +24 -0
  10. package/README.md +320 -0
  11. package/bunfig.toml +3 -0
  12. package/deno.json +7 -0
  13. package/eslint.config.js +125 -0
  14. package/examples/react-test-app/index.html +25 -0
  15. package/examples/react-test-app/package.json +19 -0
  16. package/examples/react-test-app/src/App.jsx +473 -0
  17. package/examples/react-test-app/src/main.jsx +10 -0
  18. package/examples/react-test-app/src/styles.css +323 -0
  19. package/examples/react-test-app/vite.config.js +9 -0
  20. package/package.json +89 -0
  21. package/scripts/changeset-version.mjs +38 -0
  22. package/scripts/create-github-release.mjs +93 -0
  23. package/scripts/create-manual-changeset.mjs +86 -0
  24. package/scripts/format-github-release.mjs +83 -0
  25. package/scripts/format-release-notes.mjs +216 -0
  26. package/scripts/instant-version-bump.mjs +121 -0
  27. package/scripts/merge-changesets.mjs +260 -0
  28. package/scripts/publish-to-npm.mjs +126 -0
  29. package/scripts/setup-npm.mjs +37 -0
  30. package/scripts/validate-changeset.mjs +262 -0
  31. package/scripts/version-and-commit.mjs +237 -0
  32. package/src/ARCHITECTURE.md +270 -0
  33. package/src/README.md +517 -0
  34. package/src/bindings.js +298 -0
  35. package/src/browser/launcher.js +93 -0
  36. package/src/browser/navigation.js +513 -0
  37. package/src/core/constants.js +24 -0
  38. package/src/core/engine-adapter.js +466 -0
  39. package/src/core/engine-detection.js +49 -0
  40. package/src/core/logger.js +21 -0
  41. package/src/core/navigation-manager.js +503 -0
  42. package/src/core/navigation-safety.js +160 -0
  43. package/src/core/network-tracker.js +373 -0
  44. package/src/core/page-session.js +299 -0
  45. package/src/core/page-trigger-manager.js +564 -0
  46. package/src/core/preferences.js +46 -0
  47. package/src/elements/content.js +197 -0
  48. package/src/elements/locators.js +243 -0
  49. package/src/elements/selectors.js +360 -0
  50. package/src/elements/visibility.js +166 -0
  51. package/src/exports.js +121 -0
  52. package/src/factory.js +192 -0
  53. package/src/high-level/universal-logic.js +206 -0
  54. package/src/index.js +17 -0
  55. package/src/interactions/click.js +684 -0
  56. package/src/interactions/fill.js +383 -0
  57. package/src/interactions/scroll.js +341 -0
  58. package/src/utilities/url.js +33 -0
  59. package/src/utilities/wait.js +135 -0
  60. package/tests/e2e/playwright.e2e.test.js +442 -0
  61. package/tests/e2e/puppeteer.e2e.test.js +408 -0
  62. package/tests/helpers/mocks.js +542 -0
  63. package/tests/unit/bindings.test.js +218 -0
  64. package/tests/unit/browser/navigation.test.js +345 -0
  65. package/tests/unit/core/constants.test.js +72 -0
  66. package/tests/unit/core/engine-adapter.test.js +170 -0
  67. package/tests/unit/core/engine-detection.test.js +81 -0
  68. package/tests/unit/core/logger.test.js +80 -0
  69. package/tests/unit/core/navigation-safety.test.js +202 -0
  70. package/tests/unit/core/network-tracker.test.js +198 -0
  71. package/tests/unit/core/page-trigger-manager.test.js +358 -0
  72. package/tests/unit/elements/content.test.js +318 -0
  73. package/tests/unit/elements/locators.test.js +236 -0
  74. package/tests/unit/elements/selectors.test.js +302 -0
  75. package/tests/unit/elements/visibility.test.js +234 -0
  76. package/tests/unit/factory.test.js +174 -0
  77. package/tests/unit/high-level/universal-logic.test.js +299 -0
  78. package/tests/unit/interactions/click.test.js +340 -0
  79. package/tests/unit/interactions/fill.test.js +378 -0
  80. package/tests/unit/interactions/scroll.test.js +330 -0
  81. package/tests/unit/utilities/url.test.js +63 -0
  82. package/tests/unit/utilities/wait.test.js +207 -0
@@ -0,0 +1,564 @@
1
+ /**
2
+ * PageTriggerManager - Manages stoppable page triggers with proper lifecycle
3
+ *
4
+ * Key guarantees:
5
+ * 1. Only one action runs at a time
6
+ * 2. Action is fully stopped before page loading starts
7
+ * 3. Action can gracefully cleanup on stop
8
+ * 4. All commander operations throw ActionStoppedError when stopped
9
+ *
10
+ * Terminology:
11
+ * - Trigger: A condition + action pair that fires when condition is met
12
+ * - Condition: A function that returns true/false to determine if trigger should fire
13
+ * - Action: The async function that runs when condition is true
14
+ */
15
+
16
+ /**
17
+ * Error thrown when action is stopped (navigation detected)
18
+ * Actions can catch this to do cleanup, but should re-throw or return
19
+ */
20
+ export class ActionStoppedError extends Error {
21
+ constructor(message = 'Action stopped due to navigation') {
22
+ super(message);
23
+ this.name = 'ActionStoppedError';
24
+ this.isActionStopped = true;
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Check if error is an ActionStoppedError
30
+ * @param {Error} error - Error to check
31
+ * @returns {boolean}
32
+ */
33
+ export function isActionStoppedError(error) {
34
+ return (
35
+ error &&
36
+ (error.isActionStopped === true || error.name === 'ActionStoppedError')
37
+ );
38
+ }
39
+
40
+ /**
41
+ * Create a URL condition matcher (similar to express router patterns)
42
+ *
43
+ * @param {string|RegExp|Function} pattern - URL pattern to match
44
+ * - String: Exact match or pattern with :param placeholders (like express)
45
+ * - RegExp: Regular expression to test against URL
46
+ * - Function: Custom function (url, ctx) => boolean
47
+ * @returns {Function} - Condition function (ctx) => boolean
48
+ *
49
+ * @example
50
+ * // Exact match
51
+ * makeUrlCondition('https://example.com/page')
52
+ *
53
+ * // Pattern with parameters (express-style)
54
+ * makeUrlCondition('/vacancy/:id')
55
+ * makeUrlCondition('https://hh.ru/vacancy/:vacancyId')
56
+ *
57
+ * // Contains substring
58
+ * makeUrlCondition('*checkout*') // matches any URL containing 'checkout'
59
+ *
60
+ * // RegExp
61
+ * makeUrlCondition(/\/product\/\d+/)
62
+ *
63
+ * // Custom function
64
+ * makeUrlCondition((url, ctx) => url.includes('/admin') && url.includes('edit'))
65
+ */
66
+ export function makeUrlCondition(pattern) {
67
+ // If already a function, wrap it to receive context
68
+ if (typeof pattern === 'function') {
69
+ return (ctx) => pattern(ctx.url, ctx);
70
+ }
71
+
72
+ // RegExp pattern
73
+ if (pattern instanceof RegExp) {
74
+ return (ctx) => pattern.test(ctx.url);
75
+ }
76
+
77
+ // String pattern
78
+ if (typeof pattern === 'string') {
79
+ // Wildcard pattern: *substring* means "contains"
80
+ if (pattern.startsWith('*') && pattern.endsWith('*')) {
81
+ const substring = pattern.slice(1, -1);
82
+ return (ctx) => ctx.url.includes(substring);
83
+ }
84
+
85
+ // Starts with wildcard: *suffix means "ends with"
86
+ if (pattern.startsWith('*')) {
87
+ const suffix = pattern.slice(1);
88
+ return (ctx) => ctx.url.endsWith(suffix);
89
+ }
90
+
91
+ // Ends with wildcard: prefix* means "starts with"
92
+ if (pattern.endsWith('*')) {
93
+ const prefix = pattern.slice(0, -1);
94
+ return (ctx) => ctx.url.startsWith(prefix);
95
+ }
96
+
97
+ // Express-style pattern with :params
98
+ if (pattern.includes(':')) {
99
+ // Convert express pattern to regex
100
+ // /vacancy/:id -> /vacancy/([^/]+)
101
+ // /user/:userId/profile -> /user/([^/]+)/profile
102
+ const regexPattern = pattern
103
+ .replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // Escape special chars first
104
+ .replace(/\\:/g, ':') // Unescape colons we just escaped
105
+ .replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, '([^/&?#]+)'); // Replace :param with capture group
106
+
107
+ const regex = new RegExp(regexPattern);
108
+ return (ctx) => regex.test(ctx.url);
109
+ }
110
+
111
+ // Simple substring match (no wildcards, no params)
112
+ // If it looks like a full URL, do exact match
113
+ if (pattern.startsWith('http://') || pattern.startsWith('https://')) {
114
+ return (ctx) =>
115
+ ctx.url === pattern ||
116
+ ctx.url.startsWith(`${pattern}?`) ||
117
+ ctx.url.startsWith(`${pattern}#`);
118
+ }
119
+
120
+ // Otherwise, treat as path pattern - match if URL contains this path
121
+ return (ctx) => ctx.url.includes(pattern);
122
+ }
123
+
124
+ throw new Error(
125
+ `Invalid URL pattern type: ${typeof pattern}. Expected string, RegExp, or function.`
126
+ );
127
+ }
128
+
129
+ /**
130
+ * Combine multiple conditions with AND logic
131
+ * @param {...Function} conditions - Condition functions to combine
132
+ * @returns {Function} - Combined condition function
133
+ */
134
+ export function allConditions(...conditions) {
135
+ return (ctx) => conditions.every((cond) => cond(ctx));
136
+ }
137
+
138
+ /**
139
+ * Combine multiple conditions with OR logic
140
+ * @param {...Function} conditions - Condition functions to combine
141
+ * @returns {Function} - Combined condition function
142
+ */
143
+ export function anyCondition(...conditions) {
144
+ return (ctx) => conditions.some((cond) => cond(ctx));
145
+ }
146
+
147
+ /**
148
+ * Negate a condition
149
+ * @param {Function} condition - Condition function to negate
150
+ * @returns {Function} - Negated condition function
151
+ */
152
+ export function notCondition(condition) {
153
+ return (ctx) => !condition(ctx);
154
+ }
155
+
156
+ /**
157
+ * Create a PageTriggerManager instance
158
+ * @param {Object} options - Configuration options
159
+ * @param {Object} options.navigationManager - NavigationManager instance
160
+ * @param {Function} options.log - Logger instance
161
+ * @returns {Object} - PageTriggerManager API
162
+ */
163
+ export function createPageTriggerManager(options = {}) {
164
+ const { navigationManager, log } = options;
165
+
166
+ if (!navigationManager) {
167
+ throw new Error('navigationManager is required');
168
+ }
169
+
170
+ // Registered triggers
171
+ const triggers = [];
172
+
173
+ // Current action state
174
+ let currentTrigger = null;
175
+ let currentAbortController = null;
176
+ let actionPromise = null;
177
+ let actionStopPromise = null;
178
+ let actionStopResolve = null;
179
+ let isActionRunning = false;
180
+ let isStopping = false;
181
+
182
+ /**
183
+ * Register a page trigger
184
+ * @param {Object} config - Trigger configuration
185
+ * @param {Function} config.condition - Function (ctx) => boolean, returns true if trigger should fire
186
+ * @param {Function} config.action - Async function (ctx) => void, the action to run
187
+ * @param {string} config.name - Trigger name for debugging
188
+ * @param {number} config.priority - Priority (higher runs first if multiple match), default 0
189
+ * @returns {Function} - Unregister function
190
+ */
191
+ function pageTrigger(config) {
192
+ const { condition, action, name = 'unnamed', priority = 0 } = config;
193
+
194
+ if (typeof condition !== 'function') {
195
+ throw new Error('condition must be a function');
196
+ }
197
+ if (typeof action !== 'function') {
198
+ throw new Error('action must be a function');
199
+ }
200
+
201
+ const triggerConfig = {
202
+ condition,
203
+ action,
204
+ name,
205
+ priority,
206
+ };
207
+
208
+ triggers.push(triggerConfig);
209
+
210
+ // Sort by priority (descending)
211
+ triggers.sort((a, b) => b.priority - a.priority);
212
+
213
+ log.debug(
214
+ () => `📋 Registered page trigger: "${name}" (priority: ${priority})`
215
+ );
216
+
217
+ // Return unregister function
218
+ return () => {
219
+ const index = triggers.indexOf(triggerConfig);
220
+ if (index !== -1) {
221
+ triggers.splice(index, 1);
222
+ log.debug(() => `📋 Unregistered page trigger: "${name}"`);
223
+ }
224
+ };
225
+ }
226
+
227
+ /**
228
+ * Find matching trigger for context
229
+ * @param {Object} ctx - Context with url and other properties
230
+ * @returns {Object|null} - Matching trigger config or null
231
+ */
232
+ function findMatchingTrigger(ctx) {
233
+ for (const config of triggers) {
234
+ try {
235
+ if (config.condition(ctx)) {
236
+ return config;
237
+ }
238
+ } catch (e) {
239
+ log.debug(
240
+ () => `⚠️ Error in condition for "${config.name}": ${e.message}`
241
+ );
242
+ }
243
+ }
244
+ return null;
245
+ }
246
+
247
+ /**
248
+ * Stop current action and wait for it to finish
249
+ * @returns {Promise<void>}
250
+ */
251
+ async function stopCurrentAction() {
252
+ if (!isActionRunning) {
253
+ return;
254
+ }
255
+
256
+ if (isStopping) {
257
+ // Already stopping, wait for it
258
+ if (actionStopPromise) {
259
+ await actionStopPromise;
260
+ }
261
+ return;
262
+ }
263
+
264
+ isStopping = true;
265
+ log.debug(() => `🛑 Stopping action "${currentTrigger?.name}"...`);
266
+
267
+ // Create promise that resolves when action actually stops
268
+ actionStopPromise = new Promise((resolve) => {
269
+ actionStopResolve = resolve;
270
+ });
271
+
272
+ // Abort the action
273
+ if (currentAbortController) {
274
+ currentAbortController.abort();
275
+ }
276
+
277
+ // Wait for action to finish (with timeout)
278
+ const timeoutMs = 10000; // 10 second max wait
279
+ const timeoutPromise = new Promise((resolve) => {
280
+ setTimeout(() => {
281
+ log.debug(
282
+ () =>
283
+ `⚠️ Action "${currentTrigger?.name}" did not stop gracefully within ${timeoutMs}ms`
284
+ );
285
+ resolve();
286
+ }, timeoutMs);
287
+ });
288
+
289
+ await Promise.race([actionPromise, timeoutPromise]);
290
+
291
+ // Cleanup
292
+ isActionRunning = false;
293
+ isStopping = false;
294
+ currentTrigger = null;
295
+ currentAbortController = null;
296
+ actionPromise = null;
297
+
298
+ // Resolve the stop promise
299
+ if (actionStopResolve) {
300
+ actionStopResolve();
301
+ actionStopResolve = null;
302
+ actionStopPromise = null;
303
+ }
304
+
305
+ log.debug(() => '✅ Action stopped');
306
+ }
307
+
308
+ /**
309
+ * Start action for URL
310
+ * @param {string} url - URL to start action for
311
+ * @param {Object} commander - BrowserCommander instance
312
+ */
313
+ async function startAction(url, commander) {
314
+ // Create context for condition checking
315
+ const conditionCtx = {
316
+ url,
317
+ commander,
318
+ };
319
+
320
+ // Find matching trigger
321
+ const matchingTrigger = findMatchingTrigger(conditionCtx);
322
+ if (!matchingTrigger) {
323
+ log.debug(() => `📋 No trigger registered for: ${url}`);
324
+ return;
325
+ }
326
+
327
+ log.debug(() => `🚀 Starting action "${matchingTrigger.name}" for: ${url}`);
328
+
329
+ // Setup abort controller
330
+ currentAbortController = new AbortController();
331
+ currentTrigger = matchingTrigger;
332
+ isActionRunning = true;
333
+
334
+ // Create action context
335
+ const context = createActionContext({
336
+ url,
337
+ abortSignal: currentAbortController.signal,
338
+ commander,
339
+ triggerName: matchingTrigger.name,
340
+ });
341
+
342
+ // Run action
343
+ actionPromise = (async () => {
344
+ try {
345
+ await matchingTrigger.action(context);
346
+ log.debug(
347
+ () => `✅ Action "${matchingTrigger.name}" completed normally`
348
+ );
349
+ } catch (error) {
350
+ if (isActionStoppedError(error)) {
351
+ log.debug(
352
+ () =>
353
+ `🛑 Action "${matchingTrigger.name}" stopped (caught ActionStoppedError)`
354
+ );
355
+ } else if (error.name === 'AbortError') {
356
+ log.debug(() => `🛑 Action "${matchingTrigger.name}" aborted`);
357
+ } else {
358
+ log.debug(
359
+ () => `❌ Action "${matchingTrigger.name}" error: ${error.message}`
360
+ );
361
+ console.error(`Action "${matchingTrigger.name}" error:`, error);
362
+ }
363
+ } finally {
364
+ // Only clear if this is still the current trigger
365
+ if (currentTrigger === matchingTrigger) {
366
+ isActionRunning = false;
367
+ currentTrigger = null;
368
+ currentAbortController = null;
369
+ }
370
+ }
371
+ })();
372
+ }
373
+
374
+ /**
375
+ * Create action context with abort-aware commander wrapper
376
+ * @param {Object} options
377
+ * @returns {Object} - Action context
378
+ */
379
+ function createActionContext(options) {
380
+ const { url, abortSignal, commander, triggerName } = options;
381
+
382
+ /**
383
+ * Check if stopped and throw if so
384
+ */
385
+ function checkStopped() {
386
+ if (abortSignal.aborted) {
387
+ throw new ActionStoppedError(`Action "${triggerName}" stopped`);
388
+ }
389
+ }
390
+
391
+ /**
392
+ * Wrap async function to check abort before and after
393
+ */
394
+ function wrapAsync(fn) {
395
+ return async (...args) => {
396
+ checkStopped();
397
+ const result = await fn(...args);
398
+ checkStopped();
399
+ return result;
400
+ };
401
+ }
402
+
403
+ /**
404
+ * Create abort-aware loop helper
405
+ * Use this instead of for/while loops for stoppability
406
+ */
407
+ async function forEach(items, callback) {
408
+ for (let i = 0; i < items.length; i++) {
409
+ checkStopped();
410
+ await callback(items[i], i, items);
411
+ }
412
+ }
413
+
414
+ /**
415
+ * Wait with abort support
416
+ */
417
+ async function wait(ms) {
418
+ checkStopped();
419
+ await new Promise((resolve, reject) => {
420
+ const timeout = setTimeout(resolve, ms);
421
+ abortSignal.addEventListener(
422
+ 'abort',
423
+ () => {
424
+ clearTimeout(timeout);
425
+ reject(new ActionStoppedError());
426
+ },
427
+ { once: true }
428
+ );
429
+ });
430
+ }
431
+
432
+ /**
433
+ * Register cleanup callback (called when action stops)
434
+ */
435
+ const cleanupCallbacks = [];
436
+ function onCleanup(callback) {
437
+ cleanupCallbacks.push(callback);
438
+ abortSignal.addEventListener(
439
+ 'abort',
440
+ async () => {
441
+ try {
442
+ await callback();
443
+ } catch (e) {
444
+ log.debug(() => `⚠️ Cleanup error: ${e.message}`);
445
+ }
446
+ },
447
+ { once: true }
448
+ );
449
+ }
450
+
451
+ // Wrap all commander methods to be abort-aware
452
+ const wrappedCommander = {};
453
+ for (const [key, value] of Object.entries(commander)) {
454
+ if (
455
+ typeof value === 'function' &&
456
+ key !== 'destroy' &&
457
+ key !== 'pageTrigger'
458
+ ) {
459
+ wrappedCommander[key] = wrapAsync(value);
460
+ } else {
461
+ wrappedCommander[key] = value;
462
+ }
463
+ }
464
+
465
+ return {
466
+ // URL this action is running for
467
+ url,
468
+
469
+ // Abort signal - use with fetch() or custom abort logic
470
+ abortSignal,
471
+
472
+ // Check if action should stop
473
+ isStopped: () => abortSignal.aborted,
474
+
475
+ // Throw if stopped - call this in loops
476
+ checkStopped,
477
+
478
+ // Abort-aware iteration helper
479
+ forEach,
480
+
481
+ // Abort-aware wait
482
+ wait,
483
+
484
+ // Register cleanup callback
485
+ onCleanup,
486
+
487
+ // Wrapped commander - all methods throw ActionStoppedError if stopped
488
+ commander: wrappedCommander,
489
+
490
+ // Original commander (use carefully)
491
+ rawCommander: commander,
492
+
493
+ // Trigger name for debugging
494
+ triggerName,
495
+ };
496
+ }
497
+
498
+ /**
499
+ * Handle navigation start - stop current action first
500
+ */
501
+ async function onNavigationStart() {
502
+ await stopCurrentAction();
503
+ }
504
+
505
+ /**
506
+ * Handle page ready - start matching action
507
+ */
508
+ async function onPageReady({ url }, commander) {
509
+ await startAction(url, commander);
510
+ }
511
+
512
+ /**
513
+ * Check if an action is currently running
514
+ */
515
+ function isRunning() {
516
+ return isActionRunning;
517
+ }
518
+
519
+ /**
520
+ * Get current trigger name
521
+ */
522
+ function getCurrentTriggerName() {
523
+ return currentTrigger?.name || null;
524
+ }
525
+
526
+ /**
527
+ * Initialize - connect to navigation manager
528
+ * @param {Object} commander - BrowserCommander instance
529
+ */
530
+ function initialize(commander) {
531
+ // Stop action before navigation starts
532
+ navigationManager.on('onBeforeNavigate', onNavigationStart);
533
+
534
+ // Start action when page is ready
535
+ navigationManager.on('onPageReady', (event) =>
536
+ onPageReady(event, commander)
537
+ );
538
+
539
+ log.debug(() => '📋 PageTriggerManager initialized');
540
+ }
541
+
542
+ /**
543
+ * Cleanup
544
+ */
545
+ async function destroy() {
546
+ await stopCurrentAction();
547
+ triggers.length = 0;
548
+ navigationManager.off('onBeforeNavigate', onNavigationStart);
549
+ log.debug(() => '📋 PageTriggerManager destroyed');
550
+ }
551
+
552
+ return {
553
+ pageTrigger,
554
+ stopCurrentAction,
555
+ getCurrentTriggerName,
556
+ isRunning,
557
+ initialize,
558
+ destroy,
559
+
560
+ // Export error class and checker
561
+ ActionStoppedError,
562
+ isActionStoppedError,
563
+ };
564
+ }
@@ -0,0 +1,46 @@
1
+ import path from 'path';
2
+ import fs from 'fs/promises';
3
+
4
+ /**
5
+ * Disables Chrome translate feature by modifying the Preferences file
6
+ * @param {Object} options - Configuration options
7
+ * @param {string} options.userDataDir - Path to Chrome user data directory
8
+ */
9
+ export async function disableTranslateInPreferences(options = {}) {
10
+ const { userDataDir } = options;
11
+
12
+ if (!userDataDir) {
13
+ throw new Error('userDataDir is required in options');
14
+ }
15
+ const preferencesPath = path.join(userDataDir, 'Default', 'Preferences');
16
+ const defaultDir = path.join(userDataDir, 'Default');
17
+
18
+ try {
19
+ await fs.mkdir(defaultDir, { recursive: true });
20
+
21
+ let preferences = {};
22
+
23
+ try {
24
+ const content = await fs.readFile(preferencesPath, 'utf8');
25
+ preferences = JSON.parse(content);
26
+ } catch {
27
+ // File doesn't exist yet, will create new one
28
+ }
29
+
30
+ if (!preferences.translate) {
31
+ preferences.translate = {};
32
+ }
33
+ preferences.translate.enabled = false;
34
+
35
+ await fs.writeFile(
36
+ preferencesPath,
37
+ JSON.stringify(preferences, null, 2),
38
+ 'utf8'
39
+ );
40
+ } catch (error) {
41
+ console.error(
42
+ '⚠️ Warning: Could not modify Preferences file:',
43
+ error.message
44
+ );
45
+ }
46
+ }