@thomasfarineau/anvil 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,218 @@
1
+ <div align="center">
2
+ <img src="./logo.svg" width="96" height="96" alt="anvil" />
3
+ <h1>anvil</h1>
4
+ <p>Build a native Minecraft launcher by writing only HTML.<br>The Rust backend handles everything else.</p>
5
+
6
+ [![release](https://img.shields.io/github/v/release/ThomasFarineau/anvil?style=flat-square)](https://github.com/ThomasFarineau/anvil/releases)
7
+ [![license](https://img.shields.io/github/license/ThomasFarineau/anvil?style=flat-square)](./LICENSE)
8
+ [![CI](https://img.shields.io/github/actions/workflow/status/ThomasFarineau/anvil/ci.yml?style=flat-square&label=CI)](https://github.com/ThomasFarineau/anvil/actions)
9
+ </div>
10
+
11
+ ---
12
+
13
+ **anvil** is a framework that generates a native Minecraft launcher (Windows · macOS · Linux) from a `config.json` file and an HTML page. The built-in Rust backend takes care of:
14
+
15
+ - Downloading and managing Java
16
+ - Downloading Minecraft assets (vanilla, Fabric, Forge…)
17
+ - Launching the game with session management
18
+ - Auto-updates via URL
19
+ - App icon generation from your logo
20
+
21
+ You only touch the **frontend**.
22
+
23
+ ## Prerequisites
24
+
25
+ - [Node.js](https://nodejs.org) ≥ 18
26
+ - [Rust](https://rustup.rs) (stable)
27
+ - [Tauri v2 prerequisites](https://tauri.app/start/prerequisites/) (WebView2 on Windows, Xcode on macOS)
28
+
29
+ ## Quick start
30
+
31
+ ```bash
32
+ npx anvil create my-launcher
33
+ cd my-launcher
34
+ npm install
35
+ npm run dev
36
+ ```
37
+
38
+ Or in an existing project:
39
+
40
+ ```bash
41
+ npm install -D anvil
42
+ npx anvil init
43
+ npm run dev
44
+ ```
45
+
46
+ ## Commands
47
+
48
+ | Command | Description |
49
+ |---|---|
50
+ | `npx anvil create <name>` | Scaffold a new project in `<name>/` |
51
+ | `npx anvil init` | Initialize anvil in the current folder |
52
+ | `npx anvil dev` | Start the launcher in development mode |
53
+ | `npx anvil build` | Compile the launcher for distribution |
54
+ | `npx anvil update` | Update the Rust backend and `api.js` to the latest version |
55
+
56
+ ## Project structure
57
+
58
+ ```
59
+ my-launcher/
60
+ ├── config.json ← launcher configuration
61
+ ├── src/
62
+ │ ├── index.html ← your interface (HTML/CSS/JS)
63
+ │ ├── api.js ← JS ↔ Rust bridge (do not modify)
64
+ │ └── logo.svg ← your launcher logo (optional)
65
+ └── src-anvil/ ← generated by anvil (do not modify)
66
+ ```
67
+
68
+ ## config.json
69
+
70
+ ```json
71
+ {
72
+ "$schema": "node_modules/anvil/src/client/config.schema.json",
73
+ "identifier": "com.mycompany.launcher",
74
+ "app_name": "My Launcher",
75
+ "data_folder": ".my-launcher",
76
+ "java_version": 21,
77
+ "logo": "logo.svg",
78
+ "session": "none",
79
+ "update_url": "",
80
+ "target": "dist",
81
+ "window_decorations": true,
82
+ "window_resizable": false,
83
+ "instances": [
84
+ {
85
+ "id": "survival",
86
+ "name": "Survival",
87
+ "mc_version": "1.21.4"
88
+ },
89
+ {
90
+ "id": "modded",
91
+ "name": "Modded",
92
+ "mc_version": "1.21.4",
93
+ "loader": "fabric",
94
+ "loader_version": "0.16.9"
95
+ }
96
+ ]
97
+ }
98
+ ```
99
+
100
+ ### Field reference
101
+
102
+ | Field | Type | Description |
103
+ |---|---|---|
104
+ | `identifier` | `string` | Reverse-domain app identifier (e.g. `com.mycompany.launcher`) |
105
+ | `app_name` | `string` | App name shown in the native window and UI |
106
+ | `data_folder` | `string` | Sub-folder in `%APPDATA%` / `~/Library` for game data |
107
+ | `java_version` | `17` \| `21` | Java version to download automatically |
108
+ | `logo` | `string` | Path to the logo (relative to `src/`) — `.svg` or `.png`, auto-converted to app icon |
109
+ | `session` | `"none"` \| `"mojang"` \| `"custom"` | Authentication mode |
110
+ | `update_url` | `string` | URL of the update manifest (leave empty to disable) |
111
+ | `target` | `string` | Output folder for compiled executables (e.g. `dist`) |
112
+ | `window_decorations` | `boolean` | Show the native title bar |
113
+ | `window_resizable` | `boolean` | Allow the user to resize the window |
114
+ | `instances` | `array` | List of available Minecraft instances |
115
+
116
+ ### Instance fields
117
+
118
+ | Field | Type | Description |
119
+ |---|---|---|
120
+ | `id` | `string` | Unique identifier (used as the folder name) |
121
+ | `name` | `string` | Label shown on the play button |
122
+ | `mc_version` | `string` | Minecraft version (e.g. `"1.21.4"`) |
123
+ | `loader` | `"fabric"` \| `"forge"` \| `"neoforge"` \| `"quilt"` | Mod loader (optional) |
124
+ | `loader_version` | `string` | Mod loader version (e.g. `"0.16.9"`) |
125
+ | `server_ip` | `string` | Server IP for auto-connect on launch |
126
+ | `server_port` | `number` | Server port (default: `25565`) |
127
+
128
+ ## Session
129
+
130
+ ### `"none"` — Offline
131
+
132
+ The player types their username directly in the UI. No authentication required.
133
+
134
+ ### `"custom"` — External authentication
135
+
136
+ Handle authentication on the client side (OAuth, custom API…) and pass the session to anvil:
137
+
138
+ ```js
139
+ await MC.setSession({
140
+ username: 'Steve',
141
+ uuid: '...',
142
+ access_token: '...',
143
+ });
144
+
145
+ // To log out:
146
+ await MC.clearSession();
147
+ ```
148
+
149
+ ## JavaScript API
150
+
151
+ Import `api.js` in your HTML:
152
+
153
+ ```html
154
+ <script type="module">
155
+ import { MC } from '/api.js';
156
+ </script>
157
+ ```
158
+
159
+ ### Reference
160
+
161
+ ```js
162
+ // Config & settings
163
+ MC.getConfig() // → LauncherConfig
164
+ MC.getSettings() // → Settings
165
+ MC.saveSettings(settings) // → void
166
+ MC.getDefaultDir() // → string (default %APPDATA%/... path)
167
+
168
+ // Installation
169
+ MC.getInitStatus() // → InitStatus (java_ok, instances[])
170
+ MC.runSetup() // → void (starts the download)
171
+
172
+ // Game
173
+ MC.verify(instanceId) // → void (verifies game files)
174
+ MC.play(instanceId) // → void (launches the game)
175
+
176
+ // Session (when session: "custom")
177
+ MC.setSession({ username, uuid, access_token })
178
+ MC.clearSession()
179
+
180
+ // Updates
181
+ MC.checkUpdate() // → UpdateInfo | null
182
+ MC.doUpdate(url) // → void
183
+
184
+ // Window
185
+ MC.close() // closes the application
186
+
187
+ // Events
188
+ MC.on.setupProgress(cb) // cb({ step, current, total, label, error })
189
+ MC.on.setupDone(cb) // cb()
190
+ MC.on.gameStarting(cb) // cb(instanceId)
191
+ MC.on.gameOutput(cb) // cb({ instance_id, text, stderr })
192
+ MC.on.gameExit(cb) // cb({ instance_id, code })
193
+ ```
194
+
195
+ ## App icon
196
+
197
+ Place your logo in `src/` and set the `logo` field in `config.json`. anvil converts it automatically to all required icon sizes during `init`:
198
+
199
+ - `.svg` → converted to PNG via **sharp**, then generated at all sizes
200
+ - `.png` → used directly (recommended: 1024×1024)
201
+
202
+ ## Build & distribution
203
+
204
+ ```bash
205
+ npm run build # → anvil build → tauri build
206
+ ```
207
+
208
+ Distribution artifacts are generated in the `target` folder configured in `config.json`:
209
+
210
+ | Platform | Format |
211
+ |---|---|
212
+ | Windows | `<name>_<ver>_x64-setup.exe` (NSIS) |
213
+ | Linux | `<name>_<ver>_amd64.AppImage` |
214
+ | macOS | `<name>_<ver>_x64.dmg` |
215
+
216
+ ## License
217
+
218
+ MIT © [Thomas Farineau](https://github.com/ThomasFarineau)
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@thomasfarineau/anvil",
3
+ "version": "0.0.1",
4
+ "description": "Zero-config Minecraft launcher framework built on Tauri",
5
+ "keywords": [
6
+ "launcher",
7
+ "minecraft",
8
+ "tauri"
9
+ ],
10
+ "license": "MIT",
11
+ "bin": {
12
+ "anvil": "src/cli.cjs",
13
+ "create-anvil": "src/cli.cjs"
14
+ },
15
+ "files": [
16
+ "src/"
17
+ ],
18
+ "type": "module",
19
+ "scripts": {
20
+ "test": "bun test",
21
+ "lint": "oxlint .",
22
+ "format": "oxfmt .",
23
+ "check": "bun run lint && bun run format"
24
+ },
25
+ "dependencies": {
26
+ "@tauri-apps/cli": "^2",
27
+ "sharp": "^0.33.0"
28
+ },
29
+ "devDependencies": {
30
+ "@types/node": "^26.1.0",
31
+ "oxfmt": "^0.57.0",
32
+ "oxlint": "^1.72.0"
33
+ },
34
+ "engines": {
35
+ "node": ">=18"
36
+ }
37
+ }
package/src/cli.cjs ADDED
@@ -0,0 +1,352 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const { spawnSync } = require('child_process');
7
+
8
+ // __dirname = src/ → rust/, template/, client/ are siblings
9
+ const PKG_DIR = __dirname;
10
+ const { version: VERSION, name: PKG_NAME } = require('../package.json');
11
+
12
+ // ── Helpers ───────────────────────────────────────────────────────────────────
13
+
14
+ function copyDir(src, dest) {
15
+ fs.mkdirSync(dest, { recursive: true });
16
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
17
+ const destName = entry.name === '_gitignore' ? '.gitignore' : entry.name;
18
+ const s = path.join(src, entry.name);
19
+ const d = path.join(dest, destName);
20
+ if (entry.isDirectory()) copyDir(s, d);
21
+ else fs.copyFileSync(s, d);
22
+ }
23
+ }
24
+
25
+ function renderTemplate(filePath, name, identifier) {
26
+ const safeId = name.replace(/-/g, '_');
27
+ const id = identifier || `com.launcher.${safeId}`;
28
+ return fs
29
+ .readFileSync(filePath, 'utf8')
30
+ .replace(/\{\{name\}\}/g, name)
31
+ .replace(/\{\{safe_id\}\}/g, safeId)
32
+ .replace(/\{\{identifier\}\}/g, id);
33
+ }
34
+
35
+ function deriveName(dir) {
36
+ return (
37
+ path
38
+ .basename(dir)
39
+ .toLowerCase()
40
+ .replace(/[^a-z0-9_-]/g, '-')
41
+ .replace(/^-+|-+$/g, '') || 'my-launcher'
42
+ );
43
+ }
44
+
45
+ // ── Scaffold src-anvil/ into a project directory ──────────────────────────────
46
+
47
+ function readConfig(projectDir) {
48
+ const configPath = path.join(projectDir, 'config.json');
49
+ if (!fs.existsSync(configPath)) return {};
50
+ try {
51
+ return JSON.parse(fs.readFileSync(configPath, 'utf8'));
52
+ } catch {
53
+ return {};
54
+ }
55
+ }
56
+
57
+ function readIdentifier(projectDir) {
58
+ return readConfig(projectDir).identifier || '';
59
+ }
60
+
61
+ function readConfigField(projectDir, field) {
62
+ return readConfig(projectDir)[field] || '';
63
+ }
64
+
65
+ function scaffoldTauri(projectDir, name) {
66
+ const srcTauri = path.join(projectDir, 'src-anvil');
67
+ const identifier = readIdentifier(projectDir);
68
+
69
+ copyDir(path.join(PKG_DIR, 'rust'), srcTauri);
70
+ fs.writeFileSync(
71
+ path.join(srcTauri, 'Cargo.toml'),
72
+ renderTemplate(path.join(PKG_DIR, 'rust', 'Cargo.toml'), name, identifier),
73
+ );
74
+ fs.writeFileSync(
75
+ path.join(srcTauri, 'tauri.conf.json'),
76
+ renderTemplate(
77
+ path.join(PKG_DIR, 'template', 'tauri.conf.json'),
78
+ name,
79
+ identifier,
80
+ ),
81
+ );
82
+ copyDir(
83
+ path.join(PKG_DIR, 'template', 'capabilities'),
84
+ path.join(srcTauri, 'capabilities'),
85
+ );
86
+ copyDir(
87
+ path.join(PKG_DIR, 'template', 'icons'),
88
+ path.join(srcTauri, 'icons'),
89
+ );
90
+
91
+ fs.writeFileSync(path.join(srcTauri, '.lc-version'), VERSION);
92
+ }
93
+
94
+ function generateIcons(projectDir) {
95
+ const configPath = path.join(projectDir, 'config.json');
96
+ if (!fs.existsSync(configPath)) return;
97
+
98
+ let config;
99
+ try {
100
+ config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
101
+ } catch {
102
+ return;
103
+ }
104
+
105
+ if (!config.logo) return;
106
+
107
+ const logoPath = path.join(projectDir, 'src', config.logo);
108
+ if (!fs.existsSync(logoPath)) {
109
+ console.warn(
110
+ ` Warning: logo not found at src/${config.logo}, skipping icon generation.`,
111
+ );
112
+ return;
113
+ }
114
+
115
+ const isSvg = config.logo.toLowerCase().endsWith('.svg');
116
+ const isPng = config.logo.toLowerCase().endsWith('.png');
117
+
118
+ if (!isSvg && !isPng) {
119
+ console.warn(
120
+ ` Warning: icon generation requires a .png or .svg logo (got ${config.logo}), skipping.`,
121
+ );
122
+ return;
123
+ }
124
+
125
+ const bin = findBin('tauri', projectDir);
126
+ if (!bin) return;
127
+
128
+ let iconSrc = logoPath;
129
+ let tmpPng = null;
130
+
131
+ if (isSvg) {
132
+ tmpPng = logoPath.replace(/\.svg$/i, '.tmp.png');
133
+ console.log(` Converting src/${config.logo} to PNG via sharp...`);
134
+ const sharpBin = require.resolve('sharp');
135
+ const script = [
136
+ `const sharp = require(${JSON.stringify(sharpBin)});`,
137
+ `sharp(${JSON.stringify(logoPath)}, { density: 300 })`,
138
+ ` .resize(1024, 1024).png()`,
139
+ ` .toFile(${JSON.stringify(tmpPng)})`,
140
+ ` .then(() => process.exit(0))`,
141
+ ` .catch(e => { process.stderr.write(String(e) + '\\n'); process.exit(1); });`,
142
+ ].join('\n');
143
+ const r = spawnSync(process.execPath, ['-e', script], { stdio: 'inherit' });
144
+ if (r.status !== 0) {
145
+ console.warn(
146
+ ' Warning: SVG→PNG conversion failed, skipping icon generation.',
147
+ );
148
+ return;
149
+ }
150
+ iconSrc = tmpPng;
151
+ }
152
+
153
+ console.log(` Generating app icons...`);
154
+ spawnSync(bin, ['icon', path.resolve(iconSrc)], {
155
+ stdio: 'inherit',
156
+ shell: process.platform === 'win32',
157
+ cwd: path.join(projectDir, 'src-anvil'),
158
+ });
159
+
160
+ if (tmpPng) {
161
+ try {
162
+ fs.unlinkSync(tmpPng);
163
+ } catch {}
164
+ }
165
+ }
166
+
167
+ // ── create ────────────────────────────────────────────────────────────────────
168
+
169
+ function create(target) {
170
+ const projectDir = path.resolve(target);
171
+ const name = deriveName(projectDir);
172
+
173
+ if (fs.existsSync(projectDir)) {
174
+ const entries = fs.readdirSync(projectDir).filter((e) => e !== '.git');
175
+ if (entries.length > 0) {
176
+ process.stderr.write(
177
+ `\nError: '${target}' already exists and is not empty.\n\n`,
178
+ );
179
+ process.exit(1);
180
+ }
181
+ }
182
+
183
+ console.log(`\nCreating ${PKG_NAME} project: ${name}\n`);
184
+ scaffoldTauri(projectDir, name);
185
+ copyUserFiles(projectDir, false);
186
+ writePackageJson(projectDir, name);
187
+ generateIcons(projectDir);
188
+
189
+ const cdLine = target !== '.' ? ` cd ${target}\n` : '';
190
+ console.log(
191
+ `Done!\n\n${cdLine} npm install\n # Edit config.json and src/index.html\n npm run dev\n`,
192
+ );
193
+ }
194
+
195
+ // ── init ──────────────────────────────────────────────────────────────────────
196
+
197
+ function init() {
198
+ const projectDir = process.cwd();
199
+ const name = deriveName(projectDir);
200
+
201
+ console.log(`\nInitializing ${PKG_NAME} in: ${projectDir}\n`);
202
+ scaffoldTauri(projectDir, name);
203
+ copyUserFiles(projectDir, false);
204
+ generateIcons(projectDir);
205
+
206
+ const pkgPath = path.join(projectDir, 'package.json');
207
+ if (fs.existsSync(pkgPath)) {
208
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
209
+ pkg.scripts = { dev: 'anvil dev', build: 'anvil build', ...pkg.scripts };
210
+ fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
211
+ console.log('Updated package.json');
212
+ } else {
213
+ writePackageJson(projectDir, name);
214
+ }
215
+
216
+ console.log(`\nDone!\n\n npm install\n npm run dev\n`);
217
+ }
218
+
219
+ // ── update ────────────────────────────────────────────────────────────────────
220
+
221
+ function update() {
222
+ const projectDir = process.cwd();
223
+ const srcTauri = path.join(projectDir, 'src-anvil');
224
+
225
+ if (!fs.existsSync(srcTauri)) {
226
+ process.stderr.write(
227
+ `\nNo src-anvil/ found. Run this from the root of a ${PKG_NAME} project.\n\n`,
228
+ );
229
+ process.exit(1);
230
+ }
231
+
232
+ fs.copyFileSync(
233
+ path.join(PKG_DIR, 'rust', 'src', 'lib.rs'),
234
+ path.join(srcTauri, 'src', 'lib.rs'),
235
+ );
236
+
237
+ const apiDest = path.join(projectDir, 'src', 'api.js');
238
+ if (fs.existsSync(path.dirname(apiDest))) {
239
+ fs.copyFileSync(path.join(PKG_DIR, 'template', 'src', 'api.js'), apiDest);
240
+ }
241
+
242
+ fs.writeFileSync(path.join(srcTauri, '.lc-version'), VERSION);
243
+ console.log(`\nUpdated to ${PKG_NAME}@${VERSION}\n`);
244
+ }
245
+
246
+ // ── Shared helpers ────────────────────────────────────────────────────────────
247
+
248
+ function copyUserFiles(projectDir, overwrite) {
249
+ for (const [src, dest] of [
250
+ [
251
+ path.join(PKG_DIR, 'template', 'config.json'),
252
+ path.join(projectDir, 'config.json'),
253
+ ],
254
+ [
255
+ path.join(PKG_DIR, 'template', '_gitignore'),
256
+ path.join(projectDir, '.gitignore'),
257
+ ],
258
+ ]) {
259
+ if (overwrite || !fs.existsSync(dest)) fs.copyFileSync(src, dest);
260
+ }
261
+ const destDir = path.join(projectDir, 'src');
262
+ if (overwrite || !fs.existsSync(destDir)) {
263
+ copyDir(path.join(PKG_DIR, 'template', 'src'), destDir);
264
+ }
265
+ }
266
+
267
+ function writePackageJson(projectDir, name) {
268
+ const pkgPath = path.join(projectDir, 'package.json');
269
+ if (fs.existsSync(pkgPath)) return;
270
+ fs.writeFileSync(
271
+ pkgPath,
272
+ JSON.stringify(
273
+ {
274
+ name,
275
+ version: '1.0.0',
276
+ private: true,
277
+ scripts: { dev: 'anvil dev', build: 'anvil build' },
278
+ devDependencies: { '@tauri-apps/cli': '^2' },
279
+ },
280
+ null,
281
+ 2,
282
+ ) + '\n',
283
+ );
284
+ }
285
+
286
+ // ── Helpers ───────────────────────────────────────────────────────────────────
287
+
288
+ function findBin(name, projectDir) {
289
+ const candidates = [
290
+ path.join(projectDir, 'node_modules', '.bin', name),
291
+ path.join(PKG_DIR, '..', 'node_modules', '.bin', name),
292
+ ];
293
+ for (const bin of candidates) {
294
+ if (fs.existsSync(bin) || fs.existsSync(bin + '.exe')) return bin;
295
+ }
296
+ return null;
297
+ }
298
+
299
+ // ── Tauri proxy ───────────────────────────────────────────────────────────────
300
+
301
+ function runTauri(tauriCmd) {
302
+ const projectDir = process.cwd();
303
+ const srcTauri = path.join(projectDir, 'src-anvil');
304
+
305
+ if (!fs.existsSync(srcTauri)) {
306
+ console.log(`\nNo src-anvil/ found — running anvil init first...\n`);
307
+ init();
308
+ }
309
+
310
+ const bin = findBin('tauri', projectDir);
311
+ if (!bin) {
312
+ process.stderr.write(
313
+ `\n@tauri-apps/cli not found. Try reinstalling ${PKG_NAME}.\n\n`,
314
+ );
315
+ process.exit(1);
316
+ }
317
+
318
+ const env = { ...process.env };
319
+ const targetDir = readConfigField(projectDir, 'target');
320
+ if (targetDir) env.CARGO_TARGET_DIR = path.resolve(projectDir, targetDir);
321
+
322
+ const result = spawnSync(bin, [tauriCmd], {
323
+ stdio: 'inherit',
324
+ shell: process.platform === 'win32',
325
+ cwd: path.join(projectDir, 'src-anvil'),
326
+ env,
327
+ });
328
+
329
+ process.exit(result.status ?? 1);
330
+ }
331
+
332
+ // ── CLI dispatch ──────────────────────────────────────────────────────────────
333
+
334
+ const [, , cmd, arg] = process.argv;
335
+
336
+ if (cmd === 'init') {
337
+ init();
338
+ } else if (cmd === 'dev') {
339
+ runTauri('dev');
340
+ } else if (cmd === 'build') {
341
+ runTauri('build');
342
+ } else if (cmd === 'update') {
343
+ update();
344
+ } else if (cmd === '--version' || cmd === '-v') {
345
+ console.log(`${PKG_NAME}@${VERSION}`);
346
+ } else if (!cmd || cmd === 'create' || !cmd.startsWith('-')) {
347
+ create(cmd === 'create' ? arg || '.' : cmd || '.');
348
+ } else {
349
+ console.log(
350
+ `\nUsage:\n npx ${PKG_NAME} <name>\n npx ${PKG_NAME} init\n npx ${PKG_NAME} dev\n npx ${PKG_NAME} build\n npx ${PKG_NAME} update\n`,
351
+ );
352
+ }