Skip to content

Commit eace90b

Browse files
committed
feat: initialize Vagas Full application with Electron, React, and Node.js
- Added package.json with project metadata, dependencies, and build scripts. - Created loading.html for a loading screen with a spinner and status message. - Implemented main.js to manage the Electron app lifecycle, including backend startup and health checks. - Added preload.js for potential IPC helpers in the future.
1 parent 5a67d3c commit eace90b

11 files changed

Lines changed: 6448 additions & 1734 deletions

File tree

.gitignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,7 @@ coverage/
1717
frontend/coverage/
1818
backend/coverage/
1919
*.lcov
20-
.nyc_output/
20+
.nyc_output/
21+
22+
# Dist-electron
23+
dist-electron/

backend/src/jobsApiApp.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@ import express from "express";
33
import { existsSync, mkdirSync, readdirSync, statSync } from "fs";
44
import path from "path";
55
import XLSX from "xlsx";
6+
import { run as runScraper } from "./app.js";
67

78
/**
89
* @param {{ outputDir?: string }} [options]
910
*/
1011
export function createJobsApiApp(options = {}) {
1112
const outputDir = options.outputDir ?? path.resolve(process.cwd(), "output");
1213
const app = express();
14+
let activeScraperRun = null;
1315

1416
app.use(cors());
1517

@@ -99,5 +101,33 @@ export function createJobsApiApp(options = {}) {
99101
}
100102
});
101103

104+
app.post("/api/scraper/run", async (_req, res) => {
105+
if (activeScraperRun) {
106+
return res.status(409).json({
107+
message: "O scraper ja esta em execucao.",
108+
});
109+
}
110+
111+
try {
112+
activeScraperRun = runScraper();
113+
await activeScraperRun;
114+
115+
const files = listXlsxFiles();
116+
return res.json({
117+
ok: true,
118+
file: files[0]?.file ?? null,
119+
modifiedAt: files[0]?.modifiedAt ?? null,
120+
totalFiles: files.length,
121+
});
122+
} catch (error) {
123+
return res.status(500).json({
124+
message: "Erro ao executar o scraper.",
125+
error: error?.message || "Erro desconhecido",
126+
});
127+
} finally {
128+
activeScraperRun = null;
129+
}
130+
});
131+
102132
return app;
103133
}

backend/src/server.js

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,35 @@
11
import "dotenv/config";
2+
import express from "express";
3+
import { existsSync } from "fs";
24
import path from "path";
35
import { createJobsApiApp } from "./jobsApiApp.js";
46

57
const PORT = Number(process.env.PORT || 3001);
6-
const app = createJobsApiApp({
7-
outputDir: path.resolve(process.cwd(), "output"),
8-
});
8+
9+
// When running inside Electron, main.js sets ELECTRON_OUTPUT_DIR to a
10+
// writable user-data path. Fall back to the CWD-relative folder for dev/CLI.
11+
const outputDir = process.env.ELECTRON_OUTPUT_DIR
12+
? process.env.ELECTRON_OUTPUT_DIR
13+
: path.resolve(process.cwd(), "output");
14+
15+
const app = createJobsApiApp({ outputDir });
16+
17+
// ── Electron static-file serving ───────────────────────────────────────────
18+
// When ELECTRON_STATIC_DIR is set, serve the React production build so that
19+
// the Electron window can load everything from http://localhost:<PORT>.
20+
const staticDir = process.env.ELECTRON_STATIC_DIR;
21+
if (staticDir && existsSync(staticDir)) {
22+
app.use(express.static(staticDir));
23+
24+
// SPA catch-all: return index.html for any non-API route so that React
25+
// Router (if used in future) works correctly.
26+
app.use((req, res) => {
27+
if (req.path.startsWith("/api")) {
28+
return res.status(404).json({ error: "Rota não encontrada." });
29+
}
30+
res.sendFile(path.join(staticDir, "index.html"));
31+
});
32+
}
933

1034
app.listen(PORT, () => {
1135
// eslint-disable-next-line no-console

electron/loading.html

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
<!DOCTYPE html>
2+
<html lang="pt-BR">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<!--
6+
Strict CSP: no inline scripts, no external resources.
7+
All styling is inline CSS only (allowed by 'unsafe-inline' for style-src).
8+
-->
9+
<meta http-equiv="Content-Security-Policy"
10+
content="default-src 'none'; style-src 'unsafe-inline'" />
11+
<title>Vagas Full – Carregando</title>
12+
<style>
13+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
14+
15+
body {
16+
display: flex;
17+
align-items: center;
18+
justify-content: center;
19+
height: 100vh;
20+
background: #0f172a; /* slate-900 */
21+
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
22+
color: #f1f5f9; /* slate-100 */
23+
user-select: none;
24+
-webkit-app-region: drag; /* makes frameless window draggable */
25+
}
26+
27+
.card {
28+
display: flex;
29+
flex-direction: column;
30+
align-items: center;
31+
gap: 20px;
32+
padding: 40px 48px;
33+
background: #1e293b; /* slate-800 */
34+
border: 1px solid #334155; /* slate-700 */
35+
border-radius: 16px;
36+
width: 420px;
37+
text-align: center;
38+
}
39+
40+
.logo {
41+
font-size: 26px;
42+
font-weight: 700;
43+
letter-spacing: -0.5px;
44+
color: #38bdf8; /* sky-400 */
45+
}
46+
47+
.spinner {
48+
width: 44px;
49+
height: 44px;
50+
border: 4px solid #334155;
51+
border-top-color: #38bdf8; /* sky-400 */
52+
border-radius: 50%;
53+
animation: spin 0.85s linear infinite;
54+
}
55+
56+
@keyframes spin {
57+
to { transform: rotate(360deg); }
58+
}
59+
60+
.status {
61+
font-size: 16px;
62+
font-weight: 600;
63+
color: #e2e8f0; /* slate-200 */
64+
}
65+
66+
.hint {
67+
font-size: 13px;
68+
color: #64748b; /* slate-500 */
69+
line-height: 1.5;
70+
}
71+
</style>
72+
</head>
73+
<body>
74+
<div class="card">
75+
<div class="logo">Vagas Full</div>
76+
<div class="spinner"></div>
77+
<p class="status">Buscando vagas no LinkedIn...</p>
78+
<p class="hint">Isso pode levar alguns instantes.<br>Aguarde enquanto os dados são coletados.</p>
79+
</div>
80+
</body>
81+
</html>

electron/main.js

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
'use strict';
2+
3+
// ─────────────────────────────────────────────────────────────────────────────
4+
// Vagas Full – Electron main process
5+
//
6+
// Boot flow:
7+
// 1. Show loading window while the backend starts
8+
// 2. Import backend server in-process → Express starts on localhost:3001
9+
// 3. Poll GET /api/health until the server accepts connections
10+
// 4. Open the main window (React app served by Express)
11+
// 5. Scraper execution is user-triggered from the UI via /api/scraper/run
12+
//
13+
// Why in-process import() instead of spawn('node', ...)?
14+
// spawn('node', ...) requires Node to be installed on the user's machine.
15+
// import() works because Electron ships its own Node runtime – the app
16+
// runs fully standalone, no external Node installation needed.
17+
// ─────────────────────────────────────────────────────────────────────────────
18+
19+
const { app, BrowserWindow, dialog } = require('electron');
20+
const path = require('path');
21+
const http = require('http');
22+
const { pathToFileURL } = require('url');
23+
24+
const BACKEND_PORT = 3001;
25+
const BACKEND_URL = `http://localhost:${BACKEND_PORT}`;
26+
const MAX_RETRIES = 60; // 60 × 500 ms = 30 s maximum wait
27+
const RETRY_INTERVAL_MS = 500; // ms between health-check polls
28+
29+
// ─── Path helpers ─────────────────────────────────────────────────────────────
30+
// app.getAppPath() returns:
31+
// development : workspace root (where root package.json lives)
32+
// packaged : <install>/resources/app (asar: false)
33+
34+
function getAppRootPath() {
35+
return app.getAppPath();
36+
}
37+
38+
function getFrontendDistPath() {
39+
return path.join(getAppRootPath(), 'frontend', 'dist');
40+
}
41+
42+
function getBackendServerPath() {
43+
return path.join(getAppRootPath(), 'backend', 'src', 'server.js');
44+
}
45+
46+
// userData is the OS user-data directory – writable even under Program Files,
47+
// and survives app updates.
48+
function getOutputDir() {
49+
return path.join(app.getPath('userData'), 'output');
50+
}
51+
52+
function getLoadingPagePath() {
53+
return path.join(getAppRootPath(), 'electron', 'loading.html');
54+
}
55+
56+
// ─── Backend startup (in-process) ─────────────────────────────────────────────
57+
58+
async function startBackend() {
59+
// All env vars must be set BEFORE the ESM module is imported so they are
60+
// visible at module-evaluation time.
61+
const outDir = getOutputDir();
62+
process.env.PORT = String(BACKEND_PORT);
63+
process.env.ELECTRON_OUTPUT_DIR = outDir; // API reads XLSX files from here
64+
process.env.ELECTRON_STATIC_DIR = getFrontendDistPath(); // Express serves the React build
65+
process.env.OUTPUT_FILE = path.join(outDir, 'vagas_linkedin.xlsx'); // scraper writes here
66+
process.env.PDF_FILE = path.join(outDir, 'vagas_linkedin.pdf'); // scraper writes here
67+
68+
// pathToFileURL produces a correct file:// URL on Windows (handles spaces & backslashes)
69+
await import(pathToFileURL(getBackendServerPath()).href);
70+
}
71+
72+
// ─── Health-check polling ──────────────────────────────────────────────────────
73+
74+
function waitForBackend() {
75+
return new Promise((resolve, reject) => {
76+
let retries = 0;
77+
78+
function check() {
79+
const req = http.get(
80+
`${BACKEND_URL}/api/health`,
81+
{ timeout: 2000 },
82+
(res) => {
83+
res.resume(); // drain body; only the status code matters
84+
if (res.statusCode === 200) {
85+
resolve();
86+
} else {
87+
scheduleRetry();
88+
}
89+
}
90+
);
91+
92+
req.on('error', scheduleRetry);
93+
req.on('timeout', () => {
94+
req.destroy();
95+
scheduleRetry();
96+
});
97+
}
98+
99+
function scheduleRetry() {
100+
retries += 1;
101+
if (retries >= MAX_RETRIES) {
102+
reject(
103+
new Error(
104+
`O servidor backend não respondeu após ${MAX_RETRIES} tentativas (${(MAX_RETRIES * RETRY_INTERVAL_MS) / 1000}s).`
105+
)
106+
);
107+
return;
108+
}
109+
setTimeout(check, RETRY_INTERVAL_MS);
110+
}
111+
112+
check();
113+
});
114+
}
115+
116+
// ─── Windows ──────────────────────────────────────────────────────────────────
117+
118+
function createLoadingWindow() {
119+
const win = new BrowserWindow({
120+
width: 520,
121+
height: 360,
122+
resizable: false,
123+
frame: false,
124+
show: false,
125+
backgroundColor: '#0f172a',
126+
webPreferences: {
127+
contextIsolation: true,
128+
nodeIntegration: false,
129+
sandbox: true,
130+
},
131+
});
132+
133+
win.loadFile(getLoadingPagePath());
134+
win.once('ready-to-show', () => win.show());
135+
return win;
136+
}
137+
138+
function createMainWindow() {
139+
const win = new BrowserWindow({
140+
width: 1280,
141+
height: 800,
142+
minWidth: 900,
143+
minHeight: 600,
144+
show: false,
145+
backgroundColor: '#0f172a',
146+
title: 'Vagas Full',
147+
webPreferences: {
148+
preload: path.join(__dirname, 'preload.js'),
149+
contextIsolation: true,
150+
nodeIntegration: false,
151+
sandbox: true,
152+
},
153+
});
154+
155+
// The React app is served by Express as static files.
156+
// loadURL keeps relative /api/... fetch calls working correctly.
157+
win.loadURL(BACKEND_URL);
158+
win.once('ready-to-show', () => win.show());
159+
return win;
160+
}
161+
162+
// ─── App lifecycle ─────────────────────────────────────────────────────────────
163+
164+
app.whenReady().then(async () => {
165+
const loader = createLoadingWindow();
166+
167+
try {
168+
// 1. Start Express (backend + static file serving) inside this process
169+
await startBackend();
170+
171+
// 2. Wait until the HTTP server is accepting connections
172+
await waitForBackend();
173+
174+
// 3. Open the dashboard; destroy loading screen when page is ready
175+
const main = createMainWindow();
176+
main.once('ready-to-show', () => loader.destroy());
177+
178+
} catch (fatal) {
179+
loader.destroy();
180+
dialog.showErrorBox(
181+
'Falha ao iniciar o aplicativo',
182+
`O servidor não pôde ser iniciado.\n\n${fatal.message}`,
183+
);
184+
app.quit();
185+
}
186+
187+
// macOS: recreate window when dock icon is clicked
188+
app.on('activate', () => {
189+
if (BrowserWindow.getAllWindows().length === 0) createMainWindow();
190+
});
191+
});
192+
193+
app.on('window-all-closed', () => {
194+
if (process.platform !== 'darwin') app.quit();
195+
});

electron/preload.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
'use strict';
2+
3+
/**
4+
* Preload script — runs in a sandboxed context before the renderer page loads.
5+
*
6+
* Use `contextBridge.exposeInMainWorld` here if you ever need to expose
7+
* safe IPC helpers to the React app. For now the app only needs the backend
8+
* HTTP API, so no extra bridging is required.
9+
*/

0 commit comments

Comments
 (0)