yapin.js-client 0.0.0-stage → 0.1.4

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 (86) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +96 -2
  3. package/bin/yapin-client.js +317 -0
  4. package/docs/README.md +36 -0
  5. package/docs/about-page.md +85 -0
  6. package/docs/api.md +106 -0
  7. package/docs/architecture.md +98 -0
  8. package/docs/badges.md +143 -0
  9. package/docs/cli.md +108 -0
  10. package/docs/configuration.md +217 -0
  11. package/docs/getting-started.md +126 -0
  12. package/docs/host-adapter.md +72 -0
  13. package/docs/installer.md +236 -0
  14. package/docs/pages.md +82 -0
  15. package/docs/patching.md +73 -0
  16. package/docs/plugins.md +204 -0
  17. package/docs/runtime.md +117 -0
  18. package/docs/security.md +68 -0
  19. package/docs/settings.md +83 -0
  20. package/docs/themes.md +147 -0
  21. package/docs/troubleshooting.md +71 -0
  22. package/docs/ui.md +110 -0
  23. package/docs/web.md +39 -0
  24. package/docs/welcome.md +63 -0
  25. package/package.json +56 -4
  26. package/src/build/build.js +343 -0
  27. package/src/build/git.js +66 -0
  28. package/src/build/nsis.js +763 -0
  29. package/src/build/pack.js +613 -0
  30. package/src/build/windows.js +176 -0
  31. package/src/build/zip.js +93 -0
  32. package/src/config/defaults.js +146 -0
  33. package/src/config/load.js +217 -0
  34. package/src/desktop/preload.cjs +71 -0
  35. package/src/desktop/shim.cjs +494 -0
  36. package/src/index.js +18 -0
  37. package/src/installer/asar.js +103 -0
  38. package/src/installer/cli.js +106 -0
  39. package/src/installer/fuses.js +48 -0
  40. package/src/installer/main.js +98 -0
  41. package/src/installer/nsis.js +75 -0
  42. package/src/installer/operations.js +119 -0
  43. package/src/installer/page.js +388 -0
  44. package/src/installer/patch.js +335 -0
  45. package/src/installer/paths.js +62 -0
  46. package/src/installer/processes.js +105 -0
  47. package/src/installer/server.js +132 -0
  48. package/src/installer/windows/launcher.cs +280 -0
  49. package/src/installer/windows/launcher.exe +0 -0
  50. package/src/runtime/about.js +344 -0
  51. package/src/runtime/api.js +281 -0
  52. package/src/runtime/badges.js +337 -0
  53. package/src/runtime/boot.js +46 -0
  54. package/src/runtime/builtin-pages.js +242 -0
  55. package/src/runtime/client.js +216 -0
  56. package/src/runtime/dom.js +209 -0
  57. package/src/runtime/errors.js +46 -0
  58. package/src/runtime/events.js +77 -0
  59. package/src/runtime/host.js +101 -0
  60. package/src/runtime/index.js +35 -0
  61. package/src/runtime/logger.js +57 -0
  62. package/src/runtime/meta.js +117 -0
  63. package/src/runtime/modules.js +235 -0
  64. package/src/runtime/onboarding.js +196 -0
  65. package/src/runtime/pages.js +336 -0
  66. package/src/runtime/patcher.js +136 -0
  67. package/src/runtime/plugins.js +509 -0
  68. package/src/runtime/react.js +82 -0
  69. package/src/runtime/settings.js +242 -0
  70. package/src/runtime/storage.js +188 -0
  71. package/src/runtime/themes.js +296 -0
  72. package/src/runtime/ui.js +626 -0
  73. package/src/runtime/user-content.js +149 -0
  74. package/src/runtime/util.js +142 -0
  75. package/src/shared/constants.js +23 -0
  76. package/src/shared/icons.js +52 -0
  77. package/templates/init/README.md +17 -0
  78. package/templates/init/assets/.gitkeep +0 -0
  79. package/templates/init/gitignore +2 -0
  80. package/templates/init/installer/marketing.json +10 -0
  81. package/templates/init/plugins/.gitkeep +0 -0
  82. package/templates/init/src/index.js +10 -0
  83. package/templates/init/themes/.gitkeep +0 -0
  84. package/templates/init/yapin-client.config.js +29 -0
  85. package/types/index.d.ts +285 -0
  86. package/types/runtime.d.ts +692 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 yapin.js
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,97 @@
1
- # Temporary Holding Version
1
+ # yapin.js-client
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A framework for building client apps for [Yapin’](https://www.yapin.gg): modified versions of the Yapin’ desktop app (and the web app, as a userscript) with your own pages, themes, plugins and badges.
4
+
5
+ It's a framework, not a finished client. It provides the plumbing, so your client app only has to describe what makes it different:
6
+
7
+ - **Plugins.** A full lifecycle with typed settings, dependencies, styles, DOM observers, patches and pages. Everything a plugin registers is undone automatically when it stops, and a plugin that keeps crashing is switched off on its own. Your users can install their own plugins, if you allow it.
8
+ - **Themes.** CSS files with a metadata header. `@var` declarations become colour pickers and sliders in settings. There are also base styles, custom CSS, and themes added from code.
9
+ - **Settings pages.** Add a page to Yapin’s own settings menu with one call. It looks native and leaves Yapin’s React tree untouched.
10
+ - **An About page.** Version, build, git commit and environment, out of the box. Every row can be edited, hidden or removed, and so can the whole page.
11
+ - **Badges.** Next to names on profile cards and messages, from a JSON file, your API, or code. Every badge is sanitised.
12
+ - **API access.** Named services with retries, caching and secrets stored in the operating system's key store. On desktop, requests go through the main process, checked against an allowlist that's fixed at build time.
13
+ - **A branded installer.** A setup wizard on Windows, built with NSIS: one signable `setup.exe` with your logo, icon, name and version, a zip for macOS and Linux. Configured from a JSON file with three presets. It runs on the Node built into the desktop app, so the people installing need nothing else. It handles install, update, repair after desktop updates, and uninstall.
14
+ - **Zero dependencies.** It includes its own bundler, asar reader and writer, zip writer and test suite.
15
+
16
+ > Client apps modify Yapin’. Check Yapin’s terms before you publish one, and make clear that yours is unofficial (the installer says so by default).
17
+
18
+ ## Quick start
19
+
20
+ ```sh
21
+ npx yapin.js-client init my-client
22
+ cd my-client
23
+ npm install
24
+ npm run dev
25
+ ```
26
+
27
+ `npm run dev` builds your client app, installs it into the Yapin’ desktop app on your computer, and rebuilds and reloads every time you save. Open Yapin’, go to **Settings**, and your pages are at the bottom of the menu.
28
+
29
+ `npm run build` produces everything you need to publish:
30
+
31
+ | File | What it is |
32
+ | --- | --- |
33
+ | `dist/<id>-<version>-setup.exe` | The Windows installer: one file to share. |
34
+ | `dist/<id>-<version>-installer.zip` | The installer for macOS and Linux. |
35
+ | `dist/<id>.user.js` | The web version, for Tampermonkey or Violentmonkey. |
36
+ | `dist/payload/` | What gets installed. |
37
+
38
+ ## What a client app looks like
39
+
40
+ ```text
41
+ my-client/
42
+ yapin-client.config.js name, options, installer
43
+ src/index.js your code (runs in the page)
44
+ plugins/ plugins built into your client app
45
+ themes/ themes built into your client app
46
+ installer/marketing.json what the installer page says
47
+ ```
48
+
49
+ ```js
50
+ // src/index.js
51
+ export default function setup(client) {
52
+ client.pages.add({
53
+ id: 'changelog',
54
+ title: 'Changelog',
55
+ render(root, { ui }) {
56
+ root.append(ui.heading('Changelog'), ui.hint('What changed in this version.'));
57
+ },
58
+ });
59
+
60
+ client.about.set('support.discord', { label: 'Community', value: 'https://example.com/chat', href: 'https://example.com/chat' });
61
+
62
+ client.badges.addProvider({ id: 'supporters', url: 'https://api.example.com/badges.json', refresh: '30m' });
63
+ }
64
+ ```
65
+
66
+ ```js
67
+ // plugins/compact.js
68
+ import { definePlugin } from 'yapin.js-client/runtime';
69
+
70
+ export default definePlugin({
71
+ id: 'compact',
72
+ name: 'Compact messages',
73
+ description: 'Less space between messages.',
74
+ settings: { gap: { type: 'slider', label: 'Gap', min: 0, max: 16, default: 4, unit: 'px' } },
75
+ styles: ({ gap }) => `.msg { margin-top: ${gap}px; }`,
76
+ });
77
+ ```
78
+
79
+ ## Documentation
80
+
81
+ Start with [Getting started](docs/getting-started.md), then [How it works](docs/architecture.md). Everything else is listed in [docs/](docs/README.md).
82
+
83
+ ## Requirements
84
+
85
+ - Node 18.17 or newer to build. People installing your client app don't need Node.
86
+ - The Yapin’ desktop app (Windows, macOS or Linux), or a userscript manager for the web version.
87
+
88
+ ## Testing
89
+
90
+ ```sh
91
+ npm test # offline: bundler, installer, runtime, build
92
+ npm run test:browser # needs Chrome or Edge: settings pages, badges, forms in a real browser
93
+ ```
94
+
95
+ ## License
96
+
97
+ MIT
@@ -0,0 +1,317 @@
1
+ #!/usr/bin/env node
2
+ import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, watch, writeFileSync } from 'node:fs';
3
+ import { basename, dirname, join, relative, resolve } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { parseArgs } from 'node:util';
6
+ import { loadConfig } from '../src/config/load.js';
7
+ import { build } from '../src/build/build.js';
8
+ import { pack } from '../src/build/pack.js';
9
+ import { findInstalls, install, inspect, repair, uninstall } from '../src/installer/patch.js';
10
+ import { closeApp, isRunning, launchApp } from '../src/installer/processes.js';
11
+ import { defaultTarget } from '../src/installer/paths.js';
12
+ import { parseMeta } from '../src/runtime/meta.js';
13
+ import { normalizeSchema } from '../src/runtime/settings.js';
14
+ import { style } from '../src/installer/cli.js';
15
+ import { deepMerge } from '../src/runtime/util.js';
16
+ import { apiVersion, version } from '../src/shared/constants.js';
17
+
18
+ const HERE = dirname(fileURLToPath(import.meta.url));
19
+
20
+ const HELP = `yapin-client ${version}: build Yapin’ client apps
21
+
22
+ Usage: yapin-client <command> [options]
23
+
24
+ Commands:
25
+ init [dir] Start a new client app (config, entry, folders)
26
+ build Build the payload, installer, zip and userscript into dist/
27
+ dev Build in development mode, install into the desktop app, rebuild on save
28
+ install Install the built client app into the desktop app on this computer
29
+ uninstall [--purge] Remove it again (--purge deletes its data too)
30
+ repair Put it back after the desktop app updated
31
+ status Show desktop installs and what's installed in them
32
+ pack <file> [-o out] Bundle a plugin with its imports into one file
33
+ validate <file> Check a plugin (.js) or theme (.css) file
34
+ doctor Check the project and this computer for problems
35
+
36
+ Options:
37
+ -c, --config <file> Config file (default: yapin-client.config.js|.mjs|.json)
38
+ --mode <mode> production (default) or development
39
+ --path <dir> Desktop app folder, if it isn't found on its own
40
+ --close Close the desktop app without asking when it's running
41
+ --no-installer build: skip the installer and zip
42
+ -h, --help Show this
43
+ -v, --version Show the version`;
44
+
45
+ const ok = (text) => console.log(style.green(`✔ ${text}`));
46
+ const warn = (text) => console.log(style.yellow(`! ${text}`));
47
+ const info = (text) => console.log(style.dim(` ${text}`));
48
+
49
+ function fail(error) {
50
+ console.error(style.red(`✖ ${error?.message ?? error}`));
51
+ if (error?.hint) console.error(` ${error.hint}`);
52
+ if (process.env.DEBUG && error?.stack) console.error(style.dim(error.stack));
53
+ process.exitCode = 1;
54
+ }
55
+
56
+ async function load(values, mode) {
57
+ if (mode) process.env.YAPIN_CLIENT_MODE = mode;
58
+ const loaded = await loadConfig({ file: values.config });
59
+ for (const warning of loaded.warnings) warn(warning);
60
+ return loaded;
61
+ }
62
+
63
+ function target(config) {
64
+ return deepMerge(structuredClone(defaultTarget), config.installer.target ?? {});
65
+ }
66
+
67
+ function pickInstall(config, values) {
68
+ const list = findInstalls({ target: target(config), extra: values.path ?? [] });
69
+ if (!list.length) throw Object.assign(new Error("Couldn't find the desktop app"), { hint: 'Install it from https://www.yapin.gg, or pass --path <folder>.' });
70
+ const found = list[0];
71
+ if (!found.fuseCheck.ok) throw new Error(found.fuseCheck.reason);
72
+ return found;
73
+ }
74
+
75
+ async function ensureClosed(config, values) {
76
+ const t = target(config);
77
+ const name = t.processName?.[process.platform];
78
+ if (!name || !(await isRunning(name))) return false;
79
+ if (!values.close) throw Object.assign(new Error(`${t.productName} is running`), { hint: 'Close it, or pass --close to let yapin-client close it.' });
80
+ info(`Closing ${t.productName}…`);
81
+ if (!(await closeApp(name))) throw new Error(`Couldn't close ${t.productName}`);
82
+ return true;
83
+ }
84
+
85
+ async function runBuild(values, mode = values.mode ?? 'production') {
86
+ const { config, root } = await load(values, mode);
87
+ console.log(`${style.bold(style.accent(config.name))} ${style.dim(config.version)} ${style.dim(`(${mode})`)}`);
88
+ const result = await build(config, { root, mode, installer: !values['no-installer'], onStep: info });
89
+ for (const warning of result.warnings) warn(warning);
90
+ ok(`Built in ${result.ms} ms: ${result.plugins.length} bundled plugin(s), ${result.themes.length} bundled theme(s), ${Math.round(readFileSync(join(result.payloadDir, 'runtime.js')).length / 1024)} kB runtime`);
91
+ info(`payload ${relative(process.cwd(), result.payloadDir)}`);
92
+ if (result.installerDir) info(`installer ${relative(process.cwd(), result.installerDir)}`);
93
+ if (result.exeFile) info(`windows ${relative(process.cwd(), result.exeFile)}`);
94
+ if (result.zipFile) info(`zip ${relative(process.cwd(), result.zipFile)}`);
95
+ if (result.userscriptFile) info(`userscript ${relative(process.cwd(), result.userscriptFile)}`);
96
+ return { config, root, result };
97
+ }
98
+
99
+ const commands = {
100
+ async init(values, [dir = '.']) {
101
+ const root = resolve(dir);
102
+ mkdirSync(root, { recursive: true });
103
+ const existing = readdirSync(root).filter((f) => !f.startsWith('.'));
104
+ if (existing.some((f) => f.startsWith('yapin-client.config'))) throw new Error(`${root} already has a yapin-client config`);
105
+ const id = basename(root).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'my-client';
106
+ const name = id.replace(/(^|-)([a-z])/g, (_, dash, c) => `${dash ? ' ' : ''}${c.toUpperCase()}`);
107
+ const templates = join(HERE, '..', 'templates', 'init');
108
+ cpSync(templates, root, { recursive: true, force: false, errorOnExist: false, filter: (src) => basename(src) !== 'gitignore' });
109
+ if (!existsSync(join(root, '.gitignore'))) writeFileSync(join(root, '.gitignore'), readFileSync(join(templates, 'gitignore')));
110
+ for (const file of ['yapin-client.config.js', 'installer/marketing.json', 'README.md', 'src/index.js']) {
111
+ const path = join(root, file);
112
+ if (existsSync(path)) writeFileSync(path, readFileSync(path, 'utf8').replaceAll('__ID__', id).replaceAll('__NAME__', name));
113
+ }
114
+ if (!existsSync(join(root, 'package.json'))) {
115
+ writeFileSync(
116
+ join(root, 'package.json'),
117
+ `${JSON.stringify({ name: id, version: '0.1.0', private: true, type: 'module', scripts: { build: 'yapin-client build', dev: 'yapin-client dev', status: 'yapin-client status' }, devDependencies: { 'yapin.js-client': `^${version}` } }, null, 2)}\n`,
118
+ );
119
+ }
120
+ ok(`Created ${name} in ${root}`);
121
+ info('Next: npm install, then npm run build (or npm run dev to try it in the desktop app)');
122
+ },
123
+
124
+ async build(values) {
125
+ await runBuild(values);
126
+ },
127
+
128
+ async dev(values) {
129
+ let { config, root, result } = await runBuild(values, 'development');
130
+ const found = pickInstall(config, values);
131
+ const wasRunning = await ensureClosed(config, values);
132
+ install({ resources: found.resources, payload: result.payloadDir, target: target(config), onStep: info });
133
+ ok(`Installed into ${found.location} (development build: the window reloads when you save)`);
134
+ if (wasRunning && found.executable) launchApp(found.executable);
135
+ const ignore = [config.outDir, join(root, 'node_modules'), join(root, '.git')];
136
+ let timer = null;
137
+ let building = false;
138
+ watch(root, { recursive: true }, (_event, file) => {
139
+ if (!file) return;
140
+ const full = join(root, file);
141
+ if (ignore.some((dir) => full.startsWith(dir))) return;
142
+ clearTimeout(timer);
143
+ timer = setTimeout(async () => {
144
+ if (building) return;
145
+ building = true;
146
+ try {
147
+ ({ config, root, result } = await runBuild({ ...values, 'no-installer': true }, 'development'));
148
+ install({ resources: found.resources, payload: result.payloadDir, target: target(config) });
149
+ ok('Reinstalled; the desktop app reloads');
150
+ } catch (error) {
151
+ fail(error);
152
+ process.exitCode = 0;
153
+ } finally {
154
+ building = false;
155
+ }
156
+ }, 200);
157
+ });
158
+ info('Watching for changes. Press Ctrl+C to stop.');
159
+ },
160
+
161
+ async install(values) {
162
+ const { config } = await load(values);
163
+ const payload = join(config.outDir, 'payload');
164
+ if (!existsSync(join(payload, 'manifest.json'))) throw Object.assign(new Error('Nothing built yet'), { hint: 'Run yapin-client build first.' });
165
+ const found = pickInstall(config, values);
166
+ const wasRunning = await ensureClosed(config, values);
167
+ const result = install({ resources: found.resources, payload, target: target(config), onStep: info });
168
+ ok(`${config.name} ${config.version} installed into ${found.location}`);
169
+ info(`data: ${result.dataDir}`);
170
+ if (wasRunning && found.executable) launchApp(found.executable);
171
+ },
172
+
173
+ async uninstall(values) {
174
+ const { config } = await load(values);
175
+ const found = pickInstall(config, values);
176
+ const wasRunning = await ensureClosed(config, values);
177
+ const result = uninstall({ resources: found.resources, target: target(config), id: config.id, purge: Boolean(values.purge), onStep: info });
178
+ if (result.removed) ok(`Removed${result.purged ? ' (data deleted too)' : ''}`);
179
+ else info(result.reason);
180
+ if (wasRunning && found.executable) launchApp(found.executable);
181
+ },
182
+
183
+ async repair(values) {
184
+ const { config } = await load(values);
185
+ const found = pickInstall(config, values);
186
+ const wasRunning = await ensureClosed(config, values);
187
+ const result = repair({ resources: found.resources, target: target(config), id: config.id, onStep: info });
188
+ if (result.repaired === false) info(result.reason);
189
+ else ok('Repaired');
190
+ if (wasRunning && found.executable) launchApp(found.executable);
191
+ },
192
+
193
+ async status(values) {
194
+ let t = defaultTarget;
195
+ try {
196
+ t = target((await load(values)).config);
197
+ } catch {
198
+ // no config here; show installs anyway
199
+ }
200
+ const list = findInstalls({ target: t, extra: values.path ?? [] });
201
+ if (!list.length) return warn("Couldn't find the desktop app");
202
+ for (const found of list) {
203
+ const s = found.status;
204
+ const what = s.state === 'installed' ? style.green(`${s.marker.name} ${s.marker.version}`) : s.state === 'clean' ? style.dim('nothing installed') : style.yellow(`${s.state}${s.reason ? `: ${s.reason}` : ''}`);
205
+ console.log(`${found.location} ${style.dim(s.version ?? '')} ${what}`);
206
+ if (!found.fuseCheck.ok) warn(found.fuseCheck.reason);
207
+ }
208
+ const name = t.processName?.[process.platform];
209
+ if (name) info(`${t.productName} is ${(await isRunning(name)) ? 'running' : 'not running'}`);
210
+ },
211
+
212
+ async pack(values, [file]) {
213
+ if (!file) throw new Error('Usage: yapin-client pack <file> [-o out.js]');
214
+ const entry = resolve(file);
215
+ const out = resolve(values.out ?? join(dirname(entry), `${basename(entry).replace(/\.(m?js)$/, '')}.bundle.js`));
216
+ const header = readFileSync(entry, 'utf8').match(/^\s*\/\*\*?[\s\S]*?\*\//)?.[0] ?? '';
217
+ const result = pack({ entry, format: 'function', root: dirname(entry), alias: { 'yapin.js-client/runtime': join(HERE, '..', 'src', 'runtime', 'index.js') } });
218
+ writeFileSync(out, `${header}\n// Bundled by yapin-client pack. Edit the source, not this file.\nconst __bundle = ${result.code}();\nexport default __bundle.default;\n`);
219
+ ok(`${relative(process.cwd(), out)} (${result.files.length} files, ${Math.round(result.code.length / 1024)} kB)`);
220
+ },
221
+
222
+ async validate(_values, [file]) {
223
+ if (!file) throw new Error('Usage: yapin-client validate <plugin.js | theme.css>');
224
+ const text = readFileSync(resolve(file), 'utf8');
225
+ const { meta, vars } = parseMeta(text);
226
+ const problems = [];
227
+ if (!meta.id) problems.push('no @id in the header (the file name will be used)');
228
+ else if (!/^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/.test(meta.id)) problems.push(`@id "${meta.id}" must be lowercase letters, digits and dashes`);
229
+ if (!meta.name) problems.push('no @name');
230
+ if (!meta.version) problems.push('no @version');
231
+ if (!meta.authors?.length) problems.push('no @author');
232
+ if (meta.apiVersion && meta.apiVersion > apiVersion) problems.push(`@apiVersion ${meta.apiVersion} is newer than this framework's (${apiVersion})`);
233
+ if (/\.css$/i.test(file)) {
234
+ try {
235
+ const schema = Object.fromEntries(vars.map((v) => [v.variable.replace(/^--/, ''), { ...v, type: v.type === 'color' || v.type === 'number' || v.type === 'slider' || v.type === 'select' || v.type === 'switch' ? v.type : 'text', options: v.options ?? (v.type === 'select' ? [v.default] : undefined) }]));
236
+ normalizeSchema(schema, file);
237
+ } catch (error) {
238
+ problems.push(error.message);
239
+ }
240
+ let depth = 0;
241
+ for (const c of text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/g, '')) {
242
+ if (c === '{') depth++;
243
+ if (c === '}') depth--;
244
+ if (depth < 0) break;
245
+ }
246
+ if (depth !== 0) problems.push('unbalanced { } braces');
247
+ } else {
248
+ try {
249
+ pack({ entry: resolve(file), root: dirname(resolve(file)), alias: { 'yapin.js-client/runtime': join(HERE, '..', 'src', 'runtime', 'index.js') } });
250
+ } catch (error) {
251
+ problems.push(error.message);
252
+ }
253
+ }
254
+ if (problems.length) {
255
+ for (const p of problems) warn(p);
256
+ process.exitCode = 1;
257
+ } else ok(`${meta.name ?? file} looks good${vars.length ? ` (${vars.length} variables)` : ''}`);
258
+ },
259
+
260
+ async doctor(values) {
261
+ console.log(`yapin-client ${version} on Node ${process.versions.node} (${process.platform} ${process.arch})`);
262
+ let config = null;
263
+ try {
264
+ ({ config } = await load(values));
265
+ ok(`Config is valid: ${config.name} ${config.version}`);
266
+ } catch (error) {
267
+ fail(error);
268
+ }
269
+ const list = findInstalls({ target: config ? target(config) : defaultTarget, extra: values.path ?? [] });
270
+ if (!list.length) warn("Desktop app not found (pass --path if it's somewhere unusual)");
271
+ for (const found of list) {
272
+ ok(`Desktop app at ${found.location} (${found.status.version ?? 'unknown version'}, ${found.status.state})`);
273
+ if (!found.fuseCheck.ok) warn(found.fuseCheck.reason);
274
+ else if (found.fuses) info(`fuses: ${Object.entries(found.fuses.fuses).filter(([, on]) => on).map(([n]) => n).join(', ') || 'none on'}`);
275
+ if (found.status.state === 'installed') info(`installed: ${found.status.marker.name} ${found.status.marker.version}`);
276
+ const s = inspect(found.resources, { target: config ? target(config) : defaultTarget });
277
+ if (s.state === 'needs-repair') warn(`${s.reason}; run yapin-client repair`);
278
+ }
279
+ if (config) {
280
+ for (const origin of config.origins) {
281
+ try {
282
+ const res = await fetch(origin, { method: 'GET', redirect: 'follow' });
283
+ const csp = res.headers.get('content-security-policy');
284
+ if (csp) warn(`${origin} now sends a Content-Security-Policy; user plugins may need desktop.csp: "relax" (see docs/security.md)`);
285
+ else ok(`${origin} reachable, no Content-Security-Policy`);
286
+ } catch (error) {
287
+ warn(`${origin} not reachable: ${error.message}`);
288
+ }
289
+ }
290
+ }
291
+ },
292
+ };
293
+
294
+ async function main() {
295
+ const { values, positionals } = parseArgs({
296
+ allowPositionals: true,
297
+ options: {
298
+ config: { type: 'string', short: 'c' },
299
+ mode: { type: 'string' },
300
+ path: { type: 'string', multiple: true },
301
+ close: { type: 'boolean' },
302
+ purge: { type: 'boolean' },
303
+ out: { type: 'string', short: 'o' },
304
+ 'no-installer': { type: 'boolean' },
305
+ help: { type: 'boolean', short: 'h' },
306
+ version: { type: 'boolean', short: 'v' },
307
+ },
308
+ });
309
+ if (values.version) return console.log(version);
310
+ const [command, ...rest] = positionals;
311
+ if (values.help || !command) return console.log(HELP);
312
+ const run = commands[command];
313
+ if (!run) throw new Error(`Unknown command "${command}". Run yapin-client --help`);
314
+ await run(values, rest);
315
+ }
316
+
317
+ main().catch(fail);
package/docs/README.md ADDED
@@ -0,0 +1,36 @@
1
+ # Documentation
2
+
3
+ ## Building a client app
4
+
5
+ | | |
6
+ | --- | --- |
7
+ | [Getting started](getting-started.md) | From `init` to a working client app in the desktop app |
8
+ | [How it works](architecture.md) | The installer, the stub, the main process, the preload and the runtime |
9
+ | [Configuration](configuration.md) | Every option in `yapin-client.config.js` |
10
+ | [The client object](runtime.md) | Everything your entry file and plugins can use |
11
+ | [Command line](cli.md) | `init`, `build`, `dev`, `install`, `pack`, `doctor` and friends |
12
+
13
+ ## Features
14
+
15
+ | | |
16
+ | --- | --- |
17
+ | [Plugins](plugins.md) | Writing plugins, their lifecycle, user plugins |
18
+ | [Settings](settings.md) | Typed settings for plugins, themes and your client app |
19
+ | [Themes](themes.md) | Theme files, variables, base styles, custom CSS |
20
+ | [Pages](pages.md) | Adding pages to Yapin’s settings menu |
21
+ | [The About page](about-page.md) | Editing, extending and removing it |
22
+ | [Welcome and what's new](welcome.md) | The first-run and update screens: editing, disabling, replaying |
23
+ | [Badges](badges.md) | Badge providers, the JSON format, where badges appear |
24
+ | [API access](api.md) | Services, the allowlist, secrets |
25
+ | [UI kit](ui.md) | Native-looking controls, toasts and dialogs |
26
+ | [Patching Yapin’](patching.md) | Finding Yapin’s modules, patching functions, reading React props |
27
+
28
+ ## Shipping
29
+
30
+ | | |
31
+ | --- | --- |
32
+ | [The installer](installer.md) | Presets, marketing, launchers, update and repair |
33
+ | [The web version](web.md) | The userscript build |
34
+ | [Security](security.md) | What the framework protects, and what it can't |
35
+ | [When Yapin’ changes](host-adapter.md) | Selectors, classes, and updating without a framework release |
36
+ | [Troubleshooting](troubleshooting.md) | Logs, safe mode, common problems |
@@ -0,0 +1,85 @@
1
+ # The About page
2
+
3
+ Every client app gets an About page in settings, filled in by the build:
4
+
5
+ | Section | Field ids | |
6
+ | --- | --- | --- |
7
+ | `app` | `app.name`, `app.version`, `app.description`, `app.authors`, `app.safeMode` | From your config and package.json. `app.safeMode` only shows in safe mode. |
8
+ | `build` (Build) | `build.date`, `build.mode`, `build.framework`, `build.pluginApi` | When and how it was built. |
9
+ | `git` (Source) | `git.branch`, `git.commit`, `git.tag`, `git.date`, `git.dirty`, `git.remote` | Read from git at build time. The commit links to your host (GitHub, GitLab, Bitbucket, Codeberg, Gitea). Credentials in the remote URL are never included. Missing if the project isn't a git repository, or with `git: false`. |
10
+ | `environment` (Environment) | `environment.platform`, `environment.desktop`, `environment.electron`, `environment.chrome`, `environment.node`, `environment.os` | Where it's running. |
11
+ | `links` (Links) | `links.<key>` | One per entry in your config's `links`. |
12
+
13
+ ## How it's laid out
14
+
15
+ - **A header card** for the `app` section: a logo tile, the name with a version tag (click the copy icon to copy it), and the description. Any other `app.*` field shows small underneath.
16
+ - **A row of buttons**: **Copy debug info** (`copy`), **Welcome screen** (`welcome`) and **What's new** (`whatsNew`) when those are on, and on desktop **Data folder** (`folder`) and **Safe mode** (`safeMode`).
17
+ - **A grid of compact cards**, one per other section, with labels and values side by side. Long values are cut short with the full text on hover, and copyable values get a copy icon.
18
+ - **Links** as small chips that open in the browser.
19
+
20
+ Each section's look is its `layout`, so any of them can be changed:
21
+
22
+ | Layout | |
23
+ | --- | --- |
24
+ | `rows` | Label and value side by side. The default for new sections. |
25
+ | `chips` | Compact pills: links show their label, other fields their value. The `links` section uses it. |
26
+ | `header` | The card at the top. Its `name`, `version` and `description` fields become the title, the version tag and the line under it. The `app` section uses it. Only the first `header` section is drawn as the header; give it to another section to change what's at the top. |
27
+
28
+ **Copy debug info** copies every row as text, plus enabled plugins and themes (with versions and sources) and the last 20 warnings and errors. It's what you want in bug reports.
29
+
30
+ ## Changing it from the config
31
+
32
+ ```js
33
+ about: {
34
+ title: 'About Nebula',
35
+ logo: 'sparkles', // the header tile: an icon name, SVG, or image URL (default: the first letter)
36
+ intro: 'Thanks for using Nebula!',
37
+ sections: {
38
+ environment: false, // hide a whole section
39
+ support: { title: 'Support', order: 35 }, // add one (built-in orders: 0, 10, 20, 30, 40)
40
+ links: { layout: 'rows' }, // show links as rows instead of chips
41
+ },
42
+ fields: {
43
+ 'git.remote': false, // hide a row
44
+ 'build.mode': 'Release', // a string replaces the value
45
+ 'app.version': { label: 'Release' }, // change only the label
46
+ 'support.email': { label: 'Email', value: 'help@nebula.example', copy: true },
47
+ 'support.chat': { label: 'Chat', value: 'nebula.example/chat', href: 'https://nebula.example/chat' },
48
+ },
49
+ actions: { safeMode: false }, // remove a button
50
+ },
51
+ ```
52
+
53
+ `about: false` removes the page.
54
+
55
+ ## Changing it from code
56
+
57
+ Your entry file and plugins can change it at any time. If the page is open, it redraws.
58
+
59
+ ```js
60
+ client.about.set('app.version', { value: `${client.version} (beta)` });
61
+ client.about.set('stats.plugins', { label: 'Plugins running', value: () => String(client.plugins.list().filter((p) => p.status === 'running').length) });
62
+ client.about.remove('git.dirty');
63
+ client.about.section('stats', { title: 'Stats', order: 25 });
64
+ client.about.section('community', { title: 'Community', layout: 'chips' });
65
+ client.about.sections(); // [{ id, title, order, layout }], in order
66
+ client.about.removeSection('environment');
67
+ client.about.action('report', { label: 'Report a bug', icon: 'alertCircle', run: (client) => client.openExternal('https://github.com/ada/nebula/issues/new') });
68
+ client.about.removeAction('folder');
69
+ client.about.debugInfo(); // the text Copy debug info copies
70
+ ```
71
+
72
+ A field is `{ label, value, href, copy, code, order, hidden }`:
73
+
74
+ | | |
75
+ | --- | --- |
76
+ | `value` | Text, or a function returning text (called whenever the page draws). |
77
+ | `href` | Makes the value a link. Only https and mailto links are used. |
78
+ | `copy` | Adds a Copy button: `true` copies the value, a string copies that string. |
79
+ | `code` | Monospace. |
80
+ | `order` | Position within its section. |
81
+ | `hidden` | Kept but not shown. |
82
+
83
+ `set` only changes the options you pass. Setting a field in a section that doesn't exist creates the section.
84
+
85
+ To take the page out entirely from code: `client.pages.remove('about')`. To move it: `client.pages.update('about', { order: 50, title: 'Info' })`.
package/docs/api.md ADDED
@@ -0,0 +1,106 @@
1
+ # API access
2
+
3
+ `client.api` talks to HTTP APIs: your backend, a badge server, anything your client app needs. Requests go only to origins you've allowed.
4
+
5
+ ## Services
6
+
7
+ Name your APIs in the config:
8
+
9
+ ```js
10
+ api: {
11
+ services: {
12
+ backend: { baseURL: 'https://api.nebula.example/v1', timeout: 10_000, retries: 2 },
13
+ status: { baseURL: 'https://status.nebula.example', cache: '1m' },
14
+ },
15
+ },
16
+ ```
17
+
18
+ Then:
19
+
20
+ ```js
21
+ const backend = client.api.service('backend');
22
+
23
+ const me = await backend.get('/me');
24
+ const page = await backend.get('/posts', { query: { page: 2, tags: ['a', 'b'] } }); // ?page=2&tags=a&tags=b
25
+ await backend.post('/reports', { json: { reason: 'spam' } });
26
+ await backend.delete(`/posts/${id}`);
27
+ ```
28
+
29
+ Each service option can also be passed per request:
30
+
31
+ | Option | Default | |
32
+ | --- | --- | --- |
33
+ | `baseURL` | required | https (or http for localhost). |
34
+ | `headers` | | Sent with every request. From code, can be a function (also async). |
35
+ | `query` | | Added to every request. |
36
+ | `timeout` | `15000` | ms. |
37
+ | `retries` | 2 for GET, HEAD, PUT, DELETE, OPTIONS; 0 for others | Retried on network errors, 429, 502, 503 and 504, with backoff. `Retry-After` is respected up to 30 s. If you give a POST or PATCH retries, it's retried only on 429, since the server didn't act on it. |
38
+ | `cache` | none | `'30s'`, `'5m'`: reuse GET responses that long. |
39
+ | `auth` | none | `{ secret: 'name' }` sends `Authorization: Bearer <secret>`; `{ secret, type: 'header', header: 'x-api-key' }` sends the secret in that header; `scheme` replaces `Bearer`. |
40
+
41
+ Per request, there's also `json` (a body sent as JSON), `body` (a string), `headers`, and `responseType` (`json`, `text` or `auto`, which goes by the content type).
42
+
43
+ Identical GET requests in flight at the same time share one call.
44
+
45
+ ## Results and errors
46
+
47
+ A request resolves with the parsed body. Anything other than a 2xx rejects with an `ApiError`:
48
+
49
+ ```js
50
+ import { ApiError } from 'yapin.js-client/runtime';
51
+
52
+ try {
53
+ await backend.get('/thing');
54
+ } catch (error) {
55
+ if (error instanceof ApiError && error.status === 404) { /* … */ }
56
+ error.body; // the parsed body, if any; its "message" or "error" becomes the error message
57
+ }
58
+ ```
59
+
60
+ ## The allowlist
61
+
62
+ `client.api` reaches only:
63
+
64
+ - the origins of your `api.services`;
65
+ - the origins of your `badges.sources`;
66
+ - anything in `api.allowedOrigins`.
67
+
68
+ These are fixed when you build. On desktop the main process enforces the list too, and code in the page can't change it there, so even a malicious user plugin can't use your client app to send requests somewhere you didn't choose. Services added from code with `client.api.addService(name, config)` must also be on the list.
69
+
70
+ Without a service:
71
+
72
+ ```js
73
+ await client.api.request({ url: 'https://api.nebula.example/v1/ping' });
74
+ client.api.isAllowed('https://elsewhere.example'); // false
75
+ ```
76
+
77
+ ## Desktop and web
78
+
79
+ On desktop, requests are sent by the main process, so CORS doesn't apply and your API doesn't need CORS headers. Cookies are never sent or stored. Responses are capped at 5 MB (configurable per request up to 10 MB), and redirects are followed only to allowed origins.
80
+
81
+ On the web, requests go straight from the page with `fetch` (`credentials: 'omit'`), so your API must send CORS headers for Yapin’s origins (`https://www.yapper.website` and `https://www.yapin.gg`).
82
+
83
+ Neither ever includes the person's Yapin’ session. The API client is for your services, not for automating Yapin’ accounts (which breaks Yapin’s terms).
84
+
85
+ ## Secrets
86
+
87
+ Tokens and keys for your services:
88
+
89
+ ```js
90
+ await client.secrets.set('backend-token', token);
91
+ await client.secrets.get('backend-token');
92
+ await client.secrets.delete('backend-token');
93
+ client.secrets.secure; // true on desktop
94
+ ```
95
+
96
+ On desktop, secrets are encrypted with the operating system's key store (Electron `safeStorage`: DPAPI on Windows, Keychain on macOS, libsecret on Linux) and kept in the main process; the page only ever sees the one it asks for. On the web there's no such thing: secrets are stored in `localStorage` and `secure` is false. Don't store anything there you couldn't afford to leak.
97
+
98
+ Pair secrets with `auth`, and the token is added to requests without your code handling it:
99
+
100
+ ```js
101
+ api: { services: { backend: { baseURL: 'https://api.nebula.example', auth: { secret: 'backend-token' } } } },
102
+ ```
103
+
104
+ ## Downloading
105
+
106
+ `client.api.download(url, { maxBytes, allowedHosts })` fetches a text file from any https URL, outside the allowlist but size-limited. The framework uses it to install plugins and themes from links. It's limited by `plugins.user.install` and `themes.user.install`, and on desktop the main process only allows it when installing from links is enabled.