@oss-idle/addon-sdk 0.0.0-stage → 0.6.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 +661 -0
- package/README.md +262 -2
- package/bin/cli.mjs +137 -0
- package/bin/scaffold.mjs +157 -0
- package/dist/bridge/beatmaps.d.ts +57 -0
- package/dist/bridge/beatmaps.d.ts.map +1 -0
- package/dist/bridge/beatmaps.js +65 -0
- package/dist/bridge/character.d.ts +91 -0
- package/dist/bridge/character.d.ts.map +1 -0
- package/dist/bridge/character.js +107 -0
- package/dist/bridge/db.d.ts +44 -0
- package/dist/bridge/db.d.ts.map +1 -0
- package/dist/bridge/db.js +83 -0
- package/dist/bridge/playlists.d.ts +55 -0
- package/dist/bridge/playlists.d.ts.map +1 -0
- package/dist/bridge/playlists.js +85 -0
- package/dist/bridge/scores.d.ts +28 -0
- package/dist/bridge/scores.d.ts.map +1 -0
- package/dist/bridge/scores.js +43 -0
- package/dist/build/index.d.ts +73 -0
- package/dist/build/index.d.ts.map +1 -0
- package/dist/build/index.js +126 -0
- package/dist/build/plugins.d.ts +95 -0
- package/dist/build/plugins.d.ts.map +1 -0
- package/dist/build/plugins.js +240 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/react/index.d.ts +4 -0
- package/dist/react/index.d.ts.map +1 -0
- package/dist/react/index.js +3 -0
- package/dist/react/overlay.d.ts +56 -0
- package/dist/react/overlay.d.ts.map +1 -0
- package/dist/react/overlay.js +63 -0
- package/dist/react/reactAddon.d.ts +37 -0
- package/dist/react/reactAddon.d.ts.map +1 -0
- package/dist/react/reactAddon.js +51 -0
- package/dist/react/useSettings.d.ts +16 -0
- package/dist/react/useSettings.d.ts.map +1 -0
- package/dist/react/useSettings.js +30 -0
- package/dist/runtime/addon.d.ts +94 -0
- package/dist/runtime/addon.d.ts.map +1 -0
- package/dist/runtime/addon.js +129 -0
- package/dist/runtime/buildMeta.d.ts +11 -0
- package/dist/runtime/buildMeta.d.ts.map +1 -0
- package/dist/runtime/buildMeta.js +1 -0
- package/dist/runtime/define.d.ts +26 -0
- package/dist/runtime/define.d.ts.map +1 -0
- package/dist/runtime/define.js +40 -0
- package/dist/runtime/host.d.ts +113 -0
- package/dist/runtime/host.d.ts.map +1 -0
- package/dist/runtime/host.js +8 -0
- package/dist/runtime/input.d.ts +58 -0
- package/dist/runtime/input.d.ts.map +1 -0
- package/dist/runtime/input.js +119 -0
- package/dist/runtime/settings.d.ts +36 -0
- package/dist/runtime/settings.d.ts.map +1 -0
- package/dist/runtime/settings.js +74 -0
- package/package.json +83 -4
- package/templates/common/README.md +50 -0
- package/templates/common/addon.config.json +7 -0
- package/templates/common/eslint.config.js +54 -0
- package/templates/common/gitignore +3 -0
- package/templates/common/src/env.d.ts +17 -0
- package/templates/common/tsconfig.json +14 -0
- package/templates/plain/src/index.ts +32 -0
- package/templates/react/src/Addon.tsx +44 -0
- package/templates/react/src/Panel.tsx +41 -0
- package/templates/react/src/index.ts +12 -0
- package/templates/react/src/styles.css +61 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keeping an overlay's input out of the game.
|
|
3
|
+
*
|
|
4
|
+
* The client binds its gameplay controls as **bubble-phase listeners on
|
|
5
|
+
* `window`** (`input/Controls.ts`), and the same is true of the scene-level
|
|
6
|
+
* shortcuts and the wheel handlers that drive volume and the song carousel.
|
|
7
|
+
* Nothing is bound in the capture phase, which is what makes an overlay able to
|
|
8
|
+
* coexist with it:
|
|
9
|
+
*
|
|
10
|
+
* - **Inside the overlay**, {@link isolateInput} stops these events at the
|
|
11
|
+
* overlay's root while they bubble. The event still reached its target, so a
|
|
12
|
+
* text field types normally, but it never continues up to `window` - so typing
|
|
13
|
+
* "speed" into a filter box cannot press four gameplay keys, and scrolling a
|
|
14
|
+
* results list cannot change the game's volume.
|
|
15
|
+
* - **Outside the overlay**, {@link HotkeyManager} listens in the capture phase,
|
|
16
|
+
* ahead of every game handler, so a shortcut can claim its combo before
|
|
17
|
+
* gameplay interprets it.
|
|
18
|
+
*/
|
|
19
|
+
/** Events the game acts on globally, which an overlay must not leak upward. */
|
|
20
|
+
const ISOLATED_EVENTS = [
|
|
21
|
+
'keydown', 'keyup', 'keypress',
|
|
22
|
+
'wheel',
|
|
23
|
+
'pointerdown', 'pointerup', 'mousedown', 'mouseup', 'click', 'dblclick',
|
|
24
|
+
];
|
|
25
|
+
/**
|
|
26
|
+
* Stop the game from seeing input that belongs to `root`.
|
|
27
|
+
*
|
|
28
|
+
* Bubble phase on purpose: the event has already done its job inside the
|
|
29
|
+
* overlay by the time it is stopped here. Capturing instead would take the
|
|
30
|
+
* keystroke away from the very field the player is typing into.
|
|
31
|
+
*/
|
|
32
|
+
export const isolateInput = (root) => {
|
|
33
|
+
const stop = (event) => event.stopPropagation();
|
|
34
|
+
for (const type of ISOLATED_EVENTS) {
|
|
35
|
+
root.addEventListener(type, stop, { capture: false });
|
|
36
|
+
}
|
|
37
|
+
return () => {
|
|
38
|
+
for (const type of ISOLATED_EVENTS) {
|
|
39
|
+
root.removeEventListener(type, stop, { capture: false });
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
/** Parse `"Ctrl+Shift+F"` into a {@link Hotkey}. Order and case do not matter. */
|
|
44
|
+
export const parseHotkey = (combo) => {
|
|
45
|
+
const parts = combo.split('+').map(p => p.trim().toLowerCase()).filter(Boolean);
|
|
46
|
+
const hotkey = { key: '', ctrl: false, shift: false, alt: false, meta: false };
|
|
47
|
+
for (const part of parts) {
|
|
48
|
+
if (part === 'ctrl' || part === 'control')
|
|
49
|
+
hotkey.ctrl = true;
|
|
50
|
+
else if (part === 'shift')
|
|
51
|
+
hotkey.shift = true;
|
|
52
|
+
else if (part === 'alt' || part === 'option')
|
|
53
|
+
hotkey.alt = true;
|
|
54
|
+
else if (part === 'meta' || part === 'cmd' || part === 'super')
|
|
55
|
+
hotkey.meta = true;
|
|
56
|
+
else
|
|
57
|
+
hotkey.key = part;
|
|
58
|
+
}
|
|
59
|
+
if (!hotkey.key)
|
|
60
|
+
throw new Error(`Hotkey "${combo}" names no key`);
|
|
61
|
+
return hotkey;
|
|
62
|
+
};
|
|
63
|
+
const matches = (hotkey, event) => event.key.toLowerCase() === hotkey.key
|
|
64
|
+
&& event.ctrlKey === hotkey.ctrl
|
|
65
|
+
&& event.shiftKey === hotkey.shift
|
|
66
|
+
&& event.altKey === hotkey.alt
|
|
67
|
+
&& event.metaKey === hotkey.meta;
|
|
68
|
+
/** Whether the event is going somewhere the player is typing. */
|
|
69
|
+
const isTextEntry = (target) => {
|
|
70
|
+
const el = target;
|
|
71
|
+
if (!el?.tagName)
|
|
72
|
+
return false;
|
|
73
|
+
return el.tagName === 'INPUT'
|
|
74
|
+
|| el.tagName === 'TEXTAREA'
|
|
75
|
+
|| el.tagName === 'SELECT'
|
|
76
|
+
|| el.isContentEditable;
|
|
77
|
+
};
|
|
78
|
+
/**
|
|
79
|
+
* Global shortcuts that win against the game.
|
|
80
|
+
*
|
|
81
|
+
* One capture-phase listener on `window` serves every binding. A claimed combo
|
|
82
|
+
* is stopped with `stopImmediatePropagation` and `preventDefault`, so neither
|
|
83
|
+
* the game's handlers nor the browser's own act on it.
|
|
84
|
+
*/
|
|
85
|
+
export class HotkeyManager {
|
|
86
|
+
bindings = new Set();
|
|
87
|
+
onKeyDown = (event) => {
|
|
88
|
+
if (event.repeat)
|
|
89
|
+
return;
|
|
90
|
+
for (const binding of this.bindings) {
|
|
91
|
+
if (!matches(binding.hotkey, event))
|
|
92
|
+
continue;
|
|
93
|
+
// A shortcut must not fire while the player is typing - unless it is
|
|
94
|
+
// the combo that closes the overlay they are typing in.
|
|
95
|
+
if (!binding.allowInText && isTextEntry(event.target))
|
|
96
|
+
continue;
|
|
97
|
+
event.preventDefault();
|
|
98
|
+
event.stopImmediatePropagation();
|
|
99
|
+
binding.handler(event);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
constructor() {
|
|
104
|
+
window.addEventListener('keydown', this.onKeyDown, { capture: true });
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Bind a combo. `allowInText` lets it fire even from a focused field, which
|
|
108
|
+
* is what a close/escape shortcut wants.
|
|
109
|
+
*/
|
|
110
|
+
register(combo, handler, { allowInText = false } = {}) {
|
|
111
|
+
const binding = { hotkey: parseHotkey(combo), handler, allowInText };
|
|
112
|
+
this.bindings.add(binding);
|
|
113
|
+
return () => this.bindings.delete(binding);
|
|
114
|
+
}
|
|
115
|
+
dispose() {
|
|
116
|
+
this.bindings.clear();
|
|
117
|
+
window.removeEventListener('keydown', this.onKeyDown, { capture: true });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persisted add-on settings.
|
|
3
|
+
*
|
|
4
|
+
* An add-on is remounted on every boot and on every enable/disable, so anything
|
|
5
|
+
* held only in component state is re-entered by the player each time. Settings
|
|
6
|
+
* belong in `localStorage`, which is the same store the client keeps its own
|
|
7
|
+
* options in and survives reloads without touching the game's database.
|
|
8
|
+
*
|
|
9
|
+
* **Keys are namespaced deliberately.** The client writes its settings under
|
|
10
|
+
* bare, unprefixed keys (`db/settings.ts`), and reads them back with a bare
|
|
11
|
+
* `JSON.parse`. An add-on writing a colliding key would corrupt a game setting,
|
|
12
|
+
* so everything here lives under `oss-idle-addon:<namespace>`.
|
|
13
|
+
*
|
|
14
|
+
* Stored values are merged **over** the defaults on load, so adding a setting in
|
|
15
|
+
* a later version does not invalidate what a player already saved, and only keys
|
|
16
|
+
* the defaults still declare are kept - a removed setting cleans itself up.
|
|
17
|
+
*/
|
|
18
|
+
export type SettingsStore<T extends object> = {
|
|
19
|
+
/** The full storage key, for debugging. */
|
|
20
|
+
readonly key: string;
|
|
21
|
+
/** Stored settings merged over the defaults. Never throws. */
|
|
22
|
+
load(): T;
|
|
23
|
+
/** Persist the whole settings object. Never throws. */
|
|
24
|
+
save(value: T): void;
|
|
25
|
+
/** Forget the stored settings. */
|
|
26
|
+
clear(): void;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* A namespaced settings store.
|
|
30
|
+
*
|
|
31
|
+
* Every operation is guarded: `localStorage` can be unavailable (private
|
|
32
|
+
* browsing, storage disabled) or full, and losing settings must never take the
|
|
33
|
+
* add-on - or the game - down with it.
|
|
34
|
+
*/
|
|
35
|
+
export declare const createSettings: <T extends object>(namespace: string, defaults: T) => SettingsStore<T>;
|
|
36
|
+
//# sourceMappingURL=settings.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"settings.d.ts","sourceRoot":"","sources":["../../src/runtime/settings.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAMH,MAAM,MAAM,aAAa,CAAC,CAAC,SAAS,MAAM,IAAI;IAC7C,2CAA2C;IAC3C,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,8DAA8D;IAC9D,IAAI,IAAI,CAAC,CAAC;IACV,uDAAuD;IACvD,IAAI,CAAC,KAAK,EAAE,CAAC,GAAG,IAAI,CAAC;IACrB,kCAAkC;IAClC,KAAK,IAAI,IAAI,CAAC;CACd,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,cAAc,GAAI,CAAC,SAAS,MAAM,EAC9C,WAAW,MAAM,EACjB,UAAU,CAAC,KACT,aAAa,CAAC,CAAC,CA6CjB,CAAC"}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persisted add-on settings.
|
|
3
|
+
*
|
|
4
|
+
* An add-on is remounted on every boot and on every enable/disable, so anything
|
|
5
|
+
* held only in component state is re-entered by the player each time. Settings
|
|
6
|
+
* belong in `localStorage`, which is the same store the client keeps its own
|
|
7
|
+
* options in and survives reloads without touching the game's database.
|
|
8
|
+
*
|
|
9
|
+
* **Keys are namespaced deliberately.** The client writes its settings under
|
|
10
|
+
* bare, unprefixed keys (`db/settings.ts`), and reads them back with a bare
|
|
11
|
+
* `JSON.parse`. An add-on writing a colliding key would corrupt a game setting,
|
|
12
|
+
* so everything here lives under `oss-idle-addon:<namespace>`.
|
|
13
|
+
*
|
|
14
|
+
* Stored values are merged **over** the defaults on load, so adding a setting in
|
|
15
|
+
* a later version does not invalidate what a player already saved, and only keys
|
|
16
|
+
* the defaults still declare are kept - a removed setting cleans itself up.
|
|
17
|
+
*/
|
|
18
|
+
const PREFIX = 'oss-idle-addon:';
|
|
19
|
+
/** {@link PREFIX} before the rename to oss!idle: still read, so saved settings carry over. */
|
|
20
|
+
const LEGACY_PREFIX = 'osu-idle-addon:';
|
|
21
|
+
/**
|
|
22
|
+
* A namespaced settings store.
|
|
23
|
+
*
|
|
24
|
+
* Every operation is guarded: `localStorage` can be unavailable (private
|
|
25
|
+
* browsing, storage disabled) or full, and losing settings must never take the
|
|
26
|
+
* add-on - or the game - down with it.
|
|
27
|
+
*/
|
|
28
|
+
export const createSettings = (namespace, defaults) => {
|
|
29
|
+
const key = `${PREFIX}${namespace}`;
|
|
30
|
+
return {
|
|
31
|
+
key,
|
|
32
|
+
load() {
|
|
33
|
+
try {
|
|
34
|
+
const raw = localStorage.getItem(key) ?? localStorage.getItem(`${LEGACY_PREFIX}${namespace}`);
|
|
35
|
+
if (!raw)
|
|
36
|
+
return { ...defaults };
|
|
37
|
+
const stored = JSON.parse(raw);
|
|
38
|
+
if (!stored || typeof stored !== 'object')
|
|
39
|
+
return { ...defaults };
|
|
40
|
+
// Take only keys the defaults still declare, and only when the
|
|
41
|
+
// stored value has the same shape - a hand-edited or stale entry
|
|
42
|
+
// must not put a string where a number is expected.
|
|
43
|
+
const merged = { ...defaults };
|
|
44
|
+
for (const name of Object.keys(defaults)) {
|
|
45
|
+
const value = stored[name];
|
|
46
|
+
if (value === undefined)
|
|
47
|
+
continue;
|
|
48
|
+
if (typeof value !== typeof defaults[name])
|
|
49
|
+
continue;
|
|
50
|
+
merged[name] = value;
|
|
51
|
+
}
|
|
52
|
+
return merged;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return { ...defaults };
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
save(value) {
|
|
59
|
+
try {
|
|
60
|
+
localStorage.setItem(key, JSON.stringify(value));
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// Full or unavailable storage: the add-on still works, it just
|
|
64
|
+
// will not remember. Not worth interrupting the player over.
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
clear() {
|
|
68
|
+
try {
|
|
69
|
+
localStorage.removeItem(key);
|
|
70
|
+
}
|
|
71
|
+
catch { /* nothing to do */ }
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,85 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oss-idle/addon-sdk",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.6.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"license": "AGPL-3.0-only",
|
|
6
|
+
"description": "oss!idle Addon SDK - build oss!idle add-ons in TypeScript. An Addon base class with automatic teardown, shadow-root React overlays, bridges to the game's database and beatmap store, and a bundler that emits the single-file ES module the add-on runtime loads.",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"osu",
|
|
9
|
+
"oss-idle",
|
|
10
|
+
"addon",
|
|
11
|
+
"sdk",
|
|
12
|
+
"plugin",
|
|
13
|
+
"typescript",
|
|
14
|
+
"esbuild"
|
|
15
|
+
],
|
|
16
|
+
"homepage": "https://github.com/oss-idle/addon-sdk#readme",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/oss-idle/addon-sdk.git",
|
|
20
|
+
"directory": "packages/addon-sdk"
|
|
21
|
+
},
|
|
22
|
+
"bugs": {
|
|
23
|
+
"url": "https://github.com/oss-idle/addon-sdk/issues"
|
|
24
|
+
},
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=22"
|
|
27
|
+
},
|
|
28
|
+
"main": "./dist/index.js",
|
|
29
|
+
"types": "./dist/index.d.ts",
|
|
30
|
+
"exports": {
|
|
31
|
+
".": {
|
|
32
|
+
"types": "./dist/index.d.ts",
|
|
33
|
+
"default": "./dist/index.js"
|
|
34
|
+
},
|
|
35
|
+
"./react": {
|
|
36
|
+
"types": "./dist/react/index.d.ts",
|
|
37
|
+
"default": "./dist/react/index.js"
|
|
38
|
+
},
|
|
39
|
+
"./build": {
|
|
40
|
+
"types": "./dist/build/index.d.ts",
|
|
41
|
+
"default": "./dist/build/index.js"
|
|
42
|
+
},
|
|
43
|
+
"./package.json": "./package.json",
|
|
44
|
+
"./scaffold": "./bin/scaffold.mjs"
|
|
45
|
+
},
|
|
46
|
+
"bin": {
|
|
47
|
+
"oss-idle-addon": "bin/cli.mjs"
|
|
48
|
+
},
|
|
49
|
+
"files": [
|
|
50
|
+
"dist",
|
|
51
|
+
"bin",
|
|
52
|
+
"templates",
|
|
53
|
+
"README.md",
|
|
54
|
+
"LICENSE"
|
|
55
|
+
],
|
|
56
|
+
"publishConfig": {
|
|
57
|
+
"access": "public"
|
|
58
|
+
},
|
|
59
|
+
"scripts": {
|
|
60
|
+
"build": "tsc -p tsconfig.json",
|
|
61
|
+
"dev": "tsc -p tsconfig.json --watch",
|
|
62
|
+
"prepublishOnly": "npm run build"
|
|
63
|
+
},
|
|
64
|
+
"dependencies": {
|
|
65
|
+
"esbuild": "^0.24.0"
|
|
66
|
+
},
|
|
67
|
+
"peerDependencies": {
|
|
68
|
+
"react": "^18.3.1",
|
|
69
|
+
"react-dom": "^18.3.1"
|
|
70
|
+
},
|
|
71
|
+
"peerDependenciesMeta": {
|
|
72
|
+
"react": {
|
|
73
|
+
"optional": true
|
|
74
|
+
},
|
|
75
|
+
"react-dom": {
|
|
76
|
+
"optional": true
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
"devDependencies": {
|
|
80
|
+
"@types/node": "^26.4.1",
|
|
81
|
+
"@types/react": "^18.3.12",
|
|
82
|
+
"@types/react-dom": "^18.3.1",
|
|
83
|
+
"typescript": "^5.6.3"
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# __ADDON_NAME__
|
|
2
|
+
|
|
3
|
+
An add-on for [oss!idle](https://github.com/oss-idle/oss-idle), built with the
|
|
4
|
+
[oss!idle Addon SDK](https://github.com/oss-idle/addon-sdk).
|
|
5
|
+
|
|
6
|
+
## Build
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
npm install
|
|
10
|
+
npm run build
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
That emits `__OUTFILE__` - a single self-contained ES module. Paste its contents
|
|
14
|
+
into the in-game add-on editor (Options → Add-ons → Manage) and enable it.
|
|
15
|
+
|
|
16
|
+
`npm run dev` rebuilds on save; re-paste to see changes.
|
|
17
|
+
|
|
18
|
+
## Publish from a repository
|
|
19
|
+
|
|
20
|
+
Instead of pasting, oss!idle can build your add-on from a public git repository
|
|
21
|
+
(any host: GitHub, GitLab, Codeberg...):
|
|
22
|
+
|
|
23
|
+
1. Push this folder with its `package-lock.json` (`dist/` stays out of git).
|
|
24
|
+
2. In game, create an add-on, choose **Built from a git repository** and give the
|
|
25
|
+
repository's https address.
|
|
26
|
+
3. Add the webhook address it shows to your repository as a push webhook.
|
|
27
|
+
4. Release by tagging: `npm version patch && git push --follow-tags`.
|
|
28
|
+
|
|
29
|
+
oss!idle refuses a built file that can run code nobody reviewed: `eval`, `new Function`,
|
|
30
|
+
`import()`, imports from an address, script elements, string timers and the like.
|
|
31
|
+
`npm run lint` flags them in your own code before you push.
|
|
32
|
+
|
|
33
|
+
Each new version tag is built by oss!idle itself (`npm ci`, then `npm run build`,
|
|
34
|
+
offline, with install scripts skipped) and goes to review; the moderator reads the
|
|
35
|
+
repository's changes, never the bundle.
|
|
36
|
+
|
|
37
|
+
## How an add-on runs
|
|
38
|
+
|
|
39
|
+
The game imports your built file as an ES module from a Blob URL and calls its
|
|
40
|
+
`mount` / `unmount` exports. Nothing is injected and nothing resolves at load
|
|
41
|
+
time, so every dependency is bundled into that one file.
|
|
42
|
+
|
|
43
|
+
`unmount` has to genuinely undo everything, because add-ons are enabled,
|
|
44
|
+
disabled, updated and removed without a page reload. The SDK's `Addon` base
|
|
45
|
+
class records teardown for every listener, timer and overlay it hands you and
|
|
46
|
+
reverses it for you.
|
|
47
|
+
|
|
48
|
+
## Licence
|
|
49
|
+
|
|
50
|
+
__LICENSE__
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import tseslint from 'typescript-eslint';
|
|
2
|
+
|
|
3
|
+
// oss!idle refuses an add-on whose built file can run code nobody reviewed: text turned
|
|
4
|
+
// into code, or code loaded from elsewhere. It checks the bundle when you publish;
|
|
5
|
+
// `npm run lint` tells you sooner, in your own code.
|
|
6
|
+
const NOT_REVIEWED = 'oss!idle refuses code that runs what was not reviewed';
|
|
7
|
+
|
|
8
|
+
export default tseslint.config(
|
|
9
|
+
{ ignores: ['dist/', 'node_modules/'] },
|
|
10
|
+
{
|
|
11
|
+
files: ['**/*.{js,jsx,mjs,ts,tsx,mts}'],
|
|
12
|
+
languageOptions: {
|
|
13
|
+
parser: tseslint.parser,
|
|
14
|
+
// no-implied-eval only knows the timers as globals
|
|
15
|
+
globals: Object.fromEntries(['window', 'self', 'globalThis', 'setTimeout', 'setInterval']
|
|
16
|
+
.map(name => [name, 'readonly'])),
|
|
17
|
+
},
|
|
18
|
+
rules: {
|
|
19
|
+
'no-eval': 'error',
|
|
20
|
+
'no-implied-eval': 'error',
|
|
21
|
+
'no-new-func': 'error',
|
|
22
|
+
'no-script-url': 'error',
|
|
23
|
+
'no-restricted-globals': ['error', 'eval', 'Function', 'importScripts'],
|
|
24
|
+
'no-restricted-properties': ['error',
|
|
25
|
+
{ object: 'document', property: 'write', message: NOT_REVIEWED },
|
|
26
|
+
{ object: 'document', property: 'writeln', message: NOT_REVIEWED },
|
|
27
|
+
{ property: 'eval', message: NOT_REVIEWED },
|
|
28
|
+
{ property: 'Function', message: NOT_REVIEWED },
|
|
29
|
+
{ property: 'srcdoc', message: NOT_REVIEWED },
|
|
30
|
+
{ property: 'createContextualFragment', message: NOT_REVIEWED },
|
|
31
|
+
{ property: 'serviceWorker', message: NOT_REVIEWED },
|
|
32
|
+
],
|
|
33
|
+
'no-restricted-syntax': ['error',
|
|
34
|
+
{ selector: 'ImportExpression', message: `import() loads code from elsewhere: ${NOT_REVIEWED}` },
|
|
35
|
+
{
|
|
36
|
+
selector: 'ImportDeclaration[source.value=/^([a-z]+:|\\/\\/)/i]',
|
|
37
|
+
message: `Imports from an address load code from elsewhere: ${NOT_REVIEWED}`,
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
selector: 'CallExpression[callee.property.name=/^createElement(NS)?$/] > Literal[value=/^\\s*script\\s*$/i]',
|
|
41
|
+
message: `A script element loads or runs code: ${NOT_REVIEWED}`,
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
selector: 'CallExpression[callee.property.name=/^setAttribute(NS)?$/] > Literal[value=/^on/i]',
|
|
45
|
+
message: `An on… attribute runs its text as code: use addEventListener. ${NOT_REVIEWED}`,
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
selector: 'CallExpression[callee.property.name="constructor"], NewExpression[callee.property.name="constructor"]',
|
|
49
|
+
message: `A function's constructor builds code from text: ${NOT_REVIEWED}`,
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Stylesheets are inlined as strings and injected into the overlay's shadow root. */
|
|
2
|
+
declare module '*.css' {
|
|
3
|
+
const css: string;
|
|
4
|
+
export default css;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/** Workers are inlined into the single-file bundle and spawned from a Blob URL. */
|
|
8
|
+
declare module 'worker:*' {
|
|
9
|
+
const createWorker: () => Worker;
|
|
10
|
+
export default createWorker;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Binary assets inlined as base64 (e.g. `import wasm from './x.wasm?base64'`). */
|
|
14
|
+
declare module '*?base64' {
|
|
15
|
+
const base64: string;
|
|
16
|
+
export default base64;
|
|
17
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"lib": ["ES2022", "DOM", "DOM.Iterable", "WebWorker"],
|
|
5
|
+
"module": "ES2022",
|
|
6
|
+
"moduleResolution": "bundler",
|
|
7
|
+
"jsx": "react-jsx",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"noUncheckedIndexedAccess": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"noEmit": true
|
|
12
|
+
},
|
|
13
|
+
"include": ["src"]
|
|
14
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* __ADDON_NAME__
|
|
3
|
+
*
|
|
4
|
+
* oss!idle loads an add-on by importing its built source as an ES module and
|
|
5
|
+
* calling `mount` / `unmount`. That pair is the whole contract - `defineAddon`
|
|
6
|
+
* produces it from the class below.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Addon, defineAddon, gameDb } from '@oss-idle/addon-sdk';
|
|
10
|
+
|
|
11
|
+
class __CLASS__ extends Addon {
|
|
12
|
+
|
|
13
|
+
constructor() {
|
|
14
|
+
super({ name: '__ADDON_NAME__' });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
protected async onMount(): Promise<void> {
|
|
18
|
+
// The client opens its database lazily, so wait before querying it.
|
|
19
|
+
await gameDb.ready();
|
|
20
|
+
|
|
21
|
+
const played = await gameDb.value<number>('SELECT COUNT(*) FROM score');
|
|
22
|
+
this.log.info(`hello! you have ${played ?? 0} scores stored locally`);
|
|
23
|
+
|
|
24
|
+
// Anything registered through `this.on` / `this.interval` / `this.own`
|
|
25
|
+
// is torn down automatically when the add-on is disabled or updated.
|
|
26
|
+
this.on(window, 'keydown', event => {
|
|
27
|
+
if (event.key === 'F9') this.log.info('F9');
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const { mount, unmount } = defineAddon(() => new __CLASS__());
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* __ADDON_NAME__
|
|
3
|
+
*
|
|
4
|
+
* The panel is mounted for the add-on's whole life and only *hidden* when
|
|
5
|
+
* closed. Returning `null` instead would unmount the React tree, discarding its
|
|
6
|
+
* state and running cleanup - so anything the overlay had started would be
|
|
7
|
+
* thrown away the moment the player closed it.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ReactNode } from 'react';
|
|
11
|
+
import { ReactAddon } from '@oss-idle/addon-sdk/react';
|
|
12
|
+
import Panel from './Panel.js';
|
|
13
|
+
import styles from './styles.css';
|
|
14
|
+
|
|
15
|
+
const TOGGLE = 'Ctrl+Shift+__KEY__';
|
|
16
|
+
|
|
17
|
+
export class __CLASS__ extends ReactAddon {
|
|
18
|
+
|
|
19
|
+
private open = false;
|
|
20
|
+
|
|
21
|
+
constructor() {
|
|
22
|
+
super({ name: '__ADDON_NAME__', overlayId: '__SLUG__', styles });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
protected override async onReady(): Promise<void> {
|
|
26
|
+
// Registered in the capture phase, ahead of the game's own handlers, so
|
|
27
|
+
// the combo never reaches gameplay.
|
|
28
|
+
this.own(this.hotkeys.register(TOGGLE, () => this.toggle(), { allowInText: true }));
|
|
29
|
+
this.own(this.hotkeys.register('Escape', () => {
|
|
30
|
+
if (this.open) this.toggle();
|
|
31
|
+
}, { allowInText: true }));
|
|
32
|
+
|
|
33
|
+
this.log.info(`ready - press ${TOGGLE} to open`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
private toggle(): void {
|
|
37
|
+
this.open = !this.open;
|
|
38
|
+
this.refresh();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
protected render(): ReactNode {
|
|
42
|
+
return <Panel open={this.open} version={this.meta.version ?? ''} onClose={() => this.toggle()} />;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { gameCharacter, type CharacterRow } from '@oss-idle/addon-sdk';
|
|
3
|
+
|
|
4
|
+
export default function Panel({
|
|
5
|
+
open,
|
|
6
|
+
version,
|
|
7
|
+
onClose,
|
|
8
|
+
}: {
|
|
9
|
+
open: boolean;
|
|
10
|
+
version: string;
|
|
11
|
+
onClose: () => void;
|
|
12
|
+
}) {
|
|
13
|
+
const [character, setCharacter] = useState<CharacterRow>();
|
|
14
|
+
|
|
15
|
+
// Re-read when opened: the client writes skill levels back to this row after
|
|
16
|
+
// every play, so a value read once at boot goes stale.
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
if (open) void gameCharacter.live().then(setCharacter);
|
|
19
|
+
}, [open]);
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<div style={{ display: open ? 'contents' : 'none' }} aria-hidden={!open}>
|
|
23
|
+
<div className="backdrop" onClick={onClose} />
|
|
24
|
+
<div className="panel" role="dialog" aria-label="__ADDON_NAME__">
|
|
25
|
+
<header>
|
|
26
|
+
<h1>__ADDON_NAME__</h1>
|
|
27
|
+
<span className="spacer" />
|
|
28
|
+
<span className="hint">v{version}</span>
|
|
29
|
+
<button onClick={onClose}>Close</button>
|
|
30
|
+
</header>
|
|
31
|
+
|
|
32
|
+
<div className="body">
|
|
33
|
+
{character
|
|
34
|
+
? <p>Playing as <strong>{character.name}</strong> (level {character.overallLevel}).</p>
|
|
35
|
+
: <p>Looking for your character...</p>}
|
|
36
|
+
<p>Edit <code>src/Panel.tsx</code> to build your add-on.</p>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* __ADDON_NAME__
|
|
3
|
+
*
|
|
4
|
+
* oss!idle loads an add-on by importing its built source as an ES module and
|
|
5
|
+
* calling `mount` / `unmount`. That pair is the whole contract - `defineAddon`
|
|
6
|
+
* produces it from the class in `Addon.tsx`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { defineAddon } from '@oss-idle/addon-sdk';
|
|
10
|
+
import { __CLASS__ } from './Addon.js';
|
|
11
|
+
|
|
12
|
+
export const { mount, unmount } = defineAddon(() => new __CLASS__());
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Rendered inside a shadow root, so these selectors cannot reach the game and
|
|
3
|
+
* the game's stylesheet cannot reach them.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
:host, * { box-sizing: border-box; }
|
|
7
|
+
|
|
8
|
+
.backdrop {
|
|
9
|
+
position: absolute;
|
|
10
|
+
inset: 0;
|
|
11
|
+
background: rgb(8 6 12 / 55%);
|
|
12
|
+
pointer-events: auto;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.panel {
|
|
16
|
+
position: absolute;
|
|
17
|
+
top: 50%;
|
|
18
|
+
left: 50%;
|
|
19
|
+
transform: translate(-50%, -50%);
|
|
20
|
+
width: min(720px, 92vw);
|
|
21
|
+
max-height: 80vh;
|
|
22
|
+
display: flex;
|
|
23
|
+
flex-direction: column;
|
|
24
|
+
pointer-events: auto;
|
|
25
|
+
|
|
26
|
+
font-family: ui-sans-serif, system-ui, "Segoe UI", Roboto, sans-serif;
|
|
27
|
+
font-size: 13px;
|
|
28
|
+
color: #eceaf2;
|
|
29
|
+
background: #16141c;
|
|
30
|
+
border: 1px solid #322c40;
|
|
31
|
+
border-radius: 12px;
|
|
32
|
+
box-shadow: 0 24px 80px rgb(0 0 0 / 60%);
|
|
33
|
+
overflow: hidden;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
header {
|
|
37
|
+
display: flex;
|
|
38
|
+
align-items: center;
|
|
39
|
+
gap: 12px;
|
|
40
|
+
padding: 14px 18px;
|
|
41
|
+
background: #1e1a28;
|
|
42
|
+
border-bottom: 1px solid #322c40;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
header h1 { margin: 0; font-size: 15px; font-weight: 600; }
|
|
46
|
+
header .spacer { flex: 1; }
|
|
47
|
+
header .hint { color: #8b83a0; font-size: 11px; }
|
|
48
|
+
|
|
49
|
+
button {
|
|
50
|
+
font: inherit;
|
|
51
|
+
color: #eceaf2;
|
|
52
|
+
background: #2b2438;
|
|
53
|
+
border: 1px solid #3d3550;
|
|
54
|
+
border-radius: 6px;
|
|
55
|
+
padding: 6px 12px;
|
|
56
|
+
cursor: pointer;
|
|
57
|
+
}
|
|
58
|
+
button:hover { background: #362d47; }
|
|
59
|
+
|
|
60
|
+
.body { padding: 18px; overflow: auto; line-height: 1.7; }
|
|
61
|
+
code { background: #221d2e; border-radius: 4px; padding: 1px 5px; }
|