yapin.js-client 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.
- package/LICENSE +21 -0
- package/README.md +96 -2
- package/bin/yapin-client.js +317 -0
- package/docs/README.md +35 -0
- package/docs/about-page.md +68 -0
- package/docs/api.md +106 -0
- package/docs/architecture.md +98 -0
- package/docs/badges.md +143 -0
- package/docs/cli.md +108 -0
- package/docs/configuration.md +183 -0
- package/docs/getting-started.md +126 -0
- package/docs/host-adapter.md +72 -0
- package/docs/installer.md +209 -0
- package/docs/pages.md +82 -0
- package/docs/patching.md +73 -0
- package/docs/plugins.md +204 -0
- package/docs/runtime.md +117 -0
- package/docs/security.md +68 -0
- package/docs/settings.md +83 -0
- package/docs/themes.md +147 -0
- package/docs/troubleshooting.md +71 -0
- package/docs/ui.md +93 -0
- package/docs/web.md +39 -0
- package/package.json +56 -4
- package/src/build/build.js +331 -0
- package/src/build/git.js +66 -0
- package/src/build/pack.js +613 -0
- package/src/build/windows.js +176 -0
- package/src/build/zip.js +93 -0
- package/src/config/defaults.js +119 -0
- package/src/config/load.js +209 -0
- package/src/desktop/preload.cjs +69 -0
- package/src/desktop/shim.cjs +445 -0
- package/src/index.js +18 -0
- package/src/installer/asar.js +103 -0
- package/src/installer/cli.js +106 -0
- package/src/installer/fuses.js +48 -0
- package/src/installer/main.js +93 -0
- package/src/installer/operations.js +114 -0
- package/src/installer/page.js +359 -0
- package/src/installer/patch.js +335 -0
- package/src/installer/paths.js +62 -0
- package/src/installer/processes.js +50 -0
- package/src/installer/server.js +132 -0
- package/src/installer/windows/launcher.cs +280 -0
- package/src/installer/windows/launcher.exe +0 -0
- package/src/runtime/about.js +254 -0
- package/src/runtime/api.js +281 -0
- package/src/runtime/badges.js +337 -0
- package/src/runtime/boot.js +46 -0
- package/src/runtime/builtin-pages.js +242 -0
- package/src/runtime/client.js +196 -0
- package/src/runtime/dom.js +209 -0
- package/src/runtime/errors.js +46 -0
- package/src/runtime/events.js +77 -0
- package/src/runtime/host.js +101 -0
- package/src/runtime/index.js +35 -0
- package/src/runtime/logger.js +57 -0
- package/src/runtime/meta.js +117 -0
- package/src/runtime/modules.js +235 -0
- package/src/runtime/pages.js +336 -0
- package/src/runtime/patcher.js +136 -0
- package/src/runtime/plugins.js +509 -0
- package/src/runtime/react.js +82 -0
- package/src/runtime/settings.js +242 -0
- package/src/runtime/storage.js +188 -0
- package/src/runtime/themes.js +296 -0
- package/src/runtime/ui.js +583 -0
- package/src/runtime/user-content.js +149 -0
- package/src/runtime/util.js +142 -0
- package/src/shared/constants.js +23 -0
- package/templates/init/README.md +17 -0
- package/templates/init/assets/.gitkeep +0 -0
- package/templates/init/gitignore +2 -0
- package/templates/init/installer/marketing.json +10 -0
- package/templates/init/plugins/.gitkeep +0 -0
- package/templates/init/src/index.js +10 -0
- package/templates/init/themes/.gitkeep +0 -0
- package/templates/init/yapin-client.config.js +29 -0
- package/types/index.d.ts +248 -0
- package/types/runtime.d.ts +668 -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
|
-
#
|
|
1
|
+
# yapin.js-client
|
|
2
2
|
|
|
3
|
-
|
|
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 single `setup.exe` on Windows (your icon, name and version, signable), 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,35 @@
|
|
|
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
|
+
| [Badges](badges.md) | Badge providers, the JSON format, where badges appear |
|
|
23
|
+
| [API access](api.md) | Services, the allowlist, secrets |
|
|
24
|
+
| [UI kit](ui.md) | Native-looking controls, toasts and dialogs |
|
|
25
|
+
| [Patching Yapin’](patching.md) | Finding Yapin’s modules, patching functions, reading React props |
|
|
26
|
+
|
|
27
|
+
## Shipping
|
|
28
|
+
|
|
29
|
+
| | |
|
|
30
|
+
| --- | --- |
|
|
31
|
+
| [The installer](installer.md) | Presets, marketing, launchers, update and repair |
|
|
32
|
+
| [The web version](web.md) | The userscript build |
|
|
33
|
+
| [Security](security.md) | What the framework protects, and what it can't |
|
|
34
|
+
| [When Yapin’ changes](host-adapter.md) | Selectors, classes, and updating without a framework release |
|
|
35
|
+
| [Troubleshooting](troubleshooting.md) | Logs, safe mode, common problems |
|
|
@@ -0,0 +1,68 @@
|
|
|
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
|
+
Under the rows are buttons: **Copy debug info** (`copy`), and on desktop **Open data folder** (`folder`) and **Restart in safe mode** (`safeMode`).
|
|
14
|
+
|
|
15
|
+
**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.
|
|
16
|
+
|
|
17
|
+
## Changing it from the config
|
|
18
|
+
|
|
19
|
+
```js
|
|
20
|
+
about: {
|
|
21
|
+
title: 'About Nebula',
|
|
22
|
+
intro: 'Thanks for using Nebula!',
|
|
23
|
+
sections: {
|
|
24
|
+
environment: false, // hide a whole section
|
|
25
|
+
support: { title: 'Support', order: 35 }, // add one (built-in orders: 0, 10, 20, 30, 40)
|
|
26
|
+
},
|
|
27
|
+
fields: {
|
|
28
|
+
'git.remote': false, // hide a row
|
|
29
|
+
'build.mode': 'Release', // a string replaces the value
|
|
30
|
+
'app.version': { label: 'Release' }, // change only the label
|
|
31
|
+
'support.email': { label: 'Email', value: 'help@nebula.example', copy: true },
|
|
32
|
+
'support.chat': { label: 'Chat', value: 'nebula.example/chat', href: 'https://nebula.example/chat' },
|
|
33
|
+
},
|
|
34
|
+
actions: { safeMode: false }, // remove a button
|
|
35
|
+
},
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`about: false` removes the page.
|
|
39
|
+
|
|
40
|
+
## Changing it from code
|
|
41
|
+
|
|
42
|
+
Your entry file and plugins can change it at any time. If the page is open, it redraws.
|
|
43
|
+
|
|
44
|
+
```js
|
|
45
|
+
client.about.set('app.version', { value: `${client.version} (beta)` });
|
|
46
|
+
client.about.set('stats.plugins', { label: 'Plugins running', value: () => String(client.plugins.list().filter((p) => p.status === 'running').length) });
|
|
47
|
+
client.about.remove('git.dirty');
|
|
48
|
+
client.about.section('stats', { title: 'Stats', order: 25 });
|
|
49
|
+
client.about.removeSection('environment');
|
|
50
|
+
client.about.action('report', { label: 'Report a bug', run: (client) => client.openExternal('https://github.com/ada/nebula/issues/new') });
|
|
51
|
+
client.about.removeAction('folder');
|
|
52
|
+
client.about.debugInfo(); // the text Copy debug info copies
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
A field is `{ label, value, href, copy, code, order, hidden }`:
|
|
56
|
+
|
|
57
|
+
| | |
|
|
58
|
+
| --- | --- |
|
|
59
|
+
| `value` | Text, or a function returning text (called whenever the page draws). |
|
|
60
|
+
| `href` | Makes the value a link. Only https and mailto links are used. |
|
|
61
|
+
| `copy` | Adds a Copy button: `true` copies the value, a string copies that string. |
|
|
62
|
+
| `code` | Monospace. |
|
|
63
|
+
| `order` | Position within its section. |
|
|
64
|
+
| `hidden` | Kept but not shown. |
|
|
65
|
+
|
|
66
|
+
`set` only changes the options you pass. Setting a field in a section that doesn't exist creates the section.
|
|
67
|
+
|
|
68
|
+
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.
|