build: add Windows portable EXE packaging scripts
- scripts/build-icon.js — generates icon.png/ico/tray-icon.png from pure Node.js (no dependencies, zlib + PNG/ICO encoder) - scripts/fix-wincodeSign.js — pre-populates electron-builder winCodeSign cache by extracting the .7z while ignoring macOS symlinks (which fail on Windows without SeCreateSymbolicLink) - package.json: add fix-wincodeSign script, cross-env, electron-builder downgraded to v24 (stable portable target), CSC signing disabled Build: npm run build:win → dist-electron/SRT-Streamer-Portable-1.0.0.exe Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,9 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
dist-electron/
|
dist-electron/
|
||||||
|
assets/icon.png
|
||||||
|
assets/icon.ico
|
||||||
|
assets/tray-icon.png
|
||||||
ffmpeg-bin/ffmpeg
|
ffmpeg-bin/ffmpeg
|
||||||
ffmpeg-bin/ffmpeg.exe
|
ffmpeg-bin/ffmpeg.exe
|
||||||
ffmpeg-bin/ffprobe
|
ffmpeg-bin/ffprobe
|
||||||
|
|||||||
Generated
+234
-1389
File diff suppressed because it is too large
Load Diff
+31
-10
@@ -5,10 +5,12 @@
|
|||||||
"main": "electron/main.js",
|
"main": "electron/main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently \"vite\" \"wait-on http://localhost:5173 && electron .\"",
|
"dev": "concurrently \"vite\" \"wait-on http://localhost:5173 && electron .\"",
|
||||||
"build": "vite build && electron-builder",
|
"icon": "node scripts/build-icon.js",
|
||||||
"build:win": "vite build && electron-builder --win",
|
"build": "node scripts/build-icon.js && vite build && electron-builder",
|
||||||
"build:mac": "vite build && electron-builder --mac",
|
"fix-wincodeSign": "node scripts/fix-wincodeSign.js",
|
||||||
"build:linux": "vite build && electron-builder --linux",
|
"build:win": "node scripts/build-icon.js && vite build && node scripts/fix-wincodeSign.js && cross-env CSC_IDENTITY_AUTO_DISCOVERY=false electron-builder --win portable",
|
||||||
|
"build:mac": "node scripts/build-icon.js && vite build && electron-builder --mac",
|
||||||
|
"build:linux": "node scripts/build-icon.js && vite build && electron-builder --linux",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -17,8 +19,9 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"concurrently": "^9.1.2",
|
"concurrently": "^9.1.2",
|
||||||
|
"cross-env": "^7.0.3",
|
||||||
"electron": "^33.4.0",
|
"electron": "^33.4.0",
|
||||||
"electron-builder": "^25.1.8",
|
"electron-builder": "^24.13.3",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"vite": "^6.0.7",
|
"vite": "^6.0.7",
|
||||||
@@ -28,24 +31,42 @@
|
|||||||
"build": {
|
"build": {
|
||||||
"appId": "com.srtstreamer.app",
|
"appId": "com.srtstreamer.app",
|
||||||
"productName": "SRT Streamer",
|
"productName": "SRT Streamer",
|
||||||
|
"copyright": "SRT Streamer",
|
||||||
"directories": {
|
"directories": {
|
||||||
"output": "dist-electron"
|
"output": "dist-electron",
|
||||||
|
"buildResources": "assets"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist/**/*",
|
"dist/**/*",
|
||||||
"electron/**/*",
|
"electron/**/*",
|
||||||
"assets/**/*"
|
"assets/**/*",
|
||||||
|
"package.json"
|
||||||
],
|
],
|
||||||
"extraResources": [
|
"extraResources": [
|
||||||
{
|
{
|
||||||
"from": "ffmpeg-bin/",
|
"from": "ffmpeg-bin/",
|
||||||
"to": "ffmpeg-bin/",
|
"to": "ffmpeg-bin/",
|
||||||
"filter": ["**/*"]
|
"filter": [
|
||||||
|
"**/*"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"asar": true,
|
||||||
"win": {
|
"win": {
|
||||||
"target": "portable",
|
"target": [
|
||||||
"icon": "assets/icon.ico"
|
{
|
||||||
|
"target": "portable",
|
||||||
|
"arch": [
|
||||||
|
"x64"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"icon": "assets/icon.ico",
|
||||||
|
"artifactName": "SRT-Streamer-Portable-${version}.exe",
|
||||||
|
"requestedExecutionLevel": "asInvoker",
|
||||||
|
"sign": null,
|
||||||
|
"signingHashAlgorithms": null,
|
||||||
|
"certificateFile": null
|
||||||
},
|
},
|
||||||
"mac": {
|
"mac": {
|
||||||
"target": "dmg",
|
"target": "dmg",
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
/**
|
||||||
|
* Generates assets/icon.png and assets/icon.ico
|
||||||
|
* using only built-in Node.js (no external deps).
|
||||||
|
*
|
||||||
|
* ICO contains an embedded 256x256 PNG (Vista+ format).
|
||||||
|
*/
|
||||||
|
'use strict'
|
||||||
|
const zlib = require('zlib')
|
||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
|
||||||
|
// ─── CRC32 for PNG chunks ─────────────────────────────────────────────────────
|
||||||
|
function crc32(buf) {
|
||||||
|
let crc = 0xFFFFFFFF
|
||||||
|
for (let i = 0; i < buf.length; i++) {
|
||||||
|
crc ^= buf[i]
|
||||||
|
for (let j = 0; j < 8; j++) crc = (crc & 1) ? (0xEDB88320 ^ (crc >>> 1)) : (crc >>> 1)
|
||||||
|
}
|
||||||
|
return (crc ^ 0xFFFFFFFF) >>> 0
|
||||||
|
}
|
||||||
|
function pngChunk(type, data) {
|
||||||
|
const t = Buffer.from(type, 'ascii')
|
||||||
|
const len = Buffer.alloc(4); len.writeUInt32BE(data.length)
|
||||||
|
const crcVal = Buffer.alloc(4); crcVal.writeUInt32BE(crc32(Buffer.concat([t, data])))
|
||||||
|
return Buffer.concat([len, t, data, crcVal])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Minimal PNG encoder (RGBA, no interlace) ─────────────────────────────────
|
||||||
|
function makePNG(size, drawFn) {
|
||||||
|
const stride = 1 + size * 4 // filter byte + RGBA per pixel
|
||||||
|
const raw = Buffer.alloc(size * stride)
|
||||||
|
|
||||||
|
for (let y = 0; y < size; y++) {
|
||||||
|
raw[y * stride] = 0 // filter: None
|
||||||
|
for (let x = 0; x < size; x++) {
|
||||||
|
const [r, g, b, a = 255] = drawFn(x, y, size)
|
||||||
|
const off = y * stride + 1 + x * 4
|
||||||
|
raw[off] = r; raw[off+1] = g; raw[off+2] = b; raw[off+3] = a
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ihdrData = Buffer.alloc(13)
|
||||||
|
ihdrData.writeUInt32BE(size, 0)
|
||||||
|
ihdrData.writeUInt32BE(size, 4)
|
||||||
|
ihdrData[8] = 8 // bit depth
|
||||||
|
ihdrData[9] = 6 // RGBA
|
||||||
|
ihdrData[10] = 0; ihdrData[11] = 0; ihdrData[12] = 0
|
||||||
|
|
||||||
|
return Buffer.concat([
|
||||||
|
Buffer.from([0x89,0x50,0x4e,0x47,0x0d,0x0a,0x1a,0x0a]), // PNG sig
|
||||||
|
pngChunk('IHDR', ihdrData),
|
||||||
|
pngChunk('IDAT', zlib.deflateSync(raw, { level: 9 })),
|
||||||
|
pngChunk('IEND', Buffer.alloc(0))
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Draw function ────────────────────────────────────────────────────────────
|
||||||
|
function drawIcon(x, y, S) {
|
||||||
|
const cx = S / 2, cy = S / 2
|
||||||
|
const r = S * 0.48 // outer radius
|
||||||
|
|
||||||
|
// Distance from center
|
||||||
|
const dx = x - cx, dy = y - cy
|
||||||
|
const dist = Math.sqrt(dx*dx + dy*dy)
|
||||||
|
|
||||||
|
// Outside circle → transparent
|
||||||
|
if (dist > r) return [0, 0, 0, 0]
|
||||||
|
|
||||||
|
// Background gradient: dark blue
|
||||||
|
const t = dist / r
|
||||||
|
const bg = [
|
||||||
|
Math.round(26 + t * 8),
|
||||||
|
Math.round(26 + t * 6),
|
||||||
|
Math.round(46 + t * 10),
|
||||||
|
255
|
||||||
|
]
|
||||||
|
|
||||||
|
// ── Play triangle ──────────────────────────────────────────────────────────
|
||||||
|
const tLeft = cx - S * 0.22
|
||||||
|
const tRight = cx + S * 0.28
|
||||||
|
const tTop = cy - S * 0.30
|
||||||
|
const tBot = cy + S * 0.30
|
||||||
|
// Point is inside triangle if: (x >= tLeft) AND left of right edge
|
||||||
|
const edgeTop = tLeft + (tRight - tLeft) * (y - tTop) / (tBot - tTop)
|
||||||
|
const edgeBot = tLeft + (tRight - tLeft) * (tBot - y) / (tBot - tTop)
|
||||||
|
const inTri = x >= tLeft && x <= edgeTop && x <= edgeBot
|
||||||
|
&& y >= tTop && y <= tBot
|
||||||
|
|
||||||
|
if (inTri) {
|
||||||
|
// Accent blue gradient
|
||||||
|
const fx = (x - tLeft) / (tRight - tLeft)
|
||||||
|
return [
|
||||||
|
Math.round(79 + fx * 50),
|
||||||
|
Math.round(142 + fx * 30),
|
||||||
|
Math.round(247 - fx * 50),
|
||||||
|
255
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Red "LIVE" dot (top-right) ─────────────────────────────────────────────
|
||||||
|
const dotX = cx + S * 0.25, dotY = cy - S * 0.26, dotR = S * 0.12
|
||||||
|
const ddx = x - dotX, ddy = y - dotY
|
||||||
|
const dotDist = Math.sqrt(ddx*ddx + ddy*ddy)
|
||||||
|
if (dotDist <= dotR) {
|
||||||
|
const inner = dotR * 0.45
|
||||||
|
if (dotDist <= inner) return [255, 255, 255, 255] // white center
|
||||||
|
return [239, 68, 68, 255] // red ring
|
||||||
|
}
|
||||||
|
|
||||||
|
return bg
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Wrap PNG in ICO ──────────────────────────────────────────────────────────
|
||||||
|
function pngToIco(pngBuf, size) {
|
||||||
|
const header = Buffer.alloc(6)
|
||||||
|
header.writeUInt16LE(0, 0) // reserved
|
||||||
|
header.writeUInt16LE(1, 2) // type: icon
|
||||||
|
header.writeUInt16LE(1, 4) // image count
|
||||||
|
|
||||||
|
const entry = Buffer.alloc(16)
|
||||||
|
entry[0] = size >= 256 ? 0 : size // width (0 = 256)
|
||||||
|
entry[1] = size >= 256 ? 0 : size // height (0 = 256)
|
||||||
|
entry[2] = 0 // color count
|
||||||
|
entry[3] = 0 // reserved
|
||||||
|
entry.writeUInt16LE(1, 4) // planes
|
||||||
|
entry.writeUInt16LE(32, 6) // bit count
|
||||||
|
entry.writeUInt32LE(pngBuf.length, 8) // bytes in image
|
||||||
|
entry.writeUInt32LE(6 + 16, 12) // offset = header + 1 entry
|
||||||
|
|
||||||
|
return Buffer.concat([header, entry, pngBuf])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main ─────────────────────────────────────────────────────────────────────
|
||||||
|
const assetsDir = path.join(__dirname, '..', 'assets')
|
||||||
|
if (!fs.existsSync(assetsDir)) fs.mkdirSync(assetsDir, { recursive: true })
|
||||||
|
|
||||||
|
console.log('Generating icon 256×256 PNG…')
|
||||||
|
const png256 = makePNG(256, drawIcon)
|
||||||
|
fs.writeFileSync(path.join(assetsDir, 'icon.png'), png256)
|
||||||
|
console.log(' ✓ assets/icon.png')
|
||||||
|
|
||||||
|
console.log('Wrapping in ICO…')
|
||||||
|
const ico = pngToIco(png256, 256)
|
||||||
|
fs.writeFileSync(path.join(assetsDir, 'icon.ico'), ico)
|
||||||
|
console.log(' ✓ assets/icon.ico')
|
||||||
|
|
||||||
|
// 16px tray icon
|
||||||
|
console.log('Generating tray icon 16×16…')
|
||||||
|
const png16 = makePNG(16, drawIcon)
|
||||||
|
fs.writeFileSync(path.join(assetsDir, 'tray-icon.png'), png16)
|
||||||
|
console.log(' ✓ assets/tray-icon.png')
|
||||||
|
|
||||||
|
console.log('Done.')
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
/**
|
||||||
|
* Downloads winCodeSign-2.6.0.7z and extracts it WITHOUT symlinks
|
||||||
|
* into the electron-builder cache, so electron-builder doesn't re-download.
|
||||||
|
*
|
||||||
|
* Run once: node scripts/fix-wincodeSign.js
|
||||||
|
*/
|
||||||
|
'use strict'
|
||||||
|
const https = require('https')
|
||||||
|
const http = require('http')
|
||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
const os = require('os')
|
||||||
|
const { execFileSync } = require('child_process')
|
||||||
|
|
||||||
|
const VERSION = 'winCodeSign-2.6.0'
|
||||||
|
const URL_7Z = `https://github.com/electron-userland/electron-builder-binaries/releases/download/${VERSION}/${VERSION}.7z`
|
||||||
|
const CACHE = path.join(os.homedir(), 'AppData', 'Local', 'electron-builder', 'Cache', 'winCodeSign', VERSION)
|
||||||
|
const TMP_7Z = path.join(os.tmpdir(), `${VERSION}.7z`)
|
||||||
|
const SEVEN_ZIP = path.join(__dirname, '..', 'node_modules', '7zip-bin', 'win', 'x64', '7za.exe')
|
||||||
|
|
||||||
|
function download(url, dest, redirects = 0) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (redirects > 5) return reject(new Error('Too many redirects'))
|
||||||
|
console.log(`Downloading${redirects > 0 ? ' (redirect)' : ''}: ${url}`)
|
||||||
|
const proto = url.startsWith('https') ? https : http
|
||||||
|
const req = proto.get(url, { headers: { 'User-Agent': 'node' } }, res => {
|
||||||
|
if (res.statusCode === 301 || res.statusCode === 302 || res.statusCode === 307 || res.statusCode === 308) {
|
||||||
|
return download(res.headers.location, dest, redirects + 1).then(resolve).catch(reject)
|
||||||
|
}
|
||||||
|
if (res.statusCode !== 200) return reject(new Error(`HTTP ${res.statusCode}`))
|
||||||
|
|
||||||
|
const total = parseInt(res.headers['content-length'] || '0', 10)
|
||||||
|
let received = 0
|
||||||
|
const out = fs.createWriteStream(dest)
|
||||||
|
|
||||||
|
res.on('data', chunk => {
|
||||||
|
received += chunk.length
|
||||||
|
if (total > 0) {
|
||||||
|
const pct = Math.round(received / total * 100)
|
||||||
|
process.stdout.write(`\r ${pct}% (${(received/1024/1024).toFixed(1)} MB) `)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
res.pipe(out)
|
||||||
|
out.on('finish', () => { process.stdout.write('\n'); resolve() })
|
||||||
|
out.on('error', reject)
|
||||||
|
})
|
||||||
|
req.on('error', reject)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
// Already extracted?
|
||||||
|
const nsisBin = path.join(CACHE, 'windows', 'nsis', 'Bin', 'makensis.exe')
|
||||||
|
if (fs.existsSync(nsisBin)) {
|
||||||
|
console.log('✓ winCodeSign already cached at:', CACHE)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('=== Fixing winCodeSign cache ===')
|
||||||
|
console.log('Cache target:', CACHE)
|
||||||
|
|
||||||
|
// Download
|
||||||
|
if (!fs.existsSync(TMP_7Z)) {
|
||||||
|
await download(URL_7Z, TMP_7Z)
|
||||||
|
} else {
|
||||||
|
console.log('Using cached download:', TMP_7Z)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract — ignoring exit code 1 or 2 (only symlinks fail, all real binaries extract fine)
|
||||||
|
fs.mkdirSync(CACHE, { recursive: true })
|
||||||
|
console.log('Extracting…')
|
||||||
|
try {
|
||||||
|
execFileSync(SEVEN_ZIP, ['x', '-y', '-bd', TMP_7Z, `-o${CACHE}`], {
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe']
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
// Exit code 1 = warnings, exit code 2 = fatal but only for macOS symlinks — safe to ignore
|
||||||
|
if (e.status !== 1 && e.status !== 2) throw e
|
||||||
|
console.log(' (macOS symlinks skipped — expected on Windows, continuing…)')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create placeholder empty files for the macOS symlinks that 7-Zip can't create
|
||||||
|
const macSymlinks = [
|
||||||
|
'darwin/10.12/lib/libcrypto.dylib',
|
||||||
|
'darwin/10.12/lib/libssl.dylib',
|
||||||
|
]
|
||||||
|
for (const rel of macSymlinks) {
|
||||||
|
const full = path.join(CACHE, rel)
|
||||||
|
if (!fs.existsSync(full)) {
|
||||||
|
fs.mkdirSync(path.dirname(full), { recursive: true })
|
||||||
|
fs.writeFileSync(full, '')
|
||||||
|
console.log(' Created placeholder:', rel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify NSIS was extracted
|
||||||
|
if (fs.existsSync(nsisBin)) {
|
||||||
|
console.log('✓ NSIS extracted successfully:', nsisBin)
|
||||||
|
} else {
|
||||||
|
// List what we got
|
||||||
|
console.log('Contents of cache:')
|
||||||
|
const list = fs.readdirSync(CACHE)
|
||||||
|
list.forEach(f => console.log(' ', f))
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Done! Run npm run build:win again.')
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(e => { console.error('ERROR:', e.message); process.exit(1) })
|
||||||
Reference in New Issue
Block a user