kitowall 2.3.0 → 2.4.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,32 @@ 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
+ }
46
75
  function getWePaths() {
47
76
  const root = node_path_1.default.join(node_os_1.default.homedir(), '.local', 'share', 'kitsune', 'we');
48
77
  return {
@@ -375,19 +404,46 @@ function detectAudioReactiveFromText(text) {
375
404
  function readProjectInfo(dir) {
376
405
  const projectPath = node_path_1.default.join(dir, 'project.json');
377
406
  if (!node_fs_1.default.existsSync(projectPath)) {
378
- return { type: 'unknown', audioReactive: false };
407
+ return { type: 'unknown', audioReactive: false, entry: undefined };
379
408
  }
380
409
  try {
381
410
  const raw = node_fs_1.default.readFileSync(projectPath, 'utf8');
382
411
  const json = JSON.parse(raw);
383
412
  const type = normalizeWallpaperType(json.type);
384
413
  const title = clean(json.title) ?? '';
414
+ const fileEntry = clean(json.file);
415
+ const entry = fileEntry ? node_path_1.default.join(dir, fileEntry) : undefined;
385
416
  const audioReactive = detectAudioReactiveFromText(`${title}\n${raw}`);
386
- return { type, audioReactive };
417
+ return { type, audioReactive, entry };
387
418
  }
388
419
  catch {
389
- return { type: 'unknown', audioReactive: false };
420
+ return { type: 'unknown', audioReactive: false, entry: undefined };
421
+ }
422
+ }
423
+ function inferVideoEntry(dir) {
424
+ if (!node_fs_1.default.existsSync(dir))
425
+ return undefined;
426
+ const files = node_fs_1.default.readdirSync(dir, { withFileTypes: true });
427
+ const preferred = ['.mp4', '.webm', '.gif', '.mkv', '.avi', '.mov'];
428
+ for (const ext of preferred) {
429
+ const match = files.find((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(ext));
430
+ if (match)
431
+ return node_path_1.default.join(dir, match.name);
432
+ }
433
+ return undefined;
434
+ }
435
+ function detectTypeFromEntry(entry) {
436
+ const e = clean(entry)?.toLowerCase();
437
+ if (!e)
438
+ return 'unknown';
439
+ if (e.endsWith('.mp4') || e.endsWith('.webm') || e.endsWith('.gif') || e.endsWith('.mkv') || e.endsWith('.avi') || e.endsWith('.mov')) {
440
+ return 'video';
390
441
  }
442
+ if (e.endsWith('index.html') || e.endsWith('.html'))
443
+ return 'web';
444
+ if (e.endsWith('scene.json') || e.endsWith('gifscene.json'))
445
+ return 'scene';
446
+ return 'unknown';
391
447
  }
392
448
  function readLibraryFoldersVdf(vdfPath) {
393
449
  if (!node_fs_1.default.existsSync(vdfPath))
@@ -503,13 +559,37 @@ function workshopWallpaperEngineStatus() {
503
559
  if (node_fs_1.default.existsSync(manifest))
504
560
  manifests.push(manifest);
505
561
  }
562
+ const engine = workshopSceneEngineStatus();
506
563
  return {
507
564
  ok: true,
508
565
  installed: manifests.length > 0,
509
566
  manifests,
510
- steamapps
567
+ steamapps,
568
+ engine
511
569
  };
512
570
  }
571
+ function workshopSceneEngineStatus() {
572
+ const configured = clean(process.env.KITOWALL_SCENE_ENGINE_CMD);
573
+ const candidates = configured
574
+ ? [configured]
575
+ : ['linux-wallpaperengine', 'linux-wallpaperengine-cli', 'wallpaper-engine'];
576
+ for (const cmd of candidates) {
577
+ const resolved = resolveOnPath(cmd);
578
+ if (!resolved)
579
+ continue;
580
+ const verOut = (0, node_child_process_1.spawnSync)(resolved, ['--version'], { encoding: 'utf8', timeout: 2500 });
581
+ const stdout = clean(verOut.stdout) ?? '';
582
+ const stderr = clean(verOut.stderr) ?? '';
583
+ const version = (stdout.split('\n')[0] || stderr.split('\n')[0] || '').trim();
584
+ return {
585
+ installed: true,
586
+ path: resolved,
587
+ version: version || undefined,
588
+ command: cmd
589
+ };
590
+ }
591
+ return { installed: false, command: configured ?? 'linux-wallpaperengine' };
592
+ }
513
593
  function findPreviewCandidate(dir) {
514
594
  if (!node_fs_1.default.existsSync(dir))
515
595
  return undefined;
@@ -602,17 +682,19 @@ function workshopSyncSteamDownloads() {
602
682
  author_name: 'Steam Workshop',
603
683
  time_updated: Math.floor(Date.now() / 1000),
604
684
  wallpaper_type: info.type,
605
- audio_reactive: info.audioReactive
685
+ audio_reactive: info.audioReactive,
686
+ entry: info.entry
606
687
  });
607
688
  }
608
689
  else {
609
690
  try {
610
691
  const current = JSON.parse(node_fs_1.default.readFileSync(metadataFile, 'utf8'));
611
- if (!current.wallpaper_type || current.wallpaper_type === 'unknown' || current.audio_reactive === undefined) {
692
+ if (!current.wallpaper_type || current.wallpaper_type === 'unknown' || current.audio_reactive === undefined || !current.entry) {
612
693
  writeMetaFile({
613
694
  ...current,
614
695
  wallpaper_type: current.wallpaper_type ?? info.type,
615
- audio_reactive: current.audio_reactive ?? info.audioReactive
696
+ audio_reactive: current.audio_reactive ?? info.audioReactive,
697
+ entry: current.entry ?? info.entry
616
698
  });
617
699
  }
618
700
  }
@@ -1026,9 +1108,10 @@ async function workshopCoexistenceStatus() {
1026
1108
  return { ok: true, snapshot, current };
1027
1109
  }
1028
1110
  async function workshopStop(options) {
1029
- const state = readActiveState();
1111
+ let state = readActiveState();
1030
1112
  const hadActive = !!state;
1031
1113
  const stopped = [];
1114
+ let shouldRestore = false;
1032
1115
  if (state?.instances) {
1033
1116
  if (options?.monitor) {
1034
1117
  const inst = state.instances[options.monitor];
@@ -1040,6 +1123,7 @@ async function workshopStop(options) {
1040
1123
  }
1041
1124
  else {
1042
1125
  clearActiveState();
1126
+ shouldRestore = true;
1043
1127
  }
1044
1128
  }
1045
1129
  else {
@@ -1048,12 +1132,14 @@ async function workshopStop(options) {
1048
1132
  stopped.push(monitor);
1049
1133
  }
1050
1134
  clearActiveState();
1135
+ shouldRestore = true;
1051
1136
  }
1052
1137
  }
1053
1138
  else if (options?.all) {
1054
1139
  clearActiveState();
1140
+ shouldRestore = true;
1055
1141
  }
1056
- const coexist = await workshopCoexistenceExit();
1142
+ const coexist = shouldRestore ? await workshopCoexistenceExit() : { ok: true, restored: [] };
1057
1143
  return {
1058
1144
  ok: true,
1059
1145
  stopped_instances: stopped,
@@ -1165,6 +1251,9 @@ function workshopLibrary() {
1165
1251
  if (meta.audio_reactive === undefined) {
1166
1252
  meta.audio_reactive = info.audioReactive;
1167
1253
  }
1254
+ if (!meta.entry) {
1255
+ meta.entry = info.entry;
1256
+ }
1168
1257
  }
1169
1258
  items.push({
1170
1259
  id,
@@ -1176,3 +1265,152 @@ function workshopLibrary() {
1176
1265
  items.sort((a, b) => a.id.localeCompare(b.id));
1177
1266
  return { root: paths.downloads, items };
1178
1267
  }
1268
+ function spawnMpvpaper(monitor, entry) {
1269
+ return new Promise((resolve, reject) => {
1270
+ const child = (0, node_child_process_1.spawn)('mpvpaper', ['-o', 'no-audio --loop-file=inf', monitor, entry], {
1271
+ detached: true,
1272
+ stdio: 'ignore',
1273
+ env: process.env
1274
+ });
1275
+ let settled = false;
1276
+ const done = (fn) => {
1277
+ if (settled)
1278
+ return;
1279
+ settled = true;
1280
+ fn();
1281
+ };
1282
+ child.once('error', (err) => done(() => reject(err)));
1283
+ setTimeout(() => {
1284
+ done(() => {
1285
+ child.unref();
1286
+ resolve(child.pid ?? 0);
1287
+ });
1288
+ }, 180);
1289
+ });
1290
+ }
1291
+ function spawnSceneEngine(monitor, wallpaperDir) {
1292
+ const engine = workshopSceneEngineStatus();
1293
+ if (!engine.installed || !engine.path) {
1294
+ throw new Error('Scene engine not installed. Install linux-wallpaperengine first.');
1295
+ }
1296
+ const enginePath = engine.path;
1297
+ return new Promise((resolve, reject) => {
1298
+ const child = (0, node_child_process_1.spawn)(enginePath, ['--screen-root', monitor, '--bg', wallpaperDir], {
1299
+ detached: true,
1300
+ stdio: 'ignore',
1301
+ env: process.env
1302
+ });
1303
+ let settled = false;
1304
+ const done = (fn) => {
1305
+ if (settled)
1306
+ return;
1307
+ settled = true;
1308
+ fn();
1309
+ };
1310
+ child.once('error', (err) => done(() => reject(err)));
1311
+ setTimeout(() => {
1312
+ done(() => {
1313
+ child.unref();
1314
+ resolve(child.pid ?? 0);
1315
+ });
1316
+ }, 220);
1317
+ });
1318
+ }
1319
+ async function workshopApply(input) {
1320
+ const id = clean(input.id);
1321
+ const monitor = clean(input.monitor);
1322
+ const requestedBackend = clean(input.backend) ?? 'auto';
1323
+ if (!id)
1324
+ throw new Error('id is required');
1325
+ if (!monitor)
1326
+ throw new Error('monitor is required');
1327
+ const paths = getWePaths();
1328
+ ensureWePaths(paths);
1329
+ const dir = node_path_1.default.join(paths.downloads, id);
1330
+ if (!node_fs_1.default.existsSync(dir)) {
1331
+ throw new Error(`Wallpaper not found in downloads: ${id}`);
1332
+ }
1333
+ const project = readProjectInfo(dir);
1334
+ const inferredEntry = project.entry && node_fs_1.default.existsSync(project.entry) ? project.entry : inferVideoEntry(dir);
1335
+ const type = project.type !== 'unknown'
1336
+ ? project.type
1337
+ : detectTypeFromEntry(inferredEntry ?? node_path_1.default.join(dir, 'scene.json'));
1338
+ if (type === 'web' || type === 'application' || type === 'unknown') {
1339
+ throw new Error(`Unsupported wallpaper type for apply: ${type}. Supported: video, scene.`);
1340
+ }
1341
+ let state = readActiveState();
1342
+ if (!state) {
1343
+ const coexist = await workshopCoexistenceEnter();
1344
+ state = {
1345
+ mode: 'livewallpaper',
1346
+ started_at: now(),
1347
+ snapshot_id: coexist.snapshot_id,
1348
+ instances: {}
1349
+ };
1350
+ }
1351
+ const current = state.instances[monitor];
1352
+ if (current?.pid) {
1353
+ killPid(current.pid);
1354
+ }
1355
+ let backend;
1356
+ let pid = 0;
1357
+ if (type === 'video') {
1358
+ if (!(requestedBackend === 'auto' || requestedBackend === 'mpvpaper')) {
1359
+ throw new Error(`Invalid backend for video wallpaper: ${requestedBackend}`);
1360
+ }
1361
+ if (!inferredEntry || !node_fs_1.default.existsSync(inferredEntry)) {
1362
+ throw new Error(`Video entry not found for wallpaper: ${id}`);
1363
+ }
1364
+ backend = 'mpvpaper';
1365
+ pid = await spawnMpvpaper(monitor, inferredEntry).catch((err) => {
1366
+ throw new Error(`Failed to launch mpvpaper: ${err instanceof Error ? err.message : String(err)}`);
1367
+ });
1368
+ }
1369
+ else if (type === 'scene') {
1370
+ if (!(requestedBackend === 'auto' || requestedBackend === 'linux-wallpaperengine')) {
1371
+ throw new Error(`Invalid backend for scene wallpaper: ${requestedBackend}`);
1372
+ }
1373
+ backend = 'linux-wallpaperengine';
1374
+ pid = await spawnSceneEngine(monitor, dir).catch((err) => {
1375
+ throw new Error(`Failed to launch scene engine: ${err instanceof Error ? err.message : String(err)}`);
1376
+ });
1377
+ }
1378
+ else {
1379
+ throw new Error(`Unsupported wallpaper type for apply: ${type}`);
1380
+ }
1381
+ if (!pid) {
1382
+ throw new Error(`${backend} started without pid`);
1383
+ }
1384
+ state.instances[monitor] = {
1385
+ id,
1386
+ pid,
1387
+ backend,
1388
+ type
1389
+ };
1390
+ writeActiveState(state);
1391
+ return { ok: true, applied: true, monitor, id, backend, pid, state };
1392
+ }
1393
+ async function workshopApplyMap(input) {
1394
+ const raw = clean(input.map);
1395
+ if (!raw)
1396
+ throw new Error('map is required');
1397
+ const entries = raw.split(',').map(v => v.trim()).filter(Boolean);
1398
+ if (entries.length === 0)
1399
+ throw new Error('map has no entries');
1400
+ const applied = [];
1401
+ for (const pair of entries) {
1402
+ const idx = pair.indexOf(':');
1403
+ if (idx <= 0 || idx === pair.length - 1) {
1404
+ throw new Error(`Invalid map entry: ${pair}. Expected <monitor>:<id>`);
1405
+ }
1406
+ const monitor = pair.slice(0, idx).trim();
1407
+ const id = pair.slice(idx + 1).trim();
1408
+ if (!monitor || !id) {
1409
+ throw new Error(`Invalid map entry: ${pair}. Expected <monitor>:<id>`);
1410
+ }
1411
+ const out = await workshopApply({ id, monitor, backend: input.backend });
1412
+ applied.push({ monitor: out.monitor, id: out.id, backend: out.backend, pid: out.pid });
1413
+ }
1414
+ const state = readActiveState() ?? undefined;
1415
+ return { ok: true, applied, state };
1416
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kitowall",
3
- "version": "2.3.0",
3
+ "version": "2.4.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",