kitowall 1.0.7 → 2.0.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/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  `Kitowall` is a wallpaper manager for Hyprland/Wayland using `swww`.
6
6
 
7
- Current version: `1.0.6`.
7
+ Current version: `1.0.7`.
8
8
 
9
9
  ## What You Can Do
10
10
  - Rotate wallpapers with transitions.
package/dist/cli.js CHANGED
@@ -55,6 +55,7 @@ const staticUrl_1 = require("./adapters/staticUrl");
55
55
  const logs_1 = require("./core/logs");
56
56
  const node_fs_1 = require("node:fs");
57
57
  const node_path_1 = require("node:path");
58
+ const workshop_1 = require("./core/workshop");
58
59
  function getCliVersion() {
59
60
  try {
60
61
  const pkgPath = (0, node_path_1.join)(__dirname, '..', 'package.json');
@@ -116,6 +117,17 @@ Commands:
116
117
  logs [--limit <n>] [--source <name>] [--pack <name>] [--level <info|warn|error>] [--q <text>]
117
118
  Show system logs (requests/downloads/errors)
118
119
  logs clear Clear system logs
120
+ we config set-api-key <key> Save Steam Web API key (~/.config/kitowall/we.json)
121
+ we search [--text <q>] [--tags <a,b>] [--sort <top|newest|trend|subscribed|updated>] [--page <n>] [--page-size <n>] [--days <n>] [--fixtures]
122
+ Search Wallpaper Engine workshop items (appid 431960)
123
+ we details <publishedfileid> [--fixtures]
124
+ Fetch workshop item details and additional previews
125
+ we download <publishedfileid> [--target-dir <path>] [--steam-user <user>] [--steam-pass-env <ENV>] [--steam-guard <code>] [--coexist]
126
+ Queue steamcmd download job (async)
127
+ we job <job_id> Show one download job
128
+ we jobs [--limit <n>] List recent download jobs
129
+ we library List downloaded workshop items
130
+ we coexist enter|exit|status Temporarily stop/restore wallpaper rotation services
119
131
  check [--namespace <ns>] [--json] Quick system check (no changes)
120
132
 
121
133
  init [--namespace <ns>] [--apply] [--force] Setup kitowall (install daemon + watcher + next.service), validate deps
@@ -284,6 +296,117 @@ async function main() {
284
296
  process.exitCode = report.ok ? 0 : 2;
285
297
  return;
286
298
  }
299
+ if (cmd === 'we') {
300
+ const action = cleanOpt(args[1] ?? null);
301
+ if (!action) {
302
+ throw new Error('Usage: we <config|search|details|download|job|jobs|library|run-job|coexist> ...');
303
+ }
304
+ if (action === 'config') {
305
+ const sub = cleanOpt(args[2] ?? null);
306
+ if (sub !== 'set-api-key')
307
+ throw new Error('Usage: we config set-api-key <key>');
308
+ const key = cleanOpt(args[3] ?? null);
309
+ if (!key)
310
+ throw new Error('Usage: we config set-api-key <key>');
311
+ (0, workshop_1.setWorkshopApiKey)(key);
312
+ console.log(JSON.stringify({ ok: true, updated: 'steamWebApiKey' }, null, 2));
313
+ return;
314
+ }
315
+ if (action === 'search') {
316
+ const text = cleanOpt(getOptionValue(args, '--text'));
317
+ const tags = parseList(getOptionValue(args, '--tags'));
318
+ const sort = cleanOpt(getOptionValue(args, '--sort'));
319
+ const pageRaw = cleanOpt(getOptionValue(args, '--page'));
320
+ const pageSizeRaw = cleanOpt(getOptionValue(args, '--page-size'));
321
+ const daysRaw = cleanOpt(getOptionValue(args, '--days'));
322
+ const fixtures = args.includes('--fixtures');
323
+ const out = await (0, workshop_1.workshopSearch)({
324
+ text,
325
+ tags,
326
+ sort,
327
+ page: pageRaw ? Number(pageRaw) : undefined,
328
+ pageSize: pageSizeRaw ? Number(pageSizeRaw) : undefined,
329
+ days: daysRaw ? Number(daysRaw) : undefined,
330
+ fixtures
331
+ });
332
+ console.log(JSON.stringify(out, null, 2));
333
+ return;
334
+ }
335
+ if (action === 'details') {
336
+ const id = cleanOpt(args[2] ?? null);
337
+ if (!id)
338
+ throw new Error('Usage: we details <publishedfileid> [--fixtures]');
339
+ const fixtures = args.includes('--fixtures');
340
+ const out = await (0, workshop_1.workshopDetails)(id, fixtures);
341
+ console.log(JSON.stringify(out, null, 2));
342
+ return;
343
+ }
344
+ if (action === 'download') {
345
+ const id = cleanOpt(args[2] ?? null);
346
+ if (!id)
347
+ throw new Error('Usage: we download <publishedfileid> [--target-dir <path>] [--steam-user <user>] [--steam-pass-env <ENV>] [--steam-guard <code>] [--coexist]');
348
+ const out = (0, workshop_1.workshopQueueDownload)({
349
+ publishedfileid: id,
350
+ targetDir: cleanOpt(getOptionValue(args, '--target-dir')),
351
+ steamUser: cleanOpt(getOptionValue(args, '--steam-user')),
352
+ steamPasswordEnv: cleanOpt(getOptionValue(args, '--steam-pass-env')),
353
+ steamGuardCode: cleanOpt(getOptionValue(args, '--steam-guard')),
354
+ useCoexistence: args.includes('--coexist')
355
+ });
356
+ console.log(JSON.stringify({ ok: true, ...out }, null, 2));
357
+ return;
358
+ }
359
+ if (action === 'run-job') {
360
+ const jobId = cleanOpt(args[2] ?? null);
361
+ if (!jobId)
362
+ throw new Error('Usage: we run-job <job_id>');
363
+ const out = await (0, workshop_1.workshopRunJob)(jobId);
364
+ console.log(JSON.stringify({ ok: true, job: out }, null, 2));
365
+ return;
366
+ }
367
+ if (action === 'job') {
368
+ const jobId = cleanOpt(args[2] ?? null);
369
+ if (!jobId)
370
+ throw new Error('Usage: we job <job_id>');
371
+ const out = (0, workshop_1.workshopGetJob)(jobId);
372
+ console.log(JSON.stringify(out, null, 2));
373
+ return;
374
+ }
375
+ if (action === 'jobs') {
376
+ const limitRaw = cleanOpt(getOptionValue(args, '--limit'));
377
+ const limit = limitRaw ? Number(limitRaw) : 40;
378
+ if (!Number.isFinite(limit) || limit <= 0)
379
+ throw new Error(`Invalid --limit value: ${limitRaw}`);
380
+ const out = (0, workshop_1.workshopListJobs)(Math.floor(limit));
381
+ console.log(JSON.stringify(out, null, 2));
382
+ return;
383
+ }
384
+ if (action === 'library') {
385
+ const out = (0, workshop_1.workshopLibrary)();
386
+ console.log(JSON.stringify(out, null, 2));
387
+ return;
388
+ }
389
+ if (action === 'coexist') {
390
+ const sub = cleanOpt(args[2] ?? null);
391
+ if (sub === 'enter') {
392
+ const out = await (0, workshop_1.workshopCoexistenceEnter)();
393
+ console.log(JSON.stringify(out, null, 2));
394
+ return;
395
+ }
396
+ if (sub === 'exit') {
397
+ const out = await (0, workshop_1.workshopCoexistenceExit)();
398
+ console.log(JSON.stringify(out, null, 2));
399
+ return;
400
+ }
401
+ if (sub === 'status') {
402
+ const out = await (0, workshop_1.workshopCoexistenceStatus)();
403
+ console.log(JSON.stringify(out, null, 2));
404
+ return;
405
+ }
406
+ throw new Error('Usage: we coexist <enter|exit|status>');
407
+ }
408
+ throw new Error('Usage: we <config|search|details|download|job|jobs|library|run-job|coexist> ...');
409
+ }
287
410
  // Regular commands (need config/state)
288
411
  const config = (0, config_1.loadConfig)();
289
412
  const state = (0, state_1.loadState)();
@@ -0,0 +1,740 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.setWorkshopApiKey = setWorkshopApiKey;
7
+ exports.workshopSearch = workshopSearch;
8
+ exports.workshopDetails = workshopDetails;
9
+ exports.workshopQueueDownload = workshopQueueDownload;
10
+ exports.workshopCoexistenceEnter = workshopCoexistenceEnter;
11
+ exports.workshopCoexistenceExit = workshopCoexistenceExit;
12
+ exports.workshopCoexistenceStatus = workshopCoexistenceStatus;
13
+ exports.workshopRunJob = workshopRunJob;
14
+ exports.workshopGetJob = workshopGetJob;
15
+ exports.workshopListJobs = workshopListJobs;
16
+ exports.workshopLibrary = workshopLibrary;
17
+ const node_fs_1 = __importDefault(require("node:fs"));
18
+ const node_os_1 = __importDefault(require("node:os"));
19
+ const node_path_1 = __importDefault(require("node:path"));
20
+ const node_child_process_1 = require("node:child_process");
21
+ const hash_1 = require("../utils/hash");
22
+ const fs_1 = require("../utils/fs");
23
+ const net_1 = require("../utils/net");
24
+ const logs_1 = require("./logs");
25
+ const exec_1 = require("../utils/exec");
26
+ const WE_APP_ID = 431960;
27
+ const SEARCH_TTL_MS = 10 * 60 * 1000;
28
+ const DEFAULT_PAGE_SIZE = 24;
29
+ function now() {
30
+ return Date.now();
31
+ }
32
+ function clean(input) {
33
+ if (input == null)
34
+ return undefined;
35
+ const v = input.trim();
36
+ return v.length > 0 ? v : undefined;
37
+ }
38
+ function getWePaths() {
39
+ const root = node_path_1.default.join(node_os_1.default.homedir(), '.local', 'share', 'kitsune', 'we');
40
+ return {
41
+ root,
42
+ steamcmd: node_path_1.default.join(root, 'steamcmd'),
43
+ downloads: node_path_1.default.join(root, 'downloads'),
44
+ previews: node_path_1.default.join(root, 'previews'),
45
+ metadata: node_path_1.default.join(root, 'metadata'),
46
+ cache: node_path_1.default.join(root, 'cache'),
47
+ jobs: node_path_1.default.join(root, 'jobs'),
48
+ runtime: node_path_1.default.join(root, 'runtime')
49
+ };
50
+ }
51
+ function ensureWePaths(paths) {
52
+ (0, fs_1.ensureDir)(paths.root);
53
+ (0, fs_1.ensureDir)(paths.steamcmd);
54
+ (0, fs_1.ensureDir)(paths.downloads);
55
+ (0, fs_1.ensureDir)(paths.previews);
56
+ (0, fs_1.ensureDir)(paths.metadata);
57
+ (0, fs_1.ensureDir)(paths.cache);
58
+ (0, fs_1.ensureDir)(paths.jobs);
59
+ (0, fs_1.ensureDir)(paths.runtime);
60
+ (0, fs_1.ensureDir)(node_path_1.default.join(paths.cache, 'search'));
61
+ (0, fs_1.ensureDir)(node_path_1.default.join(paths.steamcmd, 'logs'));
62
+ }
63
+ function getWeConfigPath() {
64
+ return node_path_1.default.join(node_os_1.default.homedir(), '.config', 'kitowall', 'we.json');
65
+ }
66
+ function readWeConfig() {
67
+ const p = getWeConfigPath();
68
+ if (!node_fs_1.default.existsSync(p))
69
+ return {};
70
+ try {
71
+ return JSON.parse(node_fs_1.default.readFileSync(p, 'utf8'));
72
+ }
73
+ catch {
74
+ return {};
75
+ }
76
+ }
77
+ function setWorkshopApiKey(apiKey) {
78
+ const key = clean(apiKey);
79
+ if (!key)
80
+ throw new Error('steam_web_api_key is required');
81
+ const p = getWeConfigPath();
82
+ const current = readWeConfig();
83
+ current.steamWebApiKey = key;
84
+ (0, fs_1.writeJson)(p, current);
85
+ return { ok: true };
86
+ }
87
+ function getSteamWebApiKey() {
88
+ const envKey = clean(process.env.STEAM_WEB_API_KEY) ?? clean(process.env.KITOWALL_STEAM_WEB_API_KEY);
89
+ if (envKey)
90
+ return envKey;
91
+ const cfg = readWeConfig();
92
+ return clean(cfg.steamWebApiKey);
93
+ }
94
+ function getCoexistServices() {
95
+ const cfg = readWeConfig();
96
+ const defaults = [
97
+ 'swww-daemon.service',
98
+ 'hyprwall-watch.service',
99
+ 'hyprwall-next.timer',
100
+ 'kitowall-next.timer'
101
+ ];
102
+ const configured = Array.isArray(cfg.coexistServices) ? cfg.coexistServices.map(v => String(v).trim()).filter(Boolean) : [];
103
+ return configured.length > 0 ? configured : defaults;
104
+ }
105
+ function getSearchCacheFile(query, paths) {
106
+ const key = (0, hash_1.sha256Hex)(JSON.stringify(query));
107
+ return node_path_1.default.join(paths.cache, 'search', `${key}.json`);
108
+ }
109
+ function parseTags(raw) {
110
+ if (!Array.isArray(raw))
111
+ return [];
112
+ const out = [];
113
+ for (const tag of raw) {
114
+ if (typeof tag === 'string') {
115
+ const t = clean(tag);
116
+ if (t)
117
+ out.push(t);
118
+ continue;
119
+ }
120
+ const t = clean(tag?.tag);
121
+ if (t)
122
+ out.push(t);
123
+ }
124
+ return out;
125
+ }
126
+ function getPreviewUrl(item) {
127
+ return clean(item.preview_url) ?? clean(item.previewurl);
128
+ }
129
+ function isMotionUrl(url) {
130
+ const v = clean(url);
131
+ if (!v)
132
+ return false;
133
+ const lower = v.toLowerCase();
134
+ return (lower.includes('.mp4') ||
135
+ lower.includes('.webm') ||
136
+ lower.includes('.mov') ||
137
+ lower.includes('.mkv') ||
138
+ lower.includes('.gif'));
139
+ }
140
+ function pickMotionPreview(item) {
141
+ if (Array.isArray(item.additional_previews)) {
142
+ for (const p of item.additional_previews) {
143
+ const candidate = typeof p === 'string' ? clean(p) : (clean(p.preview_url) ?? clean(p.url));
144
+ if (isMotionUrl(candidate))
145
+ return candidate;
146
+ }
147
+ }
148
+ if (Array.isArray(item.previews)) {
149
+ for (const p of item.previews) {
150
+ const candidate = clean(p.url) ?? clean(p.preview_url) ?? clean(p.filename);
151
+ if (isMotionUrl(candidate))
152
+ return candidate;
153
+ if (clean(p.preview_type)?.toLowerCase() === 'video' && candidate)
154
+ return candidate;
155
+ }
156
+ }
157
+ return undefined;
158
+ }
159
+ function normalizeMeta(item, paths) {
160
+ const id = clean(item.publishedfileid) ?? '';
161
+ const thumbDir = node_path_1.default.join(paths.previews, id);
162
+ const thumbJpg = node_path_1.default.join(thumbDir, 'thumb.jpg');
163
+ const thumbPng = node_path_1.default.join(thumbDir, 'thumb.png');
164
+ const thumbWebp = node_path_1.default.join(thumbDir, 'thumb.webp');
165
+ let previewThumbLocal;
166
+ if (node_fs_1.default.existsSync(thumbJpg))
167
+ previewThumbLocal = thumbJpg;
168
+ else if (node_fs_1.default.existsSync(thumbPng))
169
+ previewThumbLocal = thumbPng;
170
+ else if (node_fs_1.default.existsSync(thumbWebp))
171
+ previewThumbLocal = thumbWebp;
172
+ const score = typeof item.score === 'number'
173
+ ? item.score
174
+ : (typeof item.vote_data?.score === 'number' ? item.vote_data.score : undefined);
175
+ return {
176
+ id,
177
+ title: clean(item.title) ?? `Workshop ${id}`,
178
+ preview_url_remote: getPreviewUrl(item),
179
+ preview_motion_remote: pickMotionPreview(item),
180
+ preview_thumb_local: previewThumbLocal,
181
+ author_name: clean(item.creator),
182
+ tags: parseTags(item.tags),
183
+ score,
184
+ time_updated: typeof item.time_updated === 'number' ? item.time_updated : undefined
185
+ };
186
+ }
187
+ function resolveSortToQueryType(sort, hasText) {
188
+ if (hasText)
189
+ return 12;
190
+ if (sort === 'top')
191
+ return 0;
192
+ if (sort === 'newest')
193
+ return 1;
194
+ if (sort === 'trend')
195
+ return 3;
196
+ if (sort === 'subscribed')
197
+ return 9;
198
+ if (sort === 'updated')
199
+ return 21;
200
+ return 0;
201
+ }
202
+ function getFixturesDir() {
203
+ const fromEnv = clean(process.env.KITOWALL_WE_FIXTURES_DIR);
204
+ if (fromEnv)
205
+ return fromEnv;
206
+ return node_path_1.default.join(process.cwd(), 'fixtures', 'we');
207
+ }
208
+ function readFixtureJson(name) {
209
+ const file = node_path_1.default.join(getFixturesDir(), name);
210
+ if (!node_fs_1.default.existsSync(file))
211
+ throw new Error(`Fixture not found: ${file}`);
212
+ return JSON.parse(node_fs_1.default.readFileSync(file, 'utf8'));
213
+ }
214
+ async function callSteamService(method, payload, fixtures) {
215
+ if (fixtures || process.env.KITOWALL_WE_USE_FIXTURES === '1') {
216
+ return method === 'QueryFiles'
217
+ ? readFixtureJson('query-files.json')
218
+ : readFixtureJson('get-details.json');
219
+ }
220
+ const key = getSteamWebApiKey();
221
+ if (!key) {
222
+ throw new Error('Missing Steam Web API key. Set STEAM_WEB_API_KEY or run: kitowall we config set-api-key <key>');
223
+ }
224
+ const endpoint = `https://api.steampowered.com/IPublishedFileService/${method}/v1/?key=${encodeURIComponent(key)}`;
225
+ const body = new URLSearchParams({
226
+ input_json: JSON.stringify(payload)
227
+ });
228
+ (0, logs_1.appendSystemLog)({
229
+ level: 'info',
230
+ source: 'workshop',
231
+ action: `steam-api:${method.toLowerCase()}`,
232
+ url: endpoint
233
+ });
234
+ const response = await (0, net_1.fetchWithRetry)(endpoint, {
235
+ method: 'POST',
236
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
237
+ body: body.toString()
238
+ }, { timeoutMs: 20000, retries: 2, backoffMs: 350 });
239
+ const json = await response.json();
240
+ return json;
241
+ }
242
+ function extractItemsFromQuery(response) {
243
+ const root = response;
244
+ const fromA = root.response?.publishedfiledetails;
245
+ if (Array.isArray(fromA))
246
+ return fromA;
247
+ const fromB = root.response?.publishedfile_details;
248
+ if (Array.isArray(fromB))
249
+ return fromB;
250
+ return [];
251
+ }
252
+ function extractSingleFromDetails(response) {
253
+ const items = extractItemsFromQuery(response);
254
+ if (items.length > 0)
255
+ return items[0];
256
+ const root = response;
257
+ const details = root.response?.publishedfiledetails;
258
+ if (Array.isArray(details) && details.length > 0)
259
+ return details[0];
260
+ return null;
261
+ }
262
+ function toShortDescription(item) {
263
+ return clean(item.short_description) ?? clean(item.file_description);
264
+ }
265
+ function maybeAdditionalPreviews(item) {
266
+ if (!Array.isArray(item.additional_previews))
267
+ return [];
268
+ const out = [];
269
+ for (const p of item.additional_previews) {
270
+ if (typeof p === 'string') {
271
+ const v = clean(p);
272
+ if (v)
273
+ out.push(v);
274
+ continue;
275
+ }
276
+ const v = clean(p.preview_url) ?? clean(p.url);
277
+ if (v)
278
+ out.push(v);
279
+ }
280
+ return out;
281
+ }
282
+ function extFromUrl(url) {
283
+ try {
284
+ const u = new URL(url);
285
+ const ext = node_path_1.default.extname(u.pathname).toLowerCase();
286
+ if (ext === '.jpg' || ext === '.jpeg' || ext === '.png' || ext === '.webp')
287
+ return ext;
288
+ return '.jpg';
289
+ }
290
+ catch {
291
+ return '.jpg';
292
+ }
293
+ }
294
+ async function cachePreviewImage(id, imageUrl, customName) {
295
+ if (!imageUrl)
296
+ return undefined;
297
+ const paths = getWePaths();
298
+ ensureWePaths(paths);
299
+ const targetDir = node_path_1.default.join(paths.previews, id);
300
+ (0, fs_1.ensureDir)(targetDir);
301
+ const ext = extFromUrl(imageUrl);
302
+ const fileName = customName ?? `thumb${ext}`;
303
+ const target = node_path_1.default.join(targetDir, fileName);
304
+ if (node_fs_1.default.existsSync(target))
305
+ return target;
306
+ try {
307
+ const response = await (0, net_1.fetchWithRetry)(imageUrl, undefined, { timeoutMs: 15000, retries: 1 });
308
+ const bytes = Buffer.from(await response.arrayBuffer());
309
+ node_fs_1.default.writeFileSync(target, bytes);
310
+ return target;
311
+ }
312
+ catch {
313
+ return undefined;
314
+ }
315
+ }
316
+ function writeMetaFile(meta) {
317
+ const paths = getWePaths();
318
+ ensureWePaths(paths);
319
+ const p = node_path_1.default.join(paths.metadata, `${meta.id}.json`);
320
+ (0, fs_1.writeJson)(p, {
321
+ ...meta,
322
+ cached_at: now()
323
+ });
324
+ }
325
+ async function workshopSearch(input) {
326
+ const paths = getWePaths();
327
+ ensureWePaths(paths);
328
+ const text = clean(input.text) ?? '';
329
+ const page = Number.isFinite(input.page) && (input.page ?? 1) > 0 ? Math.floor(input.page ?? 1) : 1;
330
+ const pageSize = Number.isFinite(input.pageSize) && (input.pageSize ?? DEFAULT_PAGE_SIZE) > 0
331
+ ? Math.floor(input.pageSize ?? DEFAULT_PAGE_SIZE)
332
+ : DEFAULT_PAGE_SIZE;
333
+ const sort = input.sort ?? 'top';
334
+ const tags = (input.tags ?? []).map(v => String(v).trim()).filter(Boolean);
335
+ const days = Number.isFinite(input.days) && (input.days ?? 7) > 0 ? Math.floor(input.days ?? 7) : 7;
336
+ const hasText = text.length > 0;
337
+ const queryType = resolveSortToQueryType(sort, hasText);
338
+ const queryPayload = {
339
+ query_type: queryType,
340
+ page,
341
+ numperpage: pageSize,
342
+ appid: WE_APP_ID,
343
+ creator_appid: WE_APP_ID,
344
+ filetype: 0,
345
+ search_text: text,
346
+ return_tags: true,
347
+ return_vote_data: true,
348
+ return_previews: true,
349
+ return_short_description: true,
350
+ requiredtags: tags.length > 0 ? tags : undefined,
351
+ match_all_tags: false
352
+ };
353
+ if (queryType === 3) {
354
+ queryPayload.days = days;
355
+ queryPayload.include_recent_votes_only = true;
356
+ }
357
+ const cacheFile = getSearchCacheFile(queryPayload, paths);
358
+ if (node_fs_1.default.existsSync(cacheFile)) {
359
+ try {
360
+ const cachedRaw = JSON.parse(node_fs_1.default.readFileSync(cacheFile, 'utf8'));
361
+ if (now() - cachedRaw.ts <= SEARCH_TTL_MS) {
362
+ return { ...cachedRaw.payload, cached: true };
363
+ }
364
+ }
365
+ catch {
366
+ // ignore malformed cache and refresh.
367
+ }
368
+ }
369
+ const json = await callSteamService('QueryFiles', queryPayload, Boolean(input.fixtures));
370
+ const itemsRaw = extractItemsFromQuery(json);
371
+ const items = itemsRaw
372
+ .map(item => normalizeMeta(item, paths))
373
+ .filter(item => item.id.length > 0);
374
+ for (const item of items) {
375
+ writeMetaFile(item);
376
+ }
377
+ await Promise.allSettled(items.slice(0, 16).map(item => cachePreviewImage(item.id, item.preview_url_remote)));
378
+ const root = json;
379
+ const total = typeof root.response?.total === 'number'
380
+ ? root.response.total
381
+ : (typeof root.response?.total_matching === 'number' ? root.response.total_matching : undefined);
382
+ const payload = { items, page, page_size: pageSize, total };
383
+ (0, fs_1.writeJson)(cacheFile, { ts: now(), payload });
384
+ return { ...payload, cached: false };
385
+ }
386
+ async function workshopDetails(publishedfileid, fixtures = false) {
387
+ const paths = getWePaths();
388
+ ensureWePaths(paths);
389
+ const id = clean(publishedfileid);
390
+ if (!id)
391
+ throw new Error('publishedfileid is required');
392
+ const payload = {
393
+ publishedfileids: [id],
394
+ includetags: true,
395
+ includeadditionalpreviews: true
396
+ };
397
+ const json = await callSteamService('GetDetails', payload, fixtures);
398
+ const raw = extractSingleFromDetails(json);
399
+ if (!raw)
400
+ throw new Error(`Workshop item not found: ${id}`);
401
+ const meta = normalizeMeta(raw, paths);
402
+ const additional = maybeAdditionalPreviews(raw);
403
+ const details = {
404
+ ...meta,
405
+ additional_previews: additional,
406
+ description_short: toShortDescription(raw),
407
+ file_size: typeof raw.file_size === 'number' ? raw.file_size : undefined,
408
+ item_url: `https://steamcommunity.com/sharedfiles/filedetails/?id=${id}`
409
+ };
410
+ writeMetaFile(details);
411
+ await cachePreviewImage(id, details.preview_url_remote);
412
+ await Promise.allSettled(additional.slice(0, 8).map((url, idx) => cachePreviewImage(id, url, `preview-${idx + 1}${extFromUrl(url)}`)));
413
+ return details;
414
+ }
415
+ function jobFile(jobId, paths) {
416
+ return node_path_1.default.join(paths.jobs, `${jobId}.json`);
417
+ }
418
+ function readJob(jobId) {
419
+ const paths = getWePaths();
420
+ ensureWePaths(paths);
421
+ const p = jobFile(jobId, paths);
422
+ if (!node_fs_1.default.existsSync(p))
423
+ return null;
424
+ return JSON.parse(node_fs_1.default.readFileSync(p, 'utf8'));
425
+ }
426
+ function writeJob(job) {
427
+ const paths = getWePaths();
428
+ ensureWePaths(paths);
429
+ (0, fs_1.writeJson)(jobFile(job.id, paths), job);
430
+ }
431
+ function appendJobLog(job, line) {
432
+ const clipped = line.length > 2000 ? `${line.slice(0, 2000)}...` : line;
433
+ job.logs.push(clipped);
434
+ if (job.logs.length > 120) {
435
+ job.logs = job.logs.slice(job.logs.length - 120);
436
+ }
437
+ }
438
+ function setJobStatus(job, status, message) {
439
+ job.status = status;
440
+ job.updatedAt = now();
441
+ if (message)
442
+ appendJobLog(job, message);
443
+ writeJob(job);
444
+ }
445
+ function resolveTargetDir(targetDir) {
446
+ const paths = getWePaths();
447
+ if (!targetDir)
448
+ return paths.downloads;
449
+ if (targetDir === '~')
450
+ return node_os_1.default.homedir();
451
+ if (targetDir.startsWith('~/'))
452
+ return node_path_1.default.join(node_os_1.default.homedir(), targetDir.slice(2));
453
+ return targetDir;
454
+ }
455
+ function getChildCliPath() {
456
+ const current = process.argv[1];
457
+ return node_path_1.default.resolve(current);
458
+ }
459
+ function workshopQueueDownload(input) {
460
+ const id = clean(input.publishedfileid);
461
+ if (!id)
462
+ throw new Error('publishedfileid is required');
463
+ const paths = getWePaths();
464
+ ensureWePaths(paths);
465
+ const targetDir = resolveTargetDir(input.targetDir);
466
+ const jobId = (0, hash_1.sha256Hex)(`${id}:${Date.now()}:${Math.random()}`).slice(0, 16);
467
+ const job = {
468
+ id: jobId,
469
+ status: 'queued',
470
+ publishedfileid: id,
471
+ targetDir,
472
+ createdAt: now(),
473
+ updatedAt: now(),
474
+ logs: [],
475
+ steamUser: clean(input.steamUser),
476
+ steamPasswordEnv: clean(input.steamPasswordEnv) ?? 'STEAM_PASSWORD',
477
+ steamGuardCode: clean(input.steamGuardCode),
478
+ useCoexistence: input.useCoexistence ?? false
479
+ };
480
+ writeJob(job);
481
+ const child = (0, node_child_process_1.spawn)(process.execPath, [getChildCliPath(), 'we', 'run-job', jobId], {
482
+ detached: true,
483
+ stdio: 'ignore',
484
+ env: process.env
485
+ });
486
+ child.unref();
487
+ return { job_id: jobId, status: 'queued' };
488
+ }
489
+ function safeMkdir(p) {
490
+ if (!node_fs_1.default.existsSync(p))
491
+ (0, fs_1.ensureDir)(p);
492
+ }
493
+ function redacted(input, secret) {
494
+ if (!secret)
495
+ return input;
496
+ if (!secret.length)
497
+ return input;
498
+ return input.split(secret).join('***');
499
+ }
500
+ async function runSteamCmdDownload(job) {
501
+ const paths = getWePaths();
502
+ ensureWePaths(paths);
503
+ safeMkdir(paths.steamcmd);
504
+ safeMkdir(node_path_1.default.join(paths.steamcmd, 'logs'));
505
+ const logFile = node_path_1.default.join(paths.steamcmd, 'logs', `${job.id}.log`);
506
+ const steamPassword = job.steamPasswordEnv ? clean(process.env[job.steamPasswordEnv]) : undefined;
507
+ const loginCmd = job.steamUser
508
+ ? `login ${job.steamUser} ${steamPassword ?? ''}${job.steamGuardCode ? ` ${job.steamGuardCode}` : ''}`
509
+ : 'login anonymous';
510
+ const commands = [
511
+ `force_install_dir ${paths.steamcmd}`,
512
+ loginCmd,
513
+ `workshop_download_item ${WE_APP_ID} ${job.publishedfileid}`,
514
+ 'quit'
515
+ ];
516
+ await new Promise((resolve, reject) => {
517
+ const child = (0, node_child_process_1.spawn)('steamcmd', [], {
518
+ cwd: paths.steamcmd,
519
+ env: process.env,
520
+ stdio: ['pipe', 'pipe', 'pipe']
521
+ });
522
+ const onData = (chunk) => {
523
+ const raw = String(chunk);
524
+ const cleanLine = redacted(raw, steamPassword);
525
+ node_fs_1.default.appendFileSync(logFile, cleanLine, 'utf8');
526
+ appendJobLog(job, cleanLine.trim());
527
+ writeJob(job);
528
+ };
529
+ child.stdout.on('data', onData);
530
+ child.stderr.on('data', onData);
531
+ child.on('error', reject);
532
+ child.on('close', (code) => {
533
+ if (code && code !== 0) {
534
+ reject(new Error(`steamcmd exited with code ${code}`));
535
+ return;
536
+ }
537
+ resolve();
538
+ });
539
+ child.stdin.write(`${commands.join('\n')}\n`);
540
+ child.stdin.end();
541
+ });
542
+ }
543
+ function resolveDownloadedSource(publishedfileid) {
544
+ const paths = getWePaths();
545
+ return node_path_1.default.join(paths.steamcmd, 'steamapps', 'workshop', 'content', String(WE_APP_ID), publishedfileid);
546
+ }
547
+ async function copyDownloadedContent(job) {
548
+ const source = resolveDownloadedSource(job.publishedfileid);
549
+ if (!node_fs_1.default.existsSync(source)) {
550
+ throw new Error(`Downloaded workshop folder not found: ${source}`);
551
+ }
552
+ const baseTarget = resolveTargetDir(job.targetDir);
553
+ (0, fs_1.ensureDir)(baseTarget);
554
+ const target = node_path_1.default.join(baseTarget, job.publishedfileid);
555
+ if (node_fs_1.default.existsSync(target)) {
556
+ node_fs_1.default.rmSync(target, { recursive: true, force: true });
557
+ }
558
+ node_fs_1.default.cpSync(source, target, { recursive: true });
559
+ return target;
560
+ }
561
+ function snapshotFile(paths) {
562
+ return node_path_1.default.join(paths.runtime, 'coexistence.snapshot.json');
563
+ }
564
+ async function isUnitActive(unit) {
565
+ try {
566
+ const out = await (0, exec_1.run)('systemctl', ['--user', 'show', unit, '--property', 'ActiveState', '--value']);
567
+ return out.stdout.trim() === 'active';
568
+ }
569
+ catch {
570
+ return false;
571
+ }
572
+ }
573
+ async function workshopCoexistenceEnter() {
574
+ const paths = getWePaths();
575
+ ensureWePaths(paths);
576
+ const units = getCoexistServices();
577
+ const active = [];
578
+ for (const unit of units) {
579
+ if (await isUnitActive(unit))
580
+ active.push(unit);
581
+ }
582
+ for (const unit of active) {
583
+ try {
584
+ await (0, exec_1.run)('systemctl', ['--user', 'stop', unit]);
585
+ }
586
+ catch {
587
+ // best effort
588
+ }
589
+ }
590
+ (0, fs_1.writeJson)(snapshotFile(paths), { ts: now(), active });
591
+ return { ok: true, stopped: active, snapshot: active };
592
+ }
593
+ async function workshopCoexistenceExit() {
594
+ const paths = getWePaths();
595
+ ensureWePaths(paths);
596
+ const snapPath = snapshotFile(paths);
597
+ if (!node_fs_1.default.existsSync(snapPath))
598
+ return { ok: true, restored: [] };
599
+ const raw = JSON.parse(node_fs_1.default.readFileSync(snapPath, 'utf8'));
600
+ const active = Array.isArray(raw.active) ? raw.active.map(v => String(v)) : [];
601
+ const restored = [];
602
+ for (const unit of active) {
603
+ try {
604
+ await (0, exec_1.run)('systemctl', ['--user', 'start', unit]);
605
+ restored.push(unit);
606
+ }
607
+ catch {
608
+ // best effort
609
+ }
610
+ }
611
+ try {
612
+ node_fs_1.default.unlinkSync(snapPath);
613
+ }
614
+ catch {
615
+ // ignore
616
+ }
617
+ return { ok: true, restored };
618
+ }
619
+ async function workshopCoexistenceStatus() {
620
+ const paths = getWePaths();
621
+ ensureWePaths(paths);
622
+ const snapPath = snapshotFile(paths);
623
+ const snapshot = node_fs_1.default.existsSync(snapPath)
624
+ ? (JSON.parse(node_fs_1.default.readFileSync(snapPath, 'utf8')).active ?? [])
625
+ : [];
626
+ const units = getCoexistServices();
627
+ const current = {};
628
+ for (const unit of units) {
629
+ current[unit] = await isUnitActive(unit);
630
+ }
631
+ return { ok: true, snapshot, current };
632
+ }
633
+ async function workshopRunJob(jobId) {
634
+ const job = readJob(jobId);
635
+ if (!job)
636
+ throw new Error(`Job not found: ${jobId}`);
637
+ setJobStatus(job, 'downloading', 'Starting steamcmd download');
638
+ (0, logs_1.appendSystemLog)({
639
+ level: 'info',
640
+ source: 'workshop',
641
+ action: 'download:start',
642
+ message: `job=${job.id} id=${job.publishedfileid}`
643
+ });
644
+ let coexistEntered = false;
645
+ try {
646
+ if (job.useCoexistence) {
647
+ const coexist = await workshopCoexistenceEnter();
648
+ coexistEntered = true;
649
+ appendJobLog(job, `Coexistence enter: ${coexist.stopped.join(', ') || 'none'}`);
650
+ writeJob(job);
651
+ }
652
+ await runSteamCmdDownload(job);
653
+ setJobStatus(job, 'moving', 'Moving downloaded content to target directory');
654
+ const outputDir = await copyDownloadedContent(job);
655
+ job.outputDir = outputDir;
656
+ await workshopDetails(job.publishedfileid, false).catch(() => undefined);
657
+ setJobStatus(job, 'done', `Download ready at ${outputDir}`);
658
+ (0, logs_1.appendSystemLog)({
659
+ level: 'info',
660
+ source: 'workshop',
661
+ action: 'download:done',
662
+ message: `job=${job.id} id=${job.publishedfileid}`,
663
+ meta: { outputDir }
664
+ });
665
+ }
666
+ catch (err) {
667
+ const message = err instanceof Error ? err.message : String(err);
668
+ job.error = message;
669
+ setJobStatus(job, 'error', message);
670
+ (0, logs_1.appendSystemLog)({
671
+ level: 'error',
672
+ source: 'workshop',
673
+ action: 'download:error',
674
+ message: `job=${job.id} id=${job.publishedfileid} err=${message}`
675
+ });
676
+ }
677
+ finally {
678
+ if (coexistEntered) {
679
+ await workshopCoexistenceExit().catch(() => undefined);
680
+ }
681
+ }
682
+ return job;
683
+ }
684
+ function workshopGetJob(jobId) {
685
+ const job = readJob(jobId);
686
+ if (!job)
687
+ throw new Error(`Job not found: ${jobId}`);
688
+ return job;
689
+ }
690
+ function workshopListJobs(limit = 40) {
691
+ const paths = getWePaths();
692
+ ensureWePaths(paths);
693
+ const files = node_fs_1.default.readdirSync(paths.jobs)
694
+ .filter(name => name.endsWith('.json'))
695
+ .map(name => node_path_1.default.join(paths.jobs, name));
696
+ const jobs = [];
697
+ for (const file of files) {
698
+ try {
699
+ const data = JSON.parse(node_fs_1.default.readFileSync(file, 'utf8'));
700
+ jobs.push(data);
701
+ }
702
+ catch {
703
+ // ignore corrupted file
704
+ }
705
+ }
706
+ jobs.sort((a, b) => b.updatedAt - a.updatedAt);
707
+ return { jobs: jobs.slice(0, Math.max(1, Math.floor(limit))) };
708
+ }
709
+ function workshopLibrary() {
710
+ const paths = getWePaths();
711
+ ensureWePaths(paths);
712
+ const items = [];
713
+ if (!node_fs_1.default.existsSync(paths.downloads))
714
+ return { root: paths.downloads, items };
715
+ const dirs = node_fs_1.default.readdirSync(paths.downloads, { withFileTypes: true });
716
+ for (const entry of dirs) {
717
+ if (!entry.isDirectory())
718
+ continue;
719
+ const id = entry.name;
720
+ const p = node_path_1.default.join(paths.downloads, id);
721
+ const metadataFile = node_path_1.default.join(paths.metadata, `${id}.json`);
722
+ let meta;
723
+ if (node_fs_1.default.existsSync(metadataFile)) {
724
+ try {
725
+ meta = JSON.parse(node_fs_1.default.readFileSync(metadataFile, 'utf8'));
726
+ }
727
+ catch {
728
+ meta = undefined;
729
+ }
730
+ }
731
+ items.push({
732
+ id,
733
+ path: p,
734
+ metadata: node_fs_1.default.existsSync(metadataFile) ? metadataFile : undefined,
735
+ meta
736
+ });
737
+ }
738
+ items.sort((a, b) => a.id.localeCompare(b.id));
739
+ return { root: paths.downloads, items };
740
+ }
@@ -6,7 +6,7 @@ const child_process_1 = require("child_process");
6
6
  function run(cmd, args = [], options = {}) {
7
7
  return new Promise((resolve, reject) => {
8
8
  const isFlatpak = Boolean(process.env.FLATPAK_ID);
9
- const hostCommands = new Set(['swww', 'swww-daemon', 'hyprctl', 'systemctl', 'which', 'xdg-open']);
9
+ const hostCommands = new Set(['swww', 'swww-daemon', 'hyprctl', 'systemctl', 'which', 'xdg-open', 'steamcmd']);
10
10
  const useHost = isFlatpak && hostCommands.has(cmd);
11
11
  const finalCmd = useHost ? 'flatpak-spawn' : cmd;
12
12
  const finalArgs = useHost ? ['--host', cmd, ...args] : args;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kitowall",
3
- "version": "1.0.7",
3
+ "version": "2.0.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",