fantasta 0.2.0 → 0.3.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/README.md CHANGED
@@ -1,35 +1,47 @@
1
1
  # Fantasta — avvia l'asta dal terminale
2
2
 
3
- Asta di Fantacalcio giocata in **LAN senza server**. Questo comando avvia la
4
- regia sul tuo computer; i partecipanti si collegano dal telefono (o da qualunque
5
- browser) sulla tua rete locale, senza installare nulla.
6
-
7
- Niente `npm install`: servi e via.
3
+ Asta di Fantacalcio giocata in **LAN senza server**. Questo comando apre la
4
+ **vera app desktop** sul tuo computer; i partecipanti si collegano dal telefono
5
+ (o da qualunque browser) sulla tua rete locale, senza installare nulla.
8
6
 
9
7
  ```bash
10
8
  npx fantasta
11
9
  ```
12
10
 
11
+ Niente installer, niente avvisi di sicurezza: il binario Electron incluso nel
12
+ pacchetto è firmato da GitHub, quindi non c'è Gatekeeper da aggirare.
13
+
13
14
  ## Cosa succede
14
15
 
15
- - Si avvia il **server SQLite/LAN** locale (porta `47821`);
16
- - si avvia il **renderer** della regia (porta `47822`);
17
- - si apre il **browser** di default sulla home dell'asta;
16
+ - Si apre la **finestra dell'app desktop** (regia);
17
+ - si avvia il **server SQLite/LAN** locale (porta `47821`);
18
+ - si avvia il **renderer** della regia (porta `47822`), raggiungibile anche dal
19
+ browser dei partecipanti;
18
20
  - **il terminale resta attivo** mentre l'asta è in corso: chiudilo con `Ctrl+C`
19
- per fermare tutto e disconnettere i partecipanti.
21
+ per fermare tutto e disconnettere i partecipanti (oppure chiudi la finestra).
20
22
 
21
23
  I partecipanti si collegano con il **QR code** mostrato durante la creazione
22
24
  della lega (o col link) e seguono l'asta dal telefono. Serve una sola
23
25
  connessione: quella del PC che gestisce l'asta.
24
26
 
27
+ ## Modalità browser (opzionale)
28
+
29
+ Se preferisci la regia nel browser di default invece della finestra:
30
+
31
+ ```bash
32
+ npx fantasta --browser
33
+ ```
34
+
25
35
  ## Requisiti
26
36
 
27
37
  - **Node.js 22 o superiore** (il database usa `node:sqlite`, incluso nel runtime).
38
+ - Al primo avvio npm scarica anche il binario Electron (~120 MB, una volta sola).
28
39
 
29
40
  ## Dove vengono salvati i dati
30
41
 
31
- - Database: `~/.fantasta/fantasta.db`
32
- - Sessione admin: `~/.fantasta/admin-session.txt`
42
+ - Finestra desktop: `~/Library/Application Support/Fantasta/fantasta.db` (macOS),
43
+ `%APPDATA%/Fantasta/fantasta.db` (Windows), `~/.config/Fantasta/fantasta.db` (Linux).
44
+ - Modalità browser / sessioni: `~/.fantasta/` (`fantasta.db`, `admin-session.txt`).
33
45
 
34
46
  ## Opzioni (variabili d'ambiente)
35
47
 
@@ -39,9 +51,9 @@ Tutte opzionali, se non impostate valgono i default qui sotto.
39
51
  | --- | --- | --- |
40
52
  | `FANTASTA_PORT` | `47821` | Porta del server locale (SQLite/LAN) |
41
53
  | `FANTASTA_RENDERER_PORT` | `47822` | Porta del renderer (regia e partecipanti) |
42
- | `FANTASTA_DATABASE_PATH` | `~/.fantasta/fantasta.db` | Percorso del database |
43
- | `FANTASTA_DATA_DIR` | `~/.fantasta` | Cartella dei dati (se il DB non è personalizzato) |
44
- | `FANTASTA_SESSION_FILE` | `~/.fantasta/admin-session.txt` | File della sessione admin |
54
+ | `FANTASTA_DATABASE_PATH` | vedi sopra | Percorso del database |
55
+ | `FANTASTA_DATA_DIR` | `~/.fantasta` | Cartella dei dati (modalità browser) |
56
+ | `FANTASTA_BROWSER` | | `1` equivale a `--browser` |
45
57
 
46
58
  Esempio con porte diverse:
47
59
 
@@ -49,15 +61,15 @@ Esempio con porte diverse:
49
61
  FANTASTA_PORT=49001 FANTASTA_RENDERER_PORT=49002 npx fantasta
50
62
  ```
51
63
 
52
- ## Perché non c'è un installer?
53
-
54
- L'"app desktop" di Fantasta è in realtà **due processi Node**: il server LAN e
55
- il renderer. `npx` li avvia direttamente, senza `electron`, senza `.app` e senza
56
- binari nativi da firmare — per questo non c'è alcun **avviso di sicurezza**
57
- all'apertura su macOS, Windows o Linux (niente Gatekeeper da aggirare).
64
+ ## Perché npx e non l'installer?
58
65
 
59
- Se preferisci una finestra dedicata, la distribuzione installer (`.dmg`/`.exe`)
60
- è scaricabile dalle **GitHub Releases** del progetto.
66
+ L'"app desktop" di Fantasta è in realtà **due processi Node** (server LAN e
67
+ renderer) avvolti da **Electron**. Il pacchetto npm include tutto e lancia il
68
+ binario Electron ufficiale, firmato da GitHub: per questo non c'è alcun
69
+ **avviso di sicurezza** all'apertura su macOS, Windows o Linux (niente
70
+ Gatekeeper da aggirare). Se preferisci una finestra "installata" a sistema,
71
+ la distribuzione installer (`.dmg`/`.exe`) resta scaricabile dalle **GitHub
72
+ Releases** del progetto.
61
73
 
62
74
  ## Sviluppo in questo repo
63
75
 
package/bin/fantasta.js CHANGED
@@ -1,14 +1,14 @@
1
1
  #!/usr/bin/env node
2
- // Launcher Fantasta senza Electron.
2
+ // Launcher "fantasta".
3
3
  //
4
- // L'"app desktop" è, di fatto, due processi Node già inclusi come risorse:
5
- // - resources/local-server.cjs → server SQLite + WebSocket LAN (porta 47821)
6
- // - resources/web/** → renderer Next standalone (porta 47822)
4
+ // Di default avvia la VERA app desktop: avvia Electron con le risorse incluse
5
+ // (resources/app), che apre la finestra della regia e serve server SQLite/LAN
6
+ // (porta 47821) e renderer (porta 47822). Il binario Electron è firmato da
7
+ // GitHub, quindi NIENTE avviso Gatekeeper all'apertura.
7
8
  //
8
- // Questo bin li avvia, crea/riprende la sessione admin, apre la regia nel
9
- // browser di default e resta in esecuzione finché il segnale non lo ferma.
10
- // Niente .app, niente binari nativi da firmare: Gatekeeper non ha nulla da
11
- // bloccare su macOS, Windows o Linux.
9
+ // Con `--browser` (o FANTASTA_BROWSER=1) usa la modalità legacy senza Electron:
10
+ // avvia i due processi Node (local-server.cjs + renderer standalone) e apre la
11
+ // regia nel browser di default. Utile su macchine senza Electron installato.
12
12
  "use strict";
13
13
 
14
14
  const { spawn } = require("node:child_process");
@@ -19,6 +19,7 @@ const path = require("node:path");
19
19
  const resourcesRoot = path.resolve(__dirname, "..", "resources");
20
20
  const serverEntry = path.join(resourcesRoot, "local-server.cjs");
21
21
  const rendererEntry = path.join(resourcesRoot, "web", "apps", "web", "server.js");
22
+ const appDir = path.join(resourcesRoot, "app");
22
23
 
23
24
  const HOST = "0.0.0.0";
24
25
  const LOCAL_PORT = process.env.FANTASTA_PORT ?? "47821";
@@ -30,6 +31,8 @@ const DATA_DIR = process.env.FANTASTA_DATA_DIR ?? path.join(homedir(), ".fantast
30
31
  const DB_PATH = process.env.FANTASTA_DATABASE_PATH ?? path.join(DATA_DIR, "fantasta.db");
31
32
  const SESSION_FILE = process.env.FANTASTA_SESSION_FILE ?? path.join(DATA_DIR, "admin-session.txt");
32
33
 
34
+ const browserMode = process.argv.includes("--browser") || process.env.FANTASTA_BROWSER === "1";
35
+
33
36
  let running = [];
34
37
 
35
38
  function log(message) {
@@ -40,16 +43,16 @@ function error(message) {
40
43
  console.error(`[fantasta] Errore: ${message}`);
41
44
  }
42
45
 
43
- async function waitForHealth(url, label, timeoutMs = 20_000) {
46
+ async function waitForHealth(url, label, healthPath = "/api/health", timeoutMs = 30_000) {
44
47
  const deadline = Date.now() + timeoutMs;
45
48
  while (Date.now() < deadline) {
46
49
  try {
47
- const response = await fetch(new URL("/api/health", url));
50
+ const response = await fetch(new URL(healthPath, url));
48
51
  if (response.ok) return;
49
52
  } catch {
50
53
  // il processo si sta ancora avviando
51
54
  }
52
- await new Promise((resolve) => setTimeout(resolve, 200));
55
+ await new Promise((resolve) => setTimeout(resolve, 250));
53
56
  }
54
57
  throw new Error(`Il processo ${label} non ha risposto in tempo`);
55
58
  }
@@ -112,11 +115,20 @@ function openBrowser(url) {
112
115
  }
113
116
  }
114
117
 
115
- function sleep(ms) {
116
- return new Promise((resolve) => setTimeout(resolve, ms));
118
+ function electronBinary() {
119
+ try {
120
+ // Quando eseguito da Node, `require("electron")` restituisce il percorso
121
+ // del binario Electron (non il modulo). Se electron non è installato il
122
+ // require fallisce e si cade nella modalità browser.
123
+ const electron = require("electron");
124
+ return typeof electron === "string" ? electron : null;
125
+ } catch {
126
+ return null;
127
+ }
117
128
  }
118
129
 
119
- async function main() {
130
+ async function runBrowserMode() {
131
+ log("Modalità browser (senza finestra).");
120
132
  startServer();
121
133
 
122
134
  let errorLogged = false;
@@ -135,9 +147,10 @@ async function main() {
135
147
  await waitForHealth(LOCAL_URL, "server locale");
136
148
  log("Server locale pronto");
137
149
  const sessionId = await resolveAdminSession();
138
- log(`Sessione admin ${errorLogged ? "" : "pronta"}`);
150
+ log("Sessione admin pronta");
139
151
  startRenderer();
140
- await waitForHealth(new URL("/local/home", RENDERER_URL), "renderer");
152
+ // Il renderer Next NON espone /api/health: si attende la pagina /local/setup.
153
+ await waitForHealth(RENDERER_URL, "renderer", "/local/setup");
141
154
 
142
155
  const home = new URL("/local/home", RENDERER_URL);
143
156
  home.searchParams.set("server", LOCAL_URL);
@@ -175,4 +188,49 @@ async function main() {
175
188
  }
176
189
  }
177
190
 
191
+ async function runDesktopMode() {
192
+ const binary = electronBinary();
193
+ if (!binary) {
194
+ log("Electron non trovato: passo alla modalità browser.");
195
+ return runBrowserMode();
196
+ }
197
+ // Crea la directory dei dati prima che Electron apra il database.
198
+ await mkdir(DATA_DIR, { recursive: true });
199
+ log(`Avvio app desktop (Electron) da ${appDir}`);
200
+ const child = spawn(binary, [appDir], {
201
+ env: {
202
+ ...process.env,
203
+ FANTASTA_NPM_MODE: "1",
204
+ FANTASTA_LOCAL_SERVER_URL: LOCAL_URL,
205
+ FANTASTA_HOST: HOST,
206
+ FANTASTA_PORT: LOCAL_PORT,
207
+ FANTASTA_RENDERER_PORT: RENDERER_PORT,
208
+ FANTASTA_DATABASE_PATH: DB_PATH,
209
+ },
210
+ stdio: "inherit",
211
+ });
212
+ running.push(child);
213
+
214
+ const interrupted = await new Promise((resolve) => {
215
+ const onSignal = () => {
216
+ running.forEach((c) => { try { c.kill("SIGTERM"); } catch {} });
217
+ resolve(true);
218
+ };
219
+ child.on("exit", (code, signal) => resolve(code === 0 ? false : true));
220
+ process.once("SIGINT", onSignal);
221
+ process.once("SIGTERM", onSignal);
222
+ });
223
+ if (interrupted) {
224
+ log("App desktop chiusa. L'asta è offline.");
225
+ }
226
+ }
227
+
228
+ async function main() {
229
+ if (browserMode) {
230
+ await runBrowserMode();
231
+ } else {
232
+ await runDesktopMode();
233
+ }
234
+ }
235
+
178
236
  void main();
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "fantasta",
3
- "version": "0.2.0",
4
- "description": "Asta di Fantacalcio in LAN senza server: avvia la regia locale e i partecipanti si collegano dal telefono. Nessuna installazione richiesta.",
3
+ "version": "0.3.0",
4
+ "description": "Asta di Fantacalcio in LAN senza server: avvia la regia come vera app desktop e i partecipanti si collegano dal telefono. Nessuna installazione richiesta.",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",
7
7
  "bin": {
@@ -16,6 +16,9 @@
16
16
  "engines": {
17
17
  "node": ">=22.13.0"
18
18
  },
19
+ "dependencies": {
20
+ "electron": "44.0.0"
21
+ },
19
22
  "scripts": {
20
23
  "prepack": "node scripts/prepack.mjs"
21
24
  },
@@ -0,0 +1,364 @@
1
+ const { app, BrowserWindow, dialog, ipcMain, safeStorage, session, shell, utilityProcess } = require("electron");
2
+ const { spawn } = require("node:child_process");
3
+ const { readFile, writeFile, mkdir, copyFile, rename, unlink, access } = require("node:fs/promises");
4
+ const { randomUUID } = require("node:crypto");
5
+ const path = require("node:path");
6
+ const { backup, DatabaseSync } = require("node:sqlite");
7
+
8
+ const repositoryRoot = path.resolve(__dirname, "../../..");
9
+ const localServerUrl = process.env.FANTASTA_LOCAL_SERVER_URL ?? "http://127.0.0.1:47821";
10
+ let rendererOrigin = process.env.FANTASTA_RENDERER_URL ?? "http://127.0.0.1:3000";
11
+ let localService;
12
+ let rendererService;
13
+ let hostConfig = { sessionId: null, leagueCode: null };
14
+
15
+ // Modalità "npm" (npx fantasta): Electron gira dal pacchetto npm, NON da un
16
+ // .app impacchettato. Le risorse (local-server.cjs + renderer standalone)
17
+ // stanno fuori da app/: main.cjs vive in resources/app/electron, quindi
18
+ // resourcesRoot = ../../ (= resources/ del pacchetto).
19
+ const npmMode = process.env.FANTASTA_NPM_MODE === "1";
20
+ const bundled = app.isPackaged || npmMode;
21
+ const resourcesRoot = npmMode && !app.isPackaged
22
+ ? path.resolve(__dirname, "../..")
23
+ : process.resourcesPath;
24
+
25
+ function hostConfigPath() {
26
+ return path.join(app.getPath("userData"), "host.json");
27
+ }
28
+
29
+ function databasePath() {
30
+ return process.env.FANTASTA_DATABASE_PATH ?? path.join(app.getPath("userData"), "fantasta.db");
31
+ }
32
+
33
+ function assertRenderer(event) {
34
+ const sender = new URL(event.senderFrame.url);
35
+ if (sender.origin !== assertLoopback(rendererOrigin, "FANTASTA_RENDERER_URL").origin) {
36
+ throw new Error("Renderer non autorizzato");
37
+ }
38
+ }
39
+
40
+ async function exportBackup() {
41
+ const date = new Date().toISOString().slice(0, 10);
42
+ const destination = await dialog.showSaveDialog({
43
+ title: "Esporta backup Fantasta",
44
+ defaultPath: path.join(app.getPath("documents"), `fantasta-backup-${date}.db`),
45
+ filters: [{ name: "Database Fantasta", extensions: ["db", "sqlite"] }],
46
+ });
47
+ if (destination.canceled || !destination.filePath) return { canceled: true };
48
+ const database = new DatabaseSync(databasePath(), { timeout: 5_000 });
49
+ try {
50
+ const pages = await backup(database, destination.filePath, { rate: 100 });
51
+ return { canceled: false, path: destination.filePath, pages };
52
+ } finally {
53
+ database.close();
54
+ }
55
+ }
56
+
57
+ async function fileExists(filePath) {
58
+ try { await access(filePath); return true; } catch { return false; }
59
+ }
60
+
61
+ function validateBackup(filePath) {
62
+ const database = new DatabaseSync(filePath, { readOnly: true, timeout: 5_000 });
63
+ try {
64
+ const migrations = database.prepare("SELECT COUNT(*) AS count FROM schema_migrations").get();
65
+ const leagues = database.prepare("SELECT COUNT(*) AS count FROM leagues").get();
66
+ if (!migrations || !leagues) throw new Error("Il file non contiene un database Fantasta valido");
67
+ } finally {
68
+ database.close();
69
+ }
70
+ }
71
+
72
+ async function stopLocalService() {
73
+ const service = localService;
74
+ localService = undefined;
75
+ if (!service || service.exitCode !== null) return;
76
+ await new Promise((resolve) => {
77
+ const timeout = setTimeout(() => { try { service.kill(); } catch {} resolve(); }, 5_000);
78
+ service.once("exit", () => { clearTimeout(timeout); resolve(); });
79
+ try { service.kill(); } catch {}
80
+ });
81
+ }
82
+
83
+ async function stopRendererService() {
84
+ const service = rendererService;
85
+ rendererService = undefined;
86
+ if (!service || service.exitCode !== null) return;
87
+ await new Promise((resolve) => {
88
+ const timeout = setTimeout(() => { service.kill("SIGKILL"); resolve(); }, 5_000);
89
+ service.once("exit", () => { clearTimeout(timeout); resolve(); });
90
+ service.kill("SIGTERM");
91
+ });
92
+ }
93
+
94
+ async function restoreBackup() {
95
+ if (process.env.FANTASTA_START_LOCAL_SERVER === "0") {
96
+ throw new Error("Il ripristino richiede il servizio locale gestito da Electron");
97
+ }
98
+ const selection = await dialog.showOpenDialog({
99
+ title: "Ripristina backup Fantasta",
100
+ properties: ["openFile"],
101
+ filters: [{ name: "Database Fantasta", extensions: ["db", "sqlite"] }],
102
+ });
103
+ if (selection.canceled || !selection.filePaths[0]) return { canceled: true };
104
+ validateBackup(selection.filePaths[0]);
105
+ const confirmation = await dialog.showMessageBox({
106
+ type: "warning",
107
+ buttons: ["Annulla", "Ripristina"],
108
+ defaultId: 0,
109
+ cancelId: 0,
110
+ title: "Ripristinare questo backup?",
111
+ message: "L'asta corrente verrà sostituita e tutti i partecipanti dovranno riconnettersi.",
112
+ detail: "Prima del ripristino verrà conservata una copia di sicurezza del database attuale.",
113
+ });
114
+ if (confirmation.response !== 1) return { canceled: true };
115
+
116
+ const target = databasePath();
117
+ const folder = path.dirname(target);
118
+ const candidate = path.join(folder, `fantasta-restore-${randomUUID()}.tmp`);
119
+ const safety = path.join(folder, `fantasta-before-restore-${Date.now()}.db`);
120
+ let previousPath = null;
121
+ let replacementInstalled = false;
122
+ try {
123
+ await stopLocalService();
124
+ if (await fileExists(target)) {
125
+ const current = new DatabaseSync(target, { timeout: 5_000 });
126
+ try { await backup(current, safety, { rate: 100 }); } finally { current.close(); }
127
+ previousPath = `${target}.pre-restore-${Date.now()}`;
128
+ await rename(target, previousPath);
129
+ }
130
+ await copyFile(selection.filePaths[0], candidate);
131
+ validateBackup(candidate);
132
+ await rename(candidate, target);
133
+ replacementInstalled = true;
134
+ await Promise.all([unlink(`${target}-wal`).catch(() => {}), unlink(`${target}-shm`).catch(() => {})]);
135
+ await startLocalService();
136
+ for (const window of BrowserWindow.getAllWindows()) window.webContents.reloadIgnoringCache();
137
+ return { canceled: false, safetyPath: await fileExists(safety) ? safety : undefined };
138
+ } catch (error) {
139
+ await unlink(candidate).catch(() => {});
140
+ if (previousPath && await fileExists(previousPath)) {
141
+ if (replacementInstalled && await fileExists(target)) {
142
+ await rename(target, `${target}.failed-restore-${Date.now()}`).catch(() => {});
143
+ }
144
+ if (!(await fileExists(target))) await rename(previousPath, target).catch(() => {});
145
+ }
146
+ if (!localService) await startLocalService().catch(() => {});
147
+ throw error;
148
+ }
149
+ }
150
+
151
+ async function loadHostConfig() {
152
+ try {
153
+ const raw = JSON.parse(await readFile(hostConfigPath(), "utf8"));
154
+ const leagueCode = typeof raw.leagueCode === "string" ? raw.leagueCode : null;
155
+ if (typeof raw.encryptedSession !== "string" || !(await safeStorage.isAsyncEncryptionAvailable())) {
156
+ return { sessionId: null, leagueCode };
157
+ }
158
+ const decrypted = await safeStorage.decryptStringAsync(Buffer.from(raw.encryptedSession, "base64"));
159
+ return { sessionId: decrypted.result, leagueCode };
160
+ } catch {
161
+ return { sessionId: null, leagueCode: null };
162
+ }
163
+ }
164
+
165
+ async function saveHostConfig(update) {
166
+ hostConfig = { ...hostConfig, ...update };
167
+ await mkdir(path.dirname(hostConfigPath()), { recursive: true });
168
+ const payload = { leagueCode: hostConfig.leagueCode };
169
+ if (hostConfig.sessionId && await safeStorage.isAsyncEncryptionAvailable()) {
170
+ payload.encryptedSession = (await safeStorage.encryptStringAsync(hostConfig.sessionId)).toString("base64");
171
+ }
172
+ await writeFile(hostConfigPath(), JSON.stringify(payload), { mode: 0o600 });
173
+ }
174
+
175
+ function assertLoopback(value, label) {
176
+ const url = new URL(value);
177
+ if (!["127.0.0.1", "localhost", "::1"].includes(url.hostname)) {
178
+ throw new Error(`${label} deve essere un URL loopback`);
179
+ }
180
+ return url;
181
+ }
182
+
183
+ async function waitForHealth(url) {
184
+ const deadline = Date.now() + 12_000;
185
+ while (Date.now() < deadline) {
186
+ try {
187
+ const response = await fetch(new URL("/api/health", url));
188
+ if (response.ok) return;
189
+ } catch { /* il processo locale sta ancora avviandosi */ }
190
+ await new Promise((resolve) => setTimeout(resolve, 150));
191
+ }
192
+ throw new Error("Il servizio locale non ha risposto in tempo");
193
+ }
194
+
195
+ async function startLocalService() {
196
+ if (process.env.FANTASTA_START_LOCAL_SERVER === "0") return;
197
+ // In sviluppo il server SQLite gira attraverso tsx: NON e un runtime Electron,
198
+ // quindi lo si mantiene con child_process.spawn (qui l'icona Dock non interessa).
199
+ if (!bundled) {
200
+ const entrypoint = path.join(repositoryRoot, "apps/desktop/src/main.ts");
201
+ localService = spawn(process.execPath, ["--import", "tsx", entrypoint], {
202
+ cwd: repositoryRoot,
203
+ env: {
204
+ ...process.env,
205
+ ELECTRON_RUN_AS_NODE: "1",
206
+ FANTASTA_HOST: "0.0.0.0",
207
+ FANTASTA_PORT: new URL(localServerUrl).port || "47821",
208
+ FANTASTA_DATABASE_PATH: databasePath(),
209
+ },
210
+ stdio: "inherit",
211
+ });
212
+ await waitForHealth(localServerUrl);
213
+ return;
214
+ }
215
+ // Nel pacchetto il server locale e un file Node puro: utilityProcess.fork lo
216
+ // esegue come helper invisibile di Electron, senza icona separata nel Dock
217
+ // (a differenza di child_process.spawn del binario Electron con RUN_AS_NODE,
218
+ // che macOS mostra come seconda app "exec").
219
+ const entrypoint = path.join(resourcesRoot, "local-server.cjs");
220
+ localService = utilityProcess.fork(entrypoint, [], {
221
+ env: {
222
+ ...process.env,
223
+ FANTASTA_HOST: "0.0.0.0",
224
+ FANTASTA_PORT: new URL(localServerUrl).port || "47821",
225
+ FANTASTA_DATABASE_PATH: databasePath(),
226
+ },
227
+ stdio: "inherit",
228
+ });
229
+ await waitForHealth(localServerUrl);
230
+ }
231
+
232
+ async function waitForRenderer(url) {
233
+ const deadline = Date.now() + 20_000;
234
+ while (Date.now() < deadline) {
235
+ try {
236
+ const response = await fetch(new URL("/local/setup", url));
237
+ if (response.ok) return;
238
+ } catch { /* Next standalone sta ancora avviandosi */ }
239
+ await new Promise((resolve) => setTimeout(resolve, 150));
240
+ }
241
+ throw new Error("Il renderer locale non ha risposto in tempo");
242
+ }
243
+
244
+ async function startRendererService() {
245
+ if (!bundled || process.env.FANTASTA_RENDERER_URL) return;
246
+ const port = process.env.FANTASTA_RENDERER_PORT ?? "47822";
247
+ rendererOrigin = `http://127.0.0.1:${port}`;
248
+ const entrypoint = path.join(resourcesRoot, "web", "apps", "web", "server.js");
249
+ // Il renderer deve essere raggiungibile dalla LAN: il QR del wizard punta a
250
+ // http://<IP-locale>:47822, dove i telefoni caricano la vista partecipante.
251
+ // (In sviluppo il renderer è esposto con `next dev --hostname 0.0.0.0`.)
252
+ rendererService = spawn(process.execPath, [entrypoint], {
253
+ env: { ...process.env, ELECTRON_RUN_AS_NODE: "1", HOSTNAME: "0.0.0.0", PORT: port },
254
+ stdio: "inherit",
255
+ });
256
+ await waitForRenderer(rendererOrigin);
257
+ }
258
+
259
+ async function resolveAdminSession() {
260
+ const configured = process.env.FANTASTA_ADMIN_SESSION ?? hostConfig.sessionId;
261
+ if (configured) return configured;
262
+ const response = await fetch(new URL("/api/session", localServerUrl), { method: "POST" });
263
+ if (!response.ok) throw new Error("Impossibile creare la sessione admin locale");
264
+ const payload = await response.json();
265
+ if (!payload.sessionId) throw new Error("Risposta sessione locale non valida");
266
+ await saveHostConfig({ sessionId: payload.sessionId });
267
+ return payload.sessionId;
268
+ }
269
+
270
+ function createWindow(adminSession) {
271
+ const rendererUrl = assertLoopback(rendererOrigin, "FANTASTA_RENDERER_URL");
272
+ const serverUrl = assertLoopback(localServerUrl, "FANTASTA_LOCAL_SERVER_URL");
273
+ const leagueCode = process.env.FANTASTA_LEAGUE_CODE ?? hostConfig.leagueCode;
274
+ const window = new BrowserWindow({
275
+ title: "Fantasta",
276
+ width: 1440,
277
+ height: 940,
278
+ minWidth: 1024,
279
+ minHeight: 700,
280
+ show: false,
281
+ autoHideMenuBar: process.platform !== "darwin",
282
+ backgroundColor: "#f4f8f5",
283
+ webPreferences: {
284
+ preload: path.join(__dirname, "preload.cjs"),
285
+ contextIsolation: true,
286
+ sandbox: true,
287
+ nodeIntegration: false,
288
+ spellcheck: true,
289
+ },
290
+ });
291
+ // L'app si apre sempre dalla home: da lì si crea una nuova asta oppure si
292
+ // rientra in regia con la card "Entra nella tua asta" (se c'è una lega salvata).
293
+ const target = new URL("/local/home", rendererUrl);
294
+ // `server` serve a setup/regia: senza, le pagine /local mostrano il fallback
295
+ // "Configurazione desktop mancante".
296
+ target.searchParams.set("server", serverUrl.toString());
297
+ if (leagueCode) target.searchParams.set("league", leagueCode);
298
+ target.searchParams.set("session", adminSession);
299
+ window.once("ready-to-show", () => window.show());
300
+ window.webContents.setWindowOpenHandler(({ url }) => {
301
+ try {
302
+ const external = new URL(url);
303
+ if (external.protocol === "https:" && external.hostname === "github.com") {
304
+ void shell.openExternal(external.toString());
305
+ }
306
+ } catch { /* URL non valido: resta bloccato */ }
307
+ return { action: "deny" };
308
+ });
309
+ window.webContents.on("will-navigate", (event, url) => {
310
+ if (new URL(url).origin !== rendererUrl.origin) event.preventDefault();
311
+ });
312
+ void window.loadURL(target.toString());
313
+ }
314
+
315
+ if (!app.requestSingleInstanceLock()) app.quit();
316
+ else {
317
+ app.on("second-instance", () => BrowserWindow.getAllWindows()[0]?.focus());
318
+ app.whenReady().then(async () => {
319
+ // Da npm l'app gira come binario "Electron": rinominare subito fa sì che
320
+ // userData (e quindi il database) finisca in ~/Library/Application Support/
321
+ // Fantasta, come per l'app installata, non in ".../Electron".
322
+ if (npmMode) app.setName("Fantasta");
323
+ app.setAboutPanelOptions({
324
+ applicationName: "Fantasta",
325
+ applicationVersion: app.getVersion(),
326
+ copyright: "Copyright © 2026 Davide Pirovano",
327
+ website: "https://github.com/Davide-Pirovano/fantasta",
328
+ });
329
+ // Solo write sanificato della clipboard (il "copia link" nel wizard/regia):
330
+ // bloccato qui, navigator.clipboard.writeText fallisce in silenzio nel renderer.
331
+ session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback) => {
332
+ callback(permission === "clipboard-sanitized-write");
333
+ });
334
+ try {
335
+ hostConfig = await loadHostConfig();
336
+ await startLocalService();
337
+ await startRendererService();
338
+ const adminSession = await resolveAdminSession();
339
+ ipcMain.handle("fantasta:save-host-config", async (event, update) => {
340
+ assertRenderer(event);
341
+ if (!update || typeof update.leagueCode !== "string" || !/^[A-Z0-9]{4,12}$/i.test(update.leagueCode)) throw new Error("Codice lega non valido");
342
+ await saveHostConfig({ leagueCode: update.leagueCode.toUpperCase() });
343
+ });
344
+ ipcMain.handle("fantasta:export-backup", async (event) => {
345
+ assertRenderer(event);
346
+ return exportBackup();
347
+ });
348
+ ipcMain.handle("fantasta:restore-backup", async (event) => {
349
+ assertRenderer(event);
350
+ return restoreBackup();
351
+ });
352
+ createWindow(adminSession);
353
+ } catch (error) {
354
+ console.error(error);
355
+ app.quit();
356
+ }
357
+ });
358
+ }
359
+
360
+ app.on("window-all-closed", () => app.quit());
361
+ app.on("before-quit", () => {
362
+ try { localService?.kill(); } catch {}
363
+ try { rendererService?.kill("SIGTERM"); } catch {}
364
+ });
@@ -0,0 +1,9 @@
1
+ const { contextBridge, ipcRenderer } = require("electron");
2
+
3
+ // Bridge intenzionalmente minimo: nessun accesso a filesystem, shell o IPC generico.
4
+ contextBridge.exposeInMainWorld("fantastaDesktop", {
5
+ platform: process.platform,
6
+ saveHostConfig: (config) => ipcRenderer.invoke("fantasta:save-host-config", config),
7
+ exportBackup: () => ipcRenderer.invoke("fantasta:export-backup"),
8
+ restoreBackup: () => ipcRenderer.invoke("fantasta:restore-backup"),
9
+ });
@@ -0,0 +1,6 @@
1
+ {
2
+ "name": "fantasta",
3
+ "version": "1.0.0",
4
+ "main": "electron/main.cjs",
5
+ "private": true
6
+ }