# Setup-VFDashboard.ps1
# VFDashboard - Electron App Setup Script for Windows
# Usage: .\Setup-VFDashboard.ps1 [-InstallPath "C:\Temp\VFDashboard"] [-SkipBuild] [-RunAfterSetup]
#Requires -Version 5.1
[CmdletBinding()]
param(
[string]$InstallPath = "C:\Temp\VFDashboard",
[switch]$SkipBuild,
[switch]$RunAfterSetup
)
Set-StrictMode -Off
$ErrorActionPreference = "Stop"
# ---------------------------------------------------------------------------
# Helper functions
# ---------------------------------------------------------------------------
function Write-Header([string]$Text) {
Write-Host ""
Write-Host "=======================================================" -ForegroundColor Cyan
Write-Host " $Text" -ForegroundColor Cyan
Write-Host "=======================================================" -ForegroundColor Cyan
}
function Write-Step([string]$Text) { Write-Host " >> $Text" -ForegroundColor Yellow }
function Write-OK([string]$Text) { Write-Host " OK $Text" -ForegroundColor Green }
function Write-Warn([string]$Text) { Write-Host " !! $Text" -ForegroundColor DarkYellow }
function Write-Info([string]$Text) { Write-Host " $Text" -ForegroundColor Gray }
function Exit-Error([string]$Msg) {
Write-Host ""
Write-Host " ERROR: $Msg" -ForegroundColor Red
Write-Host ""
Write-Host " Press Enter to exit..."
Read-Host | Out-Null
exit 1
}
function Test-Cmd([string]$Name) {
return [bool](Get-Command $Name -ErrorAction SilentlyContinue)
}
function Refresh-Path {
$machine = [System.Environment]::GetEnvironmentVariable("Path", "Machine")
$user = [System.Environment]::GetEnvironmentVariable("Path", "User")
$env:PATH = $machine + ";" + $user
}
# ---------------------------------------------------------------------------
# Banner
# ---------------------------------------------------------------------------
Clear-Host
Write-Host ""
Write-Host " +--------------------------------------------------+" -ForegroundColor Cyan
Write-Host " | VF9 Dashboard - Electron Setup Script |" -ForegroundColor Cyan
Write-Host " | VF9 Club Vietnam |" -ForegroundColor Cyan
Write-Host " +--------------------------------------------------+" -ForegroundColor Cyan
Write-Host ""
# ---------------------------------------------------------------------------
# STEP 0 - Check admin
# ---------------------------------------------------------------------------
Write-Header "STEP 0 - Check environment"
$isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole(
[Security.Principal.WindowsBuiltInRole]::Administrator)
if ($isAdmin) {
Write-OK "Running as Administrator"
} else {
Write-Warn "Not running as Administrator - some installs may fail"
Write-Info "Tip: Right-click the script and choose 'Run as Administrator'"
}
# ---------------------------------------------------------------------------
# STEP 1 - Git
# ---------------------------------------------------------------------------
Write-Header "STEP 1 - Check Git"
if (-not (Test-Cmd "git")) {
Write-Step "Git not found. Downloading Git for Windows..."
$gitInstaller = "$env:TEMP\git-setup.exe"
try {
$rel = Invoke-RestMethod "https://api.github.com/repos/git-for-windows/git/releases/latest"
$asset = $rel.assets | Where-Object { $_.name -match "64-bit\.exe$" } | Select-Object -First 1
Invoke-WebRequest -Uri $asset.browser_download_url -OutFile $gitInstaller -UseBasicParsing
Start-Process -FilePath $gitInstaller `
-ArgumentList "/VERYSILENT /NORESTART /COMPONENTS=icons,ext\reg\shellhere,assoc,assoc_sh" `
-Wait
Refresh-Path
Write-OK "Git installed successfully"
} catch {
Exit-Error "Cannot auto-install Git. Download manually: https://git-scm.com/download/win"
} finally {
if (Test-Path $gitInstaller) { Remove-Item $gitInstaller -Force }
}
} else {
$gitVer = & git --version
Write-OK "Git found: $gitVer"
}
# ---------------------------------------------------------------------------
# STEP 2 - Node.js
# ---------------------------------------------------------------------------
Write-Header "STEP 2 - Check Node.js"
$needNode = $true
if (Test-Cmd "node") {
$rawVer = & node --version
if ($rawVer -match "^v(\d+)") {
$major = [int]$Matches[1]
if ($major -ge 23) {
Write-OK "Node.js found: $rawVer (>= v23)"
$needNode = $false
} else {
Write-Warn "Node.js $rawVer is too old (need v23+). Upgrading..."
}
}
}
if ($needNode) {
Write-Step "Looking up latest Node.js v23 version..."
$nodeMsi = "$env:TEMP\node-v23-x64.msi"
try {
# Fetch the index to find the actual latest v23 filename
$indexUrl = "https://nodejs.org/dist/latest-v23.x/"
$indexHtml = Invoke-WebRequest -Uri $indexUrl -UseBasicParsing
$msiName = ($indexHtml.Links.href | Where-Object { $_ -match "node-v23.*-x64\.msi$" } | Select-Object -First 1)
if (-not $msiName) { throw "Could not find Node.js v23 MSI on nodejs.org" }
$nodeUrl = $indexUrl + $msiName
Write-Info "Found: $msiName"
Write-Step "Downloading $msiName ..."
Invoke-WebRequest -Uri $nodeUrl -OutFile $nodeMsi -UseBasicParsing
Write-Step "Installing Node.js v23 (this may take a few minutes)..."
Start-Process "msiexec.exe" -ArgumentList "/i `"$nodeMsi`" /quiet /norestart" -Wait
Refresh-Path
Write-OK "Node.js v23 installed"
} catch {
# Fallback: try winget (available on Windows 10 1709+ / Windows 11)
Write-Warn "Direct download failed. Trying winget..."
try {
& winget install OpenJS.NodeJS.LTS --silent --accept-source-agreements --accept-package-agreements
if ($LASTEXITCODE -eq 0) {
Refresh-Path
Write-OK "Node.js installed via winget"
} else {
throw "winget install failed"
}
} catch {
Exit-Error "Cannot auto-install Node.js. Please install manually from https://nodejs.org (v23+) then re-run this script."
}
} finally {
if (Test-Path $nodeMsi) { Remove-Item $nodeMsi -Force -ErrorAction SilentlyContinue }
}
}
# ---------------------------------------------------------------------------
# STEP 3 - Clone / update repo
# ---------------------------------------------------------------------------
Write-Header "STEP 3 - Clone VFDashboard"
if (Test-Path (Join-Path $InstallPath ".git")) {
Write-Step "Repo already exists. Running git pull..."
Push-Location $InstallPath
& git pull origin main 2>&1 | Out-Null
Pop-Location
Write-OK "Repo updated"
} else {
Write-Step "Cloning into: $InstallPath"
if (Test-Path $InstallPath) { Remove-Item $InstallPath -Recurse -Force }
& git clone https://github.com/VF9-Club/VFDashboard.git $InstallPath
if ($LASTEXITCODE -ne 0) { Exit-Error "git clone failed. Check your internet connection." }
Write-OK "Clone successful"
}
Set-Location $InstallPath
Write-Info "Working directory: $InstallPath"
# ---------------------------------------------------------------------------
# STEP 4 - npm install
# ---------------------------------------------------------------------------
Write-Header "STEP 4 - Install dependencies"
Write-Step "Running npm install (first run may take 2-5 minutes)..."
& npm install --legacy-peer-deps
if ($LASTEXITCODE -ne 0) { Exit-Error "npm install failed" }
Write-OK "Base dependencies installed"
Write-Step "Installing Electron + build tools..."
& npm install --save-dev electron electron-builder wait-on concurrently --legacy-peer-deps
if ($LASTEXITCODE -ne 0) { Exit-Error "Failed to install Electron" }
Write-OK "Electron installed"
# ---------------------------------------------------------------------------
# STEP 5 - Create electron/main.js
# Write each line individually to avoid here-string encoding problems
# ---------------------------------------------------------------------------
Write-Header "STEP 5 - Create Electron main process"
$electronDir = Join-Path $InstallPath "electron"
if (-not (Test-Path $electronDir)) { New-Item -ItemType Directory -Path $electronDir | Out-Null }
$jsLines = New-Object System.Collections.Generic.List[string]
$jsLines.Add("const { app, BrowserWindow, shell } = require('electron');")
$jsLines.Add("const { spawn } = require('child_process');")
$jsLines.Add("const path = require('path');")
$jsLines.Add("const waitOn = require('wait-on');")
$jsLines.Add("")
$jsLines.Add("const DEV_URL = 'http://localhost:4321';")
$jsLines.Add("let astroProcess = null;")
$jsLines.Add("let mainWindow = null;")
$jsLines.Add("")
$jsLines.Add("function startAstroServer() {")
$jsLines.Add(" astroProcess = spawn('npm', ['run', 'dev'], {")
$jsLines.Add(" cwd: path.join(__dirname, '..'),")
$jsLines.Add(" stdio: 'pipe',")
$jsLines.Add(" shell: true,")
$jsLines.Add(" windowsHide: true,")
$jsLines.Add(" });")
$jsLines.Add(" astroProcess.stdout.on('data', (d) => process.stdout.write('[Astro] ' + d));")
$jsLines.Add(" astroProcess.stderr.on('data', (d) => process.stderr.write('[Astro ERR] ' + d));")
$jsLines.Add(" astroProcess.on('exit', (c) => console.log('[Astro] exited with code ' + c));")
$jsLines.Add("}")
$jsLines.Add("")
$jsLines.Add("function createWindow() {")
$jsLines.Add(" const fs = require('fs');")
$jsLines.Add(" const iconPath = path.join(__dirname, '../public/favicon.ico');")
$jsLines.Add(" mainWindow = new BrowserWindow({")
$jsLines.Add(" width: 1440,")
$jsLines.Add(" height: 900,")
$jsLines.Add(" minWidth: 1024,")
$jsLines.Add(" minHeight: 600,")
$jsLines.Add(" icon: fs.existsSync(iconPath) ? iconPath : undefined,")
$jsLines.Add(" title: 'VF9 Dashboard',")
$jsLines.Add(" backgroundColor: '#0f172a',")
$jsLines.Add(" webPreferences: { nodeIntegration: false, contextIsolation: true },")
$jsLines.Add(" autoHideMenuBar: true,")
$jsLines.Add(" show: false,")
$jsLines.Add(" });")
$jsLines.Add(" mainWindow.once('ready-to-show', () => { mainWindow.show(); mainWindow.focus(); });")
$jsLines.Add(" mainWindow.loadURL(DEV_URL);")
$jsLines.Add(" mainWindow.webContents.setWindowOpenHandler(({ url }) => {")
$jsLines.Add(" shell.openExternal(url);")
$jsLines.Add(" return { action: 'deny' };")
$jsLines.Add(" });")
$jsLines.Add(" mainWindow.on('closed', () => { mainWindow = null; });")
$jsLines.Add("}")
$jsLines.Add("")
$jsLines.Add("app.whenReady().then(async () => {")
$jsLines.Add(" startAstroServer();")
$jsLines.Add(" console.log('Waiting for Astro server...');")
$jsLines.Add(" try {")
$jsLines.Add(" await waitOn({ resources: [DEV_URL], timeout: 90000, interval: 500 });")
$jsLines.Add(" console.log('Astro server is ready!');")
$jsLines.Add(" } catch (err) {")
$jsLines.Add(" console.error('Astro server did not respond:', err.message);")
$jsLines.Add(" app.quit();")
$jsLines.Add(" return;")
$jsLines.Add(" }")
$jsLines.Add(" createWindow();")
$jsLines.Add("});")
$jsLines.Add("")
$jsLines.Add("app.on('will-quit', () => {")
$jsLines.Add(" if (astroProcess) { astroProcess.kill(); astroProcess = null; }")
$jsLines.Add("});")
$jsLines.Add("app.on('window-all-closed', () => app.quit());")
$jsLines.Add("app.on('activate', () => { if (mainWindow === null) createWindow(); });")
# Save as .cjs so Node treats it as CommonJS even when package.json has "type":"module"
$mainCjsPath = Join-Path $electronDir "main.cjs"
[System.IO.File]::WriteAllLines(
$mainCjsPath,
$jsLines.ToArray(),
(New-Object System.Text.UTF8Encoding $false)
)
# Remove old main.js if a previous run created it
$oldMainJs = Join-Path $electronDir "main.js"
if (Test-Path $oldMainJs) { Remove-Item $oldMainJs -Force }
Write-OK "Created electron/main.cjs"
# ---------------------------------------------------------------------------
# STEP 6 - Patch package.json
# ---------------------------------------------------------------------------
Write-Header "STEP 6 - Update package.json"
$pkgPath = Join-Path $InstallPath "package.json"
$pkgRaw = [System.IO.File]::ReadAllText($pkgPath, [System.Text.Encoding]::UTF8)
$pkg = $pkgRaw | ConvertFrom-Json
if (-not ($pkg.PSObject.Properties.Name -contains "main")) {
$pkg | Add-Member -NotePropertyName "main" -NotePropertyValue "electron/main.cjs"
} else {
$pkg.main = "electron/main.cjs"
}
# Update electron:dev script to reference correct entry point explicitly
$pkg.scripts | Add-Member -NotePropertyName "electron" -NotePropertyValue "electron electron/main.cjs" -Force
$pkg.scripts | Add-Member -NotePropertyName "electron:dev" -NotePropertyValue "concurrently `"npm run dev`" `"wait-on http://localhost:4321 && electron electron/main.cjs`"" -Force
$pkg.scripts | Add-Member -NotePropertyName "electron:build" -NotePropertyValue "electron-builder" -Force
$buildCfg = [PSCustomObject]@{
appId = "com.vf9club.dashboard"
productName = "VF9 Dashboard"
directories = [PSCustomObject]@{ output = "dist-electron" }
win = [PSCustomObject]@{ target = "portable"; icon = "public/favicon.ico" }
files = @(
"electron/**/*.cjs", "src/**/*", "public/**/*",
"astro.config.mjs", "tsconfig.json", "package.json", "package-lock.json",
"node_modules/**/*", "!node_modules/.cache/**/*", "!dist-electron/**/*"
)
}
if (-not ($pkg.PSObject.Properties.Name -contains "build")) {
$pkg | Add-Member -NotePropertyName "build" -NotePropertyValue $buildCfg
} else {
$pkg.build = $buildCfg
}
$newJson = $pkg | ConvertTo-Json -Depth 10
[System.IO.File]::WriteAllText($pkgPath, $newJson, (New-Object System.Text.UTF8Encoding $false))
Write-OK "package.json updated"
# ---------------------------------------------------------------------------
# STEP 7 - Desktop shortcut + batch launcher
# ---------------------------------------------------------------------------
Write-Header "STEP 7 - Create shortcuts"
$batPath = Join-Path $InstallPath "run-dashboard.bat"
$batLines = New-Object System.Collections.Generic.List[string]
$batLines.Add("@echo off")
$batLines.Add("cd /d `"$InstallPath`"")
$batLines.Add("echo Starting VF9 Dashboard...")
$batLines.Add("npx electron electron/main.cjs")
$batLines.Add("pause")
[System.IO.File]::WriteAllLines($batPath, $batLines.ToArray(), (New-Object System.Text.ASCIIEncoding))
Write-OK "Created run-dashboard.bat"
$desktop = [System.Environment]::GetFolderPath("Desktop")
$lnkPath = Join-Path $desktop "VF9 Dashboard.lnk"
$wsh = New-Object -ComObject WScript.Shell
$lnk = $wsh.CreateShortcut($lnkPath)
$lnk.TargetPath = $batPath
$lnk.WorkingDirectory = $InstallPath
$lnk.WindowStyle = 1
$lnk.Description = "VF9 Dashboard - VF9 Club Vietnam"
$iconFile = Join-Path $InstallPath "public\favicon.ico"
if (Test-Path $iconFile) { $lnk.IconLocation = $iconFile }
$lnk.Save()
Write-OK "Desktop shortcut created: 'VF9 Dashboard'"
# ---------------------------------------------------------------------------
# STEP 8 - Build portable .exe (optional)
# ---------------------------------------------------------------------------
if (-not $SkipBuild) {
Write-Header "STEP 8 - Build portable .exe"
Write-Info "Skip next time with: -SkipBuild flag"
Write-Step "Building .exe (may take 5-10 minutes, do not close this window)..."
& npm run electron:build
if ($LASTEXITCODE -eq 0) {
$exeFile = Get-ChildItem (Join-Path $InstallPath "dist-electron") -Filter "*.exe" -ErrorAction SilentlyContinue |
Select-Object -First 1
if ($exeFile) {
Write-OK "Build successful!"
Write-Info "File : $($exeFile.FullName)"
Write-Info "Size : $([math]::Round($exeFile.Length / 1MB, 1)) MB"
} else {
Write-OK "Build done. Check: $InstallPath\dist-electron\"
}
} else {
Write-Warn "Build .exe failed - you can still run via the Desktop shortcut"
}
} else {
Write-Header "STEP 8 - Build .exe"
Write-Info "Skipped (-SkipBuild flag set)"
Write-Info "To build later: cd '$InstallPath' then: npm run electron:build"
}
# ---------------------------------------------------------------------------
# Done
# ---------------------------------------------------------------------------
Write-Header "ALL DONE"
Write-Host ""
Write-Host " VFDashboard installed successfully!" -ForegroundColor Green
Write-Host ""
Write-Host " Install path : $InstallPath" -ForegroundColor White
Write-Host " Desktop link : VF9 Dashboard.lnk" -ForegroundColor White
if (-not $SkipBuild) {
Write-Host " Portable exe : $InstallPath\dist-electron\" -ForegroundColor White
}
Write-Host ""
Write-Host " HOW TO RUN:" -ForegroundColor DarkGray
Write-Host " - Double-click 'VF9 Dashboard' on Desktop" -ForegroundColor Gray
Write-Host " - Or double-click run-dashboard.bat in install folder" -ForegroundColor Gray
Write-Host " - Or: cd '$InstallPath' then: npm run electron:dev" -ForegroundColor Gray
Write-Host ""
Write-Host " TO UPDATE LATER:" -ForegroundColor DarkGray
Write-Host " cd $InstallPath" -ForegroundColor Gray
Write-Host " git pull" -ForegroundColor Gray
Write-Host ""
if ($RunAfterSetup) {
Write-Step "Launching VF9 Dashboard..."
Start-Process -FilePath $batPath -WorkingDirectory $InstallPath
} else {
try {
$ans = Read-Host " Launch VF9 Dashboard now? (y/n)"
if ($ans -match "^[yY]") {
Write-Step "Launching..."
Start-Process -FilePath $batPath -WorkingDirectory $InstallPath
}
} catch { }
}
Write-Host ""
Write-Host " Built with love by VF9 Club Vietnam" -ForegroundColor DarkCyan
Write-Host ""