feat(toolbox): ui update
This commit is contained in:
127
electron/bridge.py
Normal file
127
electron/bridge.py
Normal 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()
|
||||
52
electron/fourdst-backend.spec
Normal file
52
electron/fourdst-backend.spec
Normal 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
104
electron/index.html
Normal 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">×</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">×</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
299
electron/main.js
Normal 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
3873
electron/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
58
electron/package.json
Normal file
58
electron/package.json
Normal 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
488
electron/renderer.js
Normal 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
426
electron/styles.css
Normal 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); }
|
||||
}
|
||||
Reference in New Issue
Block a user