fantasta 0.2.0 → 0.3.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 +34 -22
- package/bin/fantasta.js +74 -16
- package/package.json +5 -2
- package/resources/app/build/icon.icns +0 -0
- package/resources/app/build/icon.ico +0 -0
- package/resources/app/electron/main.cjs +385 -0
- package/resources/app/electron/preload.cjs +9 -0
- package/resources/app/package.json +6 -0
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
|
|
4
|
-
|
|
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
|
|
16
|
-
- si avvia il **
|
|
17
|
-
- si
|
|
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
|
-
-
|
|
32
|
-
|
|
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` |
|
|
43
|
-
| `FANTASTA_DATA_DIR` | `~/.fantasta` | Cartella dei dati (
|
|
44
|
-
| `
|
|
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
|
|
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
|
-
|
|
60
|
-
|
|
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
|
|
2
|
+
// Launcher "fantasta".
|
|
3
3
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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 =
|
|
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(
|
|
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,
|
|
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
|
|
116
|
-
|
|
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
|
|
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(
|
|
150
|
+
log("Sessione admin pronta");
|
|
139
151
|
startRenderer();
|
|
140
|
-
|
|
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.
|
|
4
|
-
"description": "Asta di Fantacalcio in LAN senza server: avvia la regia
|
|
3
|
+
"version": "0.3.1",
|
|
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
|
},
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
const { app, BrowserWindow, dialog, ipcMain, nativeImage, 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
|
+
// utilityProcess.fork, come per il server locale, evita la seconda icona
|
|
253
|
+
// "exec" nel Dock di macOS (spawn del binario Electron con RUN_AS_NODE la
|
|
254
|
+
// creerebbe).
|
|
255
|
+
rendererService = utilityProcess.fork(entrypoint, [], {
|
|
256
|
+
cwd: path.join(resourcesRoot, "web"),
|
|
257
|
+
env: { ...process.env, HOSTNAME: "0.0.0.0", PORT: port },
|
|
258
|
+
stdio: "inherit",
|
|
259
|
+
});
|
|
260
|
+
await waitForRenderer(rendererOrigin);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async function resolveAdminSession() {
|
|
264
|
+
const configured = process.env.FANTASTA_ADMIN_SESSION ?? hostConfig.sessionId;
|
|
265
|
+
if (configured) return configured;
|
|
266
|
+
const response = await fetch(new URL("/api/session", localServerUrl), { method: "POST" });
|
|
267
|
+
if (!response.ok) throw new Error("Impossibile creare la sessione admin locale");
|
|
268
|
+
const payload = await response.json();
|
|
269
|
+
if (!payload.sessionId) throw new Error("Risposta sessione locale non valida");
|
|
270
|
+
await saveHostConfig({ sessionId: payload.sessionId });
|
|
271
|
+
return payload.sessionId;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Icona dell'app quando si gira dal bundle Electron generico (modalità npm).
|
|
275
|
+
// Nel pacchetto .dmg/.exe l'icona arriva già dal bundle, qui va impostata a mano.
|
|
276
|
+
function applyAppIcon(window) {
|
|
277
|
+
if (process.platform === "darwin") {
|
|
278
|
+
try {
|
|
279
|
+
const image = nativeImage.createFromPath(path.join(__dirname, "..", "build", "icon.icns"));
|
|
280
|
+
if (!image.isEmpty() && app.dock) app.dock.setIcon(image);
|
|
281
|
+
} catch { /* icona opzionale */ }
|
|
282
|
+
} else if (window) {
|
|
283
|
+
const icon = process.platform === "win32"
|
|
284
|
+
? path.join(__dirname, "..", "build", "icon.ico")
|
|
285
|
+
: path.join(__dirname, "..", "build", "icon.icns");
|
|
286
|
+
try { window.setIcon(nativeImage.createFromPath(icon)); } catch { /* icona opzionale */ }
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function createWindow(adminSession) {
|
|
291
|
+
const rendererUrl = assertLoopback(rendererOrigin, "FANTASTA_RENDERER_URL");
|
|
292
|
+
const serverUrl = assertLoopback(localServerUrl, "FANTASTA_LOCAL_SERVER_URL");
|
|
293
|
+
const leagueCode = process.env.FANTASTA_LEAGUE_CODE ?? hostConfig.leagueCode;
|
|
294
|
+
const window = new BrowserWindow({
|
|
295
|
+
title: "Fantasta",
|
|
296
|
+
width: 1440,
|
|
297
|
+
height: 940,
|
|
298
|
+
minWidth: 1024,
|
|
299
|
+
minHeight: 700,
|
|
300
|
+
show: false,
|
|
301
|
+
autoHideMenuBar: process.platform !== "darwin",
|
|
302
|
+
backgroundColor: "#f4f8f5",
|
|
303
|
+
webPreferences: {
|
|
304
|
+
preload: path.join(__dirname, "preload.cjs"),
|
|
305
|
+
contextIsolation: true,
|
|
306
|
+
sandbox: true,
|
|
307
|
+
nodeIntegration: false,
|
|
308
|
+
spellcheck: true,
|
|
309
|
+
},
|
|
310
|
+
});
|
|
311
|
+
if (npmMode) applyAppIcon(window);
|
|
312
|
+
// L'app si apre sempre dalla home: da lì si crea una nuova asta oppure si
|
|
313
|
+
// rientra in regia con la card "Entra nella tua asta" (se c'è una lega salvata).
|
|
314
|
+
const target = new URL("/local/home", rendererUrl);
|
|
315
|
+
// `server` serve a setup/regia: senza, le pagine /local mostrano il fallback
|
|
316
|
+
// "Configurazione desktop mancante".
|
|
317
|
+
target.searchParams.set("server", serverUrl.toString());
|
|
318
|
+
if (leagueCode) target.searchParams.set("league", leagueCode);
|
|
319
|
+
target.searchParams.set("session", adminSession);
|
|
320
|
+
window.once("ready-to-show", () => window.show());
|
|
321
|
+
window.webContents.setWindowOpenHandler(({ url }) => {
|
|
322
|
+
try {
|
|
323
|
+
const external = new URL(url);
|
|
324
|
+
if (external.protocol === "https:" && external.hostname === "github.com") {
|
|
325
|
+
void shell.openExternal(external.toString());
|
|
326
|
+
}
|
|
327
|
+
} catch { /* URL non valido: resta bloccato */ }
|
|
328
|
+
return { action: "deny" };
|
|
329
|
+
});
|
|
330
|
+
window.webContents.on("will-navigate", (event, url) => {
|
|
331
|
+
if (new URL(url).origin !== rendererUrl.origin) event.preventDefault();
|
|
332
|
+
});
|
|
333
|
+
void window.loadURL(target.toString());
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (!app.requestSingleInstanceLock()) app.quit();
|
|
337
|
+
else {
|
|
338
|
+
// Da npm l'app gira come binario "Electron": rinominare subito fa sì che
|
|
339
|
+
// userData (e quindi il database) finisca in ~/Library/Application Support/
|
|
340
|
+
// Fantasta, come per l'app installata, non in ".../Electron".
|
|
341
|
+
if (npmMode) app.setName("Fantasta");
|
|
342
|
+
app.on("second-instance", () => BrowserWindow.getAllWindows()[0]?.focus());
|
|
343
|
+
app.whenReady().then(async () => {
|
|
344
|
+
app.setAboutPanelOptions({
|
|
345
|
+
applicationName: "Fantasta",
|
|
346
|
+
applicationVersion: app.getVersion(),
|
|
347
|
+
copyright: "Copyright © 2026 Davide Pirovano",
|
|
348
|
+
website: "https://github.com/Davide-Pirovano/fantasta",
|
|
349
|
+
});
|
|
350
|
+
// Solo write sanificato della clipboard (il "copia link" nel wizard/regia):
|
|
351
|
+
// bloccato qui, navigator.clipboard.writeText fallisce in silenzio nel renderer.
|
|
352
|
+
session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback) => {
|
|
353
|
+
callback(permission === "clipboard-sanitized-write");
|
|
354
|
+
});
|
|
355
|
+
try {
|
|
356
|
+
hostConfig = await loadHostConfig();
|
|
357
|
+
await startLocalService();
|
|
358
|
+
await startRendererService();
|
|
359
|
+
const adminSession = await resolveAdminSession();
|
|
360
|
+
ipcMain.handle("fantasta:save-host-config", async (event, update) => {
|
|
361
|
+
assertRenderer(event);
|
|
362
|
+
if (!update || typeof update.leagueCode !== "string" || !/^[A-Z0-9]{4,12}$/i.test(update.leagueCode)) throw new Error("Codice lega non valido");
|
|
363
|
+
await saveHostConfig({ leagueCode: update.leagueCode.toUpperCase() });
|
|
364
|
+
});
|
|
365
|
+
ipcMain.handle("fantasta:export-backup", async (event) => {
|
|
366
|
+
assertRenderer(event);
|
|
367
|
+
return exportBackup();
|
|
368
|
+
});
|
|
369
|
+
ipcMain.handle("fantasta:restore-backup", async (event) => {
|
|
370
|
+
assertRenderer(event);
|
|
371
|
+
return restoreBackup();
|
|
372
|
+
});
|
|
373
|
+
createWindow(adminSession);
|
|
374
|
+
} catch (error) {
|
|
375
|
+
console.error(error);
|
|
376
|
+
app.quit();
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
app.on("window-all-closed", () => app.quit());
|
|
382
|
+
app.on("before-quit", () => {
|
|
383
|
+
try { localService?.kill(); } catch {}
|
|
384
|
+
try { rendererService?.kill("SIGTERM"); } catch {}
|
|
385
|
+
});
|
|
@@ -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
|
+
});
|