Initial upload for secondary development
This commit is contained in:
234
scripts/build-desktop.ps1
Normal file
234
scripts/build-desktop.ps1
Normal file
@@ -0,0 +1,234 @@
|
||||
param(
|
||||
[string]$PythonLauncher = "py",
|
||||
[string]$PythonVersion = "-3.12",
|
||||
[switch]$SkipIcon,
|
||||
[switch]$SkipBackend,
|
||||
[switch]$SkipFrontend,
|
||||
[switch]$SkipInstaller,
|
||||
[switch]$Sign,
|
||||
[string]$CertificateFile,
|
||||
[string]$CertificatePassword,
|
||||
[string]$PublisherName,
|
||||
[string]$TimestampServer = "http://timestamp.digicert.com",
|
||||
[string[]]$VoiceSmokeKeys = @(),
|
||||
[switch]$ForceSign
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$Root = Resolve-Path (Join-Path $PSScriptRoot "..")
|
||||
$Frontend = Join-Path $Root "chatlab-web\frontend"
|
||||
$Backend = Join-Path $Root "chatlog_fastAPI"
|
||||
$Electron = Join-Path $Root "electron-launcher"
|
||||
$Resources = Join-Path $Electron "build-resources"
|
||||
$Release = Join-Path $Root "release"
|
||||
|
||||
function Invoke-Python312($Arguments) {
|
||||
$pyCommand = Get-Command $PythonLauncher -ErrorAction SilentlyContinue
|
||||
if ($pyCommand) {
|
||||
try {
|
||||
& $PythonLauncher $PythonVersion -V | Out-Null
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
& $PythonLauncher $PythonVersion @Arguments
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
Write-Host "Python launcher $PythonLauncher $PythonVersion is not available, falling back to user Python312."
|
||||
}
|
||||
}
|
||||
|
||||
$fallback = Join-Path $env:LOCALAPPDATA "Programs\Python\Python312\python.exe"
|
||||
if (-not (Test-Path $fallback)) {
|
||||
throw "Python 3.12 was not found. Install it first or pass -PythonLauncher/-PythonVersion."
|
||||
}
|
||||
|
||||
& $fallback @Arguments
|
||||
}
|
||||
|
||||
function Reset-Dir($Path) {
|
||||
if (Test-Path $Path) {
|
||||
$resolved = Resolve-Path $Path
|
||||
if (-not $resolved.Path.StartsWith($Root.Path)) {
|
||||
throw "Refusing to remove path outside project: $($resolved.Path)"
|
||||
}
|
||||
Remove-Item -LiteralPath $resolved.Path -Recurse -Force
|
||||
}
|
||||
New-Item -ItemType Directory -Force -Path $Path | Out-Null
|
||||
}
|
||||
|
||||
function Copy-Dir($Source, $Dest) {
|
||||
if (-not (Test-Path $Source)) {
|
||||
throw "Missing required source: $Source"
|
||||
}
|
||||
New-Item -ItemType Directory -Force -Path (Split-Path $Dest) | Out-Null
|
||||
Copy-Item -LiteralPath $Source -Destination $Dest -Recurse -Force
|
||||
}
|
||||
|
||||
function Set-OptionalEnv($Name, $Value) {
|
||||
if ([string]::IsNullOrWhiteSpace($Value)) {
|
||||
Remove-Item -Path "Env:$Name" -ErrorAction SilentlyContinue
|
||||
} else {
|
||||
Set-Item -Path "Env:$Name" -Value $Value
|
||||
}
|
||||
}
|
||||
|
||||
function Test-ForbiddenReleaseFile($File) {
|
||||
return $File.Name -eq ".env" -or
|
||||
$File.Name -like "knowledge*.db" -or
|
||||
$File.FullName -match "\\__pycache__\\" -or
|
||||
$File.Name -like "*.pfx" -or
|
||||
$File.Name -like "*.p12" -or
|
||||
$File.Name -like "*.pvk" -or
|
||||
$File.Name -like "*.cer" -or
|
||||
$File.Name -like "*.crt" -or
|
||||
$File.Name -like "*.key" -or
|
||||
$File.FullName -match "\\certs\\"
|
||||
}
|
||||
|
||||
Set-Location $Root
|
||||
|
||||
$resolvedCertificateFile = $null
|
||||
if ($CertificateFile) {
|
||||
if (-not (Test-Path -LiteralPath $CertificateFile)) {
|
||||
throw "Certificate file was not found: $CertificateFile"
|
||||
}
|
||||
$resolvedCertificateFile = (Resolve-Path -LiteralPath $CertificateFile).Path
|
||||
if ($resolvedCertificateFile.StartsWith($Root.Path)) {
|
||||
throw "For safety, keep the code signing certificate outside the project folder: $resolvedCertificateFile"
|
||||
}
|
||||
}
|
||||
|
||||
$envCertificateFile = if ($resolvedCertificateFile) { $resolvedCertificateFile } else { $env:CHATLAB_PFX_FILE }
|
||||
$envCertificateFile = if ($envCertificateFile) { $envCertificateFile.Trim() } else { "" }
|
||||
$shouldSign = $Sign -or -not [string]::IsNullOrWhiteSpace($envCertificateFile)
|
||||
|
||||
if ($ForceSign -and -not $shouldSign) {
|
||||
throw "-ForceSign requires -CertificateFile or CHATLAB_PFX_FILE."
|
||||
}
|
||||
|
||||
if ($shouldSign) {
|
||||
if (-not $envCertificateFile) {
|
||||
throw "Signing was requested, but no certificate was provided. Use -CertificateFile or CHATLAB_PFX_FILE."
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $envCertificateFile)) {
|
||||
throw "Certificate file was not found: $envCertificateFile"
|
||||
}
|
||||
$envCertificateFile = (Resolve-Path -LiteralPath $envCertificateFile).Path
|
||||
if ($envCertificateFile.StartsWith($Root.Path)) {
|
||||
throw "For safety, keep the code signing certificate outside the project folder: $envCertificateFile"
|
||||
}
|
||||
Set-OptionalEnv "CHATLAB_PFX_FILE" $envCertificateFile
|
||||
if ($CertificatePassword) {
|
||||
Set-OptionalEnv "CHATLAB_PFX_PASSWORD" $CertificatePassword
|
||||
Set-OptionalEnv "WIN_CSC_KEY_PASSWORD" $CertificatePassword
|
||||
Set-OptionalEnv "CSC_KEY_PASSWORD" $CertificatePassword
|
||||
}
|
||||
if ($PublisherName) { Set-OptionalEnv "CHATLAB_CERT_PUBLISHER_NAME" $PublisherName }
|
||||
Set-OptionalEnv "CHATLAB_TIMESTAMP_SERVER" $TimestampServer
|
||||
if ($ForceSign) { Set-OptionalEnv "CHATLAB_FORCE_SIGN" "1" }
|
||||
Write-Host "Code signing enabled. Certificate: $envCertificateFile"
|
||||
} else {
|
||||
Remove-Item -Path "Env:CHATLAB_PFX_FILE" -ErrorAction SilentlyContinue
|
||||
Remove-Item -Path "Env:CHATLAB_PFX_PASSWORD" -ErrorAction SilentlyContinue
|
||||
Remove-Item -Path "Env:CHATLAB_CERT_PUBLISHER_NAME" -ErrorAction SilentlyContinue
|
||||
Remove-Item -Path "Env:CHATLAB_FORCE_SIGN" -ErrorAction SilentlyContinue
|
||||
Remove-Item -Path "Env:WIN_CSC_KEY_PASSWORD" -ErrorAction SilentlyContinue
|
||||
Remove-Item -Path "Env:CSC_KEY_PASSWORD" -ErrorAction SilentlyContinue
|
||||
Write-Host "Code signing disabled. Unsigned installer build is allowed."
|
||||
}
|
||||
|
||||
if (-not $SkipIcon) {
|
||||
& node (Join-Path $Root "scripts\make-icon.cjs")
|
||||
}
|
||||
|
||||
if (-not $SkipFrontend) {
|
||||
$dist = Join-Path $Frontend "dist"
|
||||
if (Test-Path $dist) {
|
||||
Remove-Item -LiteralPath (Resolve-Path $dist).Path -Recurse -Force
|
||||
}
|
||||
Push-Location $Frontend
|
||||
& npm.cmd run build
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
if (-not $SkipBackend) {
|
||||
Push-Location $Backend
|
||||
Invoke-Python312 @("-m", "PyInstaller", "ChatLabBackend.spec", "--noconfirm", "--clean")
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
Reset-Dir $Resources
|
||||
New-Item -ItemType Directory -Force -Path $Release | Out-Null
|
||||
|
||||
Copy-Item -LiteralPath (Join-Path $Root "chatlog.exe") -Destination (Join-Path $Resources "chatlog.exe") -Force
|
||||
$previousErrorActionPreference = $ErrorActionPreference
|
||||
$ErrorActionPreference = "Continue"
|
||||
$chatlogVersionOutput = & (Join-Path $Resources "chatlog.exe") version 2>&1
|
||||
$chatlogVersionExitCode = $LASTEXITCODE
|
||||
$ErrorActionPreference = $previousErrorActionPreference
|
||||
if ($chatlogVersionExitCode -ne 0) {
|
||||
throw "chatlog.exe version check failed:`n$chatlogVersionOutput"
|
||||
}
|
||||
Write-Host "chatlog.exe version: $chatlogVersionOutput"
|
||||
|
||||
foreach ($voiceKey in $VoiceSmokeKeys) {
|
||||
if ([string]::IsNullOrWhiteSpace($voiceKey)) { continue }
|
||||
try {
|
||||
$response = Invoke-WebRequest -Uri "http://127.0.0.1:5030/voice/$voiceKey" -UseBasicParsing -TimeoutSec 15
|
||||
if ($response.StatusCode -ge 400 -or $response.RawContentLength -le 0) {
|
||||
throw "HTTP $($response.StatusCode), length=$($response.RawContentLength)"
|
||||
}
|
||||
Write-Host "voice smoke passed: $voiceKey ($($response.RawContentLength) bytes)"
|
||||
} catch {
|
||||
throw "voice smoke failed for $voiceKey. Do not ship this installer until chatlog can read WeChat voice media. $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
Copy-Dir (Join-Path $Root "lib") (Join-Path $Resources "lib")
|
||||
Copy-Dir (Join-Path $Frontend "dist") (Join-Path $Resources "frontend")
|
||||
Copy-Dir (Join-Path $Backend "dist\ChatLabBackend") (Join-Path $Resources "backend")
|
||||
Copy-Item -LiteralPath (Join-Path $Root "DISCLAIMER.md") -Destination (Join-Path $Resources "DISCLAIMER.md") -Force
|
||||
Copy-Item -LiteralPath (Join-Path $Root "LICENSE") -Destination (Join-Path $Resources "LICENSE") -Force
|
||||
|
||||
$forbidden = Get-ChildItem -LiteralPath $Resources -Recurse -Force |
|
||||
Where-Object { Test-ForbiddenReleaseFile $_ }
|
||||
|
||||
if ($forbidden) {
|
||||
$names = ($forbidden | Select-Object -ExpandProperty FullName) -join "`n"
|
||||
throw "Sensitive or cache files found in release resources:`n$names"
|
||||
}
|
||||
|
||||
Get-ChildItem -LiteralPath $Resources -Recurse -File |
|
||||
Select-Object FullName, Length |
|
||||
Out-File -Encoding UTF8 (Join-Path $Release "manifest.txt")
|
||||
|
||||
if (-not $SkipInstaller) {
|
||||
Push-Location $Electron
|
||||
& npm.cmd run build
|
||||
Pop-Location
|
||||
Copy-Item -Path (Join-Path $Electron "dist\*.exe") -Destination $Release -Force
|
||||
Copy-Item -Path (Join-Path $Electron "dist\*.blockmap") -Destination $Release -Force -ErrorAction SilentlyContinue
|
||||
|
||||
$releaseForbidden = Get-ChildItem -LiteralPath $Release -Recurse -Force |
|
||||
Where-Object { Test-ForbiddenReleaseFile $_ }
|
||||
if ($releaseForbidden) {
|
||||
$names = ($releaseForbidden | Select-Object -ExpandProperty FullName) -join "`n"
|
||||
throw "Sensitive certificate, private data, or cache files found in release output:`n$names"
|
||||
}
|
||||
|
||||
$installer = Get-ChildItem -LiteralPath $Release -Filter "ChatLab-Setup-*.exe" -File |
|
||||
Sort-Object LastWriteTime -Descending |
|
||||
Select-Object -First 1
|
||||
if ($installer) {
|
||||
$signature = Get-AuthenticodeSignature -FilePath $installer.FullName
|
||||
if ($shouldSign -or $ForceSign) {
|
||||
if ($signature.Status -ne "Valid") {
|
||||
throw "Installer signing verification failed: $($signature.Status) $($signature.StatusMessage)"
|
||||
}
|
||||
Write-Host "Installer signature verified: $($signature.SignerCertificate.Subject)"
|
||||
} else {
|
||||
Write-Host "Installer is unsigned by design for this build."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "Desktop build completed. Output: $Release"
|
||||
226
scripts/make-icon.cjs
Normal file
226
scripts/make-icon.cjs
Normal file
@@ -0,0 +1,226 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
const { spawnSync } = require("child_process");
|
||||
|
||||
const root = path.resolve(__dirname, "..");
|
||||
const sourceImagePath = path.join(root, "chatlab-web", "frontend", "public", "company-logo.jpg");
|
||||
const outputDir = path.join(root, "electron-launcher", "build");
|
||||
const outputIco = path.join(outputDir, "icon.ico");
|
||||
const outputPng = path.join(outputDir, "icon.png");
|
||||
const sizes = [16, 24, 32, 48, 64, 128, 256];
|
||||
const electronNodeModules = path.join(root, "electron-launcher", "node_modules");
|
||||
const electronUserData =
|
||||
process.env.CHATLAB_ICON_USER_DATA || path.join(os.tmpdir(), "chatlab-icon-renderer-user-data");
|
||||
|
||||
function electronApi() {
|
||||
if (process.versions.electron) {
|
||||
return require("electron");
|
||||
}
|
||||
return require(path.join(electronNodeModules, "electron"));
|
||||
}
|
||||
|
||||
function resolveElectronBinary() {
|
||||
return electronApi();
|
||||
}
|
||||
|
||||
function writeIco(entries, destination) {
|
||||
const headerSize = 6;
|
||||
const entrySize = 16;
|
||||
let offset = headerSize + entries.length * entrySize;
|
||||
const header = Buffer.alloc(offset);
|
||||
|
||||
header.writeUInt16LE(0, 0);
|
||||
header.writeUInt16LE(1, 2);
|
||||
header.writeUInt16LE(entries.length, 4);
|
||||
|
||||
for (const [index, entry] of entries.entries()) {
|
||||
const pos = headerSize + index * entrySize;
|
||||
header.writeUInt8(entry.size === 256 ? 0 : entry.size, pos);
|
||||
header.writeUInt8(entry.size === 256 ? 0 : entry.size, pos + 1);
|
||||
header.writeUInt8(0, pos + 2);
|
||||
header.writeUInt8(0, pos + 3);
|
||||
header.writeUInt16LE(1, pos + 4);
|
||||
header.writeUInt16LE(32, pos + 6);
|
||||
header.writeUInt32LE(entry.png.length, pos + 8);
|
||||
header.writeUInt32LE(offset, pos + 12);
|
||||
offset += entry.png.length;
|
||||
}
|
||||
|
||||
fs.writeFileSync(destination, Buffer.concat([header, ...entries.map((entry) => entry.png)]));
|
||||
}
|
||||
|
||||
async function renderSourcePng() {
|
||||
const { app, BrowserWindow, nativeImage } = electronApi();
|
||||
await app.whenReady();
|
||||
|
||||
const imageBytes = fs.readFileSync(sourceImagePath);
|
||||
const dataUri = `data:image/jpeg;base64,${imageBytes.toString("base64")}`;
|
||||
const html = `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
html, body {
|
||||
width: 256px;
|
||||
height: 256px;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
body {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
img {
|
||||
display: block;
|
||||
width: 256px;
|
||||
height: 256px;
|
||||
object-fit: contain;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<img src="${dataUri}" alt="">
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const win = new BrowserWindow({
|
||||
show: false,
|
||||
width: 256,
|
||||
height: 256,
|
||||
transparent: true,
|
||||
backgroundColor: "#00000000",
|
||||
resizable: false,
|
||||
webPreferences: {
|
||||
backgroundThrottling: false,
|
||||
contextIsolation: true,
|
||||
offscreen: true,
|
||||
sandbox: true,
|
||||
},
|
||||
});
|
||||
|
||||
await win.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`);
|
||||
await win.webContents.executeJavaScript(`
|
||||
new Promise((resolve, reject) => {
|
||||
const image = document.querySelector("img");
|
||||
if (!image) {
|
||||
reject(new Error("Icon image element was not created"));
|
||||
return;
|
||||
}
|
||||
if (image.complete && image.naturalWidth > 0) {
|
||||
setTimeout(resolve, 80);
|
||||
return;
|
||||
}
|
||||
image.onload = () => setTimeout(resolve, 80);
|
||||
image.onerror = () => reject(new Error("Icon SVG failed to render"));
|
||||
})
|
||||
`);
|
||||
|
||||
const captured = await win.webContents.capturePage({ x: 0, y: 0, width: 256, height: 256 });
|
||||
win.destroy();
|
||||
|
||||
const normalized = nativeImage
|
||||
.createFromBuffer(captured.toPNG())
|
||||
.resize({ width: 256, height: 256, quality: "best" });
|
||||
|
||||
return normalized.toPNG();
|
||||
}
|
||||
|
||||
async function mainElectron() {
|
||||
const { app } = electronApi();
|
||||
app.disableHardwareAcceleration();
|
||||
app.setPath("userData", electronUserData);
|
||||
app.commandLine.appendSwitch("disable-gpu");
|
||||
app.commandLine.appendSwitch("disable-gpu-compositing");
|
||||
app.commandLine.appendSwitch("disable-software-rasterizer");
|
||||
app.commandLine.appendSwitch("disk-cache-dir", path.join(electronUserData, "cache"));
|
||||
|
||||
if (!fs.existsSync(sourceImagePath)) {
|
||||
throw new Error(`Missing icon source: ${sourceImagePath}`);
|
||||
}
|
||||
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
const { nativeImage } = electronApi();
|
||||
const sourcePng = await renderSourcePng();
|
||||
const sourceImage = nativeImage.createFromBuffer(sourcePng);
|
||||
const pngEntries = sizes.map((size) => ({
|
||||
size,
|
||||
png: sourceImage.resize({ width: size, height: size, quality: "best" }).toPNG(),
|
||||
}));
|
||||
|
||||
fs.writeFileSync(outputPng, sourcePng);
|
||||
writeIco(pngEntries, outputIco);
|
||||
|
||||
console.log(`Generated ${path.relative(root, outputIco)} (${sizes.join(", ")} px)`);
|
||||
console.log(`Generated ${path.relative(root, outputPng)} (256 px)`);
|
||||
app.quit();
|
||||
}
|
||||
|
||||
function mainNode() {
|
||||
const rendererDir = fs.mkdtempSync(path.join(os.tmpdir(), "chatlab-icon-renderer-"));
|
||||
const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "chatlab-icon-user-data-"));
|
||||
fs.mkdirSync(rendererDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(rendererDir, "package.json"),
|
||||
JSON.stringify({ name: "chatlab-icon-renderer", main: "main.js" }, null, 2),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(rendererDir, "main.js"),
|
||||
`process.env.CHATLAB_ICON_RENDER = "1";\nrequire(${JSON.stringify(__filename)});\n`,
|
||||
);
|
||||
|
||||
const electronBinary = resolveElectronBinary();
|
||||
const env = { ...process.env };
|
||||
delete env.ELECTRON_RUN_AS_NODE;
|
||||
env.NODE_PATH = [electronNodeModules, process.env.NODE_PATH].filter(Boolean).join(path.delimiter);
|
||||
env.ELECTRON_DISABLE_CRASHPAD = "1";
|
||||
env.ELECTRON_ENABLE_LOGGING = "0";
|
||||
env.ELECTRON_DISABLE_SECURITY_WARNINGS = "true";
|
||||
env.CHATLAB_ICON_USER_DATA = userDataDir;
|
||||
|
||||
const result = spawnSync(
|
||||
electronBinary,
|
||||
[
|
||||
rendererDir,
|
||||
"--disable-crash-reporter",
|
||||
"--disable-gpu",
|
||||
"--disable-gpu-compositing",
|
||||
`--user-data-dir=${userDataDir}`,
|
||||
`--disk-cache-dir=${path.join(userDataDir, "cache")}`,
|
||||
],
|
||||
{
|
||||
cwd: root,
|
||||
stdio: "inherit",
|
||||
windowsHide: true,
|
||||
env,
|
||||
},
|
||||
);
|
||||
|
||||
fs.rmSync(rendererDir, { recursive: true, force: true });
|
||||
fs.rmSync(userDataDir, { recursive: true, force: true });
|
||||
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
|
||||
if (process.versions.electron && process.env.CHATLAB_ICON_RENDER === "1") {
|
||||
mainElectron().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
try {
|
||||
electronApi().app.quit();
|
||||
} catch {
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
mainNode();
|
||||
}
|
||||
Reference in New Issue
Block a user