feat(toolbox): ui update

This commit is contained in:
2025-08-09 18:48:34 -04:00
parent d13484d282
commit b251bc34f3
30 changed files with 7525 additions and 1267 deletions

127
electron/bridge.py Normal file
View File

@@ -0,0 +1,127 @@
#!/usr/bin/env python3
"""
Electron Bridge Script for 4DSTAR Bundle Management
UPDATED ARCHITECTURE (2025-08-09):
=====================================
This bridge script has been simplified to work with the refactored core functions
that now return JSON directly. No more complex stdout mixing or data wrapping.
Key Changes:
- Core functions return JSON-serializable dictionaries directly
- Progress messages go to stderr only (never mixed with JSON output)
- Clean JSON output to stdout for Electron to parse
- Simplified error handling with consistent JSON error format
"""
import sys
import json
import inspect
import traceback
from pathlib import Path
import datetime
# Custom JSON encoder to handle Path and datetime objects
class FourdstEncoder(json.JSONEncoder):
def default(self, o):
if isinstance(o, Path):
return str(o)
if isinstance(o, (datetime.datetime, datetime.date)):
return o.isoformat()
return super().default(o)
# Add the project root to the Python path to allow importing 'fourdst'
project_root = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(project_root))
from fourdst.core import bundle
def main():
# Use stderr for all logging to avoid interfering with JSON output on stdout
log_file = sys.stderr
print("--- Python backend bridge started ---", file=log_file, flush=True)
if len(sys.argv) < 3:
print(f"FATAL: Not enough arguments provided. Got {len(sys.argv)}. Exiting.", file=log_file, flush=True)
# Return JSON error even for argument errors
error_response = {
'success': False,
'error': f'Invalid arguments. Expected: <command> <json_args>. Got {len(sys.argv)} args.'
}
print(json.dumps(error_response), flush=True)
sys.exit(1)
command = sys.argv[1]
args_json = sys.argv[2]
print(f"[BRIDGE_INFO] Received command: {command}", file=log_file, flush=True)
print(f"[BRIDGE_INFO] Received raw args: {args_json}", file=log_file, flush=True)
try:
kwargs = json.loads(args_json)
print(f"[BRIDGE_INFO] Parsed kwargs: {kwargs}", file=log_file, flush=True)
# Convert path strings to Path objects where needed
for key, value in kwargs.items():
if isinstance(value, str) and ('path' in key.lower() or 'key' in key.lower()):
kwargs[key] = Path(value)
elif isinstance(value, list) and 'dirs' in key.lower():
kwargs[key] = [Path(p) for p in value]
func = getattr(bundle, command)
# Create progress callback that sends structured progress to stderr
# This keeps progress separate from the final JSON result on stdout
def progress_callback(message):
# Progress goes to stderr to avoid mixing with JSON output
if isinstance(message, dict):
# Structured progress message (e.g., from fill_bundle)
progress_msg = f"[PROGRESS] {json.dumps(message)}"
else:
# Simple string message
progress_msg = f"[PROGRESS] {message}"
print(progress_msg, file=log_file, flush=True)
# Inspect the function signature to see if it accepts 'progress_callback'.
sig = inspect.signature(func)
if 'progress_callback' in sig.parameters:
kwargs['progress_callback'] = progress_callback
print(f"[BRIDGE_INFO] Calling function `bundle.{command}`...", file=log_file, flush=True)
result = func(**kwargs)
print(f"[BRIDGE_INFO] Function returned successfully.", file=log_file, flush=True)
# Core functions now return JSON-serializable dictionaries directly
# No need for wrapping or complex data transformation
if result is None:
# Fallback for functions that might still return None
result = {
'success': True,
'message': f'{command} completed successfully.'
}
# Send the result directly as JSON to stdout
print("[BRIDGE_INFO] Sending JSON response to stdout.", file=log_file, flush=True)
json_response = json.dumps(result, cls=FourdstEncoder)
print(json_response, flush=True)
print("--- Python backend bridge finished successfully ---", file=log_file, flush=True)
except Exception as e:
# Get the full traceback for detailed debugging
tb_str = traceback.format_exc()
# Print the traceback to stderr so it appears in the terminal
print(f"[BRIDGE_ERROR] Exception occurred: {tb_str}", file=sys.stderr, flush=True)
# Send consistent JSON error response to stdout
error_response = {
'success': False,
'error': f'Bridge error in {command}: {str(e)}',
'traceback': tb_str # Include traceback for debugging
}
json_response = json.dumps(error_response, cls=FourdstEncoder)
print(json_response, flush=True)
print("--- Python backend bridge finished with error ---", file=sys.stderr, flush=True)
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,52 @@
# -*- mode: python ; coding: utf-8 -*-
import sys
from pathlib import Path
# This is a PyInstaller spec file. It is used to bundle the Python backend
# into a single executable that can be shipped with the Electron app.
# The project_root is the 'fourdst/' directory that contains 'electron/', 'fourdst/', etc.
# SPECPATH is a variable provided by PyInstaller that contains the absolute path
# to the directory containing the spec file.
project_root = Path(SPECPATH).parent
# We need to add the project root to the path so that PyInstaller can find the 'fourdst' module.
sys.path.insert(0, str(project_root))
# The main script to be bundled.
analysis = Analysis(['bridge.py'],
pathex=[str(project_root)],
binaries=[],
# Add any modules that PyInstaller might not find automatically.
hiddenimports=['docker'],
hookspath=[],
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=None,
noarchive=False)
pyz = PYZ(analysis.pure, analysis.zipped_data,
cipher=None)
exe = EXE(pyz,
analysis.scripts,
[],
exclude_binaries=True,
name='fourdst-backend',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
console=True )
coll = COLLECT(exe,
analysis.binaries,
analysis.zipfiles,
analysis.datas,
strip=False,
upx=True,
upx_exclude=[],
name='fourdst-backend')

104
electron/index.html Normal file
View File

@@ -0,0 +1,104 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>4DSTAR Bundle Manager</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="main-container">
<aside class="sidebar">
<div class="sidebar-header">
<h3>4DSTAR</h3>
</div>
<nav class="sidebar-nav">
<button id="open-bundle-btn" class="nav-button active">Open Bundle</button>
<button id="create-bundle-btn" class="nav-button">Create Bundle</button>
</nav>
<div class="sidebar-footer">
<p>v1.0.0</p>
</div>
</aside>
<main class="content-area">
<div id="welcome-screen">
<h1>Welcome to 4DSTAR Bundle Manager</h1>
<p>Open or create a bundle to get started.</p>
</div>
<div id="bundle-view" class="hidden">
<header class="content-header">
<h2 id="bundle-title"></h2>
<div class="action-buttons">
<button id="edit-bundle-btn">Edit</button>
<button id="sign-bundle-btn">Sign</button>
<button id="validate-bundle-btn">Validate</button>
<button id="fill-bundle-btn">Fill</button>
<button id="clear-bundle-btn">Clear</button>
</div>
</header>
<div class="tab-nav">
<button class="tab-link active" data-tab="overview-tab">Overview</button>
<button class="tab-link" data-tab="plugins-tab">Plugins</button>
<button class="tab-link" data-tab="validation-tab" class="hidden">Validation</button>
</div>
<div id="tab-content">
<div id="overview-tab" class="tab-pane active">
<div class="action-buttons">
<button id="sign-bundle-button" class="action-button">Sign Bundle</button>
<button id="validate-bundle-button" class="action-button">Validate Bundle</button>
<button id="fill-bundle-button" class="action-button">Fill Bundle...</button>
<button id="clear-bundle-button" class="action-button">Clear Binaries</button>
</div>
<div id="manifest-details"></div>
</div>
<div id="plugins-tab" class="tab-pane">
<div id="plugins-list"></div>
</div>
<div id="validation-tab" class="tab-pane">
<pre id="validation-results"></pre>
</div>
</div>
</div>
<div id="create-bundle-form" class="hidden">
<!-- The create form will be moved into a modal later -->
</div>
</main>
</div>
<!-- Modal for status/error messages -->
<div id="modal" class="modal-container hidden">
<div class="modal-content">
<span id="modal-close-btn" class="modal-close">&times;</span>
<h3 id="modal-title"></h3>
<div id="modal-message"></div>
</div>
</div>
<!-- Fill Modal -->
<div id="fill-modal" class="modal">
<div class="modal-content">
<span class="close-fill-modal-button">&times;</span>
<h2 id="fill-modal-title">Fill Bundle</h2>
<div id="fill-modal-body">
<p>Select targets to build and add to the bundle:</p>
<div id="fill-targets-list"></div>
<button id="start-fill-button" class="action-button">Start Fill</button>
</div>
<div id="fill-progress-view" style="display: none;">
<h3>Fill Progress:</h3>
<div id="fill-progress-list"></div>
</div>
</div>
</div>
<div id="spinner" class="spinner hidden"></div>
<script src="renderer.js"></script>
</body>
</html>

299
electron/main.js Normal file
View File

@@ -0,0 +1,299 @@
const { app, BrowserWindow, ipcMain, dialog, nativeTheme } = require('electron');
const path = require('path');
const fs = require('fs-extra');
const yaml = require('js-yaml');
const AdmZip = require('adm-zip');
const { spawn } = require('child_process');
// Handle creating/removing shortcuts on Windows when installing/uninstalling.
if (require('electron-squirrel-startup')) {
app.quit();
}
let mainWindow;
const createWindow = () => {
// Create the browser window.
mainWindow = new BrowserWindow({
width: 1200,
height: 800,
webPreferences: {
nodeIntegration: true,
contextIsolation: false,
enableRemoteModule: true,
},
});
// and load the index.html of the app.
mainWindow.loadFile(path.join(__dirname, 'index.html'));
// Open the DevTools for debugging
// mainWindow.webContents.openDevTools();
nativeTheme.on('updated', () => {
if (mainWindow) {
mainWindow.webContents.send('theme-updated', { shouldUseDarkColors: nativeTheme.shouldUseDarkColors });
}
});
};
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.on('ready', createWindow);
// Quit when all windows are closed, except on macOS. There, it's common
// for applications and their menu bar to stay active until the user quits
// explicitly with Cmd + Q.
ipcMain.handle('get-dark-mode', () => {
return nativeTheme.shouldUseDarkColors;
});
ipcMain.on('show-error-dialog', (event, { title, content }) => {
dialog.showErrorBox(title, content);
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
app.on('activate', () => {
// On OS X it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
// IPC handlers
ipcMain.handle('select-file', async () => {
const result = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [
{ name: 'Fbundle Archives', extensions: ['fbundle'] },
{ name: 'All Files', extensions: ['*'] }
]
});
if (!result.canceled && result.filePaths.length > 0) {
return result.filePaths[0];
}
return null;
});
ipcMain.handle('select-directory', async () => {
const result = await dialog.showOpenDialog({
properties: ['openDirectory']
});
if (!result.canceled && result.filePaths.length > 0) {
return result.filePaths[0];
}
return null;
});
ipcMain.handle('select-save-file', async () => {
const result = await dialog.showSaveDialog({
filters: [
{ name: 'Fbundle Archives', extensions: ['fbundle'] }
]
});
if (!result.canceled) {
return result.filePath;
}
return null;
});
// Helper function to run python commands via the bundled backend
function runPythonCommand(command, kwargs, event) {
const buildDir = path.resolve(__dirname, '..', 'build');
let backendPath;
if (app.isPackaged) {
backendPath = path.join(process.resourcesPath, 'fourdst-backend');
} else {
backendPath = path.join(buildDir, 'electron', 'dist', 'fourdst-backend', 'fourdst-backend');
}
console.log(`[MAIN_PROCESS] Spawning backend: ${backendPath}`);
const args = [command, JSON.stringify(kwargs)];
console.log(`[MAIN_PROCESS] With args: [${args.join(', ')}]`);
return new Promise((resolve) => {
const process = spawn(backendPath, args);
let stdoutBuffer = '';
let errorOutput = '';
process.stderr.on('data', (data) => {
errorOutput += data.toString();
console.error('Backend STDERR:', data.toString().trim());
});
const isStreaming = command === 'fill_bundle';
process.stdout.on('data', (data) => {
const chunk = data.toString();
stdoutBuffer += chunk;
if (isStreaming && event) {
// Process buffer line by line for streaming commands
let newlineIndex;
while ((newlineIndex = stdoutBuffer.indexOf('\n')) >= 0) {
const line = stdoutBuffer.substring(0, newlineIndex).trim();
stdoutBuffer = stdoutBuffer.substring(newlineIndex + 1);
if (line) {
try {
const parsed = JSON.parse(line);
if (parsed.type === 'progress') {
event.sender.send('fill-bundle-progress', parsed.data);
} else {
// Not a progress update, put it back in the buffer for final processing
stdoutBuffer = line + '\n' + stdoutBuffer;
break; // Stop processing lines
}
} catch (e) {
// Ignore parsing errors for intermediate lines in a stream
}
}
}
}
});
process.on('close', (code) => {
console.log(`[MAIN_PROCESS] Backend process exited with code ${code}`);
let resultData = null;
try {
// Core functions now return clean JSON directly
const finalJson = JSON.parse(stdoutBuffer.trim());
resultData = finalJson; // Use the JSON response directly
} catch (e) {
console.error(`[MAIN_PROCESS] Could not parse backend output as JSON: ${e}`);
console.error(`[MAIN_PROCESS] Raw output: "${stdoutBuffer}"`);
// If parsing fails, return a structured error response
resultData = {
success: false,
error: `JSON parsing failed: ${e.message}`,
raw_output: stdoutBuffer
};
}
const finalError = errorOutput.trim();
if (finalError && !resultData) {
resolve({ success: false, error: finalError });
} else if (resultData) {
resolve(resultData);
} else {
const errorMessage = finalError || `The script finished without returning a result (exit code: ${code})`;
resolve({ success: false, error: errorMessage });
}
});
process.on('error', (err) => {
resolve({ success: false, error: `Failed to start backend process: ${err.message}` });
});
});
}
ipcMain.handle('create-bundle', async (event, bundleData) => {
const kwargs = {
plugin_dirs: bundleData.pluginDirs,
output_path: bundleData.outputPath,
bundle_name: bundleData.bundleName,
bundle_version: bundleData.bundleVersion,
bundle_author: bundleData.bundleAuthor,
bundle_comment: bundleData.bundleComment,
};
const result = await runPythonCommand('create_bundle', kwargs, event);
// The renderer expects a 'path' property on success
if (result.success) {
result.path = bundleData.outputPath;
}
return result;
});
ipcMain.handle('sign-bundle', async (event, bundlePath) => {
// Prompt for private key
const result = await dialog.showOpenDialog({
properties: ['openFile'],
title: 'Select Private Key',
filters: [{ name: 'PEM Private Key', extensions: ['pem'] }],
});
if (result.canceled || !result.filePaths || result.filePaths.length === 0) {
return { success: false, error: 'Private key selection was canceled.' };
}
const privateKeyPath = result.filePaths[0];
const kwargs = {
bundle_path: bundlePath,
private_key: privateKeyPath,
};
return runPythonCommand('sign_bundle', kwargs, event);
});
ipcMain.handle('validate-bundle', async (event, bundlePath) => {
const kwargs = {
bundle_path: bundlePath
};
return runPythonCommand('validate_bundle', kwargs, event);
});
ipcMain.handle('clear-bundle', async (event, bundlePath) => {
const kwargs = { bundle_path: bundlePath };
return runPythonCommand('clear_bundle', kwargs, event);
});
ipcMain.handle('get-fillable-targets', async (event, bundlePath) => {
const kwargs = { bundle_path: bundlePath };
return runPythonCommand('get_fillable_targets', kwargs, event);
});
ipcMain.handle('fill-bundle', async (event, { bundlePath, targetsToBuild }) => {
const kwargs = {
bundle_path: bundlePath,
targets_to_build: targetsToBuild
};
// Pass event to stream progress
return runPythonCommand('fill_bundle', kwargs, event);
});
ipcMain.handle('edit-bundle', async (event, { bundlePath, updatedManifest }) => {
const kwargs = {
bundle_path: bundlePath,
metadata: updatedManifest
};
return runPythonCommand('edit_bundle_metadata', kwargs, event);
});
ipcMain.handle('open-bundle', async (event, bundlePath) => {
console.log(`[IPC_HANDLER] Opening bundle: ${bundlePath}`);
const kwargs = { bundle_path: bundlePath };
const result = await runPythonCommand('inspect_bundle', kwargs, event);
console.log(`[IPC_HANDLER] inspect_bundle result:`, result);
// Core functions now return consistent JSON structure directly
if (result && result.success) {
// The core inspect_bundle function returns the data directly
// We just need to add the bundlePath for the renderer
return {
success: true,
manifest: result.manifest,
report: result, // The entire result is the report
bundlePath: bundlePath
};
}
// Return error as-is since it's already in the correct format
return result || { success: false, error: 'An unknown error occurred while opening the bundle.' };
});

3873
electron/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

58
electron/package.json Normal file
View File

@@ -0,0 +1,58 @@
{
"name": "fourdst-bundle-manager",
"version": "1.0.0",
"description": "Electron app for managing fbundle archives",
"main": "main.js",
"scripts": {
"start": "electron .",
"dev": "electron .",
"build": "electron-builder",
"pack": "electron-builder --dir"
},
"repository": {
"type": "git",
"url": "https://github.com/tboudreaux/fourdst"
},
"keywords": [
"Electron",
"fbundle",
"4DSTAR"
],
"author": "4DSTAR Team",
"license": "MIT",
"devDependencies": {
"electron": "^31.0.2",
"adm-zip": "^0.5.14",
"electron-builder": "^24.0.0",
"electron-squirrel-startup": "^1.0.1"
},
"dependencies": {
"fs-extra": "^11.0.0",
"js-yaml": "^4.1.0",
"adm-zip": "^0.5.14",
"@electron/remote": "^2.0.0",
"python-shell": "^5.0.0"
},
"build": {
"appId": "com.fourdst.bundlemanager",
"productName": "4DSTAR Bundle Manager",
"directories": {
"output": "dist"
},
"mac": {
"category": "public.app-category.developer-tools",
"target": [
"dmg",
"zip"
]
},
"linux": {
"target": [
"AppImage",
"deb",
"rpm"
],
"category": "Development"
}
}
}

488
electron/renderer.js Normal file
View File

@@ -0,0 +1,488 @@
const { ipcRenderer } = require('electron');
const path = require('path');
// --- STATE ---
let currentBundle = null;
// --- DOM ELEMENTS ---
// Views
const welcomeScreen = document.getElementById('welcome-screen');
const bundleView = document.getElementById('bundle-view');
const createBundleForm = document.getElementById('create-bundle-form'); // This will be a modal later
// Sidebar buttons
const openBundleBtn = document.getElementById('open-bundle-btn');
const createBundleBtn = document.getElementById('create-bundle-btn');
// Bundle action buttons
const editBundleBtn = document.getElementById('edit-bundle-btn');
const signBundleBtn = document.getElementById('sign-bundle-btn');
const validateBundleBtn = document.getElementById('validate-bundle-btn');
const fillBundleBtn = document.getElementById('fill-bundle-btn');
const clearBundleBtn = document.getElementById('clear-bundle-btn');
// Bundle display
const bundleTitle = document.getElementById('bundle-title');
const manifestDetails = document.getElementById('manifest-details');
const pluginsList = document.getElementById('plugins-list');
const validationResults = document.getElementById('validation-results');
// Tabs
const tabLinks = document.querySelectorAll('.tab-link');
const tabPanes = document.querySelectorAll('.tab-pane');
const validationTabLink = document.querySelector('button[data-tab="validation-tab"]');
// Modal
const modal = document.getElementById('modal');
const modalTitle = document.getElementById('modal-title');
const modalMessage = document.getElementById('modal-message');
const modalCloseBtn = document.getElementById('modal-close-btn');
// Spinner
const spinner = document.getElementById('spinner');
// Fill Modal elements
const fillModal = document.getElementById('fill-modal');
const closeFillModalButton = document.querySelector('.close-fill-modal-button');
const fillModalTitle = document.getElementById('fill-modal-title');
const fillModalBody = document.getElementById('fill-modal-body');
const fillTargetsList = document.getElementById('fill-targets-list');
const startFillButton = document.getElementById('start-fill-button');
const fillProgressView = document.getElementById('fill-progress-view');
const fillProgressList = document.getElementById('fill-progress-list');
let currentBundlePath = null;
// --- INITIALIZATION ---
document.addEventListener('DOMContentLoaded', async () => {
// Set initial view
showView('welcome-screen');
// Set initial theme
const isDarkMode = await ipcRenderer.invoke('get-dark-mode');
document.body.classList.toggle('dark-mode', isDarkMode);
// Setup event listeners
setupEventListeners();
});
// --- EVENT LISTENERS ---
function setupEventListeners() {
// Theme updates
ipcRenderer.on('theme-updated', (event, { shouldUseDarkColors }) => {
document.body.classList.toggle('dark-mode', shouldUseDarkColors);
});
// Sidebar navigation
openBundleBtn.addEventListener('click', handleOpenBundle);
createBundleBtn.addEventListener('click', () => {
// TODO: Replace with modal
showView('create-bundle-form');
showModal('Not Implemented', 'The create bundle form will be moved to a modal dialog.');
});
// Tab navigation
tabLinks.forEach(link => {
link.addEventListener('click', () => switchTab(link.dataset.tab));
});
// Modal close button
modalCloseBtn.addEventListener('click', hideModal);
// Bundle actions
signBundleBtn.addEventListener('click', handleSignBundle);
validateBundleBtn.addEventListener('click', handleValidateBundle);
clearBundleBtn.addEventListener('click', handleClearBundle);
fillBundleBtn.addEventListener('click', async () => {
if (!currentBundlePath) {
showModal('Error', 'No bundle is currently open.');
return;
}
showSpinner();
const result = await ipcRenderer.invoke('get-fillable-targets', currentBundlePath);
hideSpinner();
if (!result.success) {
showModal('Error', `Failed to get fillable targets: ${result.error}`);
return;
}
const targets = result.data;
if (Object.keys(targets).length === 0) {
showModal('Info', 'The bundle is already full. No new targets to build.');
return;
}
populateFillTargetsList(targets);
fillModal.style.display = 'block';
});
closeFillModalButton.addEventListener('click', () => {
fillModal.style.display = 'none';
});
function populateFillTargetsList(plugins) {
fillTargetsList.innerHTML = '';
for (const [pluginName, targets] of Object.entries(plugins)) {
if (targets.length > 0) {
const pluginHeader = document.createElement('h4');
pluginHeader.textContent = `Plugin: ${pluginName}`;
fillTargetsList.appendChild(pluginHeader);
targets.forEach(target => {
const item = document.createElement('div');
item.className = 'fill-target-item';
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.checked = true;
checkbox.id = `target-${pluginName}-${target.triplet}`;
checkbox.dataset.pluginName = pluginName;
checkbox.dataset.targetTriplet = target.triplet;
checkbox.dataset.targetInfo = JSON.stringify(target);
const label = document.createElement('label');
label.htmlFor = checkbox.id;
label.textContent = `${target.triplet} (${target.type})`;
item.appendChild(checkbox);
item.appendChild(label);
fillTargetsList.appendChild(item);
});
}
}
// Reset view
fillModalBody.style.display = 'block';
fillProgressView.style.display = 'none';
}
startFillButton.addEventListener('click', async () => {
const selectedTargets = {};
const checkboxes = fillTargetsList.querySelectorAll('input[type="checkbox"]:checked');
if (checkboxes.length === 0) {
showModal('Info', 'No targets selected to fill.');
return;
}
checkboxes.forEach(cb => {
const pluginName = cb.dataset.pluginName;
if (!selectedTargets[pluginName]) {
selectedTargets[pluginName] = [];
}
selectedTargets[pluginName].push(JSON.parse(cb.dataset.targetInfo));
});
fillModalBody.style.display = 'none';
fillProgressView.style.display = 'block';
fillModalTitle.textContent = 'Filling Bundle...';
populateFillProgressList(selectedTargets);
const result = await ipcRenderer.invoke('fill-bundle', {
bundlePath: currentBundlePath,
targetsToBuild: selectedTargets
});
fillModalTitle.textContent = 'Fill Complete';
if (!result.success) {
// A final error message if the whole process fails.
const p = document.createElement('p');
p.style.color = 'var(--error-color)';
p.textContent = `Error: ${result.error}`;
fillProgressList.appendChild(p);
}
});
function populateFillProgressList(plugins) {
fillProgressList.innerHTML = '';
for (const [pluginName, targets] of Object.entries(plugins)) {
targets.forEach(target => {
const item = document.createElement('div');
item.className = 'fill-target-item';
item.id = `progress-${pluginName}-${target.triplet}`;
const indicator = document.createElement('div');
indicator.className = 'progress-indicator';
const label = document.createElement('span');
label.textContent = `${pluginName} - ${target.triplet}`;
item.appendChild(indicator);
item.appendChild(label);
fillProgressList.appendChild(item);
});
}
}
ipcRenderer.on('fill-bundle-progress', (event, progress) => {
console.log('Progress update:', progress);
if (typeof progress === 'object' && progress.status) {
const { status, plugin, target, message } = progress;
const progressItem = document.getElementById(`progress-${plugin}-${target}`);
if (progressItem) {
const indicator = progressItem.querySelector('.progress-indicator');
indicator.className = 'progress-indicator'; // Reset classes
switch (status) {
case 'building':
indicator.classList.add('spinner-icon');
break;
case 'success':
indicator.classList.add('success-icon');
break;
case 'failure':
indicator.classList.add('failure-icon');
break;
}
const label = progressItem.querySelector('span');
if (message) {
label.textContent = `${plugin} - ${target}: ${message}`;
}
}
} else if (typeof progress === 'object' && progress.message) {
// Handle final completion message
if (progress.message.includes('✅')) {
fillModalTitle.textContent = 'Fill Complete!';
}
} else {
// Handle simple string progress messages
const p = document.createElement('p');
p.textContent = progress;
fillProgressList.appendChild(p);
}
});
}
// --- VIEW AND UI LOGIC ---
function showView(viewId) {
[welcomeScreen, bundleView, createBundleForm].forEach(view => {
view.classList.toggle('hidden', view.id !== viewId);
});
}
function switchTab(tabId) {
tabPanes.forEach(pane => {
pane.classList.toggle('active', pane.id === tabId);
});
tabLinks.forEach(link => {
link.classList.toggle('active', link.dataset.tab === tabId);
});
}
function showSpinner() {
spinner.classList.remove('hidden');
}
function hideSpinner() {
spinner.classList.add('hidden');
}
function showModal(title, message, type = 'info') {
modalTitle.textContent = title;
modalMessage.innerHTML = message; // Use innerHTML to allow for formatted messages
modal.classList.remove('hidden');
}
function hideModal() {
modal.classList.add('hidden');
}
// --- BUNDLE ACTIONS HANDLERS ---
async function handleOpenBundle() {
const bundlePath = await ipcRenderer.invoke('select-file');
if (!bundlePath) return;
showSpinner();
showModal('Opening...', `Opening bundle: ${path.basename(bundlePath)}`);
const result = await ipcRenderer.invoke('open-bundle', bundlePath);
hideSpinner();
if (result.success) {
currentBundle = result;
currentBundlePath = bundlePath;
displayBundleInfo(result.report);
showView('bundle-view');
hideModal();
} else {
showModal('Error Opening Bundle', `Failed to open bundle: ${result ? result.error : 'Unknown error'}`);
}
}
async function handleSignBundle() {
if (!currentBundlePath) return;
const result = await ipcRenderer.invoke('select-private-key');
if (result.canceled || !result.filePaths.length) {
return; // User canceled the dialog
}
const privateKeyPath = result.filePaths[0];
showSpinner();
const signResult = await ipcRenderer.invoke('sign-bundle', { bundlePath: currentBundlePath, privateKey: privateKeyPath });
hideSpinner();
if (signResult.success) {
showModal('Success', 'Bundle signed successfully. Reloading...');
await reloadCurrentBundle();
hideModal();
} else {
showModal('Sign Error', `Failed to sign bundle: ${signResult.error}`);
}
}
async function handleValidateBundle() {
if (!currentBundlePath) return;
showSpinner();
const result = await ipcRenderer.invoke('validate-bundle', currentBundlePath);
hideSpinner();
if (result.success) {
const validation = result.data;
const validationIssues = validation.errors.concat(validation.warnings);
if (validationIssues.length > 0) {
validationResults.textContent = validationIssues.join('\n');
validationTabLink.classList.remove('hidden');
} else {
validationResults.textContent = 'Bundle is valid.';
validationTabLink.classList.add('hidden');
}
// Switch to the validation tab to show the results.
switchTab('validation-tab');
showModal('Validation Complete', 'Validation check has finished.');
} else {
showModal('Validation Error', `Failed to validate bundle: ${result.error}`);
}
}
async function handleClearBundle() {
if (!currentBundlePath) return;
showSpinner();
const result = await ipcRenderer.invoke('clear-bundle', currentBundlePath);
hideSpinner();
if (result.success) {
showModal('Success', 'All binaries have been cleared. Reloading...');
await reloadCurrentBundle();
hideModal();
} else {
showModal('Clear Error', `Failed to clear binaries: ${result.error}`);
}
}
async function handleFillBundle() {
if (!currentBundle) return showModal('Action Canceled', 'Please open a bundle first.');
showSpinner();
showModal('Filling Bundle...', 'Adding local binaries to bundle.');
const result = await ipcRenderer.invoke('fill-bundle', currentBundle.bundlePath);
hideSpinner();
if (result.success) {
showModal('Success', 'Binaries filled successfully. Reloading...');
await reloadCurrentBundle();
hideModal();
} else {
showModal('Fill Error', `Failed to fill bundle: ${result.error}`);
}
}
// --- DATA DISPLAY ---
async function reloadCurrentBundle() {
if (!currentBundle) return;
const reloadResult = await ipcRenderer.invoke('open-bundle', currentBundle.bundlePath);
if (reloadResult.success) {
currentBundle = reloadResult;
displayBundleInfo(reloadResult.report);
} else {
showModal('Reload Error', `Failed to reload bundle details: ${reloadResult.error}`);
}
}
function displayBundleInfo(report) {
if (!report) {
showModal('Display Error', 'Could not load bundle information.');
return;
}
const { manifest, signature, validation, plugins } = report;
// Set bundle title
bundleTitle.textContent = manifest.bundleName || 'Untitled Bundle';
// --- Overview Tab ---
const trustStatus = signature.status || 'UNSIGNED';
const trustColorClass = {
'TRUSTED': 'trusted',
'UNTRUSTED': 'untrusted',
'INVALID': 'untrusted',
'TAMPERED': 'untrusted',
'UNSIGNED': 'unsigned',
'ERROR': 'untrusted',
'UNSUPPORTED': 'warning'
}[trustStatus] || 'unsigned';
manifestDetails.innerHTML = `
<div class="card">
<div class="card-header">
<h3>Trust Status</h3>
<div class="trust-indicator-container">
<div class="trust-indicator ${trustColorClass}"></div>
<span>${trustStatus}</span>
</div>
</div>
</div>
<div class="card">
<div class="card-header"><h3>Manifest Details</h3></div>
<div class="card-content">
<p><strong>Version:</strong> ${manifest.bundleVersion || 'N/A'}</p>
<p><strong>Author:</strong> ${manifest.bundleAuthor || 'N/A'}</p>
<p><strong>Bundled On:</strong> ${manifest.bundledOn || 'N/A'}</p>
<p><strong>Comment:</strong> ${manifest.bundleComment || 'N/A'}</p>
${manifest.bundleAuthorKeyFingerprint ? `<p><strong>Author Key:</strong> ${manifest.bundleAuthorKeyFingerprint}</p>` : ''}
${manifest.bundleSignature ? `<p><strong>Signature:</strong> <span class="signature">${manifest.bundleSignature}</span></p>` : ''}
</div>
</div>
`;
// --- Plugins Tab ---
pluginsList.innerHTML = '';
if (plugins && Object.keys(plugins).length > 0) {
Object.entries(plugins).forEach(([pluginName, pluginData]) => {
const binariesInfo = pluginData.binaries.map(b => {
const compatClass = b.is_compatible ? 'compatible' : 'incompatible';
const compatText = b.is_compatible ? 'Compatible' : 'Incompatible';
const platformTriplet = b.platform && b.platform.triplet ? `(${b.platform.triplet})` : '';
return `<li class="binary-info ${compatClass}"><strong>${b.path}</strong> ${platformTriplet} - ${compatText}</li>`;
}).join('');
const pluginCard = document.createElement('div');
pluginCard.className = 'card';
pluginCard.innerHTML = `
<div class="card-header"><h4>${pluginName}</h4></div>
<div class="card-content">
<p><strong>Source:</strong> ${pluginData.sdist_path}</p>
<p><strong>Binaries:</strong></p>
<ul>${binariesInfo.length > 0 ? binariesInfo : '<li>No binaries found.</li>'}</ul>
</div>
`;
pluginsList.appendChild(pluginCard);
});
} else {
pluginsList.innerHTML = '<div class="card"><div class="card-content"><p>No plugins found in this bundle.</p></div></div>';
}
// --- Validation Tab ---
const validationIssues = validation.errors.concat(validation.warnings);
if (validationIssues.length > 0) {
validationResults.textContent = validationIssues.join('\n');
validationTabLink.classList.remove('hidden');
} else {
validationResults.textContent = 'Bundle is valid.';
validationTabLink.classList.add('hidden');
}
// Reset to overview tab by default
switchTab('overview-tab');
}

426
electron/styles.css Normal file
View File

@@ -0,0 +1,426 @@
/* Modern CSS for 4DSTAR Bundle Manager - v2 */
/* Global Resets and Variables */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
--bg-color: #f4f7fa;
--sidebar-bg: #ffffff;
--content-bg: #ffffff;
--text-color: #2c3e50;
--text-light: #7f8c8d;
--border-color: #e1e5e8;
--primary-color: #3498db;
--primary-hover: #2980b9;
--danger-color: #e74c3c;
--success-color: #27ae60;
--warning-color: #f39c12;
--sidebar-width: 220px;
--header-height: 60px;
}
body.dark-mode {
--bg-color: #2c3e50;
--sidebar-bg: #34495e;
--content-bg: #34495e;
--text-color: #ecf0f1;
--text-light: #95a5a6;
--border-color: #4a6278;
--primary-color: #3498db;
--primary-hover: #4aa3df;
}
body {
font-family: var(--font-family);
background-color: var(--bg-color);
color: var(--text-color);
transition: background-color 0.2s, color 0.2s;
overflow: hidden;
}
/* Main Layout */
.main-container {
display: flex;
height: 100vh;
}
.sidebar {
width: var(--sidebar-width);
background-color: var(--sidebar-bg);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
flex-shrink: 0;
transition: background-color 0.2s;
}
.sidebar-header {
padding: 20px;
border-bottom: 1px solid var(--border-color);
text-align: center;
}
.sidebar-header h3 {
font-size: 1.2rem;
font-weight: 600;
}
.sidebar-nav {
padding: 15px 10px;
flex-grow: 1;
}
.nav-button {
display: block;
width: 100%;
padding: 12px 15px;
margin-bottom: 8px;
border: none;
border-radius: 6px;
background-color: transparent;
color: var(--text-color);
font-size: 0.95rem;
text-align: left;
cursor: pointer;
transition: background-color 0.2s, color 0.2s;
}
.nav-button:hover {
background-color: var(--primary-color);
color: white;
}
.nav-button.active {
background-color: var(--primary-color);
color: white;
font-weight: 600;
}
.sidebar-footer {
padding: 20px;
text-align: center;
font-size: 0.8rem;
color: var(--text-light);
}
/* Content Area */
.content-area {
flex-grow: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
#welcome-screen {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100%;
text-align: center;
color: var(--text-light);
}
#welcome-screen h1 {
font-size: 2rem;
margin-bottom: 10px;
}
.content-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 25px;
height: var(--header-height);
border-bottom: 1px solid var(--border-color);
background-color: var(--content-bg);
flex-shrink: 0;
}
.content-header h2 {
font-size: 1.4rem;
}
.action-buttons button {
margin-left: 10px;
padding: 8px 16px;
border-radius: 5px;
border: 1px solid var(--primary-color);
background-color: transparent;
color: var(--primary-color);
cursor: pointer;
transition: all 0.2s;
}
.action-buttons button:hover {
background-color: var(--primary-color);
color: white;
}
/* Tabs */
.tab-nav {
display: flex;
padding: 0 25px;
border-bottom: 1px solid var(--border-color);
background-color: var(--content-bg);
flex-shrink: 0;
}
.tab-link {
padding: 15px 20px;
border: none;
background: none;
cursor: pointer;
color: var(--text-light);
font-size: 1rem;
border-bottom: 3px solid transparent;
transition: color 0.2s, border-color 0.2s;
}
.tab-link:hover {
color: var(--primary-color);
}
.tab-link.active {
color: var(--text-color);
border-bottom-color: var(--primary-color);
font-weight: 600;
}
#tab-content {
padding: 25px;
overflow-y: auto;
flex-grow: 1;
background-color: var(--bg-color);
}
.tab-pane {
display: none;
}
.tab-pane.active {
display: block;
}
/* Card-based info display */
.card {
background-color: var(--content-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 2px 5px rgba(0,0,0,0.05);
}
.card-title {
font-size: 1.2rem;
font-weight: 600;
margin-bottom: 15px;
border-bottom: 1px solid var(--border-color);
padding-bottom: 10px;
}
.info-grid {
display: grid;
grid-template-columns: 150px 1fr;
gap: 12px;
}
.info-grid .label {
font-weight: 600;
color: var(--text-light);
}
.info-grid .value.signature {
word-break: break-all;
font-family: monospace;
font-size: 0.9rem;
}
/* Trust Indicator */
.trust-indicator-container {
display: flex;
align-items: center;
gap: 10px;
}
.trust-indicator {
width: 12px;
height: 12px;
border-radius: 50%;
flex-shrink: 0;
}
.trust-indicator.trusted { background-color: var(--success-color); }
.trust-indicator.untrusted { background-color: var(--danger-color); }
.trust-indicator.unsigned { background-color: var(--warning-color); }
.trust-indicator.warning { background-color: var(--warning-color); }
/* Plugins List */
#plugins-list .plugin-item {
background-color: var(--content-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 20px;
margin-bottom: 15px;
}
#plugins-list h4 {
font-size: 1.1rem;
margin-bottom: 10px;
}
/* Validation Results */
#validation-results {
background-color: var(--content-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 20px;
white-space: pre-wrap;
word-wrap: break-word;
font-family: monospace;
}
/* Modal */
.modal-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.6);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content {
background-color: var(--content-bg);
padding: 30px;
border-radius: 8px;
min-width: 400px;
max-width: 600px;
box-shadow: 0 5px 15px rgba(0,0,0,0.3);
position: relative;
}
.modal-close {
position: absolute;
top: 15px;
right: 15px;
font-size: 1.5rem;
font-weight: bold;
cursor: pointer;
color: var(--text-light);
}
.modal-close:hover {
color: var(--text-color);
}
#modal-title {
font-size: 1.4rem;
margin-bottom: 20px;
}
/* Utility */
.hidden {
display: none;
}
/* Fill Modal Specifics */
#fill-targets-list,
#fill-progress-list {
max-height: 300px;
overflow-y: auto;
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 10px;
margin-top: 10px;
margin-bottom: 15px;
}
.fill-target-item {
display: flex;
align-items: center;
padding: 8px;
border-bottom: 1px solid var(--border-color);
}
.fill-target-item:last-child {
border-bottom: none;
}
.fill-target-item label {
flex-grow: 1;
margin-left: 10px;
}
.progress-indicator {
width: 20px;
height: 20px;
margin-right: 10px;
display: inline-block;
vertical-align: middle;
}
.progress-indicator.spinner-icon {
border: 2px solid var(--text-color-light);
border-top: 2px solid var(--primary-color);
border-radius: 50%;
width: 16px;
height: 16px;
animation: spin 1s linear infinite;
}
.progress-indicator.success-icon::before {
content: '✔';
color: var(--success-color);
font-size: 20px;
}
.progress-indicator.failure-icon::before {
content: '✖';
color: var(--error-color);
font-size: 20px;
}
#start-fill-button {
background-color: var(--primary-color);
color: white;
padding: 10px 15px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1em;
transition: background-color 0.3s;
}
#start-fill-button:hover {
background-color: var(--primary-color-dark);
}
.spinner {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
border: 4px solid rgba(0, 0, 0, 0.1);
width: 36px;
height: 36px;
border-radius: 50%;
border-left-color: var(--primary-color);
animation: spin 1s ease infinite;
z-index: 2000;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}