kitowall 2.3.0 → 2.5.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.
package/dist/cli.js CHANGED
@@ -131,8 +131,12 @@ Commands:
131
131
  we library List downloaded workshop items
132
132
  we scan-steam Detect Steam workshop folders and list downloaded ids
133
133
  we sync-steam Sync local Steam Workshop 431960 items into Kitsune downloads
134
- we app-status Detect if Wallpaper Engine (AppID 431960) is installed
134
+ we app-status Detect if Wallpaper Engine and scene engine are installed
135
135
  we active Show current livewallpaper authority/lock state
136
+ we apply <id> --monitor <name> [--backend auto|mpvpaper|linux-wallpaperengine]
137
+ Apply live wallpaper on one monitor (auto resolves by wallpaper type)
138
+ we apply --map DP-1:<id1>,HDMI-A-1:<id2> [--backend auto|mpvpaper|linux-wallpaperengine]
139
+ Apply wallpapers in batch by monitor map
136
140
  we stop [--monitor <name> | --all] Stop livewallpaper instances and restore previous services
137
141
  we coexist enter|exit|status Temporarily stop/restore wallpaper rotation services
138
142
  check [--namespace <ns>] [--json] Quick system check (no changes)
@@ -425,6 +429,23 @@ async function main() {
425
429
  console.log(JSON.stringify(out, null, 2));
426
430
  return;
427
431
  }
432
+ if (action === 'apply') {
433
+ const mapValue = cleanOpt(getOptionValue(args, '--map'));
434
+ const backend = cleanOpt(getOptionValue(args, '--backend')) ?? 'auto';
435
+ if (mapValue) {
436
+ const out = await (0, workshop_1.workshopApplyMap)({ map: mapValue, backend });
437
+ console.log(JSON.stringify(out, null, 2));
438
+ return;
439
+ }
440
+ const id = cleanOpt(args[2] ?? null);
441
+ const monitor = cleanOpt(getOptionValue(args, '--monitor'));
442
+ if (!id || !monitor) {
443
+ throw new Error('Usage: we apply <id> --monitor <name> [--backend auto|mpvpaper|linux-wallpaperengine] OR we apply --map DP-1:<id1>,HDMI-A-1:<id2>');
444
+ }
445
+ const out = await (0, workshop_1.workshopApply)({ id, monitor, backend });
446
+ console.log(JSON.stringify(out, null, 2));
447
+ return;
448
+ }
428
449
  if (action === 'stop') {
429
450
  const monitor = cleanOpt(getOptionValue(args, '--monitor'));
430
451
  const out = await (0, workshop_1.workshopStop)({
@@ -453,7 +474,7 @@ async function main() {
453
474
  }
454
475
  throw new Error('Usage: we coexist <enter|exit|status>');
455
476
  }
456
- throw new Error('Usage: we <config|search|details|download|job|jobs|library|scan-steam|sync-steam|app-status|active|stop|run-job|coexist> ...');
477
+ throw new Error('Usage: we <config|search|details|download|job|jobs|library|scan-steam|sync-steam|app-status|active|apply|stop|run-job|coexist> ...');
457
478
  }
458
479
  // Regular commands (need config/state)
459
480
  const config = (0, config_1.loadConfig)();
@@ -7,6 +7,7 @@ exports.setWorkshopApiKey = setWorkshopApiKey;
7
7
  exports.workshopGetSteamRoots = workshopGetSteamRoots;
8
8
  exports.workshopSetSteamRoots = workshopSetSteamRoots;
9
9
  exports.workshopWallpaperEngineStatus = workshopWallpaperEngineStatus;
10
+ exports.workshopSceneEngineStatus = workshopSceneEngineStatus;
10
11
  exports.workshopScanSteamDownloads = workshopScanSteamDownloads;
11
12
  exports.workshopSyncSteamDownloads = workshopSyncSteamDownloads;
12
13
  exports.workshopSearch = workshopSearch;
@@ -22,6 +23,8 @@ exports.workshopRunJob = workshopRunJob;
22
23
  exports.workshopGetJob = workshopGetJob;
23
24
  exports.workshopListJobs = workshopListJobs;
24
25
  exports.workshopLibrary = workshopLibrary;
26
+ exports.workshopApply = workshopApply;
27
+ exports.workshopApplyMap = workshopApplyMap;
25
28
  const node_fs_1 = __importDefault(require("node:fs"));
26
29
  const node_os_1 = __importDefault(require("node:os"));
27
30
  const node_path_1 = __importDefault(require("node:path"));
@@ -43,6 +46,74 @@ function clean(input) {
43
46
  const v = input.trim();
44
47
  return v.length > 0 ? v : undefined;
45
48
  }
49
+ function isExecutable(filePath) {
50
+ try {
51
+ node_fs_1.default.accessSync(filePath, node_fs_1.default.constants.X_OK);
52
+ return true;
53
+ }
54
+ catch {
55
+ return false;
56
+ }
57
+ }
58
+ function resolveOnPath(binary) {
59
+ const bin = clean(binary);
60
+ if (!bin)
61
+ return undefined;
62
+ if (bin.includes('/')) {
63
+ const resolved = node_path_1.default.resolve(bin);
64
+ return node_fs_1.default.existsSync(resolved) && isExecutable(resolved) ? resolved : undefined;
65
+ }
66
+ const pathEnv = clean(process.env.PATH) ?? '';
67
+ const parts = pathEnv.split(':').filter(Boolean);
68
+ for (const p of parts) {
69
+ const candidate = node_path_1.default.join(p, bin);
70
+ if (node_fs_1.default.existsSync(candidate) && isExecutable(candidate))
71
+ return candidate;
72
+ }
73
+ return undefined;
74
+ }
75
+ function buildSceneEngineEnv(enginePath) {
76
+ const env = { ...process.env };
77
+ const libPaths = [];
78
+ const seen = new Set();
79
+ const pushLibPath = (candidate) => {
80
+ const value = clean(candidate);
81
+ if (!value || seen.has(value))
82
+ return;
83
+ seen.add(value);
84
+ libPaths.push(value);
85
+ };
86
+ const current = clean(process.env.LD_LIBRARY_PATH);
87
+ if (current) {
88
+ for (const segment of current.split(':')) {
89
+ const v = clean(segment);
90
+ if (v)
91
+ pushLibPath(v);
92
+ }
93
+ }
94
+ const resolvedEngine = clean(enginePath);
95
+ if (resolvedEngine) {
96
+ const engineDir = node_path_1.default.dirname(resolvedEngine);
97
+ if (node_fs_1.default.existsSync(node_path_1.default.join(engineDir, 'libcef.so'))) {
98
+ pushLibPath(engineDir);
99
+ }
100
+ const siblingLib = node_path_1.default.join(engineDir, 'lib');
101
+ if (node_fs_1.default.existsSync(siblingLib) && node_fs_1.default.statSync(siblingLib).isDirectory()) {
102
+ pushLibPath(siblingLib);
103
+ }
104
+ }
105
+ // Arch linux-wallpaperengine installs runtime libs here when bypassing the wrapper.
106
+ if (node_fs_1.default.existsSync('/opt/linux-wallpaperengine/libcef.so')) {
107
+ pushLibPath('/opt/linux-wallpaperengine');
108
+ }
109
+ if (node_fs_1.default.existsSync('/opt/linux-wallpaperengine/lib') && node_fs_1.default.statSync('/opt/linux-wallpaperengine/lib').isDirectory()) {
110
+ pushLibPath('/opt/linux-wallpaperengine/lib');
111
+ }
112
+ if (libPaths.length > 0) {
113
+ env.LD_LIBRARY_PATH = libPaths.join(':');
114
+ }
115
+ return env;
116
+ }
46
117
  function getWePaths() {
47
118
  const root = node_path_1.default.join(node_os_1.default.homedir(), '.local', 'share', 'kitsune', 'we');
48
119
  return {
@@ -375,19 +446,46 @@ function detectAudioReactiveFromText(text) {
375
446
  function readProjectInfo(dir) {
376
447
  const projectPath = node_path_1.default.join(dir, 'project.json');
377
448
  if (!node_fs_1.default.existsSync(projectPath)) {
378
- return { type: 'unknown', audioReactive: false };
449
+ return { type: 'unknown', audioReactive: false, entry: undefined };
379
450
  }
380
451
  try {
381
452
  const raw = node_fs_1.default.readFileSync(projectPath, 'utf8');
382
453
  const json = JSON.parse(raw);
383
454
  const type = normalizeWallpaperType(json.type);
384
455
  const title = clean(json.title) ?? '';
456
+ const fileEntry = clean(json.file);
457
+ const entry = fileEntry ? node_path_1.default.join(dir, fileEntry) : undefined;
385
458
  const audioReactive = detectAudioReactiveFromText(`${title}\n${raw}`);
386
- return { type, audioReactive };
459
+ return { type, audioReactive, entry };
387
460
  }
388
461
  catch {
389
- return { type: 'unknown', audioReactive: false };
462
+ return { type: 'unknown', audioReactive: false, entry: undefined };
463
+ }
464
+ }
465
+ function inferVideoEntry(dir) {
466
+ if (!node_fs_1.default.existsSync(dir))
467
+ return undefined;
468
+ const files = node_fs_1.default.readdirSync(dir, { withFileTypes: true });
469
+ const preferred = ['.mp4', '.webm', '.gif', '.mkv', '.avi', '.mov'];
470
+ for (const ext of preferred) {
471
+ const match = files.find((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(ext));
472
+ if (match)
473
+ return node_path_1.default.join(dir, match.name);
474
+ }
475
+ return undefined;
476
+ }
477
+ function detectTypeFromEntry(entry) {
478
+ const e = clean(entry)?.toLowerCase();
479
+ if (!e)
480
+ return 'unknown';
481
+ if (e.endsWith('.mp4') || e.endsWith('.webm') || e.endsWith('.gif') || e.endsWith('.mkv') || e.endsWith('.avi') || e.endsWith('.mov')) {
482
+ return 'video';
390
483
  }
484
+ if (e.endsWith('index.html') || e.endsWith('.html'))
485
+ return 'web';
486
+ if (e.endsWith('scene.json') || e.endsWith('gifscene.json'))
487
+ return 'scene';
488
+ return 'unknown';
391
489
  }
392
490
  function readLibraryFoldersVdf(vdfPath) {
393
491
  if (!node_fs_1.default.existsSync(vdfPath))
@@ -503,13 +601,41 @@ function workshopWallpaperEngineStatus() {
503
601
  if (node_fs_1.default.existsSync(manifest))
504
602
  manifests.push(manifest);
505
603
  }
604
+ const engine = workshopSceneEngineStatus();
506
605
  return {
507
606
  ok: true,
508
607
  installed: manifests.length > 0,
509
608
  manifests,
510
- steamapps
609
+ steamapps,
610
+ engine
511
611
  };
512
612
  }
613
+ function workshopSceneEngineStatus() {
614
+ const configured = clean(process.env.KITOWALL_SCENE_ENGINE_CMD);
615
+ const candidates = configured
616
+ ? [configured]
617
+ : ['linux-wallpaperengine', 'linux-wallpaperengine-cli', 'wallpaper-engine'];
618
+ for (const cmd of candidates) {
619
+ const resolved = resolveOnPath(cmd);
620
+ if (!resolved)
621
+ continue;
622
+ const verOut = (0, node_child_process_1.spawnSync)(resolved, ['--version'], {
623
+ encoding: 'utf8',
624
+ timeout: 2500,
625
+ env: buildSceneEngineEnv(resolved)
626
+ });
627
+ const stdout = clean(verOut.stdout) ?? '';
628
+ const stderr = clean(verOut.stderr) ?? '';
629
+ const version = (stdout.split('\n')[0] || stderr.split('\n')[0] || '').trim();
630
+ return {
631
+ installed: true,
632
+ path: resolved,
633
+ version: version || undefined,
634
+ command: cmd
635
+ };
636
+ }
637
+ return { installed: false, command: configured ?? 'linux-wallpaperengine' };
638
+ }
513
639
  function findPreviewCandidate(dir) {
514
640
  if (!node_fs_1.default.existsSync(dir))
515
641
  return undefined;
@@ -602,17 +728,19 @@ function workshopSyncSteamDownloads() {
602
728
  author_name: 'Steam Workshop',
603
729
  time_updated: Math.floor(Date.now() / 1000),
604
730
  wallpaper_type: info.type,
605
- audio_reactive: info.audioReactive
731
+ audio_reactive: info.audioReactive,
732
+ entry: info.entry
606
733
  });
607
734
  }
608
735
  else {
609
736
  try {
610
737
  const current = JSON.parse(node_fs_1.default.readFileSync(metadataFile, 'utf8'));
611
- if (!current.wallpaper_type || current.wallpaper_type === 'unknown' || current.audio_reactive === undefined) {
738
+ if (!current.wallpaper_type || current.wallpaper_type === 'unknown' || current.audio_reactive === undefined || !current.entry) {
612
739
  writeMetaFile({
613
740
  ...current,
614
741
  wallpaper_type: current.wallpaper_type ?? info.type,
615
- audio_reactive: current.audio_reactive ?? info.audioReactive
742
+ audio_reactive: current.audio_reactive ?? info.audioReactive,
743
+ entry: current.entry ?? info.entry
616
744
  });
617
745
  }
618
746
  }
@@ -1026,9 +1154,10 @@ async function workshopCoexistenceStatus() {
1026
1154
  return { ok: true, snapshot, current };
1027
1155
  }
1028
1156
  async function workshopStop(options) {
1029
- const state = readActiveState();
1157
+ let state = readActiveState();
1030
1158
  const hadActive = !!state;
1031
1159
  const stopped = [];
1160
+ let shouldRestore = false;
1032
1161
  if (state?.instances) {
1033
1162
  if (options?.monitor) {
1034
1163
  const inst = state.instances[options.monitor];
@@ -1040,6 +1169,7 @@ async function workshopStop(options) {
1040
1169
  }
1041
1170
  else {
1042
1171
  clearActiveState();
1172
+ shouldRestore = true;
1043
1173
  }
1044
1174
  }
1045
1175
  else {
@@ -1048,12 +1178,14 @@ async function workshopStop(options) {
1048
1178
  stopped.push(monitor);
1049
1179
  }
1050
1180
  clearActiveState();
1181
+ shouldRestore = true;
1051
1182
  }
1052
1183
  }
1053
1184
  else if (options?.all) {
1054
1185
  clearActiveState();
1186
+ shouldRestore = true;
1055
1187
  }
1056
- const coexist = await workshopCoexistenceExit();
1188
+ const coexist = shouldRestore ? await workshopCoexistenceExit() : { ok: true, restored: [] };
1057
1189
  return {
1058
1190
  ok: true,
1059
1191
  stopped_instances: stopped,
@@ -1165,6 +1297,9 @@ function workshopLibrary() {
1165
1297
  if (meta.audio_reactive === undefined) {
1166
1298
  meta.audio_reactive = info.audioReactive;
1167
1299
  }
1300
+ if (!meta.entry) {
1301
+ meta.entry = info.entry;
1302
+ }
1168
1303
  }
1169
1304
  items.push({
1170
1305
  id,
@@ -1176,3 +1311,152 @@ function workshopLibrary() {
1176
1311
  items.sort((a, b) => a.id.localeCompare(b.id));
1177
1312
  return { root: paths.downloads, items };
1178
1313
  }
1314
+ function spawnMpvpaper(monitor, entry) {
1315
+ return new Promise((resolve, reject) => {
1316
+ const child = (0, node_child_process_1.spawn)('mpvpaper', ['-o', 'no-audio --loop-file=inf', monitor, entry], {
1317
+ detached: true,
1318
+ stdio: 'ignore',
1319
+ env: process.env
1320
+ });
1321
+ let settled = false;
1322
+ const done = (fn) => {
1323
+ if (settled)
1324
+ return;
1325
+ settled = true;
1326
+ fn();
1327
+ };
1328
+ child.once('error', (err) => done(() => reject(err)));
1329
+ setTimeout(() => {
1330
+ done(() => {
1331
+ child.unref();
1332
+ resolve(child.pid ?? 0);
1333
+ });
1334
+ }, 180);
1335
+ });
1336
+ }
1337
+ function spawnSceneEngine(monitor, wallpaperDir) {
1338
+ const engine = workshopSceneEngineStatus();
1339
+ if (!engine.installed || !engine.path) {
1340
+ throw new Error('Scene engine not installed. Install linux-wallpaperengine first.');
1341
+ }
1342
+ const enginePath = engine.path;
1343
+ return new Promise((resolve, reject) => {
1344
+ const child = (0, node_child_process_1.spawn)(enginePath, ['--screen-root', monitor, '--bg', wallpaperDir], {
1345
+ detached: true,
1346
+ stdio: 'ignore',
1347
+ env: buildSceneEngineEnv(enginePath)
1348
+ });
1349
+ let settled = false;
1350
+ const done = (fn) => {
1351
+ if (settled)
1352
+ return;
1353
+ settled = true;
1354
+ fn();
1355
+ };
1356
+ child.once('error', (err) => done(() => reject(err)));
1357
+ setTimeout(() => {
1358
+ done(() => {
1359
+ child.unref();
1360
+ resolve(child.pid ?? 0);
1361
+ });
1362
+ }, 220);
1363
+ });
1364
+ }
1365
+ async function workshopApply(input) {
1366
+ const id = clean(input.id);
1367
+ const monitor = clean(input.monitor);
1368
+ const requestedBackend = clean(input.backend) ?? 'auto';
1369
+ if (!id)
1370
+ throw new Error('id is required');
1371
+ if (!monitor)
1372
+ throw new Error('monitor is required');
1373
+ const paths = getWePaths();
1374
+ ensureWePaths(paths);
1375
+ const dir = node_path_1.default.join(paths.downloads, id);
1376
+ if (!node_fs_1.default.existsSync(dir)) {
1377
+ throw new Error(`Wallpaper not found in downloads: ${id}`);
1378
+ }
1379
+ const project = readProjectInfo(dir);
1380
+ const inferredEntry = project.entry && node_fs_1.default.existsSync(project.entry) ? project.entry : inferVideoEntry(dir);
1381
+ const type = project.type !== 'unknown'
1382
+ ? project.type
1383
+ : detectTypeFromEntry(inferredEntry ?? node_path_1.default.join(dir, 'scene.json'));
1384
+ if (type === 'web' || type === 'application' || type === 'unknown') {
1385
+ throw new Error(`Unsupported wallpaper type for apply: ${type}. Supported: video, scene.`);
1386
+ }
1387
+ let state = readActiveState();
1388
+ if (!state) {
1389
+ const coexist = await workshopCoexistenceEnter();
1390
+ state = {
1391
+ mode: 'livewallpaper',
1392
+ started_at: now(),
1393
+ snapshot_id: coexist.snapshot_id,
1394
+ instances: {}
1395
+ };
1396
+ }
1397
+ const current = state.instances[monitor];
1398
+ if (current?.pid) {
1399
+ killPid(current.pid);
1400
+ }
1401
+ let backend;
1402
+ let pid = 0;
1403
+ if (type === 'video') {
1404
+ if (!(requestedBackend === 'auto' || requestedBackend === 'mpvpaper')) {
1405
+ throw new Error(`Invalid backend for video wallpaper: ${requestedBackend}`);
1406
+ }
1407
+ if (!inferredEntry || !node_fs_1.default.existsSync(inferredEntry)) {
1408
+ throw new Error(`Video entry not found for wallpaper: ${id}`);
1409
+ }
1410
+ backend = 'mpvpaper';
1411
+ pid = await spawnMpvpaper(monitor, inferredEntry).catch((err) => {
1412
+ throw new Error(`Failed to launch mpvpaper: ${err instanceof Error ? err.message : String(err)}`);
1413
+ });
1414
+ }
1415
+ else if (type === 'scene') {
1416
+ if (!(requestedBackend === 'auto' || requestedBackend === 'linux-wallpaperengine')) {
1417
+ throw new Error(`Invalid backend for scene wallpaper: ${requestedBackend}`);
1418
+ }
1419
+ backend = 'linux-wallpaperengine';
1420
+ pid = await spawnSceneEngine(monitor, dir).catch((err) => {
1421
+ throw new Error(`Failed to launch scene engine: ${err instanceof Error ? err.message : String(err)}`);
1422
+ });
1423
+ }
1424
+ else {
1425
+ throw new Error(`Unsupported wallpaper type for apply: ${type}`);
1426
+ }
1427
+ if (!pid) {
1428
+ throw new Error(`${backend} started without pid`);
1429
+ }
1430
+ state.instances[monitor] = {
1431
+ id,
1432
+ pid,
1433
+ backend,
1434
+ type
1435
+ };
1436
+ writeActiveState(state);
1437
+ return { ok: true, applied: true, monitor, id, backend, pid, state };
1438
+ }
1439
+ async function workshopApplyMap(input) {
1440
+ const raw = clean(input.map);
1441
+ if (!raw)
1442
+ throw new Error('map is required');
1443
+ const entries = raw.split(',').map(v => v.trim()).filter(Boolean);
1444
+ if (entries.length === 0)
1445
+ throw new Error('map has no entries');
1446
+ const applied = [];
1447
+ for (const pair of entries) {
1448
+ const idx = pair.indexOf(':');
1449
+ if (idx <= 0 || idx === pair.length - 1) {
1450
+ throw new Error(`Invalid map entry: ${pair}. Expected <monitor>:<id>`);
1451
+ }
1452
+ const monitor = pair.slice(0, idx).trim();
1453
+ const id = pair.slice(idx + 1).trim();
1454
+ if (!monitor || !id) {
1455
+ throw new Error(`Invalid map entry: ${pair}. Expected <monitor>:<id>`);
1456
+ }
1457
+ const out = await workshopApply({ id, monitor, backend: input.backend });
1458
+ applied.push({ monitor: out.monitor, id: out.id, backend: out.backend, pid: out.pid });
1459
+ }
1460
+ const state = readActiveState() ?? undefined;
1461
+ return { ok: true, applied, state };
1462
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kitowall",
3
- "version": "2.3.0",
3
+ "version": "2.5.0",
4
4
  "description": "CLI/daemon for Hyprland wallpapers using swww with pack-based rotation.",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "type": "commonjs",