feat: Add build scripts and runtime config support

Backend:
- Add setup-backend.sh/bat for one-click backend setup
- Fix test_auth.py mock settings (JWT_EXPIRE_HOURS)
- Fix test_excel_export.py TEMPLATE_DIR reference

Frontend:
- Add config.json for runtime API URL configuration
- Add init.js and settings.js for config loading
- Update main.js to load config from external file
- Update api.js to use dynamic API_BASE_URL
- Update all pages to initialize config before API calls
- Update package.json with extraResources for config

Build:
- Add build-client.sh/bat for packaging Electron + Sidecar
- Add build-all.ps1 PowerShell script with -ApiUrl parameter
- Add GitHub Actions workflow for Windows builds
- Add scripts/README.md documentation

This allows IT to configure backend URL without rebuilding.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
egg
2025-12-16 20:03:16 +08:00
parent 01aee1fd0d
commit 7d4fc69071
20 changed files with 2454 additions and 32 deletions

10
client/config.json Normal file
View File

@@ -0,0 +1,10 @@
{
"apiBaseUrl": "http://localhost:8000/api",
"uploadTimeout": 600000,
"appTitle": "Meeting Assistant",
"whisper": {
"model": "medium",
"device": "cpu",
"compute": "int8"
}
}

View File

@@ -29,19 +29,44 @@
],
"extraResources": [
{
"from": "../sidecar/dist",
"to": "sidecar",
"from": "../sidecar/dist/transcriber",
"to": "sidecar/transcriber",
"filter": ["**/*"]
},
{
"from": "config.json",
"to": "config.json"
}
],
"win": {
"target": "portable"
"target": [
{
"target": "portable",
"arch": ["x64"]
}
],
"icon": "assets/icon.ico"
},
"mac": {
"target": "dmg"
"target": [
{
"target": "dmg",
"arch": ["x64", "arm64"]
}
],
"icon": "assets/icon.icns"
},
"linux": {
"target": "AppImage"
"target": [
{
"target": "AppImage",
"arch": ["x64"]
}
],
"icon": "assets/icon.png"
},
"portable": {
"artifactName": "${productName}-${version}-portable.${ext}"
}
}
}

View File

@@ -0,0 +1,116 @@
/**
* Runtime Configuration Module
*
* Loads settings from external config.json file at runtime,
* allowing IT to configure the app without rebuilding.
*
* Config file location:
* - Development: client/config.json
* - Packaged app: <app>/resources/config.json
*/
// Default settings (used if config.json is not found)
const DEFAULT_SETTINGS = {
apiBaseUrl: "http://localhost:8000/api",
uploadTimeout: 600000,
appTitle: "Meeting Assistant",
whisper: {
model: "medium",
device: "cpu",
compute: "int8"
}
};
let _settings = null;
let _configPath = null;
/**
* Get the config file path based on environment
*/
function getConfigPath() {
if (_configPath) return _configPath;
// Check if running in Electron
if (typeof window !== "undefined" && window.electronAPI) {
// Will be set by preload.js
return null;
}
// Browser/development fallback
return "./config.json";
}
/**
* Load settings from config file
* Called once at app startup
*/
export async function loadSettings() {
if (_settings) return _settings;
try {
// Try to load from Electron's exposed config
if (typeof window !== "undefined" && window.electronAPI?.getConfig) {
_settings = await window.electronAPI.getConfig();
console.log("Settings loaded from Electron config:", _settings);
return _settings;
}
// Fallback: try to fetch config.json
const configPath = getConfigPath();
if (configPath) {
const response = await fetch(configPath);
if (response.ok) {
const config = await response.json();
_settings = { ...DEFAULT_SETTINGS, ...config };
console.log("Settings loaded from config.json:", _settings);
return _settings;
}
}
} catch (error) {
console.warn("Failed to load config.json, using defaults:", error.message);
}
// Use defaults
_settings = { ...DEFAULT_SETTINGS };
console.log("Using default settings:", _settings);
return _settings;
}
/**
* Get current settings (must call loadSettings first)
*/
export function getSettings() {
if (!_settings) {
console.warn("Settings not loaded yet, returning defaults");
return { ...DEFAULT_SETTINGS };
}
return _settings;
}
/**
* Get API base URL
*/
export function getApiBaseUrl() {
return getSettings().apiBaseUrl;
}
/**
* Get upload timeout
*/
export function getUploadTimeout() {
return getSettings().uploadTimeout;
}
/**
* Get app title
*/
export function getAppTitle() {
return getSettings().appTitle;
}
/**
* Get Whisper configuration
*/
export function getWhisperConfig() {
return getSettings().whisper;
}

View File

@@ -8,11 +8,61 @@ let mainWindow;
let sidecarProcess;
let sidecarReady = false;
let streamingActive = false;
let appConfig = null;
/**
* Load configuration from external config.json
* Config file location:
* - Development: client/config.json
* - Packaged: <app>/resources/config.json
*/
function loadConfig() {
const configPaths = [
// Packaged app: resources folder
app.isPackaged ? path.join(process.resourcesPath, "config.json") : null,
// Development: client folder
path.join(__dirname, "..", "config.json"),
// Fallback: same directory
path.join(__dirname, "config.json"),
].filter(Boolean);
for (const configPath of configPaths) {
try {
if (fs.existsSync(configPath)) {
const configData = fs.readFileSync(configPath, "utf-8");
appConfig = JSON.parse(configData);
console.log("Config loaded from:", configPath);
console.log("Config:", appConfig);
return appConfig;
}
} catch (error) {
console.warn("Failed to load config from:", configPath, error.message);
}
}
// Default configuration
appConfig = {
apiBaseUrl: "http://localhost:8000/api",
uploadTimeout: 600000,
appTitle: "Meeting Assistant",
whisper: {
model: "medium",
device: "cpu",
compute: "int8"
}
};
console.log("Using default config:", appConfig);
return appConfig;
}
function createWindow() {
// Set window title from config
const windowTitle = appConfig?.appTitle || "Meeting Assistant";
mainWindow = new BrowserWindow({
width: 1200,
height: 800,
title: windowTitle,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
@@ -32,34 +82,65 @@ function startSidecar() {
? path.join(process.resourcesPath, "sidecar")
: path.join(__dirname, "..", "..", "sidecar");
const sidecarScript = path.join(sidecarDir, "transcriber.py");
const venvPython = path.join(sidecarDir, "venv", "bin", "python");
// Determine the sidecar executable path based on packaging and platform
let sidecarExecutable;
let sidecarArgs = [];
if (!fs.existsSync(sidecarScript)) {
console.log("Sidecar script not found at:", sidecarScript);
if (app.isPackaged) {
// Packaged app: use PyInstaller-built executable
if (process.platform === "win32") {
sidecarExecutable = path.join(sidecarDir, "transcriber", "transcriber.exe");
} else if (process.platform === "darwin") {
sidecarExecutable = path.join(sidecarDir, "transcriber", "transcriber");
} else {
sidecarExecutable = path.join(sidecarDir, "transcriber", "transcriber");
}
} else {
// Development mode: use Python script with venv
const sidecarScript = path.join(sidecarDir, "transcriber.py");
if (!fs.existsSync(sidecarScript)) {
console.log("Sidecar script not found at:", sidecarScript);
console.log("Transcription will not be available.");
return;
}
// Check for virtual environment Python
let venvPython;
if (process.platform === "win32") {
venvPython = path.join(sidecarDir, "venv", "Scripts", "python.exe");
} else {
venvPython = path.join(sidecarDir, "venv", "bin", "python");
}
sidecarExecutable = fs.existsSync(venvPython) ? venvPython : "python3";
sidecarArgs = [sidecarScript];
}
if (!fs.existsSync(sidecarExecutable)) {
console.log("Sidecar executable not found at:", sidecarExecutable);
console.log("Transcription will not be available.");
return;
}
const pythonPath = fs.existsSync(venvPython) ? venvPython : "python3";
try {
// Get Whisper configuration from environment variables
// Get Whisper configuration from config.json or environment variables
const whisperConfig = appConfig?.whisper || {};
const whisperEnv = {
...process.env,
WHISPER_MODEL: process.env.WHISPER_MODEL || "medium",
WHISPER_DEVICE: process.env.WHISPER_DEVICE || "cpu",
WHISPER_COMPUTE: process.env.WHISPER_COMPUTE || "int8",
WHISPER_MODEL: process.env.WHISPER_MODEL || whisperConfig.model || "medium",
WHISPER_DEVICE: process.env.WHISPER_DEVICE || whisperConfig.device || "cpu",
WHISPER_COMPUTE: process.env.WHISPER_COMPUTE || whisperConfig.compute || "int8",
};
console.log("Starting sidecar with:", pythonPath, sidecarScript);
console.log("Starting sidecar with:", sidecarExecutable, sidecarArgs.join(" "));
console.log("Whisper config:", {
model: whisperEnv.WHISPER_MODEL,
device: whisperEnv.WHISPER_DEVICE,
compute: whisperEnv.WHISPER_COMPUTE,
});
sidecarProcess = spawn(pythonPath, [sidecarScript], {
sidecarProcess = spawn(sidecarExecutable, sidecarArgs, {
cwd: sidecarDir,
stdio: ["pipe", "pipe", "pipe"],
env: whisperEnv,
@@ -121,6 +202,9 @@ function startSidecar() {
}
app.whenReady().then(() => {
// Load configuration first
loadConfig();
createWindow();
startSidecar();
@@ -144,6 +228,12 @@ app.on("window-all-closed", () => {
});
// IPC handlers
// Get app configuration (for renderer to initialize API settings)
ipcMain.handle("get-config", () => {
return appConfig;
});
ipcMain.handle("navigate", (event, page) => {
mainWindow.loadFile(path.join(__dirname, "pages", `${page}.html`));
});

View File

@@ -26,8 +26,12 @@
</div>
<script type="module">
import { initApp } from '../services/init.js';
import { login } from '../services/api.js';
// Initialize app config before any API calls
await initApp();
const form = document.getElementById('login-form');
const errorAlert = document.getElementById('error-alert');
const loginBtn = document.getElementById('login-btn');

View File

@@ -235,6 +235,7 @@
</div>
<script type="module">
import { initApp } from '../services/init.js';
import {
getMeeting,
updateMeeting,
@@ -244,6 +245,9 @@
transcribeAudio
} from '../services/api.js';
// Initialize app config before any API calls
await initApp();
const meetingId = localStorage.getItem('currentMeetingId');
let currentMeeting = null;
let isRecording = false;

View File

@@ -67,8 +67,12 @@
</div>
<script type="module">
import { initApp } from '../services/init.js';
import { getMeetings, createMeeting, clearToken } from '../services/api.js';
// Initialize app config before any API calls
await initApp();
const meetingsContainer = document.getElementById('meetings-container');
const newMeetingBtn = document.getElementById('new-meeting-btn');
const logoutBtn = document.getElementById('logout-btn');

View File

@@ -1,6 +1,9 @@
const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("electronAPI", {
// App configuration (loaded from config.json)
getConfig: () => ipcRenderer.invoke("get-config"),
// Navigation
navigate: (page) => ipcRenderer.invoke("navigate", page),

View File

@@ -1,5 +1,34 @@
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "http://localhost:8000/api";
const UPLOAD_TIMEOUT = parseInt(import.meta.env.VITE_UPLOAD_TIMEOUT || "600000", 10);
/**
* API Service Module
*
* API base URL and settings are loaded from external config at runtime,
* allowing deployment configuration without rebuilding.
*/
// Settings will be loaded from config.json via Electron IPC
let API_BASE_URL = "http://localhost:8000/api";
let UPLOAD_TIMEOUT = 600000;
let _settingsLoaded = false;
/**
* Initialize API settings from external config
* Called by Electron preload or at app startup
*/
export async function initializeApi(settings) {
if (settings) {
API_BASE_URL = settings.apiBaseUrl || API_BASE_URL;
UPLOAD_TIMEOUT = settings.uploadTimeout || UPLOAD_TIMEOUT;
_settingsLoaded = true;
console.log("API initialized with:", { API_BASE_URL, UPLOAD_TIMEOUT });
}
}
/**
* Get current API base URL
*/
export function getApiBaseUrl() {
return API_BASE_URL;
}
let authToken = null;
let tokenRefreshTimer = null;

View File

@@ -0,0 +1,39 @@
/**
* App Initialization Module
*
* Loads configuration from Electron main process and initializes API settings.
* Should be imported and called at the start of every page.
*/
import { initializeApi } from './api.js';
let _initialized = false;
/**
* Initialize the app with configuration from main process
* Call this at the start of every page
*/
export async function initApp() {
if (_initialized) return;
try {
// Get config from Electron main process
if (window.electronAPI?.getConfig) {
const config = await window.electronAPI.getConfig();
await initializeApi(config);
console.log("App initialized with config:", config);
} else {
console.warn("electronAPI not available, using defaults");
}
_initialized = true;
} catch (error) {
console.error("Failed to initialize app:", error);
}
}
/**
* Check if app is initialized
*/
export function isInitialized() {
return _initialized;
}