codex-ringside 0.0.0-stage → 0.1.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.
@@ -0,0 +1,261 @@
1
+ 'use strict';
2
+ const crypto = require('node:crypto');
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const { execFile } = require('node:child_process');
6
+ const { promisify } = require('node:util');
7
+
8
+ const run = promisify(execFile);
9
+
10
+ const SIPS = '/usr/bin/sips';
11
+ const MAX_EDGE = 3840;
12
+ const WALLPAPER_MS = 60000;
13
+ const MIN_CROP = 0.05;
14
+
15
+ const SOURCES = ['default', 'image', 'wallpaper', 'glass'];
16
+ const LAYOUTS = ['cover', 'contain', 'stretch', 'center', 'tile'];
17
+ const SCREEN_SOURCES = new Set(['wallpaper', 'glass']);
18
+ const PICTURE_SOURCES = new Set(['image', 'wallpaper']);
19
+ const INVISIBLE_TRANSPARENCY = 95;
20
+ // [min, max, default]
21
+ const NUMBERS = {
22
+ transparency: [0, 100, 45],
23
+ blur: [0, 60, 0],
24
+ zoom: [10, 400, 100],
25
+ x: [0, 100, 50],
26
+ y: [0, 100, 30],
27
+ brightness: [0, 200, 100],
28
+ contrast: [0, 200, 100],
29
+ saturation: [0, 200, 100]
30
+ };
31
+ // Everything that only makes sense for one picture, and so starts over with a new one.
32
+ // y sits above centre: a tall photo in a wide window keeps its subject, usually in the upper part.
33
+ const IMAGE_EDITS = { layout: 'cover', zoom: 100, x: 50, y: 30, crop: null, brightness: 100, contrast: 100, saturation: 100 };
34
+ const CONFIG_KEYS = ['source', 'image', 'transparency', 'blur', ...Object.keys(IMAGE_EDITS)];
35
+
36
+ const text = value => (typeof value === 'string' && value ? value : null);
37
+
38
+ function normalizeCrop(raw) {
39
+ const values = [raw?.x, raw?.y, raw?.w, raw?.h];
40
+ if (!values.every(Number.isFinite)) return null;
41
+ const x = Math.min(1 - MIN_CROP, Math.max(0, raw.x));
42
+ const y = Math.min(1 - MIN_CROP, Math.max(0, raw.y));
43
+ const w = Math.min(1 - x, raw.w);
44
+ const h = Math.min(1 - y, raw.h);
45
+ if (w < MIN_CROP || h < MIN_CROP || (w === 1 && h === 1)) return null;
46
+ const round = value => Math.round(value * 10000) / 10000;
47
+ return { x: round(x), y: round(y), w: round(w), h: round(h) };
48
+ }
49
+
50
+ function normalizeBackground(raw) {
51
+ const image = text(raw?.image);
52
+ const known = SOURCES.includes(raw?.source) && (raw.source !== 'image' || image);
53
+ const numbers = Object.fromEntries(Object.entries(NUMBERS).map(([key, [min, max, fallback]]) => {
54
+ const value = raw?.[key];
55
+ return [key, Number.isFinite(value) ? Math.round(Math.min(max, Math.max(min, value))) : fallback];
56
+ }));
57
+ return {
58
+ source: known ? raw.source : 'default',
59
+ image,
60
+ ...numbers,
61
+ layout: LAYOUTS.includes(raw?.layout) ? raw.layout : 'cover',
62
+ crop: normalizeCrop(raw?.crop)
63
+ };
64
+ }
65
+
66
+ function parseNumber(flag, value) {
67
+ const number = Number(value);
68
+ if (value === '' || !Number.isFinite(number)) throw new Error(`${flag} 需要一个数字`);
69
+ return number;
70
+ }
71
+
72
+ function parseCrop(value) {
73
+ if (value === 'none') return null;
74
+ const parts = value.split(',').map(Number);
75
+ if (parts.length !== 4 || !parts.every(Number.isFinite)) throw new Error('--crop 的格式是 左,上,宽,高(百分比)或 none');
76
+ const [x, y, w, h] = parts.map(part => part / 100);
77
+ return { x, y, w, h };
78
+ }
79
+
80
+ // Only what was asked for: anything left out keeps its saved value.
81
+ function parseBackgroundArgs(args) {
82
+ const changes = {};
83
+ const positional = [];
84
+ for (let i = 0; i < args.length; i++) {
85
+ const flag = args[i];
86
+ if (!flag.startsWith('--')) {
87
+ positional.push(flag);
88
+ continue;
89
+ }
90
+ const value = args[++i];
91
+ const name = flag.slice(2);
92
+ if (value === undefined) throw new Error(`${flag} 缺少参数`);
93
+ if (Object.hasOwn(NUMBERS, name)) changes[name] = parseNumber(flag, value);
94
+ else if (name === 'crop') changes.crop = parseCrop(value);
95
+ else if (name === 'layout') {
96
+ if (!LAYOUTS.includes(value)) throw new Error(`未知布局:${value},可选 ${LAYOUTS.join(' / ')}`);
97
+ changes.layout = value;
98
+ } else throw new Error(`未知参数:${flag}`);
99
+ }
100
+ const [source, image, ...extra] = positional;
101
+ if (source !== undefined && !SOURCES.includes(source)) throw new Error(`未知来源:${source},可选 ${SOURCES.join(' / ')}`);
102
+ const unexpected = [...(source === 'image' || image === undefined ? [] : [image]), ...extra];
103
+ if (unexpected.length) throw new Error(`多余的参数:${unexpected.join(' ')}`);
104
+ if (source) changes.source = source;
105
+ if (source === 'image' && image) changes.image = path.resolve(image);
106
+ return changes;
107
+ }
108
+
109
+ async function imageSize(source) {
110
+ const { stdout } = await run(SIPS, ['-g', 'pixelWidth', '-g', 'pixelHeight', source], { timeout: 15000 });
111
+ return [...stdout.matchAll(/pixel(?:Width|Height):\s*(\d+)/g)].map(match => Number(match[1]));
112
+ }
113
+
114
+ // Output is named after the source's identity, so an unchanged file is never converted twice.
115
+ async function prepareImage(source, dir) {
116
+ let output;
117
+ let temp;
118
+ try {
119
+ const { mtimeMs, size } = fs.statSync(source);
120
+ const name = crypto.createHash('sha256').update(`${source}\0${mtimeMs}\0${size}`).digest('hex').slice(0, 16);
121
+ output = path.join(dir, `${name}.jpg`);
122
+ if (fs.existsSync(output)) return output;
123
+ temp = `${output}.tmp`;
124
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
125
+ // sips -Z also enlarges, and a small picture should stay small.
126
+ const resize = Math.max(...await imageSize(source)) > MAX_EDGE ? ['-Z', String(MAX_EDGE)] : [];
127
+ await run(SIPS, ['-s', 'format', 'jpeg', '-s', 'formatOptions', '85', ...resize, source, '--out', temp], { timeout: 30000 });
128
+ fs.renameSync(temp, output);
129
+ return output;
130
+ } catch {
131
+ throw new Error(`无法读取图片:${source}`);
132
+ } finally {
133
+ if (temp) fs.rmSync(temp, { force: true });
134
+ }
135
+ }
136
+
137
+ async function readScreens(root, app) {
138
+ const { stdout } = await run(path.join(root, 'bridge'), ['wallpaper', app], { timeout: 10000, maxBuffer: 65536 });
139
+ return JSON.parse(stdout).screens;
140
+ }
141
+
142
+ class BackgroundStore {
143
+ constructor({ root, readScreens: screens = async () => [], prepare = prepareImage, now = Date.now }) {
144
+ this.file = path.join(root, 'background.json');
145
+ this.dir = path.join(root, 'background');
146
+ this.readScreens = screens;
147
+ this.prepare = prepare;
148
+ this.now = now;
149
+ this.seen = this.stamp();
150
+ this.config = this.read();
151
+ this.current = null;
152
+ this.nextScreens = 0;
153
+ }
154
+
155
+ stamp() {
156
+ try {
157
+ return fs.statSync(this.file).mtimeMs;
158
+ } catch {
159
+ return null;
160
+ }
161
+ }
162
+
163
+ read() {
164
+ try {
165
+ return normalizeBackground(JSON.parse(fs.readFileSync(this.file, 'utf8')));
166
+ } catch {
167
+ return normalizeBackground(null);
168
+ }
169
+ }
170
+
171
+ // Prepares before saving, so a bad file never replaces a working background.
172
+ async commit(changes) {
173
+ const allowed = Object.fromEntries(Object.entries(changes ?? {}).filter(([key]) => CONFIG_KEYS.includes(key)));
174
+ const newImage = allowed.image && allowed.image !== this.config.image;
175
+ const config = normalizeBackground({ ...this.config, ...(newImage ? IMAGE_EDITS : {}), ...allowed });
176
+ if (allowed.source === 'image' && !config.image) throw new Error('请先选择图片');
177
+ // A picture someone just chose has to show up, whatever the slider was left at.
178
+ const newSource = PICTURE_SOURCES.has(allowed.source) && allowed.source !== this.config.source;
179
+ if ((newImage || newSource) && allowed.transparency === undefined && config.transparency >= INVISIBLE_TRANSPARENCY) {
180
+ config.transparency = NUMBERS.transparency[2];
181
+ }
182
+ if (newImage) await this.prepare(config.image, this.dir);
183
+ const temp = `${this.file}.tmp`;
184
+ fs.writeFileSync(temp, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
185
+ fs.renameSync(temp, this.file);
186
+ this.config = config;
187
+ this.seen = this.stamp();
188
+ return config;
189
+ }
190
+
191
+ async build() {
192
+ const { config } = this;
193
+ const files = {};
194
+ const use = async source => {
195
+ if (!source) return null;
196
+ const file = await this.prepare(source, this.dir);
197
+ const id = path.basename(file, '.jpg');
198
+ files[id] = file;
199
+ return id;
200
+ };
201
+ let image = null;
202
+ const screens = [];
203
+ let error = null;
204
+ try {
205
+ if (config.source === 'image') {
206
+ image = await use(config.image);
207
+ } else if (SCREEN_SOURCES.has(config.source)) {
208
+ let found = [];
209
+ try {
210
+ found = await this.readScreens();
211
+ } catch {
212
+ // Reported below, the same as a screen with no wallpaper.
213
+ }
214
+ // One at a time: two screens usually share a wallpaper, and the second reuses the first's file.
215
+ for (const screen of found) screens.push({ ...screen, image: await use(screen.image) });
216
+ if (!screens.some(screen => screen.image)) error = '读不到桌面壁纸,可以改用自己的图片';
217
+ }
218
+ } catch (failure) {
219
+ error = failure.message;
220
+ }
221
+ this.prune(files);
222
+ const key = crypto.createHash('sha256').update(JSON.stringify({ config, image, screens, error })).digest('hex').slice(0, 16);
223
+ return { payload: { key, config, image, screens, error }, files };
224
+ }
225
+
226
+ prune(files) {
227
+ let names;
228
+ try {
229
+ names = fs.readdirSync(this.dir);
230
+ } catch {
231
+ return;
232
+ }
233
+ const keep = new Set(Object.keys(files).map(id => `${id}.jpg`));
234
+ for (const name of names) {
235
+ if (!keep.has(name)) fs.rmSync(path.join(this.dir, name), { force: true });
236
+ }
237
+ }
238
+
239
+ // Resolves to the new background, or null when nothing the renderer sees has changed.
240
+ async rebuild() {
241
+ this.nextScreens = this.now() + WALLPAPER_MS;
242
+ const next = await this.build();
243
+ if (next.payload.key === this.current?.payload.key) return null;
244
+ this.current = next;
245
+ return next;
246
+ }
247
+
248
+ async refresh() {
249
+ const stamp = this.stamp();
250
+ const changed = stamp !== this.seen;
251
+ const due = SCREEN_SOURCES.has(this.config.source) && this.now() >= this.nextScreens;
252
+ if (!changed && !due && this.current) return null;
253
+ if (changed) {
254
+ this.seen = stamp;
255
+ this.config = this.read();
256
+ }
257
+ return this.rebuild();
258
+ }
259
+ }
260
+
261
+ module.exports = { BackgroundStore, IMAGE_EDITS, normalizeBackground, parseBackgroundArgs, prepareImage, readScreens };