@@ -0,0 +1,539 @@
// src/features/featuresIpc.js
// IPC-Handler für: Upload-Vorschau mit Geheimnis-Warnung, Server-Diagnose,
// lokale Änderungen (Status/Diff/Commit), Pull Requests & Issues und Repo-Backup.
const fs = require ( 'fs' ) ;
const os = require ( 'os' ) ;
const path = require ( 'path' ) ;
const dns = require ( 'dns' ) . promises ;
const net = require ( 'net' ) ;
const http = require ( 'http' ) ;
const https = require ( 'https' ) ;
const { spawn } = require ( 'child_process' ) ;
const axios = require ( 'axios' ) ;
const { normalizeAndValidateBaseUrl , uploadItemsViaGiteaApi } = require ( '../git/apiHandler.js' ) ;
const { scanForSecrets } = require ( './secretScan.js' ) ;
const { createRepoBackup , authHeaderArgs } = require ( '../backup/GiteaRepoBackup.js' ) ;
const { commitIdentity } = require ( '../git/gitBinary.js' ) ;
const { createRepoRestore } = require ( '../backup/GiteaRepoRestore.js' ) ;
const agentOptions = { keepAlive : true , autoSelectFamily : true , autoSelectFamilyAttemptTimeout : 250 } ;
const api = axios . create ( {
httpAgent : new http . Agent ( agentOptions ) ,
httpsAgent : new https . Agent ( agentOptions ) ,
timeout : 30000 ,
validateStatus : ( ) => true
} ) ;
const enc = encodeURIComponent ;
const repoApiPath = ( owner , repo ) => ` /repos/ ${ enc ( owner ) } / ${ enc ( repo ) } ` ;
function giteaContext ( readCredentials ) {
const c = readCredentials ( ) || { } ;
if ( ! c . giteaURL || ! c . giteaToken ) throw new Error ( 'Gitea-Zugangsdaten fehlen. Bitte in den Einstellungen eintragen.' ) ;
const base = normalizeAndValidateBaseUrl ( c . giteaURL ) ;
return { base , apiBase : ` ${ base } /api/v1 ` , token : c . giteaToken , headers : { Authorization : ` token ${ c . giteaToken } ` } } ;
}
async function giteaCall ( ctx , method , apiPath , { params , data , timeout } = { } ) {
const res = await api . request ( { method , url : ctx . apiBase + apiPath , params , data , headers : ctx . headers , timeout } ) ;
if ( res . status >= 200 && res . status < 300 ) return res . data ;
const err = new Error ( ( res . data && res . data . message ) || ` HTTP ${ res . status } ` ) ;
err . status = res . status ;
throw err ;
}
// Git mit Zeitlimit, ohne den Hauptprozess zu blockieren.
function runGit ( args , { cwd , timeoutMs = 60000 } = { } ) {
return new Promise ( ( resolve ) => {
let child ;
try {
child = spawn ( 'git' , args , { cwd , windowsHide : true , env : process . env } ) ;
} catch ( e ) {
resolve ( { status : - 1 , stdout : '' , stderr : String ( e && e . message || e ) } ) ;
return ;
}
const out = [ ] ;
const err = [ ] ;
let timedOut = false ;
const timer = setTimeout ( ( ) => { timedOut = true ; try { child . kill ( ) ; } catch ( _ ) { } } , timeoutMs ) ;
child . stdout . on ( 'data' , c => out . push ( c ) ) ;
child . stderr . on ( 'data' , c => err . push ( c ) ) ;
child . on ( 'error' , e => err . push ( Buffer . from ( String ( e && e . message || e ) ) ) ) ;
child . on ( 'close' , ( code ) => {
clearTimeout ( timer ) ;
resolve ( {
status : timedOut ? - 2 : code ,
timedOut ,
stdout : Buffer . concat ( out ) . toString ( 'utf8' ) ,
stderr : Buffer . concat ( err ) . toString ( 'utf8' )
} ) ;
} ) ;
child . stdin . end ( ) ;
} ) ;
}
// Token nie in Fehlermeldungen oder Logs durchreichen.
function redact ( text , token ) {
let s = String ( text || '' ) ;
if ( token ) s = s . split ( token ) . join ( '***' ) ;
return s . replace ( /\/\/[^/@\s]+@/g , '//***@' ) ;
}
function isPackFailure ( stderr ) {
return /early eof|index-pack|bad pack header|unexpected disconnect|remote end hung up|rpc failed/i . test ( String ( stderr || '' ) ) ;
}
/* ----------------------------------------------------------------
Upload-Vorschau
---------------------------------------------------------------- */
async function previewUpload ( { paths , destPath } , deps ) {
const { items , skipped , ignored } = await deps . buildItemsFromPaths ( paths , destPath ) ;
const files = [ ] ;
let totalBytes = 0 ;
for ( const item of items ) {
let size = 0 ;
try { size = ( await fs . promises . stat ( item . localFile ) ) . size ; } catch ( _ ) { }
totalBytes += size ;
files . push ( { localFile : item . localFile , targetPath : item . targetPath , size } ) ;
}
const prefs = deps . prefs ? deps . prefs . get ( ) : { uploadSecretScan : true , uploadPreview : 'always' } ;
// Geheimnis-Erkennung lässt sich unter Einstellungen → Uploads abschalten
const secrets = prefs . uploadSecretScan ? await scanForSecrets ( files ) : new Map ( ) ;
for ( const f of files ) {
const reason = secrets . get ( f . localFile ) ;
if ( reason ) f . secret = reason ;
}
const ignoredEntries = [
... skipped . filter ( s => s . error === 'gitignore' ) . map ( s => ( { path : s . path , isDir : isDirSafe ( s . path ) } ) ) ,
... ( ignored || [ ] )
] ;
return {
ok : true , files , totalBytes , ignored : ignoredEntries , secretCount : secrets . size ,
previewMode : prefs . uploadPreview , secretScan : prefs . uploadSecretScan
} ;
}
function isDirSafe ( p ) {
try { return fs . statSync ( p ) . isDirectory ( ) ; } catch ( _ ) { return false ; }
}
/* ----------------------------------------------------------------
Server-Diagnose
---------------------------------------------------------------- */
const SERVER _HINT _COMMANDS = 'docker logs --tail 300 gitea 2>&1 | grep -iE "pack|upload-pack|error|killed"\ndf -h\nfree -m' ;
async function probeFamily ( ctx , family ) {
const lib = ctx . base . startsWith ( 'https:' ) ? https : http ;
const agent = new lib . Agent ( { family , keepAlive : false } ) ;
const started = Date . now ( ) ;
try {
const res = await axios . get ( ` ${ ctx . apiBase } /version ` , {
httpAgent : agent , httpsAgent : agent , timeout : 8000 , validateStatus : ( ) => true
} ) ;
return { ok : res . status < 500 , ms : Date . now ( ) - started , status : res . status } ;
} catch ( e ) {
return { ok : false , error : e . code || e . message } ;
} finally {
agent . destroy ( ) ;
}
}
async function runDiagnosis ( data , deps ) {
const checks = [ ] ;
const add = ( label , status , detail , hint = null ) => checks . push ( { label , status , detail , hint } ) ;
let ctx ;
try {
ctx = giteaContext ( deps . readCredentials ) ;
add ( 'Zugangsdaten' , 'ok' , ` Server: ${ ctx . base } ` ) ;
} catch ( e ) {
add ( 'Zugangsdaten' , 'fail' , e . message , 'In den Einstellungen Gitea-URL und Token eintragen.' ) ;
return { ok : true , checks } ;
}
// Namensauflösung und Erreichbarkeit pro Adressfamilie
const host = new URL ( ctx . base ) . hostname . replace ( /^\[|\]$/g , '' ) ;
let families = [ ] ;
if ( net . isIP ( host ) ) {
families = [ net . isIP ( host ) ] ;
add ( 'Namensauflösung' , 'ok' , ` IP-Adresse direkt angegeben (IPv ${ families [ 0 ] } ) ` ) ;
} else {
try {
const addrs = await dns . lookup ( host , { all : true } ) ;
const v4 = addrs . filter ( a => a . family === 4 ) . map ( a => a . address ) ;
const v6 = addrs . filter ( a => a . family === 6 ) . map ( a => a . address ) ;
if ( v4 . length ) families . push ( 4 ) ;
if ( v6 . length ) families . push ( 6 ) ;
add ( 'Namensauflösung' , 'ok' , [ v4 . length ? ` IPv4: ${ v4 . join ( ', ' ) } ` : null , v6 . length ? ` IPv6: ${ v6 . join ( ', ' ) } ` : null ] . filter ( Boolean ) . join ( ' · ' ) ) ;
} catch ( e ) {
add ( 'Namensauflösung' , 'fail' , ` ${ host } lässt sich nicht auflösen ( ${ e . code || e . message } ) ` ,
'DNS-Eintrag (bei ipv64.net: DynDNS-Update) prüfen und ob der Name richtig geschrieben ist.' ) ;
return { ok : true , checks } ;
}
}
const reach = { } ;
for ( const fam of families ) reach [ fam ] = await probeFamily ( ctx , fam ) ;
const working = families . filter ( f => reach [ f ] . ok ) ;
for ( const fam of families ) {
const r = reach [ fam ] ;
if ( r . ok ) add ( ` Erreichbar über IPv ${ fam } ` , 'ok' , ` Antwort in ${ r . ms } ms ` ) ;
else add ( ` Erreichbar über IPv ${ fam } ` , working . length ? 'warn' : 'fail' , ` Keine Verbindung ( ${ r . error || 'HTTP ' + r . status } ) ` ,
working . length
? ` Die App weicht automatisch auf IPv ${ working [ 0 ] } aus. Falls IPv ${ fam } gewollt ist: Firewall/Portfreigabe und ${ fam === 6 ? 'AAAA' : 'A' } -Eintrag prüfen. `
: 'Server läuft nicht oder Port/Firewall/Reverse-Proxy blockiert.' ) ;
}
if ( ! working . length ) return { ok : true , checks } ;
// API und Token
let login = null ;
try {
const v = await giteaCall ( ctx , 'get' , '/version' ) ;
add ( 'Gitea-API' , 'ok' , ` Gitea ${ v && v . version ? v . version : '(Version unbekannt)' } ` ) ;
} catch ( e ) {
add ( 'Gitea-API' , 'fail' , e . message , 'Gitea-URL prüfen, sie muss auf die Startseite von Gitea zeigen (ohne /api).' ) ;
return { ok : true , checks } ;
}
try {
const u = await giteaCall ( ctx , 'get' , '/user' ) ;
login = u . login ;
add ( 'Token' , 'ok' , ` Angemeldet als ${ u . login } ` ) ;
} catch ( e ) {
add ( 'Token' , 'fail' , e . status === 401 ? 'Token ungültig oder abgelaufen' : e . message , 'In Gitea unter Einstellungen → Anwendungen einen neuen Token erzeugen.' ) ;
return { ok : true , checks } ;
}
// Git-Transport mit einem echten Repo testen
let owner = data && data . owner ;
let repo = data && data . repo ;
if ( ! owner || ! repo ) {
try {
const repos = await giteaCall ( ctx , 'get' , '/user/repos' , { params : { limit : 1 } } ) ;
if ( repos && repos [ 0 ] ) { owner = repos [ 0 ] . owner . login ; repo = repos [ 0 ] . name ; }
} catch ( _ ) { }
}
if ( ! owner || ! repo ) {
add ( 'Git-Transport' , 'warn' , 'Kein Repository zum Testen gefunden' ) ;
} else {
const u = new URL ( ctx . base ) ;
const authUrl = ` ${ u . protocol } // ${ enc ( ctx . token ) } @ ${ u . host } ${ u . pathname . replace ( /\/$/ , '' ) } / ${ owner } / ${ repo } .git ` ;
const lr = await runGit ( [ 'ls-remote' , '--heads' , authUrl ] , { timeoutMs : 30000 } ) ;
if ( lr . status !== 0 ) {
add ( 'Git-Anmeldung' , 'fail' , ` ls-remote für ${ owner } / ${ repo } fehlgeschlagen: ${ redact ( lr . stderr , ctx . token ) . trim ( ) . slice ( 0 , 300 ) } ` ,
'Token braucht Lese- und Schreibrechte für Repositories. Reverse-Proxy muss /info/refs durchlassen.' ) ;
} else {
add ( 'Git-Anmeldung' , 'ok' , ` ${ owner } / ${ repo } erreichbar ` ) ;
const tmp = fs . mkdtempSync ( path . join ( os . tmpdir ( ) , 'gm-diag-' ) ) ;
try {
const started = Date . now ( ) ;
let pk = await runGit ( [ 'clone' , '--bare' , '--depth' , '1' , '--filter=tree:0' , authUrl , path . join ( tmp , 'r.git' ) ] , { timeoutMs : 90000 } ) ;
if ( pk . status !== 0 && /filter|not recognized|unsupported/i . test ( pk . stderr ) && ! isPackFailure ( pk . stderr ) ) {
pk = await runGit ( [ 'clone' , '--bare' , '--depth' , '1' , authUrl , path . join ( tmp , 'r2.git' ) ] , { timeoutMs : 90000 } ) ;
}
if ( pk . status === 0 ) {
add ( 'Git-Pakete' , 'ok' , ` Server liefert Pakete aus ( ${ Date . now ( ) - started } ms) ` ) ;
if ( deps . clearGitTransportBlock && deps . clearGitTransportBlock ( u . host ) ) {
add ( 'Schneller Git-Weg' , 'ok' , 'Sperre aufgehoben: Uploads und Löschungen laufen wieder über Git statt über die langsame API.' ) ;
}
} else if ( pk . timedOut ) {
add ( 'Git-Pakete' , 'fail' , 'Zeitüberschreitung beim Abholen eines Pakets' , 'Verbindung oder Server sehr langsam. Reverse-Proxy-Timeouts prüfen.' ) ;
} else if ( isPackFailure ( pk . stderr ) ) {
add ( 'Git-Pakete' , 'fail' ,
'Der Server bricht beim Ausliefern von Git-Paketen ab (early EOF). Deshalb laufen Uploads und Löschungen über die langsame API.' ,
'Das liegt am Server. Häufige Ursachen: Datenträger voll, zu wenig RAM für git pack-objects, defektes Repo oder ein Proxy, der die Antwort abschneidet. Auf dem Server prüfen:\n' + SERVER _HINT _COMMANDS ) ;
} else {
add ( 'Git-Pakete' , 'fail' , redact ( pk . stderr , ctx . token ) . trim ( ) . slice ( 0 , 300 ) ) ;
}
} finally {
try { fs . rmSync ( tmp , { recursive : true , force : true } ) ; } catch ( _ ) { }
}
}
}
add ( 'Speicherplatz & RAM' , 'info' , 'Lassen sich über die Gitea-API nicht abfragen.' , 'Auf dem Server prüfen mit:\ndf -h\nfree -m' ) ;
return { ok : true , checks , login } ;
}
/* ----------------------------------------------------------------
Lokale Änderungen
---------------------------------------------------------------- */
const STATUS _LABELS = { M : 'geändert' , A : 'neu' , D : 'gelöscht' , R : 'umbenannt' , C : 'kopiert' , U : 'Konflikt' , '?' : 'neu (nicht verfolgt)' , T : 'Typ geändert' } ;
function parsePorcelainZ ( out ) {
const parts = out . split ( '\0' ) ;
const files = [ ] ;
for ( let i = 0 ; i < parts . length ; i ++ ) {
const entry = parts [ i ] ;
if ( ! entry ) continue ;
const x = entry [ 0 ] ;
const y = entry [ 1 ] ;
const file = entry . slice ( 3 ) ;
let from = null ;
if ( x === 'R' || x === 'C' ) { from = parts [ i + 1 ] ; i ++ ; }
const code = x === '?' ? '?' : ( x === 'U' || y === 'U' || ( x === 'A' && y === 'A' ) || ( x === 'D' && y === 'D' ) ) ? 'U' : ( y !== ' ' ? y : x ) ;
files . push ( { path : file , from , code , label : STATUS _LABELS [ code ] || code , staged : x !== ' ' && x !== '?' } ) ;
}
return files ;
}
async function localGit ( data , deps = { } ) {
const folder = data && data . folder ;
if ( ! folder || ! fs . existsSync ( folder ) ) return { ok : false , error : 'Ordner nicht gefunden' } ;
const top = await runGit ( [ '-C' , folder , 'rev-parse' , '--show-toplevel' ] ) ;
if ( top . status !== 0 ) return { ok : false , notRepo : true , error : 'Dieser Ordner ist kein Git-Repository.' } ;
const root = path . resolve ( top . stdout . trim ( ) ) ;
switch ( data . action ) {
case 'status' : {
const st = await runGit ( [ '-C' , root , 'status' , '--porcelain=v1' , '-z' , '-uall' ] ) ;
if ( st . status !== 0 ) return { ok : false , error : st . stderr . trim ( ) } ;
const br = await runGit ( [ '-C' , root , 'rev-parse' , '--abbrev-ref' , 'HEAD' ] ) ;
const ahead = await runGit ( [ '-C' , root , 'rev-list' , '--count' , '@{u}..HEAD' ] ) ;
return {
ok : true , root ,
branch : br . status === 0 ? br . stdout . trim ( ) : '(noch kein Commit)' ,
ahead : ahead . status === 0 ? Number ( ahead . stdout . trim ( ) ) || 0 : null ,
files : parsePorcelainZ ( st . stdout )
} ;
}
case 'diff' : {
const file = String ( data . file || '' ) ;
if ( ! file ) return { ok : false , error : 'Keine Datei' } ;
const abs = path . join ( root , file ) ;
if ( data . code === '?' || data . code === 'A' ) {
try {
const st = await fs . promises . stat ( abs ) ;
if ( st . size > 300 * 1024 ) return { ok : true , diff : ` Neue Datei ( ${ Math . round ( st . size / 1024 ) } KB), zu groß für die Vorschau. ` } ;
const buf = await fs . promises . readFile ( abs ) ;
if ( buf . subarray ( 0 , 8000 ) . includes ( 0 ) ) return { ok : true , diff : 'Neue Binärdatei' } ;
return { ok : true , diff : buf . toString ( 'utf8' ) . split ( '\n' ) . map ( l => '+' + l ) . join ( '\n' ) , allNew : true } ;
} catch ( e ) {
return { ok : false , error : e . message } ;
}
}
const d = await runGit ( [ '-C' , root , 'diff' , 'HEAD' , '--no-color' , '--' , file ] ) ;
if ( d . status !== 0 ) return { ok : false , error : d . stderr . trim ( ) } ;
return { ok : true , diff : d . stdout . length > 400000 ? d . stdout . slice ( 0 , 400000 ) + '\n… (gekürzt)' : ( d . stdout || 'Keine inhaltlichen Unterschiede (z. B. nur Zeilenenden).' ) } ;
}
case 'commit' : {
const files = Array . isArray ( data . files ) ? data . files . filter ( Boolean ) : [ ] ;
const message = String ( data . message || '' ) . trim ( ) ;
if ( ! files . length ) return { ok : false , error : 'Keine Dateien ausgewählt' } ;
if ( ! message ) return { ok : false , error : 'Commit-Nachricht fehlt' } ;
// Nur die ausgewählten Dateien in den Commit nehmen.
await runGit ( [ '-C' , root , 'reset' , '-q' ] ) ;
for ( let i = 0 ; i < files . length ; i += 100 ) {
const r = await runGit ( [ '-C' , root , 'add' , '-A' , '--' , ... files . slice ( i , i + 100 ) ] ) ;
if ( r . status !== 0 ) return { ok : false , error : r . stderr . trim ( ) } ;
}
let c = await runGit ( [ '-C' , root , 'commit' , '-m' , message ] ) ;
if ( c . status !== 0 && /tell me who you are|user\.email|user\.name/i . test ( c . stderr ) ) {
const id = commitIdentity ( deps . prefs && deps . prefs . get ( ) ) ;
c = await runGit ( [ '-C' , root , '-c' , ` user.name= ${ id . name } ` , '-c' , ` user.email= ${ id . email } ` , 'commit' , '-m' , message ] ) ;
}
if ( c . status !== 0 ) return { ok : false , error : ( c . stderr || c . stdout ) . trim ( ) } ;
return { ok : true , output : c . stdout . trim ( ) } ;
}
case 'push' : {
const p = await runGit ( [ '-C' , root , 'push' , 'origin' , 'HEAD' ] , { timeoutMs : 600000 } ) ;
if ( p . status !== 0 ) {
return { ok : false , error : redact ( p . stderr || p . stdout , null ) . trim ( ) || 'Push fehlgeschlagen' ,
hint : isPackFailure ( p . stderr ) ? 'Der Server bricht die Übertragung ab. Server-Diagnose in den Einstellungen ausführen.' : null } ;
}
return { ok : true , output : p . stderr . trim ( ) } ;
}
case 'discard' : {
const file = String ( data . file || '' ) ;
if ( ! file ) return { ok : false , error : 'Keine Datei' } ;
if ( data . code === '?' ) {
await fs . promises . rm ( path . join ( root , file ) , { force : true } ) ;
return { ok : true } ;
}
const r = await runGit ( [ '-C' , root , 'checkout' , 'HEAD' , '--' , file ] ) ;
return r . status === 0 ? { ok : true } : { ok : false , error : r . stderr . trim ( ) } ;
}
default :
return { ok : false , error : 'Unbekannte Aktion' } ;
}
}
/* ----------------------------------------------------------------
Pull Requests & Issues
---------------------------------------------------------------- */
function mapUser ( u ) { return u ? { login : u . login , avatar : u . avatar _url } : null ; }
function mapIssue ( i ) {
return {
number : i . number , title : i . title , body : i . body || '' , state : i . state ,
user : mapUser ( i . user ) , comments : i . comments , created : i . created _at , updated : i . updated _at ,
labels : ( i . labels || [ ] ) . map ( l => ( { name : l . name , color : l . color } ) ) , url : i . html _url ,
isPull : ! ! i . pull _request
} ;
}
function mapPull ( p ) {
return {
... mapIssue ( p ) , isPull : true ,
head : p . head && p . head . label , base : p . base && p . base . label ,
mergeable : p . mergeable , merged : ! ! p . merged
} ;
}
async function giteaCollab ( data , deps ) {
const ctx = giteaContext ( deps . readCredentials ) ;
const { owner , repo } = data || { } ;
if ( ! owner || ! repo ) return { ok : false , error : 'Repository fehlt' } ;
const rp = repoApiPath ( owner , repo ) ;
const idx = Number ( data . index ) ;
const state = [ 'open' , 'closed' , 'all' ] . includes ( data . state ) ? data . state : 'open' ;
const page = Math . max ( 1 , Number ( data . page ) || 1 ) ;
switch ( data . action ) {
case 'listPulls' : {
const list = await giteaCall ( ctx , 'get' , ` ${ rp } /pulls ` , { params : { state , page , limit : 30 , sort : 'recentupdate' } } ) ;
return { ok : true , items : ( list || [ ] ) . map ( mapPull ) } ;
}
case 'listIssues' : {
const list = await giteaCall ( ctx , 'get' , ` ${ rp } /issues ` , { params : { state , page , limit : 30 , type : 'issues' } } ) ;
return { ok : true , items : ( list || [ ] ) . map ( mapIssue ) } ;
}
case 'get' : {
const item = data . isPull
? mapPull ( await giteaCall ( ctx , 'get' , ` ${ rp } /pulls/ ${ idx } ` ) )
: mapIssue ( await giteaCall ( ctx , 'get' , ` ${ rp } /issues/ ${ idx } ` ) ) ;
const comments = await giteaCall ( ctx , 'get' , ` ${ rp } /issues/ ${ idx } /comments ` ) ;
return {
ok : true , item ,
comments : ( comments || [ ] ) . map ( c => ( { id : c . id , body : c . body || '' , user : mapUser ( c . user ) , created : c . created _at } ) )
} ;
}
case 'comment' : {
const body = String ( data . body || '' ) . trim ( ) ;
if ( ! body ) return { ok : false , error : 'Kommentar ist leer' } ;
await giteaCall ( ctx , 'post' , ` ${ rp } /issues/ ${ idx } /comments ` , { data : { body } } ) ;
return { ok : true } ;
}
case 'setState' : {
if ( ! [ 'open' , 'closed' ] . includes ( data . state ) ) return { ok : false , error : 'Ungültiger Status' } ;
await giteaCall ( ctx , 'patch' , ` ${ rp } /issues/ ${ idx } ` , { data : { state : data . state } } ) ;
return { ok : true } ;
}
case 'merge' : {
const style = [ 'merge' , 'rebase' , 'rebase-merge' , 'squash' ] . includes ( data . style ) ? data . style : 'merge' ;
await giteaCall ( ctx , 'post' , ` ${ rp } /pulls/ ${ idx } /merge ` , {
data : { Do : style , delete _branch _after _merge : ! ! data . deleteBranch } ,
timeout : 120000
} ) ;
return { ok : true } ;
}
case 'createIssue' : {
const title = String ( data . title || '' ) . trim ( ) ;
if ( ! title ) return { ok : false , error : 'Titel fehlt' } ;
const created = await giteaCall ( ctx , 'post' , ` ${ rp } /issues ` , { data : { title , body : String ( data . body || '' ) } } ) ;
return { ok : true , item : mapIssue ( created ) } ;
}
default :
return { ok : false , error : 'Unbekannte Aktion' } ;
}
}
/* ----------------------------------------------------------------
Registrierung
---------------------------------------------------------------- */
function wrap ( fn ) {
return async ( event , data ) => {
try {
return await fn ( data || { } , event ) ;
} catch ( e ) {
console . error ( '[features]' , e && e . message ? e . message : e ) ;
return { ok : false , error : e && e . message ? e . message : String ( e ) } ;
}
} ;
}
function registerFeatureIpc ( deps ) {
const { ipcMain } = deps ;
ipcMain . handle ( 'preview-upload-paths' , wrap ( ( data ) => previewUpload ( data , deps ) ) ) ;
ipcMain . handle ( 'run-server-diagnosis' , wrap ( ( data ) => runDiagnosis ( data , deps ) ) ) ;
ipcMain . handle ( 'local-git' , wrap ( ( data ) => localGit ( data , deps ) ) ) ;
// App-Einstellungen (ohne Geheimnisse)
ipcMain . handle ( 'prefs-get' , wrap ( ( ) => ( { ok : true , prefs : deps . prefs ? deps . prefs . get ( ) : { } } ) ) ) ;
ipcMain . handle ( 'prefs-set' , wrap ( ( data ) => {
if ( ! deps . prefs ) return { ok : false , error : 'Einstellungen nicht verfügbar' } ;
const r = deps . prefs . set ( data ) ;
return { ok : r . rejected . length === 0 , prefs : r . prefs , rejected : r . rejected ,
error : r . rejected . includes ( 'gitEmail' ) ? 'Die E-Mail-Adresse ist ungültig und wurde nicht übernommen.' : undefined } ;
} ) ) ;
// Gesperrter schneller Git-Weg (nach "early EOF") anzeigen und von Hand freigeben
ipcMain . handle ( 'git-transport-state' , wrap ( ( ) => {
const blocks = deps . getGitTransportBlocks ? deps . getGitTransportBlocks ( ) : { } ;
return { ok : true , blocked : Object . entries ( blocks ) . map ( ( [ host , since ] ) => ( { host , since : new Date ( since ) . toISOString ( ) } ) ) } ;
} ) ) ;
ipcMain . handle ( 'git-transport-clear' , wrap ( ( data ) => {
const host = String ( ( data && data . host ) || '' ) ;
return { ok : ! ! ( deps . clearGitTransportBlock && deps . clearGitTransportBlock ( host ) ) } ;
} ) ) ;
ipcMain . handle ( 'gitea-collab' , wrap ( ( data ) => giteaCollab ( data , deps ) ) ) ;
const backup = createRepoBackup ( {
configPath : path . join ( deps . app . getPath ( 'userData' ) , 'repo-backup.json' ) ,
getContext : ( ) => giteaContext ( deps . readCredentials ) ,
giteaCall ,
runGit ,
isPackFailure ,
redact
} ) ;
ipcMain . handle ( 'repo-backup-get-config' , wrap ( ( ) => ( { ok : true , ... backup . getState ( ) } ) ) ) ;
ipcMain . handle ( 'repo-backup-set-config' , wrap ( ( data ) => ( { ok : true , ... backup . setConfig ( data ) } ) ) ) ;
// Fortschritt an alle Fenster melden – auch bei einem geplanten Backup und für später geöffnete Ansichten
backup . onProgress ( ( p ) => {
if ( deps . broadcast ) deps . broadcast ( 'repo-backup-progress' , p ) ;
// Windows-Benachrichtigung am Ende – je nach Einstellung immer, nur bei Problemen oder nie
if ( ! p || ( p . phase !== 'done' && p . phase !== 'error' ) || ! deps . notify ) return ;
const mode = backup . getState ( ) . config . notify ;
const s = p . summary ;
const problem = p . phase === 'error' || ( s && ( s . failed > 0 || s . cancelled ) ) ;
if ( mode === 'never' || ( mode === 'errors' && ! problem ) ) return ;
if ( p . phase === 'error' ) deps . notify ( 'Repo-Backup fehlgeschlagen' , p . error || 'Unbekannter Fehler' ) ;
else if ( s . cancelled ) deps . notify ( 'Repo-Backup abgebrochen' , ` ${ s . ok } von ${ s . total } Repos gesichert. ` ) ;
else if ( s . failed ) deps . notify ( 'Repo-Backup mit Fehlern' , ` ${ s . ok } von ${ s . total } gesichert, ${ s . failed } fehlgeschlagen. Details in den Einstellungen unter Backup. ` ) ;
else deps . notify ( 'Repo-Backup fertig' , ` ${ s . ok } von ${ s . total } Repos gesichert. ` ) ;
} ) ;
// Ordner im Explorer öffnen – nur feste, bekannte Ziele, keine beliebigen Pfade aus dem Renderer
ipcMain . handle ( 'open-known-folder' , wrap ( async ( data ) => {
const targets = {
backup : ( ) => backup . getState ( ) . config . targetDir ,
appData : ( ) => deps . app . getPath ( 'userData' ) ,
logs : ( ) => deps . app . getPath ( 'logs' )
} ;
const resolve = targets [ data && data . which ] ;
const dir = resolve && resolve ( ) ;
if ( ! dir ) return { ok : false , error : 'Kein Ordner festgelegt.' } ;
if ( ! fs . existsSync ( dir ) ) return { ok : false , error : ` Ordner " ${ dir } " nicht gefunden. ` } ;
const err = deps . openPath ? await deps . openPath ( dir ) : 'Öffnen nicht möglich' ;
return err ? { ok : false , error : err } : { ok : true , path : dir } ;
} ) ) ;
ipcMain . handle ( 'repo-backup-run-now' , wrap ( ( ) => backup . runNow ( null ) ) ) ;
ipcMain . handle ( 'repo-backup-cancel' , wrap ( ( ) => backup . cancel ( ) ) ) ;
backup . startScheduler ( ) ;
const restore = createRepoRestore ( {
getContext : ( ) => giteaContext ( deps . readCredentials ) ,
giteaCall , runGit , isPackFailure , redact , uploadItemsViaGiteaApi , authHeaderArgs ,
identity : ( ) => commitIdentity ( deps . prefs && deps . prefs . get ( ) )
} ) ;
ipcMain . handle ( 'repo-backup-list' , wrap ( ( data ) => {
const dir = ( data && data . dir ) || backup . getState ( ) . config . targetDir ;
if ( ! dir ) return { ok : false , error : 'Kein Backup-Ordner festgelegt.' } ;
if ( ! fs . existsSync ( dir ) ) return { ok : false , error : ` Backup-Ordner " ${ dir } " nicht gefunden. ` } ;
return { ok : true , dir , backups : restore . listBackups ( dir ) } ;
} ) ) ;
ipcMain . handle ( 'repo-restore-local' , wrap ( ( data ) => restore . restoreLocal ( data ) ) ) ;
ipcMain . handle ( 'repo-restore-gitea' , wrap ( ( data , event ) => restore . restoreToGitea ( data , ( p ) => {
try { event . sender . send ( 'repo-restore-progress' , p ) ; } catch ( _ ) { }
} ) ) ) ;
return { backup , restore } ;
}
module . exports = { registerFeatureIpc , parsePorcelainZ } ;