92 Commits
2.0.5 ... main

Author SHA1 Message Date
6f11730fc7 Upload via Git Manager GUI - updater.js 2026-04-01 19:30:32 +00:00
e0827faf42 Upload via Git Manager GUI - preload.js 2026-04-01 19:30:32 +00:00
1041f39ced Upload via Git Manager GUI - package-lock.json 2026-04-01 19:30:31 +00:00
7a43d24a32 Upload via Git Manager GUI - package.json 2026-04-01 19:30:31 +00:00
d6968a4954 Upload via Git Manager GUI - main.js 2026-04-01 19:30:30 +00:00
464d15464a Update from Git Manager GUI 2026-04-01 21:30:29 +02:00
da47343b2e Upload via Git Manager GUI - main.js 2026-03-27 23:03:55 +00:00
d41865608f Update from Git Manager GUI 2026-03-28 00:03:52 +01:00
e4b1215aa7 Upload via Git Manager GUI - updater.js 2026-03-27 23:03:51 +00:00
1d7b5e8d6e Upload via Git Manager GUI - preload.js 2026-03-27 23:03:51 +00:00
e79c0f411d Upload via Git Manager GUI - package-lock.json 2026-03-27 23:03:50 +00:00
9da186e5d2 Upload via Git Manager GUI - package.json 2026-03-27 23:03:50 +00:00
a9f0728232 Upload via Git Manager GUI - updater.js 2026-03-27 09:07:19 +00:00
edeb05f088 Upload via Git Manager GUI - preload.js 2026-03-27 09:07:19 +00:00
4b4d1520b9 Upload via Git Manager GUI - package-lock.json 2026-03-27 09:07:18 +00:00
b8644b248f Upload via Git Manager GUI - package.json 2026-03-27 09:07:18 +00:00
71ed9b7c67 Upload via Git Manager GUI - main.js 2026-03-27 09:07:18 +00:00
78a94f2263 Update from Git Manager GUI 2026-03-25 23:40:40 +01:00
b153431543 Upload via Git Manager GUI - updater.js 2026-03-25 22:40:39 +00:00
93ebe5aea9 Upload via Git Manager GUI - preload.js 2026-03-25 22:40:38 +00:00
1ca5856fe3 Upload via Git Manager GUI - package-lock.json 2026-03-25 22:40:38 +00:00
e2dbacc77c Upload via Git Manager GUI - package.json 2026-03-25 22:40:37 +00:00
925c214a1f Upload via Git Manager GUI - main.js 2026-03-25 22:40:37 +00:00
aafd5c3e66 Upload via Git Manager GUI - updater.js 2026-03-25 22:07:13 +00:00
68002a31df Upload via Git Manager GUI - preload.js 2026-03-25 22:07:12 +00:00
ec72cd2a11 Upload via Git Manager GUI - package-lock.json 2026-03-25 22:07:12 +00:00
cd739363ae Upload via Git Manager GUI - package.json 2026-03-25 22:07:11 +00:00
f064ffc8a2 Upload via Git Manager GUI - main.js 2026-03-25 22:07:11 +00:00
6965503bb3 Update from Git Manager GUI 2026-03-25 23:07:09 +01:00
2a9812575c Update from Git Manager GUI 2026-03-25 23:07:07 +01:00
89272fb899 Update from Git Manager GUI 2026-03-25 23:07:05 +01:00
14d15dc355 Upload via Git Manager GUI - main.js 2026-03-25 22:06:21 +00:00
2b2d6a8303 Delete data/credentials.json via Git Manager GUI 2026-03-25 21:46:23 +00:00
b613e3bd73 assets/Thumbs.db gelöscht 2026-03-24 21:17:48 +00:00
62e4f9b85f Upload updater.js via GUI 2026-03-24 21:08:26 +00:00
7b68e92a95 Upload preload.js via GUI 2026-03-24 21:08:25 +00:00
83782d547f Upload package-lock.json via GUI 2026-03-24 21:08:25 +00:00
f1acea14fa Upload package.json via GUI 2026-03-24 21:08:24 +00:00
5513bdcef4 Upload main.js via GUI 2026-03-24 21:08:24 +00:00
f1f896ba65 Upload updater.js via GUI 2026-03-24 21:06:34 +00:00
ca55bbdcae Upload preload.js via GUI 2026-03-24 21:06:34 +00:00
e97e714826 Upload package-lock.json via GUI 2026-03-24 21:06:33 +00:00
cb185d7714 Upload package.json via GUI 2026-03-24 21:06:33 +00:00
4215429114 Upload main.js via GUI 2026-03-24 21:06:32 +00:00
1195dd4c0e Update from Git Manager GUI 2026-03-24 22:02:56 +01:00
a6585a856b Upload updater.js via GUI 2026-03-24 21:02:54 +00:00
5988d98d9a Upload preload.js via GUI 2026-03-24 21:02:53 +00:00
89dcd9f311 Upload package-lock.json via GUI 2026-03-24 21:02:53 +00:00
0366253134 Upload package.json via GUI 2026-03-24 21:02:52 +00:00
853a93e142 Upload main.js via GUI 2026-03-24 21:02:52 +00:00
24dd12c360 Update from Git Manager GUI 2026-03-24 21:59:25 +01:00
f52b99f192 Update from Git Manager GUI 2026-03-24 21:59:23 +01:00
6ba774cb26 Upload updater.js via GUI 2026-03-24 20:59:21 +00:00
f5542a6114 Upload preload.js via GUI 2026-03-24 20:59:19 +00:00
3c0c1e78dc Upload package-lock.json via GUI 2026-03-24 20:59:17 +00:00
f041b3bc32 Upload package.json via GUI 2026-03-24 20:59:15 +00:00
3ecb3125d3 Upload main.js via GUI 2026-03-24 20:59:13 +00:00
272bd00c80 Upload updater.js via GUI 2026-03-24 20:46:42 +00:00
0e9c14b144 Upload preload.js via GUI 2026-03-24 20:46:41 +00:00
a9c0c97287 Upload package-lock.json via GUI 2026-03-24 20:46:41 +00:00
cdd7dc430f Upload package.json via GUI 2026-03-24 20:46:40 +00:00
433c70d389 Upload main.js via GUI 2026-03-24 20:46:40 +00:00
d61c2b39e3 Upload package.json via GUI 2026-03-24 20:44:59 +00:00
96c3a063cc Upload main.js via GUI 2026-03-24 20:44:59 +00:00
9dd3696a09 Update from Git Manager GUI 2026-03-24 21:44:55 +01:00
4bef46201b Upload updater.js via GUI 2026-03-24 20:44:53 +00:00
fd80283328 Upload preload.js via GUI 2026-03-24 20:44:53 +00:00
2dba14ca58 Upload package-lock.json via GUI 2026-03-24 20:44:52 +00:00
1391f092d8 Upload package.json via GUI 2026-03-24 20:38:27 +00:00
88e2535e62 Upload main.js via GUI 2026-03-24 20:38:27 +00:00
de581d878f Update from Git Manager GUI 2026-03-24 21:38:24 +01:00
a1f7c66f67 Update from Git Manager GUI 2026-03-24 21:38:23 +01:00
12dbdbb28e Upload updater.js via GUI 2026-03-24 20:38:20 +00:00
687a80924a Upload preload.js via GUI 2026-03-24 20:38:19 +00:00
b22ea34032 Upload package-lock.json via GUI 2026-03-24 20:38:19 +00:00
c7fb8ce0bf Upload updater.js via GUI 2026-03-24 18:18:33 +00:00
b5f744db12 Upload README.md via GUI 2026-03-24 18:18:33 +00:00
553333c623 Upload preload.js via GUI 2026-03-24 18:18:32 +00:00
13ee1b0339 Upload package-lock.json via GUI 2026-03-24 18:18:32 +00:00
3755796ef6 Upload package.json via GUI 2026-03-24 18:18:31 +00:00
e3dbc6c663 Upload main.js via GUI 2026-03-24 18:18:31 +00:00
8613720a0b Upload CREATE_COMPLETE_FILES.sh via GUI 2026-03-24 18:18:30 +00:00
0f758616cf Update from Git Manager GUI 2026-03-24 19:18:28 +01:00
b2ba2a09e2 Update from Git Manager GUI 2026-03-24 19:18:26 +01:00
2ef1e0df61 Upload preload.js via GUI 2026-03-24 15:34:45 +00:00
fe0e7b7794 Upload package-lock.json via GUI 2026-03-24 15:34:44 +00:00
c8e06bf576 Upload package.json via GUI 2026-03-24 15:34:44 +00:00
7b480acd10 Upload main.js via GUI 2026-03-24 15:34:44 +00:00
658b29368b Update from Git Manager GUI 2026-03-24 16:34:42 +01:00
c64d40fbda Update from Git Manager GUI 2026-03-24 16:34:40 +01:00
f6598cfb19 Update from Git Manager GUI 2026-03-24 16:34:38 +01:00
5787c4ca22 Upload updater.js via GUI 2026-03-24 15:34:36 +00:00
17 changed files with 11000 additions and 1158 deletions

View File

@@ -56,6 +56,6 @@ Bei Problemen oder Fragen kannst du ein Issue auf GitHub erstellen.
---
**Copyright © 2026 - M_Viper - Alle Rechte vorbehalten**
## Lizenz
Die unbefugte Vervielfältigung, Verbreitung oder Weitergabe dieses Plugins ist strafbar und wird rechtlich verfolgt.
Dieses Projekt ist Open-Source unter der [GPL Lizenz](LICENSE).

2211
main.js

File diff suppressed because it is too large Load Diff

686
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,27 +1,56 @@
{
"name": "git-manager-gui",
"version": "2.0.4",
"version": "2.0.9",
"description": "Git Manager GUI - Verwaltung von Git Repositories",
"author": "M_Viper",
"main": "main.js",
"scripts": {
"start": "electron .",
"build": "electron-builder"
"build": "electron-builder",
"test": "node --test __tests__/*.test.js",
"pdf": "electron generate-pdf.js"
},
"dependencies": {
"axios": "^1.13.4",
"axios": "^1.13.5",
"form-data": "^4.0.5",
"simple-git": "^3.19.1"
"simple-git": "^3.32.3",
"archiver": "^6.0.0",
"unzipper": "^0.11.0"
},
"overrides": {
"minimatch": "^3.1.4"
},
"devDependencies": {
"electron": "^26.2.0",
"electron": "^41.0.4",
"electron-builder": "^26.8.1"
},
"build": {
"asar": true,
"appId": "com.viper.gitmanager",
"productName": "Git Manager GUI",
"files": [
"**/*",
"!node_modules/.cache",
"!**/*.map"
"!**/*.map",
"!**/.git",
"!**/.git/**",
"!repos/**",
"!data/**",
"!backup/**",
"!*.zip",
"!*.rar",
"!*.log"
],
"extraResources": [
"repos/",
{
"from": "data",
"to": "data",
"filter": [
"**/*",
"!credentials.json"
]
}
],
"directories": {
"buildResources": "assets"

View File

@@ -1,5 +1,5 @@
// preload.js — expose IPC to renderer
const { contextBridge, ipcRenderer } = require('electron');
const { contextBridge, ipcRenderer, webUtils } = require('electron');
contextBridge.exposeInMainWorld('electronAPI', {
// Lokale Datei-Operationen
@@ -20,6 +20,13 @@ contextBridge.exposeInMainWorld('electronAPI', {
// Gitea Datei-Operationen
listGiteaRepos: (data) => ipcRenderer.invoke('list-gitea-repos', data),
getGiteaCurrentUser: () => ipcRenderer.invoke('get-gitea-current-user'),
getGiteaUserHeatmap: (data) => ipcRenderer.invoke('get-gitea-user-heatmap', data),
// GitHub Datei-Operationen
listGithubRepos: (data) => ipcRenderer.invoke('list-github-repos', data),
getGithubCurrentUser: () => ipcRenderer.invoke('get-github-current-user'),
getGithubUserHeatmap: (data) => ipcRenderer.invoke('get-github-user-heatmap', data),
getGiteaRepoContents: (data) => ipcRenderer.invoke('get-gitea-repo-contents', data),
getGiteaFileContent: (data) => ipcRenderer.invoke('get-gitea-file-content', data),
readGiteaFile: (data) => ipcRenderer.invoke('read-gitea-file', data),
@@ -32,16 +39,42 @@ contextBridge.exposeInMainWorld('electronAPI', {
// Repository & Git Management
saveCredentials: (data) => ipcRenderer.invoke('save-credentials', data),
loadCredentials: () => ipcRenderer.invoke('load-credentials'),
getCredentialsStatus: () => ipcRenderer.invoke('get-credentials-status'),
testGiteaConnection: (data) => ipcRenderer.invoke('test-gitea-connection', data),
testGithubConnection: (data) => ipcRenderer.invoke('test-github-connection', data),
updateGiteaAvatar: (data) => ipcRenderer.invoke('update-gitea-avatar', data),
updateGiteaRepoAvatar: (data) => ipcRenderer.invoke('update-gitea-repo-avatar', data),
updateGiteaRepoVisibility: (data) => ipcRenderer.invoke('update-gitea-repo-visibility', data),
updateGiteaRepoTopics: (data) => ipcRenderer.invoke('update-gitea-repo-topics', data),
getGiteaTopicsCatalog: () => ipcRenderer.invoke('get-gitea-topics-catalog'),
migrateRepoToGitea: (data) => ipcRenderer.invoke('migrate-repo-to-gitea', data),
createRepo: (data) => ipcRenderer.invoke('create-repo', data),
pushProject: (data) => ipcRenderer.invoke('push-project', data),
getBranches: (data) => ipcRenderer.invoke('getBranches', data),
getCommitLogs: (data) => ipcRenderer.invoke('getCommitLogs', data),
uploadAndPush: (data) => ipcRenderer.invoke('upload-and-push', data),
deleteGiteaRepo: (data) => ipcRenderer.invoke('delete-gitea-repo', data),
syncRepoToGitHub: (data) => ipcRenderer.invoke('sync-repo-to-github', data),
runBatchRepoAction: (data) => ipcRenderer.invoke('run-batch-repo-action', data),
validateRepoName: (data) => ipcRenderer.invoke('validate-repo-name', data),
checkCloneTargetCollisions: (data) => ipcRenderer.invoke('check-clone-target-collisions', data),
// Offline/Retry Queue
getRetryQueue: () => ipcRenderer.invoke('get-retry-queue'),
processRetryQueueNow: () => ipcRenderer.invoke('process-retry-queue-now'),
removeRetryQueueItem: (data) => ipcRenderer.invoke('remove-retry-queue-item', data),
// Drag & Drop
prepareDownloadDrag: (data) => ipcRenderer.invoke('prepare-download-drag', data),
startNativeDrag: (filePath) => ipcRenderer.send('ondragstart', filePath),
getPathType: (filePath) => ipcRenderer.invoke('get-path-type', filePath),
getPathForFile: (file) => {
try {
return webUtils.getPathForFile(file) || '';
} catch (_) {
return '';
}
},
// Release Management
listReleases: (data) => ipcRenderer.invoke('list-releases', data),
@@ -74,7 +107,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
saveRecent: (data) => ipcRenderer.invoke('save-recent', data),
// === UPDATER APIs ===
checkForUpdates: () => ipcRenderer.invoke('check-for-updates'),
checkForUpdates: (options) => ipcRenderer.invoke('check-for-updates', options || {}),
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
// Triggert den tatsächlichen Download des Assets
@@ -88,6 +121,12 @@ contextBridge.exposeInMainWorld('electronAPI', {
return () => ipcRenderer.removeListener('update-available', listener);
},
onUpdateNotAvailable: (cb) => {
const listener = (event, info) => cb(info);
ipcRenderer.on('update-not-available', listener);
return () => ipcRenderer.removeListener('update-not-available', listener);
},
onUpdateProgress: (cb) => {
const listener = (event, percent) => cb(percent);
ipcRenderer.on('update-progress', listener);
@@ -110,5 +149,35 @@ contextBridge.exposeInMainWorld('electronAPI', {
const listener = (event, payload) => { try { cb(payload); } catch (_) {} };
ipcRenderer.on('folder-download-progress', listener);
return () => ipcRenderer.removeListener('folder-download-progress', listener);
}
},
onRetryQueueUpdated: (cb) => {
const listener = (event, payload) => { try { cb(payload); } catch (_) {} };
ipcRenderer.on('retry-queue-updated', listener);
return () => ipcRenderer.removeListener('retry-queue-updated', listener);
},
onBatchActionProgress: (cb) => {
const listener = (event, payload) => { try { cb(payload); } catch (_) {} };
ipcRenderer.on('batch-action-progress', listener);
return () => ipcRenderer.removeListener('batch-action-progress', listener);
},
// Window Controls
windowMinimize: () => ipcRenderer.send('window-minimize'),
windowMaximize: () => ipcRenderer.send('window-maximize'),
windowClose: () => ipcRenderer.send('window-close'),
// Autostart
setAutostart: (enable) => ipcRenderer.invoke('set-autostart', enable),
getAutostart: () => ipcRenderer.invoke('get-autostart'),
// Utility
copyToClipboard: (text) => ipcRenderer.invoke('copy-to-clipboard', text),
openExternalUrl: (url) => ipcRenderer.invoke('open-external-url', url),
debugToMain: (level, message, payload) => ipcRenderer.send('renderer-debug-log', { level, message, payload }),
// Debugging & Diagnostics
getDebugInfo: () => ipcRenderer.invoke('get-debug-info'),
clearCache: (type) => ipcRenderer.invoke('clear-cache', type || 'all')
});

View File

@@ -4,39 +4,42 @@ import Settings from './Settings.jsx';
export default function App() {
const [folder, setFolder] = useState('');
const [repoName, setRepoName] = useState('');
const [platform, setPlatform] = useState('github');
const [status, setStatus] = useState('');
const [platform, setPlatform] = useState('gitea');
const [status, setStatus] = useState('Bereit');
const [showSettings, setShowSettings] = useState(false);
const [branches, setBranches] = useState([]);
const [selectedBranch, setSelectedBranch] = useState('master');
const [selectedBranch, setSelectedBranch] = useState('main');
const [logs, setLogs] = useState([]);
const [progress, setProgress] = useState(0);
async function selectFolder() {
const selected = await window.electronAPI.selectFolder();
if (selected) setFolder(selected);
// Branches laden
if (selected) {
if (!selected) return;
setFolder(selected);
const branchList = await window.electronAPI.getBranches({ folder: selected });
setBranches(branchList);
if (branchList.includes('master')) setSelectedBranch('master');
}
if (branchList.includes('main')) setSelectedBranch('main');
else if (branchList.includes('master')) setSelectedBranch('master');
else if (branchList.length > 0) setSelectedBranch(branchList[0]);
}
async function createRepoHandler() {
if (!repoName) return alert('Repo Name required!');
setStatus('Creating repository...');
if (!repoName) return alert('Repo-Name erforderlich!');
setStatus('Repository wird erstellt…');
const result = await window.electronAPI.createRepo({ name: repoName, platform });
setStatus(result ? 'Repository created!' : 'Failed to create repository.');
setStatus(result ? 'Repository erstellt!' : 'Fehler beim Erstellen des Repositories.');
}
async function pushProjectHandler() {
if (!folder) return alert('Select a project folder first!');
setStatus('Pushing project...');
if (!folder) return alert('Bitte zuerst einen Projektordner auswählen!');
setStatus('Projekt wird gepusht…');
setProgress(0);
const onProgress = (p) => setProgress(p); // Callback für Fortschritt
const result = await window.electronAPI.pushProject({ folder, branch: selectedBranch, onProgress });
setStatus(result ? 'Project pushed!' : 'Failed to push project.');
const result = await window.electronAPI.pushProject({
folder,
branch: selectedBranch,
onProgress: (p) => setProgress(p),
});
setStatus(result ? 'Projekt gepusht!' : 'Push fehlgeschlagen.');
if (result) {
const logList = await window.electronAPI.getCommitLogs({ folder });
setLogs(logList);
@@ -44,61 +47,168 @@ export default function App() {
}
return (
<div style={{ padding: 20 }}>
<h1>Git Manager GUI - High-End</h1>
<button onClick={() => setShowSettings(!showSettings)}>Settings</button>
{showSettings && <Settings />}
<div id="app" style={{ display: 'flex', flexDirection: 'column', height: '100vh' }}>
<div style={{ marginTop: 20 }}>
<label>Platform:</label>
<select value={platform} onChange={e => setPlatform(e.target.value)}>
<option value="github">GitHub</option>
<option value="gitea">Gitea</option>
</select>
{/* ── Toolbar ── */}
<div id="toolbar">
<div className="toolbar-row toolbar-row--top">
<div className="toolbar-brand">
<div className="toolbar-brand-mark">
<img src="./icon.png" alt="Git Manager Logo" className="toolbar-brand-logo" />
</div>
<div className="toolbar-brand-copy">
<span className="toolbar-kicker">Workspace Control</span>
<strong>Git Manager Explorer Pro</strong>
</div>
</div>
<div style={{ marginTop: 20 }}>
<button onClick={selectFolder}>Select Project Folder</button>
<span style={{ marginLeft: 10 }}>{folder}</span>
<div className="toolbar-top-actions">
<div className="tool-group tool-group--quick-actions">
<button onClick={createRepoHandler} title="Neues Repository erstellen">🚀 New Repo</button>
<button onClick={pushProjectHandler} title="Projekt pushen"> Push</button>
</div>
<div style={{ marginTop: 20 }}>
<label>Branch:</label>
<select value={selectedBranch} onChange={e => setSelectedBranch(e.target.value)}>
<div className="tool-group tool-group--utility">
<button onClick={() => setShowSettings(true)} title="Einstellungen"> Settings</button>
</div>
<div className="toolbar-status-wrap">
<span className="status-dot" aria-hidden="true" />
<span className="status">{status}</span>
</div>
</div>
</div>
<div className="toolbar-row toolbar-row--bottom">
<div className="tool-group tool-group--workspace">
<button className="accent-btn" onClick={selectFolder} title="Lokalen Ordner öffnen">
📂 Open Local
</button>
</div>
<div className="tool-group tool-group--repo">
<div className="platform-switch" role="tablist" aria-label="Plattform auswählen">
{['gitea', 'github'].map(p => (
<button
key={p}
type="button"
className={`platform-option${platform === p ? ' active' : ''}`}
data-platform={p}
aria-pressed={platform === p}
onClick={() => setPlatform(p)}
>
{p === 'gitea' ? 'Gitea' : 'GitHub'}
</button>
))}
</div>
{branches.length > 0 && (
<select
value={selectedBranch}
onChange={e => setSelectedBranch(e.target.value)}
title="Branch auswählen"
>
{branches.map(b => <option key={b} value={b}>{b}</option>)}
</select>
)}
</div>
</div>
</div>
<div style={{ marginTop: 20 }}>
{/* ── Main ── */}
<main id="main">
{/* Fortschrittsbalken */}
{progress > 0 && progress < 100 && (
<div className="input-group" style={{ marginBottom: 20 }}>
<label>Fortschritt</label>
<progress value={progress} max="100" style={{ width: '100%', height: 8, borderRadius: 4 }} />
</div>
)}
{/* Repo erstellen / Ordner */}
<div className="card" style={{ marginBottom: 20 }}>
<h2>📁 Projekt & Repository</h2>
<div className="input-group">
<label>Lokaler Projektordner</label>
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<input
type="text"
placeholder="Repository Name"
readOnly
value={folder}
placeholder="Noch kein Ordner ausgewählt…"
style={{ flex: 1 }}
/>
<button onClick={selectFolder} style={{ flex: '0 0 auto' }}>📂 Auswählen</button>
</div>
</div>
<div className="input-group">
<label>Repository Name</label>
<input
type="text"
placeholder="mein-projekt"
value={repoName}
onChange={e => setRepoName(e.target.value)} />
<button onClick={createRepoHandler} style={{ marginLeft: 10 }}>Create Repo</button>
onChange={e => setRepoName(e.target.value)}
/>
</div>
<div style={{ marginTop: 20 }}>
<button onClick={pushProjectHandler}>Push / Update Project</button>
<div style={{ display: 'flex', gap: 10, marginTop: 8 }}>
<button className="accent-btn" onClick={createRepoHandler}>🚀 Repo erstellen</button>
<button onClick={pushProjectHandler}> Push / Aktualisieren</button>
</div>
</div>
<div style={{ marginTop: 10 }}>
<label>Progress:</label>
<progress value={progress} max="100" style={{ width: '100%' }} />
</div>
<div style={{ marginTop: 20 }}>
<strong>Status: </strong>{status}
</div>
<div style={{ marginTop: 20 }}>
<h3>Commit Logs:</h3>
<ul>
{/* Commit-Logs */}
{logs.length > 0 && (
<div className="card">
<h2>📊 Commit-Verlauf</h2>
<ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: 6 }}>
{logs.map((log, i) => (
<li key={i}>{log}</li>
<li
key={i}
style={{
padding: '8px 12px',
borderRadius: 8,
background: 'rgba(255,255,255,0.04)',
border: '1px solid rgba(140,173,255,0.08)',
fontSize: 13,
color: 'var(--text-secondary)',
fontFamily: 'monospace',
}}
>
{log}
</li>
))}
</ul>
</div>
)}
{logs.length === 0 && !folder && (
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
paddingTop: 60,
gap: 16,
opacity: 0.55,
}}>
<div style={{ fontSize: 64, filter: 'drop-shadow(0 8px 16px rgba(88,213,255,0.2))' }}>📂</div>
<p style={{ color: 'var(--text-muted)', fontSize: 15, textAlign: 'center' }}>
Öffne einen lokalen Ordner oder lade Repos über die Toolbar.
</p>
</div>
)}
</main>
{/* ── Settings-Modal ── */}
{showSettings && (
<div id="settingsModal" className="modal" onClick={e => { if (e.target === e.currentTarget) setShowSettings(false); }}>
<Settings onClose={() => setShowSettings(false)} />
</div>
)}
</div>
);
}

View File

@@ -1,9 +1,37 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
export default function Settings() {
export default function Settings({ onClose }) {
const [githubToken, setGithubToken] = useState('');
const [giteaToken, setGiteaToken] = useState('');
const [giteaURL, setGiteaURL] = useState('');
const [avatarB64, setAvatarB64] = useState(null);
const [savedOk, setSavedOk] = useState(false);
const [avatarUploading, setAvatarUploading] = useState(false);
const fileInputRef = useRef(null);
function normalizeAndValidateGiteaUrl(rawUrl) {
const value = (rawUrl || '').trim();
if (!value) return { ok: true, value: '' };
let parsed;
try {
parsed = new URL(value);
} catch (_) {
return {
ok: false,
error: 'Ungültige Gitea-URL. Beispiel für IPv6: http://[2001:db8::1]:3000',
};
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return {
ok: false,
error: 'Die Gitea-URL muss mit http:// oder https:// beginnen.',
};
}
return { ok: true, value: value.replace(/\/$/, '') };
}
useEffect(() => {
window.electronAPI.loadCredentials().then(data => {
@@ -11,31 +39,145 @@ export default function Settings() {
setGithubToken(data.githubToken || '');
setGiteaToken(data.giteaToken || '');
setGiteaURL(data.giteaURL || '');
setAvatarB64(data.avatarB64 || null);
}
});
}, []);
const save = () => {
window.electronAPI.saveCredentials({ githubToken, giteaToken, giteaURL });
alert('Settings saved securely!');
function handleAvatarFileChange(e) {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => setAvatarB64(ev.target.result);
reader.readAsDataURL(file);
}
async function save() {
const checkedUrl = normalizeAndValidateGiteaUrl(giteaURL);
if (!checkedUrl.ok) {
alert(checkedUrl.error);
return;
}
window.electronAPI.saveCredentials({
githubToken,
giteaToken,
giteaURL: checkedUrl.value,
avatarB64: avatarB64 || null
});
// Avatar automatisch zu Gitea pushen, wenn Token + URL vorhanden
if (avatarB64 && giteaToken && checkedUrl.value) {
setAvatarUploading(true);
const result = await window.electronAPI.updateGiteaAvatar({
token: giteaToken,
url: checkedUrl.value,
imageBase64: avatarB64
});
setAvatarUploading(false);
if (!result.ok) {
console.warn('Avatar-Upload fehlgeschlagen:', result.error);
}
}
setSavedOk(true);
setTimeout(() => setSavedOk(false), 2500);
}
return (
<div style={{ padding: 20 }}>
<h2>Settings</h2>
<div>
<label>GitHub Token:</label>
<input type="password" value={githubToken} onChange={e => setGithubToken(e.target.value)} />
<div className="modalContent card settings-modal-content">
<div className="settings-header">
<div className="settings-header-inner">
<div className="settings-avatar-wrap" onClick={() => fileInputRef.current?.click()} title="Profilbild ändern">
{avatarB64
? <img src={avatarB64} alt="Avatar" className="settings-avatar-img" />
: <div className="settings-avatar-placeholder">👤</div>
}
<div className="settings-avatar-overlay"></div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
style={{ display: 'none' }}
onChange={handleAvatarFileChange}
/>
</div>
<div>
<label>Gitea Token:</label>
<input type="password" value={giteaToken} onChange={e => setGiteaToken(e.target.value)} />
<div className="settings-eyebrow">Konfiguration</div>
<h2> Einstellungen</h2>
<p className="settings-subtitle">
Zugangsdaten für GitHub und Gitea hinterlegen.
</p>
</div>
</div>
</div>
<div className="settings-layout">
<div className="settings-column settings-column--left">
<section className="settings-panel settings-panel--credentials">
<div className="settings-panel-header">
<div>
<label>Gitea URL:</label>
<input type="text" value={giteaURL} onChange={e => setGiteaURL(e.target.value)} />
<h3>Zugangsdaten</h3>
<p>API-Zugriffe für GitHub und Gitea konfigurieren.</p>
</div>
</div>
<div className="settings-fields-grid">
<div className="input-group">
<label htmlFor="react-githubToken">GitHub Token</label>
<input
id="react-githubToken"
type="password"
placeholder="ghp_…"
value={githubToken}
onChange={e => setGithubToken(e.target.value)}
/>
</div>
<div className="input-group">
<label htmlFor="react-giteaToken">Gitea Token</label>
<input
id="react-giteaToken"
type="password"
placeholder="Token hier einfügen"
value={giteaToken}
onChange={e => setGiteaToken(e.target.value)}
/>
</div>
</div>
<div className="input-group input-group--wide">
<label htmlFor="react-giteaURL">Gitea URL</label>
<input
id="react-giteaURL"
type="text"
placeholder="https://gitea.example.com"
value={giteaURL}
onChange={e => setGiteaURL(e.target.value)}
/>
<div className="settings-connection-tools">
<div className="settings-inline-hint">
Hinweis: IPv6 mit Klammern eingeben, z.B. http://[2001:db8::1]:3000
</div>
</div>
</div>
</section>
</div>
</div>
<div className="modal-buttons settings-modal-actions">
<button
className="accent-btn"
onClick={save}
disabled={avatarUploading}
style={savedOk ? { background: 'var(--success)', borderColor: 'var(--success)' } : {}}
>
{avatarUploading ? '⏳ Bild wird hochgeladen…' : savedOk ? '✅ Gespeichert' : 'Speichern'}
</button>
{onClose && (
<button className="secondary" onClick={onClose}>Abbrechen</button>
)}
</div>
<button onClick={save} style={{ marginTop: 10 }}>Save</button>
</div>
);
}

BIN
renderer/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

View File

@@ -4,97 +4,288 @@
<meta charset="utf-8" />
<title>Git Manager Explorer Pro</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https: http:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none';">
<link rel="icon" type="image/png" href="./icon.png">
<link rel="stylesheet" href="style.css">
</head>
<body>
<div id="app">
<div id="toolbar">
<div class="tool-group">
<button id="btnSettings" title="Einstellungen">⚙️ Settings</button>
<button id="btnBack" class="secondary hidden" title="Zurück">⬅️ Zurück</button>
<button id="btnSelectFolder" class="accent-btn" title="Lokalen Ordner öffnen">📂 Open Local</button>
<button id="btnLoadGiteaRepos" class="accent-btn" title="Gitea Repositories laden">🌐 Load Gitea</button>
<!-- Titelbalken-Streifen -->
<div id="titlebar-strip">
<div class="titlebar-strip-brand">
<img src="./icon.png" alt="" class="titlebar-strip-icon">
<span class="titlebar-strip-title">Git Manager Explorer Pro</span>
</div>
<div class="win-controls" aria-label="Fenster-Steuerung">
<button class="win-btn win-btn--minimize" id="btnWinMinimize" title="Minimieren">
<svg width="10" height="1" viewBox="0 0 10 1"><rect width="10" height="1" fill="currentColor"/></svg>
</button>
<button class="win-btn win-btn--maximize" id="btnWinMaximize" title="Maximieren">
<svg width="10" height="10" viewBox="0 0 10 10"><rect x="0.5" y="0.5" width="9" height="9" rx="1" fill="none" stroke="currentColor"/></svg>
</button>
<button class="win-btn win-btn--close" id="btnWinClose" title="Schließen">
<svg width="10" height="10" viewBox="0 0 10 10"><line x1="0" y1="0" x2="10" y2="10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><line x1="10" y1="0" x2="0" y2="10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
</button>
</div>
</div>
<div class="tool-group">
<select id="platform" title="Plattform auswählen">
<option value="gitea" selected>Gitea</option>
<option value="github">GitHub</option>
</select>
<div id="toolbar">
<div class="toolbar-row toolbar-row--top">
<div class="toolbar-brand" aria-label="App Kopfbereich">
<div class="toolbar-brand-mark">
<img src="./icon.png" alt="Git Manager Logo" class="toolbar-brand-logo">
</div>
<div class="toolbar-brand-copy">
<span class="toolbar-kicker">Workspace Control</span>
<strong>Git Manager Explorer Pro</strong>
</div>
<span id="project-toolbar-title" class="toolbar-project-title"></span>
</div>
<div class="toolbar-top-actions">
<div class="tool-group tool-group--quick-actions">
<button id="btnOpenRepoActions" title="Neues Repository erstellen">🚀 New Repo</button>
<button id="btnOpenMigration" title="Repository von GitHub/GitLab zu Gitea migrieren">📥 Migrieren</button>
<button id="btnPush" title="Projekt pushen">⬆️ Push</button>
</div>
<div class="tool-group tool-group--utility">
<span class="tool-group-title">Steuerung</span>
<button id="btnSettings" title="Einstellungen">⚙️ Settings</button>
<button id="btnBatchActions" title="Batch-Aktionen">🧩 Batch</button>
<button id="btnOpenActivityLog" title="Aktivitätsprotokoll">📝 Activity</button>
<button id="btnRetryQueueNow" class="secondary" title="Retry-Queue jetzt verarbeiten">🔁 Queue (0)</button>
<button id="btnBack" class="secondary hidden" title="Zurück">⬅️ Zurück</button>
</div>
<div class="toolbar-status-wrap">
<span class="status-dot" aria-hidden="true"></span>
<span id="status" class="status">Bereit</span>
</div>
</div>
</div>
<div class="toolbar-row toolbar-row--bottom">
<div class="tool-group tool-group--workspace">
<span class="tool-group-title">Quelle</span>
<button id="btnSelectFolder" class="accent-btn" title="Lokalen Ordner öffnen">📂 Open Local</button>
<button id="btnLoadGiteaRepos" class="accent-btn" title="Projekte der gewählten Plattform laden">🌐 Load Projekte</button>
</div>
<div class="tool-group tool-group--repo">
<span class="tool-group-title">Repository</span>
<input id="platform" type="hidden" value="gitea">
<div class="platform-switch" role="tablist" aria-label="Plattform auswählen">
<button type="button" class="platform-option active" data-platform="gitea" aria-pressed="true">Gitea</button>
<button type="button" class="platform-option" data-platform="github" aria-pressed="false">GitHub</button>
</div>
<button id="btnCommits" class="hidden" title="Commit History anzeigen">📊 Commits</button>
<button id="btnReleases" class="hidden" title="Releases anzeigen">📦 Releases</button>
</div>
<span id="status" class="status">Bereit</span>
</div>
</div>
<div id="project-gravur-bar"><span id="project-gravur-title" class="project-gravur-title"></span></div>
<div class="project-gravur-separator"></div>
<div id="contentArea" class="content-area">
<aside id="favHistorySidebar" class="fav-history-sidebar" aria-label="Favoriten und Verlauf"></aside>
<main id="main">
<div id="explorerGrid" class="explorer-grid">
</div>
<div id="explorerGrid" class="explorer-grid"></div>
</main>
</div>
<div id="settingsModal" class="modal hidden">
<div class="modalContent card">
<h2>⚙️ Einstellungen</h2>
<div class="modalContent card settings-modal-content">
<button id="btnSettingsWatermark" class="settings-watermark-btn" title="Projektinformationen anzeigen" aria-label="Projektinformationen anzeigen"></button>
<div id="settingsWatermarkCard" class="settings-watermark-card hidden" role="dialog" aria-label="Projektinformationen">
<h4>Projektinformationen</h4>
<div class="settings-watermark-row"><span>Ersteller:</span><strong>M_Viper</strong></div>
<div class="settings-watermark-row"><span>Webseite:</span><a href="https://m-viper.de" target="_blank" rel="noopener noreferrer">https://m-viper.de</a></div>
<div class="settings-watermark-row"><span>Discord:</span><a id="watermarkDiscord" href="https://discord.com/invite/FdRs4BRd8D" target="_blank" rel="noopener noreferrer">discord.com/invite/FdRs4BRd8D</a></div>
<div class="settings-watermark-row"><span>E-Mail:</span><a id="watermarkMail" href="mailto:admin@m-viper.de">admin@m-viper.de</a></div>
<div class="settings-watermark-row"><span>Version:</span><strong id="watermarkVersion">-</strong></div>
<div class="settings-watermark-row"><span>Copyright:</span><strong id="watermarkCopyright">-</strong></div>
<div class="settings-watermark-row"><span>Projekt:</span><strong>Git Manager Explorer Pro</strong></div>
</div>
<div class="input-group">
<label>GitHub Token</label>
<div class="settings-header">
<div class="settings-header-inner">
<div class="settings-avatar-wrap" id="settingsAvatarWrap" title="Profilbild ändern">
<img id="settingsAvatarImg" class="settings-avatar-img" src="" alt="Avatar" style="display:none;">
<div id="settingsAvatarPlaceholder" class="settings-avatar-placeholder">👤</div>
<div class="settings-avatar-overlay">✏️</div>
<input id="settingsAvatarInput" type="file" accept="image/*" style="display:none;">
</div>
<button id="btnUploadAvatar" class="settings-avatar-upload-btn" title="Gespeichertes Bild jetzt auf Gitea hochladen">📤 Auf Gitea aktualisieren</button>
<div>
<div class="settings-eyebrow">Konfiguration</div>
<h2>⚙️ Einstellungen</h2>
<p class="settings-subtitle">Alle wichtigen Optionen auf einer Seite: Zugangsdaten, Verbindungscheck, Darstellung und Updates.</p>
</div>
</div>
</div>
<div class="settings-layout">
<div class="settings-column settings-column--left">
<section class="settings-panel settings-panel--credentials">
<div class="settings-panel-header">
<div>
<h3>Zugangsdaten</h3>
<p>API-Zugriffe für GitHub und Gitea konfigurieren.</p>
</div>
</div>
<div class="settings-credentials-grid">
<article class="settings-auth-card settings-auth-card--github">
<div class="settings-auth-card-header">
<h4>GitHub</h4>
<button id="btnTestGithubConnection" class="secondary" type="button">🔌 Verbindung testen</button>
</div>
<div class="input-group settings-auth-input">
<label for="githubToken">GitHub Token</label>
<input id="githubToken" type="password" placeholder="ghp_...">
</div>
<div class="settings-auth-spacer" aria-hidden="true">GitHub benötigt keine Server-URL</div>
<div id="githubTokenHint" class="settings-inline-hint">Hinweis: Der Token wird direkt über api.github.com geprüft.</div>
</article>
<div class="input-group">
<label>Gitea Token</label>
<article class="settings-auth-card settings-auth-card--gitea">
<div class="settings-auth-card-header">
<h4>Gitea</h4>
<button id="btnTestGiteaConnection" class="secondary" type="button">🔌 Verbindung testen</button>
</div>
<div class="input-group settings-auth-input">
<label for="giteaToken">Gitea Token</label>
<input id="giteaToken" type="password" placeholder="Token hier einfügen">
</div>
<div class="input-group">
<label>Gitea URL</label>
<div class="input-group settings-auth-input">
<label for="giteaURL">Gitea URL</label>
<input id="giteaURL" type="text" placeholder="https://gitea.example.com">
</div>
<div id="giteaUrlHint" class="settings-inline-hint">Hinweis: IPv6 mit Klammern eingeben, z.B. http://[2001:db8::1]:3000</div>
</article>
</div>
</section>
<div class="input-group" style="margin-top: 20px; padding-top: 20px; border-top: 1px solid rgba(255,255,255,0.1);">
<label style="margin-bottom: 12px;">Übersicht</label>
<div style="display: flex; flex-direction: column; gap: 10px;">
<label style="display: flex; align-items: center; gap: 10px; cursor: pointer; text-transform: none; letter-spacing: normal; font-weight: normal;">
<section class="settings-panel settings-panel--display">
<div class="settings-panel-header">
<div>
<h3>Darstellung</h3>
<p>Übersicht und Explorer an deinen Arbeitsstil anpassen.</p>
</div>
</div>
<div class="settings-toggle-list">
<label class="settings-toggle-row" for="settingFavorites">
<span class="settings-toggle-info">
<span class="settings-toggle-title">⭐ Favoriten-Bereich anzeigen</span>
<span class="settings-toggle-desc">Pinnt wichtige Repositories und Ordner sichtbar im Kopfbereich.</span>
</span>
<span class="toggle-switch">
<input type="checkbox" id="settingFavorites" checked>
<span>⭐ Favoriten-Bereich anzeigen</span>
<span class="toggle-track"></span>
</span>
</label>
<label style="display: flex; align-items: center; gap: 10px; cursor: pointer; text-transform: none; letter-spacing: normal; font-weight: normal;">
<label class="settings-toggle-row" for="settingRecent">
<span class="settings-toggle-info">
<span class="settings-toggle-title">🕐 Zuletzt geöffnet anzeigen</span>
<span class="settings-toggle-desc">Zeigt deine letzten Projekte direkt in der Übersicht an.</span>
</span>
<span class="toggle-switch">
<input type="checkbox" id="settingRecent" checked>
<span>🕐 Zuletzt geöffnet anzeigen</span>
<span class="toggle-track"></span>
</span>
</label>
<label style="display: flex; align-items: center; gap: 10px; cursor: pointer; text-transform: none; letter-spacing: normal; font-weight: normal;">
<label class="settings-toggle-row" for="settingCompact">
<span class="settings-toggle-info">
<span class="settings-toggle-title">⊞ Kompakt-Modus</span>
<span class="settings-toggle-desc">Verdichtet Karten und Abstände für kleinere Fenster.</span>
</span>
<span class="toggle-switch">
<input type="checkbox" id="settingCompact">
<span>⊞ Kompakt-Modus (kleinere Karten)</span>
<span class="toggle-track"></span>
</span>
</label>
<label style="display: flex; align-items: center; gap: 10px; cursor: pointer; text-transform: none; letter-spacing: normal; font-weight: normal;">
<label class="settings-toggle-row" for="settingColoredIcons">
<span class="settings-toggle-info">
<span class="settings-toggle-title">🎨 Farbige Datei-Icons</span>
<span class="settings-toggle-desc">Setzt stärkere Dateityp-Farben für schnellere Orientierung.</span>
</span>
<span class="toggle-switch">
<input type="checkbox" id="settingColoredIcons" checked>
<span>🎨 Farbige Datei-Icons</span>
<span class="toggle-track"></span>
</span>
</label>
</div>
</section>
</div>
<div class="settings-column settings-column--right">
<section class="settings-panel settings-panel--health">
<div class="settings-panel-header">
<div>
<h3>Verbindungsstatus</h3>
<p>Direkt sehen, ob URL, API und Auth sauber antworten.</p>
</div>
</div>
<div class="settings-health-box">
<div class="settings-health-row"><span>URL</span><strong id="healthUrl">Unbekannt</strong></div>
<div class="settings-health-row"><span>API</span><strong id="healthApi">Unbekannt</strong></div>
<div class="settings-health-row"><span>Auth</span><strong id="healthAuth">Unbekannt</strong></div>
<div class="settings-health-row"><span>Latenz</span><strong id="healthLatency">-</strong></div>
<div class="settings-health-row"><span>Server</span><strong id="healthVersion">-</strong></div>
<div class="settings-health-row"><span>Letzter Fehler</span><strong id="healthLastError">-</strong></div>
</div>
</section>
<section class="settings-panel settings-panel--system">
<div class="settings-panel-header">
<div>
<h3>System</h3>
<p>Verhalten beim Windows-Start steuern.</p>
</div>
</div>
<div class="settings-toggle-list">
<label class="settings-toggle-row" for="settingAutostart">
<span class="settings-toggle-info">
<span class="settings-toggle-title">🚀 Mit Windows starten</span>
<span class="settings-toggle-desc">Startet die App automatisch beim Anmelden, minimiert im System-Tray.</span>
</span>
<span class="toggle-switch">
<input type="checkbox" id="settingAutostart">
<span class="toggle-track"></span>
</span>
</label>
</div>
</div>
</section>
<div class="input-group" style="margin-top: 30px; padding-top: 20px; border-top: 1px solid rgba(255,255,255,0.1);">
<label>App Version</label>
<div style="display: flex; gap: 12px; align-items: center;">
<input id="appVersion" type="text" readonly style="flex: 1; background: rgba(255,255,255,0.05); cursor: not-allowed;">
<button id="btnCheckUpdates" style="
background: linear-gradient(135deg, #00d4ff, #8b5cf6);
color: #000;
border: none;
padding: 10px 20px;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
">🔄 Nach Updates suchen</button>
<section class="settings-panel settings-panel--app">
<div class="settings-panel-header">
<div>
<h3>App & Updates</h3>
<p>Version prüfen und neue Releases direkt anstoßen.</p>
</div>
</div>
<div class="modal-buttons">
<button id="btnSaveSettings">Speichern</button>
<div class="settings-version-card">
<div class="input-group input-group--wide settings-version-field">
<label for="appVersion">App Version</label>
<input id="appVersion" class="settings-readonly-input" type="text" readonly>
</div>
<button id="btnCheckUpdates" class="settings-update-btn">🔄 Nach Updates suchen</button>
</div>
</section>
</div>
</div>
<div class="modal-buttons settings-modal-actions">
<button id="btnSaveSettings" class="accent-btn">Speichern</button>
<button id="btnCloseSettings" class="secondary">Abbrechen</button>
</div>
</div>
@@ -107,6 +298,7 @@
<div class="input-group">
<label>Repository Name</label>
<input id="repoName" type="text" placeholder="mein-projekt">
<div id="repoNameValidationHint" class="settings-inline-hint">Name prüfen: Duplikate, ähnliche Namen und ungültige Zeichen werden erkannt.</div>
</div>
<div class="input-group">
@@ -147,6 +339,134 @@
</div>
</div>
<!-- Migration Modal -->
<div id="migrationModal" class="modal hidden">
<div class="modalContent card migration-modal-content">
<h2>📥 Repository migrieren</h2>
<p class="settings-subtitle">Ein Repository von GitHub, GitLab oder einer anderen Git-Quelle auf deine Gitea-Instanz kopieren.</p>
<div class="migration-fields">
<div class="input-group">
<label for="migrateCloneUrl">Quell-URL (Clone-URL)</label>
<input id="migrateCloneUrl" type="text" placeholder="https://github.com/benutzer/repo.git">
<div class="settings-inline-hint">Beispiel: https://github.com/M_Viper/NexTrade.git</div>
</div>
<div class="input-group">
<label for="migrateRepoName">Neuer Repository-Name auf Gitea</label>
<input id="migrateRepoName" type="text" placeholder="NexTrade">
</div>
<div class="input-group">
<label for="migrateDescription">Beschreibung (optional)</label>
<input id="migrateDescription" type="text" placeholder="">
</div>
<div class="migration-row-split">
<div class="input-group">
<label for="migrateAuthUsername">Auth-Benutzername (bei privaten Repos)</label>
<input id="migrateAuthUsername" type="text" placeholder="GitHub-Benutzername">
</div>
<div class="input-group">
<label for="migrateAuthToken">Auth-Token (bei privaten Repos)</label>
<input id="migrateAuthToken" type="password" placeholder="ghp_…">
</div>
</div>
<label class="settings-toggle-row" style="margin-top:4px;" for="migratePrivate">
<span class="settings-toggle-info">
<span class="settings-toggle-title">🔒 Privates Repository</span>
</span>
<span class="toggle-switch">
<input type="checkbox" id="migratePrivate">
<span class="toggle-track"></span>
</span>
</label>
</div>
<div id="migrationStatus" class="migration-status hidden"></div>
<div class="modal-buttons">
<button id="btnStartMigration" class="accent-btn">📥 Migration starten</button>
<button id="btnCloseMigration" class="secondary">Abbrechen</button>
</div>
</div>
</div>
<div id="batchActionModal" class="modal hidden">
<div class="modalContent card">
<h2>🧩 Batch-Aktionen</h2>
<div class="input-group">
<label>Aktion</label>
<select id="batchActionType">
<option value="refresh">Repos aktualisieren</option>
<option value="clone">Repos klonen</option>
<option value="create-tag">Tag erstellen</option>
<option value="create-release">Release erstellen</option>
</select>
</div>
<div class="input-group">
<label>Repositories (pro Zeile: owner/repo)</label>
<textarea id="batchRepoList" class="batch-textarea" placeholder="M_Viper/ProjektA&#10;M_Viper/ProjektB"></textarea>
</div>
<div id="batchCloneGroup" class="input-group hidden">
<label>Zielordner für Clone</label>
<div class="batch-inline-row">
<input id="batchCloneTarget" type="text" readonly placeholder="Bitte Zielordner auswählen">
<button id="btnSelectBatchCloneTarget" class="secondary">📁 Wählen</button>
</div>
<div id="batchCloneValidationHint" class="settings-inline-hint">Kollisionsprüfung aktiv: vorhandene Zielordner und Namenskonflikte werden angezeigt.</div>
</div>
<div id="batchTagGroup" class="input-group hidden">
<label>Tag</label>
<input id="batchTagName" type="text" placeholder="v1.0.0">
</div>
<div id="batchReleaseNameGroup" class="input-group hidden">
<label>Release-Name</label>
<input id="batchReleaseName" type="text" placeholder="Release v1.0.0">
</div>
<div id="batchReleaseBodyGroup" class="input-group hidden">
<label>Release-Text</label>
<textarea id="batchReleaseBody" class="batch-textarea" placeholder="Changelog..."></textarea>
</div>
<div class="modal-buttons">
<button id="btnRunBatchAction" class="accent-btn">Ausführen</button>
<button id="btnCloseBatchAction" class="secondary">Abbrechen</button>
</div>
</div>
</div>
<div id="activityLogModal" class="modal hidden">
<div class="modalContent card">
<h2>📝 Aktivitätsprotokoll</h2>
<div class="activity-toolbar">
<select id="activityFilterLevel">
<option value="all">Alle</option>
<option value="info">Info</option>
<option value="warning">Warn</option>
<option value="error">Error</option>
</select>
<button id="btnRetryQueueRefresh" class="secondary">🔁 Queue jetzt retry</button>
<button id="btnClearActivityLog" class="secondary">🧹 Log leeren</button>
</div>
<div id="activityQueueInfo" class="activity-queue-info">Retry-Queue: 0</div>
<div id="activityLogList" class="activity-log-list"></div>
<div class="modal-buttons">
<button id="btnCloseActivityLog" class="secondary">Schließen</button>
</div>
</div>
</div>
<div id="fileEditorModal" class="modal hidden">
<div class="file-editor-card">
<div class="file-editor-header">
@@ -197,7 +517,7 @@
<div style="display: flex; align-items: center; gap: 20px; margin-bottom: 20px;">
<div style="font-size: 3rem; filter: drop-shadow(0 0 10px var(--accent-primary));">🚀</div>
<div>
<h2 style="margin: 0; background: var(--accent-gradient); -webkit-background-clip: text; -webkit-text-fill-color: transparent;">Update verfügbar!</h2>
<h2 style="margin: 0; background: var(--accent-gradient); background-clip: text; -webkit-background-clip: text; -webkit-text-fill-color: transparent;">Update verfügbar!</h2>
<p id="updateVersionInfo" style="color: var(--text-secondary); margin: 5px 0 0 0; font-family: monospace;"></p>
</div>
</div>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

183
src/backup/BackupManager.js Normal file
View File

@@ -0,0 +1,183 @@
/**
* BackupManager — Orchestrates backup operations
*/
const archiver = require('archiver')
const { createReadStream, createWriteStream } = require('fs')
const { mkdir } = require('fs').promises
const path = require('path')
const { Transform } = require('stream')
class BackupManager {
constructor(provider) {
this.provider = provider
}
/**
* Create a backup from a project folder
* @param {string} projectPath - Path to project folder
* @param {string} repoName - Repository name (used for filenames)
* @returns {Promise<{filename, size, timestamp}>}
*/
async createBackup(projectPath, repoName) {
try {
// Create ZIP buffer
const buffer = await this._createZip(projectPath)
const timestamp = this._getTimestamp()
const filename = `${repoName}-backup-${timestamp}.zip`
// Upload to provider
const result = await this.provider.uploadBackup(buffer, filename)
// Cleanup old backups
await this._cleanupOldBackups(repoName)
return {
filename,
size: result.size || buffer.length,
timestamp
}
} catch (err) {
throw new Error(`Backup creation failed: ${err.message}`)
}
}
/**
* List all backups for a repository
* @param {string} repoName - Repository name
* @returns {Promise<Array>}
*/
async listBackups(repoName) {
try {
const backups = await this.provider.listBackups()
return backups
.filter(b => b.name.startsWith(repoName))
.sort((a, b) => new Date(b.date || b.name) - new Date(a.date || a.name))
} catch (err) {
throw new Error(`Failed to list backups: ${err.message}`)
}
}
/**
* Restore a backup to a target folder
* @param {string} repoName - Repository name
* @param {string} filename - Backup filename
* @param {string} targetPath - Target folder path
*/
async restoreBackup(repoName, filename, targetPath) {
try {
// Download backup
const buffer = await this.provider.downloadBackup(filename)
// Extract ZIP
await this._extractZip(buffer, targetPath)
return { ok: true, restored: filename }
} catch (err) {
throw new Error(`Restore failed: ${err.message}`)
}
}
/**
* Delete a backup
* @param {string} filename - Backup filename
*/
async deleteBackup(filename) {
try {
await this.provider.deleteBackup(filename)
return { ok: true }
} catch (err) {
throw new Error(`Delete failed: ${err.message}`)
}
}
// ==================== PRIVATE METHODS ====================
/**
* Create ZIP buffer from project folder
* Excludes: .git, node_modules, dist, build, .env
*/
async _createZip(projectPath) {
return new Promise((resolve, reject) => {
const output = []
const archive = archiver('zip', { zlib: { level: 5 } })
archive.on('data', chunk => output.push(chunk))
archive.on('end', () => resolve(Buffer.concat(output)))
archive.on('error', reject)
// Add files with exclusions
archive.glob('**/*', {
cwd: projectPath,
ignore: [
'.git/**',
'.git',
'node_modules/**',
'node_modules',
'dist/**',
'dist',
'build/**',
'build',
'.env',
'.env.local',
'.env.*.local',
'*.log',
'data/backups/**'
],
dot: true
})
archive.finalize()
})
}
/**
* Extract ZIP buffer to target folder
*/
async _extractZip(buffer, targetPath) {
const unzipper = require('unzipper')
return new Promise((resolve, reject) => {
const { Readable } = require('stream')
const stream = Readable.from(buffer)
stream
.pipe(unzipper.Extract({ path: targetPath }))
.on('close', resolve)
.on('error', reject)
})
}
/**
* Get ISO timestamp (YYYYMMDD-HHMMSS format)
*/
_getTimestamp() {
const now = new Date()
const year = now.getFullYear()
const month = String(now.getMonth() + 1).padStart(2, '0')
const day = String(now.getDate()).padStart(2, '0')
const hours = String(now.getHours()).padStart(2, '0')
const minutes = String(now.getMinutes()).padStart(2, '0')
const seconds = String(now.getSeconds()).padStart(2, '0')
return `${year}${month}${day}-${hours}${minutes}${seconds}`
}
/**
* Cleanup old backups (keep only last N versions)
*/
async _cleanupOldBackups(repoName, maxVersions = 5) {
try {
const backups = await this.listBackups(repoName)
for (let i = maxVersions; i < backups.length; i++) {
await this.provider.deleteBackup(backups[i].name)
}
} catch (err) {
// Silently ignore cleanup errors
console.warn(`Cleanup warning: ${err.message}`)
}
}
}
module.exports = BackupManager

View File

@@ -0,0 +1,59 @@
/**
* BackupProvider — Abstract base class for backup providers
* All providers must implement these methods
*/
class BackupProvider {
/**
* Authenticate with the backup service
* @param {Object} credentials - Provider-specific credentials
*/
async authenticate(credentials) {
throw new Error('authenticate() not implemented in ' + this.constructor.name)
}
/**
* Upload a backup file
* @param {Buffer} buffer - File content
* @param {string} filename - Filename (e.g., 'repo-backup-2025-03-24.zip')
* @returns {Promise<{size: number}>}
*/
async uploadBackup(buffer, filename) {
throw new Error('uploadBackup() not implemented in ' + this.constructor.name)
}
/**
* List all backups for a repository
* @returns {Promise<Array>} Array of {name, size, date}
*/
async listBackups() {
throw new Error('listBackups() not implemented in ' + this.constructor.name)
}
/**
* Download a specific backup
* @param {string} filename - Filename to download
* @returns {Promise<Buffer>}
*/
async downloadBackup(filename) {
throw new Error('downloadBackup() not implemented in ' + this.constructor.name)
}
/**
* Delete a backup file
* @param {string} filename - Filename to delete
*/
async deleteBackup(filename) {
throw new Error('deleteBackup() not implemented in ' + this.constructor.name)
}
/**
* Test connection to backup service
* @returns {Promise<{ok: boolean, error?: string}>}
*/
async testConnection() {
throw new Error('testConnection() not implemented in ' + this.constructor.name)
}
}
module.exports = BackupProvider

View File

@@ -0,0 +1,84 @@
/**
* LocalProvider — Backup provider for local folders
*/
const path = require('path')
const fs = require('fs').promises
const BackupProvider = require('./BackupProvider')
class LocalProvider extends BackupProvider {
constructor() {
super()
this.basePath = null
}
async authenticate(credentials) {
const basePath = String(credentials && credentials.basePath ? credentials.basePath : '').trim()
if (!basePath) {
throw new Error('Lokaler Backup-Ordner fehlt')
}
this.basePath = path.resolve(basePath)
await fs.mkdir(this.basePath, { recursive: true })
}
async testConnection() {
if (!this.basePath) return { ok: false, error: 'Not authenticated' }
try {
const stat = await fs.stat(this.basePath)
if (!stat.isDirectory()) {
return { ok: false, error: 'Pfad ist kein Ordner' }
}
return { ok: true }
} catch (err) {
return { ok: false, error: err.message }
}
}
async uploadBackup(buffer, filename) {
if (!this.basePath) throw new Error('Not authenticated')
const target = path.join(this.basePath, filename)
await fs.mkdir(path.dirname(target), { recursive: true })
await fs.writeFile(target, buffer)
return { size: buffer.length }
}
async listBackups() {
if (!this.basePath) throw new Error('Not authenticated')
const entries = await fs.readdir(this.basePath, { withFileTypes: true })
const files = entries.filter(e => e.isFile() && e.name.endsWith('.zip'))
const backups = []
for (const f of files) {
const full = path.join(this.basePath, f.name)
const stat = await fs.stat(full)
backups.push({
name: f.name,
size: stat.size,
date: stat.mtime.toISOString()
})
}
backups.sort((a, b) => new Date(b.date) - new Date(a.date))
return backups
}
async downloadBackup(filename) {
if (!this.basePath) throw new Error('Not authenticated')
const file = path.join(this.basePath, filename)
return fs.readFile(file)
}
async deleteBackup(filename) {
if (!this.basePath) throw new Error('Not authenticated')
const file = path.join(this.basePath, filename)
await fs.unlink(file)
}
}
module.exports = LocalProvider

File diff suppressed because it is too large Load Diff

293
src/utils/helpers.js Normal file
View File

@@ -0,0 +1,293 @@
/**
* Gemeinsame Utility-Funktionen für Git Manager GUI
* - Branch Handling
* - API Error Handling
* - Standardisiertes Logging
* - Caching
*/
const fs = require('fs');
const ppath = require('path');
// ===== LOGGING SYSTEM =====
const LOG_LEVELS = { DEBUG: 0, INFO: 1, WARN: 2, ERROR: 3 };
let currentLogLevel = process.env.NODE_ENV === 'production' ? LOG_LEVELS.INFO : LOG_LEVELS.DEBUG;
let logQueue = [];
const MAX_LOG_BUFFER = 100;
function formatLog(level, context, message, details = null) {
const timestamp = new Date().toISOString();
const levelStr = Object.keys(LOG_LEVELS).find(k => LOG_LEVELS[k] === level);
return {
timestamp,
level: levelStr,
context,
message,
details,
pid: process.pid
};
}
function writeLog(logEntry) {
logQueue.push(logEntry);
if (logQueue.length > MAX_LOG_BUFFER) {
logQueue.shift();
}
// Auch in Console schreiben
const { level, timestamp, context, message, details } = logEntry;
const prefix = `[${timestamp}] [${level}] [${context}]`;
if (level === 'ERROR' && details?.error) {
console.error(prefix, message, details.error);
} else if (level === 'WARN') {
console.warn(prefix, message, details ? JSON.stringify(details) : '');
} else if (level !== 'DEBUG' || process.env.DEBUG) {
console.log(prefix, message, details ? JSON.stringify(details) : '');
}
}
const logger = {
debug: (context, message, details) => writeLog(formatLog(LOG_LEVELS.DEBUG, context, message, details)),
info: (context, message, details) => writeLog(formatLog(LOG_LEVELS.INFO, context, message, details)),
warn: (context, message, details) => writeLog(formatLog(LOG_LEVELS.WARN, context, message, details)),
error: (context, message, details) => writeLog(formatLog(LOG_LEVELS.ERROR, context, message, details)),
getRecent: (count = 20) => logQueue.slice(-count),
setLevel: (level) => { currentLogLevel = LOG_LEVELS[level] || LOG_LEVELS.INFO; }
};
// ===== BRANCH HANDLING =====
const BRANCH_DEFAULTS = {
gitea: 'main',
github: 'main'
};
function normalizeBranch(branch = 'HEAD', platform = 'gitea') {
const value = String(branch || '').trim();
// HEAD sollte immer zu Standard konvertiert werden
if (value.toLowerCase() === 'head') {
return BRANCH_DEFAULTS[platform] || 'main';
}
// Validierung: nur sichere Git-Referenzen
if (/^[a-zA-Z0-9._\-/]+$/.test(value)) {
return value;
}
return BRANCH_DEFAULTS[platform] || 'main';
}
function isSafeBranch(branch) {
return /^[a-zA-Z0-9._\-/]+$/.test(String(branch || ''));
}
// ===== ERROR HANDLING =====
const ERROR_CODES = {
NETWORK: 'NETWORK_ERROR',
AUTH_FAILED: 'AUTH_FAILED',
NOT_FOUND: 'NOT_FOUND',
VALIDATION: 'VALIDATION_ERROR',
RATE_LIMIT: 'RATE_LIMIT',
SERVER_ERROR: 'SERVER_ERROR',
UNKNOWN: 'UNKNOWN_ERROR'
};
function parseApiError(error, defaultCode = ERROR_CODES.UNKNOWN) {
if (!error) {
return { code: defaultCode, message: 'Unknown error', statusCode: null };
}
// Axios-style error
if (error.response) {
const status = error.response.status;
const data = error.response.data;
let code = defaultCode;
if (status === 401 || status === 403) {
code = ERROR_CODES.AUTH_FAILED;
} else if (status === 404) {
code = ERROR_CODES.NOT_FOUND;
} else if (status === 429) {
code = ERROR_CODES.RATE_LIMIT;
} else if (status >= 500) {
code = ERROR_CODES.SERVER_ERROR;
}
return {
code,
message: data?.message || error.message || `HTTP ${status}`,
statusCode: status,
rawMessage: data?.message
};
}
// Network error
if (error.message?.includes('timeout') || error.code?.includes('TIMEOUT')) {
return { code: ERROR_CODES.NETWORK, message: 'Request timeout', statusCode: null };
}
if (error.code?.includes('ECONNREFUSED') || error.message?.includes('ECONNREFUSED')) {
return { code: ERROR_CODES.NETWORK, message: 'Connection refused', statusCode: null };
}
return {
code: ERROR_CODES.UNKNOWN,
message: error.message || String(error),
statusCode: null
};
}
function formatErrorForUser(error, context = 'Operation') {
const parsed = parseApiError(error);
const messages = {
[ERROR_CODES.AUTH_FAILED]: `Authentifizierung fehlgeschlagen. Bitte Token überprüfen.`,
[ERROR_CODES.NOT_FOUND]: `Ressource nicht gefunden.`,
[ERROR_CODES.NETWORK]: `Netzwerkfehler. Bitte Verbindung überprüfen.`,
[ERROR_CODES.RATE_LIMIT]: `Zu viele Anfragen. Bitte später versuchen.`,
[ERROR_CODES.SERVER_ERROR]: `Server-Fehler. Bitte später versuchen.`,
[ERROR_CODES.UNKNOWN]: `${context} fehlgeschlagen.`
};
return {
userMessage: messages[parsed.code],
technicalMessage: parsed.message,
code: parsed.code,
details: parsed
};
}
// ===== CACHING SYSTEM =====
class Cache {
constructor(ttl = 300000) { // 5 min default
this.store = new Map();
this.ttl = ttl;
}
set(key, value, customTtl = null) {
const expiry = Date.now() + (customTtl || this.ttl);
this.store.set(key, { value, expiry });
}
get(key) {
const item = this.store.get(key);
if (!item) return null;
if (Date.now() > item.expiry) {
this.store.delete(key);
return null;
}
return item.value;
}
invalidate(keyPattern) {
for (const [key] of this.store) {
if (key.includes(keyPattern)) {
this.store.delete(key);
}
}
}
clear() {
this.store.clear();
}
size() {
return this.store.size;
}
}
// Standard Caches
const caches = {
repos: new Cache(600000), // 10 min
fileTree: new Cache(300000), // 5 min
api: new Cache(120000) // 2 min
};
// ===== PARALLEL OPERATIONS =====
async function runParallel(operations, concurrency = 4, onProgress = null) {
const results = new Array(operations.length);
let completed = 0;
let index = 0;
async function worker() {
while (index < operations.length) {
const i = index++;
try {
results[i] = { ok: true, result: await operations[i]() };
} catch (e) {
results[i] = { ok: false, error: e };
}
completed++;
if (onProgress) {
try { onProgress(completed, operations.length); } catch (_) {}
}
}
}
const workers = Array.from({ length: Math.min(concurrency, operations.length) }, () => worker());
await Promise.all(workers);
return results;
}
// ===== RETRY LOGIC =====
async function retryWithBackoff(fn, maxAttempts = 3, baseDelay = 1000) {
let lastError;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
return await fn();
} catch (e) {
lastError = e;
if (attempt < maxAttempts - 1) {
const delay = baseDelay * Math.pow(2, attempt);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
throw lastError;
}
// ===== FILE OPERATIONS =====
function ensureDirectory(dirPath) {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
}
function safeReadFile(filePath, defaultValue = null) {
try {
if (!fs.existsSync(filePath)) return defaultValue;
return fs.readFileSync(filePath, 'utf8');
} catch (e) {
logger.warn('safeReadFile', `Failed to read ${filePath}`, { error: e.message });
return defaultValue;
}
}
function safeWriteFile(filePath, content) {
try {
ensureDirectory(ppath.dirname(filePath));
fs.writeFileSync(filePath, content, 'utf8');
return true;
} catch (e) {
logger.error('safeWriteFile', `Failed to write ${filePath}`, { error: e.message });
return false;
}
}
// ===== EXPORTS =====
module.exports = {
logger,
normalizeBranch,
isSafeBranch,
parseApiError,
formatErrorForUser,
ERROR_CODES,
Cache,
caches,
runParallel,
retryWithBackoff,
ensureDirectory,
safeReadFile,
safeWriteFile,
LOG_LEVELS
};

View File

@@ -3,9 +3,11 @@ const { app, shell } = require('electron');
const https = require('https');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { spawn } = require('child_process');
const GITEA_API_URL = 'https://git.viper.ipv64.net/api/v1/repos/M_Viper/Git-Manager-Gui/releases';
const TRUSTED_UPDATE_HOST = 'git.viper.ipv64.net';
class Updater {
constructor(mainWindow) {
@@ -31,15 +33,28 @@ class Updater {
console.log(`[Updater] Version-Check: Server(${serverVer}) vs Lokal(${localVer})`);
if (this.compareVersions(serverVer, localVer) > 0) {
const asset = this.findAsset(latestRelease.assets);
const checksumAsset = this.findChecksumAsset(latestRelease.assets, asset);
const expectedSha256 = this.extractChecksumFromReleaseBody(latestRelease.body, asset?.name);
console.log("[Updater] Update verfügbar. Sende Daten an Renderer...");
this.mainWindow.webContents.send('update-available', {
version: serverVer,
body: latestRelease.body,
url: latestRelease.html_url,
asset: this.findAsset(latestRelease.assets)
asset: asset ? {
...asset,
checksumUrl: checksumAsset ? checksumAsset.browser_download_url : null,
expectedSha256
} : null
});
} else {
console.log("[Updater] Anwendung ist auf dem neuesten Stand.");
if (!silent) {
this.mainWindow.webContents.send('update-not-available', {
version: localVer
});
}
}
} catch (error) {
console.error('[Updater] Fehler beim Update-Check:', error);
@@ -77,7 +92,118 @@ class Updater {
findAsset(assets) {
if (!assets) return null;
const ext = process.platform === 'win32' ? '.exe' : '.AppImage';
return assets.find(a => a.name.toLowerCase().endsWith(ext));
return assets.find(a => {
const name = String(a?.name || '').toLowerCase();
// Leerzeichen im Namen erlauben!
const validName = /^[a-z0-9._\- ]+$/i.test(name);
return validName && name.endsWith(ext);
});
}
findChecksumAsset(assets, targetAsset) {
if (!Array.isArray(assets) || !targetAsset?.name) return null;
const targetLower = String(targetAsset.name).toLowerCase();
const exactCandidates = [
`${targetLower}.sha256`,
`${targetLower}.sha256sum`,
`${targetLower}.sha512`,
`${targetLower}.sha512sum`
];
const exact = assets.find(a => exactCandidates.includes(String(a?.name || '').toLowerCase()));
if (exact) return exact;
return assets.find(a => {
const name = String(a?.name || '').toLowerCase();
return name.includes('checksum') || name.includes('checksums') || name.endsWith('.sha256') || name.endsWith('.sha256sum');
}) || null;
}
extractChecksumFromReleaseBody(body, fileName) {
const text = String(body || '');
const target = String(fileName || '').trim();
if (!text || !target) return null;
const lines = text.split(/\r?\n/);
const targetLower = target.toLowerCase();
for (const line of lines) {
const normalized = String(line || '').trim();
if (!normalized) continue;
const match = normalized.match(/\b([a-fA-F0-9]{64})\b/);
if (!match) continue;
if (normalized.toLowerCase().includes(targetLower)) {
return match[1].toLowerCase();
}
}
return null;
}
downloadText(url) {
return new Promise((resolve, reject) => {
if (!this.isTrustedDownloadUrl(url)) {
reject(new Error('Unsichere Checksum-URL blockiert.'));
return;
}
https.get(url, { headers: { 'User-Agent': 'Electron-Updater' } }, (res) => {
if (res.statusCode === 301 || res.statusCode === 302) {
return resolve(this.downloadText(res.headers.location || ''));
}
if (res.statusCode !== 200) {
reject(new Error(`Checksum-Download fehlgeschlagen: HTTP ${res.statusCode}`));
return;
}
let data = '';
res.on('data', chunk => data += chunk.toString('utf8'));
res.on('end', () => resolve(data));
}).on('error', reject);
});
}
extractChecksumFromText(text, fileName) {
const lines = String(text || '').split(/\r?\n/);
const targetLower = String(fileName || '').toLowerCase();
for (const line of lines) {
const normalized = String(line || '').trim();
if (!normalized) continue;
const hashMatch = normalized.match(/\b([a-fA-F0-9]{64})\b/);
if (!hashMatch) continue;
if (!targetLower || normalized.toLowerCase().includes(targetLower)) {
return hashMatch[1].toLowerCase();
}
}
return null;
}
computeFileSha256(filePath) {
return new Promise((resolve, reject) => {
const hash = crypto.createHash('sha256');
const stream = fs.createReadStream(filePath);
stream.on('error', reject);
stream.on('data', chunk => hash.update(chunk));
stream.on('end', () => resolve(hash.digest('hex').toLowerCase()));
});
}
async resolveExpectedSha256(asset) {
const expectedFromAsset = String(asset?.expectedSha256 || '').trim().toLowerCase();
if (/^[a-f0-9]{64}$/.test(expectedFromAsset)) return expectedFromAsset;
const checksumUrl = String(asset?.checksumUrl || '').trim();
if (!checksumUrl) return null;
const checksumText = await this.downloadText(checksumUrl);
return this.extractChecksumFromText(checksumText, asset?.name);
}
isTrustedDownloadUrl(rawUrl) {
try {
const parsed = new URL(String(rawUrl || ''));
return parsed.protocol === 'https:' && parsed.hostname === TRUSTED_UPDATE_HOST;
} catch (_) {
return false;
}
}
/**
@@ -89,16 +215,40 @@ class Updater {
return;
}
if (!this.isTrustedDownloadUrl(asset.browser_download_url)) {
console.error('[Updater] Unsichere Download-URL blockiert.');
return;
}
const tempPath = path.join(app.getPath('temp'), asset.name);
console.log(`[Updater] Download gestartet: ${asset.name} -> ${tempPath}`);
let expectedSha256 = null;
try {
expectedSha256 = await this.resolveExpectedSha256(asset);
} catch (e) {
console.error('[Updater] Konnte erwartete Checksumme nicht laden:', e?.message || e);
return;
}
if (!expectedSha256) {
console.error('[Updater] Kein SHA-256-Checksum-Wert gefunden. Update wurde aus Sicherheitsgruenden blockiert.');
return;
}
const file = fs.createWriteStream(tempPath);
const download = (url) => {
if (!this.isTrustedDownloadUrl(url)) {
console.error('[Updater] Unsicherer Redirect/Download blockiert.');
fs.unlink(tempPath, () => {});
return;
}
https.get(url, { headers: { 'User-Agent': 'Electron-Updater' } }, (res) => {
// Handle Redirects
if (res.statusCode === 301 || res.statusCode === 302) {
return download(res.headers.location);
return download(res.headers.location || '');
}
if (res.statusCode !== 200) {
@@ -108,9 +258,22 @@ class Updater {
res.pipe(file);
file.on('finish', () => {
file.on('finish', async () => {
file.close();
console.log("[Updater] Download abgeschlossen. Initialisiere entkoppelten Installer...");
try {
const actualSha256 = await this.computeFileSha256(tempPath);
if (actualSha256 !== expectedSha256) {
console.error('[Updater] Checksum-Validierung fehlgeschlagen. Installation wurde blockiert.');
fs.unlink(tempPath, () => {});
return;
}
} catch (verifyErr) {
console.error('[Updater] Checksum-Validierung konnte nicht ausgeführt werden:', verifyErr?.message || verifyErr);
fs.unlink(tempPath, () => {});
return;
}
console.log("[Updater] Download und Checksum-Validierung abgeschlossen. Initialisiere entkoppelten Installer...");
this.installAndQuit(tempPath);
});
}).on('error', (err) => {
@@ -129,15 +292,14 @@ class Updater {
console.log(`[Updater] Bereite Installation vor: ${filePath}`);
if (process.platform === 'win32') {
// Wir nutzen spawn mit detached: true, damit der Installer weiterläuft,
// wenn der Hauptprozess (Electron) beendet wird.
try {
const child = spawn('cmd.exe', ['/c', 'start', '""', filePath], {
const child = spawn(filePath, [], {
detached: true,
stdio: 'ignore'
stdio: 'ignore',
shell: false
});
child.unref(); // Trennt die Referenz zum Installer
child.unref();
console.log("[Updater] Installer-Prozess entkoppelt gestartet. App schließt in 2 Sek...");