diff --git a/Agent.ps1 b/Agent.ps1 index ae276f9..4481791 100644 --- a/Agent.ps1 +++ b/Agent.ps1 @@ -191,7 +191,7 @@ function Invoke-Api { '^POST /api/office/xml$' { $b = Read-Body $Ctx; return Send-Response $Ctx ([Text.Encoding]::UTF8.GetBytes((New-OfficeXml $b))) 'application/xml; charset=utf-8' } '^POST /api/office/install$' { $b = Read-Body $Ctx - $t = @{ online = 'Office installieren'; download = 'Office-Build auf Stick erstellen'; offline = 'Office offline installieren' }[$b.mode] + $t = @{ online = 'Office installieren'; download = 'Office-Build auf Stick erstellen'; offline = 'Office offline installieren'; custom = 'Office aus eigenem Pfad installieren' }[$b.mode] return Send-Json $Ctx @{ job = (Add-KdsJob $t 'Install-Office' $b) } } '^GET /api/office/configs$' { diff --git a/README.md b/README.md index d4a3601..00e7e19 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ Die Web-API ist immer mit einem zufälligen Token geschützt, das bei jedem Star - *Online*: direkt vom Microsoft-CDN installieren - *Build*: Installationsdateien nach `office\source\-` auf den Stick laden - *Offline*: vom Stick installieren + - *Offline aus eigenem Pfad*: vorhandene Office-Quelle nutzen (Netzlaufwerk/Ordner mit `Office\Data`), wird als `SourcePath` in die XML übernommen - `setup.exe` wird bei Bedarf automatisch geladen; generierte XMLs landen in `office\configs\`. - **Desktop-Icons** für alle Benutzer (öffentlicher Desktop): Web-Links (`.url`), Programmverknüpfungen (`.lnk`) und **RDP-Verbindungen** (`.rdp`: im Formular erzeugt inkl. Gateway, RemoteApp, Umleitungen, oder vorhandene .rdp-Dateien hochladen), eigene Icons hochladbar nach `icons\` (werden nach `C:\ProgramData\KDS-Agent\Icons` kopiert). Vorlagen in `config/shortcuts.json`. - **WLAN**: SSIDs mit Kennwort (WPA2/WPA3/offen, versteckt, automatisch verbinden) in der Blaupause speichern und für alle Benutzer anlegen. WLAN-Profile eines Referenzgeräts lassen sich inkl. Kennwort übernehmen. @@ -34,7 +35,7 @@ Die Web-API ist immer mit einem zufälligen Token geschützt, das bei jedem Star - **Energie**: Energieplan, Timeouts für Bildschirm/Standby/Ruhezustand (Netz & Akku), Deckel- und Netzschalteraktion, Ruhezustand, Schnellstart. - **BitLocker**: Status, Aktivierung mit TPM oder TPM+PIN, XTS-AES 128/256, nur belegten Speicher, Sicherung in Entra ID / AD. Der Wiederherstellungsschlüssel wird **immer** unter `bitlocker-keys\` auf dem Stick gespeichert (Computername + Seriennummer). - **Updates & Treiber**: Windows- und Treiberupdates über die Windows-Update-API (inkl. Microsoft Update) suchen, auswählen, installieren. Hersteller-Tools: Dell Command Update, Lenovo System Update, HP Image Assistant. -- **Blaupausen**: Komplette Standard-Einrichtungen je Kunde unter `blueprints\` speichern, nach Kunde gruppiert anzeigen, suchen, laden, duplizieren, exportieren/importieren. Auf der Übersicht per Dropdown auswählen und direkt starten. Mitgeliefert: `Beispiel / Büro-Standard` und `Beispiel / Minimal`. +- **Blaupausen**: Komplette Standard-Einrichtungen je Kunde unter `blueprints\` speichern, nach Kunde gruppiert anzeigen, suchen, laden, duplizieren, exportieren/importieren. Auf der Übersicht per Dropdown auswählen und direkt starten. Mitgeliefert: `Beispiel / Büro-Standard` und `Beispiel / Minimal`. Ungespeicherte Änderungen werden in der Seitenleiste angezeigt (Speichern-Button direkt darunter); vor dem Laden einer anderen Blaupause, dem Beenden oder Schließen wird gewarnt. - **Einrichtung mit einem Klick**: Auf der Übersicht alle Schritte in sinnvoller Reihenfolge ausführen. - **Aufgaben**: Alle Aktionen laufen als Hintergrund-Jobs in einer Warteschlange (nacheinander, damit sich Installer nicht blockieren). Live-Protokoll in der Oberfläche; Logs unter `logs\\`. diff --git a/config/apps.json b/config/apps.json index 50e347e..2d67a57 100644 --- a/config/apps.json +++ b/config/apps.json @@ -3,6 +3,7 @@ "apps": [ { "category": "Browser", "name": "Google Chrome", "id": "Google.Chrome", "source": "winget" }, { "category": "Browser", "name": "Mozilla Firefox (DE)", "id": "Mozilla.Firefox.de", "source": "winget" }, + { "category": "Browser", "name": "Mozilla Firefox ESR (DE)", "id": "Mozilla.Firefox.ESR.de", "source": "winget" }, { "category": "Browser", "name": "Brave", "id": "Brave.Brave", "source": "winget" }, { "category": "Kommunikation", "name": "Microsoft Teams", "id": "Microsoft.Teams", "source": "winget" }, diff --git a/lib/Functions.ps1 b/lib/Functions.ps1 index 56a1fa9..be61397 100644 --- a/lib/Functions.ps1 +++ b/lib/Functions.ps1 @@ -307,6 +307,14 @@ function Install-Office { $cfgDir = Join-Path $global:KdsRoot 'office\configs' New-Item $cfgDir -ItemType Directory -Force | Out-Null + if ($mode -eq 'custom') { + # vorhandene Office-Quelle (z.B. Netzlaufwerk oder anderer Ordner) verwenden + $src = [Environment]::ExpandEnvironmentVariables([string]$Data.sourcePath).TrimEnd('\') + if (-not $src) { throw 'Kein Pfad zur Office-Quelle angegeben' } + if (-not (Test-Path (Join-Path $src 'Office\Data'))) { throw "Unter '$src' wurde kein Office-Build gefunden (erwartet: $src\Office\Data)" } + $cfg | Add-Member -NotePropertyName sourcePath -NotePropertyValue $src -Force + Write-Log "Office-Quelle: $src" + } if ($mode -in 'download', 'offline') { $src = Join-Path $global:KdsRoot "office\source\$($cfg.product)-$($cfg.arch)" New-Item $src -ItemType Directory -Force | Out-Null @@ -1453,7 +1461,7 @@ function Invoke-FullProfile { if ('security' -in $steps) { & $run 'Sicherheit' { Set-SecurityConfig $Data } } if ('power' -in $steps) { & $run 'Energie' { Set-PowerConfig $Data } } if ('apps' -in $steps) { & $run 'Programme' { Install-Apps $Data } } - if ('office' -in $steps) { & $run 'Office' { Install-Office ([pscustomobject]@{ office = $Data.office; mode = $Data.officeMode; name = $Data.name }) } } + if ('office' -in $steps) { & $run 'Office' { Install-Office ([pscustomobject]@{ office = $Data.office; mode = $Data.officeMode; sourcePath = $Data.officeSource; name = $Data.name }) } } if ('shortcuts' -in $steps) { & $run 'Desktop-Verknuepfungen' { Set-DesktopShortcuts $Data } } if ('drivers' -in $steps) { & $run 'Hersteller-Treiber' { Invoke-VendorDriverUpdate $Data } } if ('updates' -in $steps) { & $run 'Windows Update' { Install-WindowsUpdates ([pscustomobject]@{ all = $true; type = 'all' }) | Out-Null } } diff --git a/web/app.js b/web/app.js index fe7a399..0d83e71 100644 --- a/web/app.js +++ b/web/app.js @@ -37,7 +37,7 @@ const DEFAULTS = () => ({ exclude: ['Groove', 'Lync', 'Bing'], extraProducts: [], pidKey: '', company: '', updates: true, removeMsi: true, migrateArch: true, sharedComputer: false, disableVba: true, ooxmlDefault: true, showUi: false }, - officeMode: 'online', + officeMode: 'online', officeSource: '', shortcuts: [], system: { computerName: '', timeZone: '', showExtensions: true, disableConsumer: true }, security: { defender: true, firewall: true, smartScreen: true, uac: false, disableSmb1: true, disableAutorun: true, lsaProtection: false, telemetryMin: false, rdp: 'keep', localAdminName: '', localAdminPassword: '' }, @@ -61,7 +61,22 @@ function deepMerge(base, add) { } const getPath = p => p.split('.').reduce((o, k) => o?.[k], S); function setPath(p, v) { const ks = p.split('.'); const last = ks.pop(); ks.reduce((o, k) => o[k] ??= {}, S)[last] = v; } -function persist() { try { localStorage.setItem('kdsState', JSON.stringify(S)); } catch { } renderSummary(); } +function persist() { try { localStorage.setItem('kdsState', JSON.stringify(S)); } catch { } renderSummary(); updateDirty(); } + +// ---- Ungespeicherte Änderungen erkennen (Vergleich mit dem zuletzt gespeicherten/geladenen Stand) +const snap = o => JSON.stringify(o); +let savedSnap = null; +try { savedSnap = localStorage.getItem('kdsSaved'); } catch { } +if (savedSnap == null) savedSnap = snap(S); +const isDirty = () => snap(S) !== savedSnap; +function markSaved() { savedSnap = snap(S); try { localStorage.setItem('kdsSaved', savedSnap); } catch { } updateDirty(); } +function updateDirty() { + const d = isDirty(); + $('#dirtyWarn')?.classList.toggle('hidden', !d); + $('#btnSideSave')?.classList.toggle('pulse', d); +} +const confirmDiscard = () => !isDirty() || confirm('Es gibt ungespeicherte Änderungen an der aktuellen Blaupause. Trotzdem fortfahren und Änderungen verwerfen?'); +window.addEventListener('beforeunload', e => { if (isDirty()) { e.preventDefault(); e.returnValue = ''; } }); // generische Formularbindung: data-bind="pfad" und data-bind-list="pfad" (Checkbox-Gruppen) function bindForms() { @@ -87,12 +102,14 @@ function fillForms() { $$('input', box).forEach(i => i.checked = v.includes(i.value)); }); $$('#steps input').forEach(i => i.checked = arr(S.steps).includes(i.value)); + $('#officeSourceRow').classList.toggle('hidden', S.officeMode !== 'custom'); $('#profileName').textContent = 'Blaupause: ' + ([S.customer, S.name].filter(Boolean).join(' / ') || '(ungespeichert)'); $$('.pinonly').forEach(e => e.classList.toggle('hidden', !S.bitlocker.usePin)); } function onChange(path) { if (path.startsWith('office')) { if (path === 'office.product') { renderOfficeChannels(); renderOfficeExtras(); } updateOfficeXml(); } if (path === 'bitlocker.usePin') fillForms(); + if (path === 'officeMode' || path === 'officeSource') { fillForms(); updateOfficeXml(); } if (path === 'bloatware.recommended') renderBloat(); } function loadState(obj) { S = deepMerge(DEFAULTS(), obj); persist(); fillForms(); renderAll(); } @@ -288,12 +305,13 @@ $('#officeExtras').onchange = () => { S.office.extraProducts = $$('#officeExtras let xmlTimer; function updateOfficeXml() { clearTimeout(xmlTimer); - xmlTimer = setTimeout(() => guard(async () => { $('#officeXml').textContent = await api('POST', '/api/office/xml', S.office, true); }), 250); + xmlTimer = setTimeout(() => guard(async () => { $('#officeXml').textContent = await api('POST', '/api/office/xml', { ...S.office, sourcePath: S.officeMode === 'custom' ? S.officeSource : '' }, true); }), 250); } $('#btnXmlDownload').onclick = () => download('configuration.xml', $('#officeXml').textContent, 'application/xml'); $('#btnXmlCopy').onclick = () => navigator.clipboard.writeText($('#officeXml').textContent).then(() => toast('XML kopiert')); $('#btnOffice').onclick = () => guard(async () => { - jobStarted((await api('POST', '/api/office/install', { office: S.office, mode: S.officeMode, name: S.name })).job); + if (S.officeMode === 'custom' && !S.officeSource) return toast('Pfad zur Office-Quelle angeben', true); + jobStarted((await api('POST', '/api/office/install', { office: S.office, mode: S.officeMode, sourcePath: S.officeSource, name: S.name })).job); }); async function loadOfficeBuilds() { const b = arr(await api('GET', '/api/office/configs').catch(() => [])); @@ -670,8 +688,9 @@ function renderQuickSelect() { } $('#bpFilter').oninput = renderBlueprints; async function loadBlueprint(id) { + if (!confirmDiscard()) { renderQuickSelect(); return; } const data = await api('GET', '/api/blueprints/' + encodeURIComponent(id)); - loadState({ ...data, id }); + loadState({ ...data, id }); markSaved(); renderBlueprints(); renderQuickSelect(); $('#bpCustomer').value = S.customer || ''; $('#profileSaveName').value = S.name || ''; $('#bpDescription').value = S.description || ''; toast(`Blaupause "${[S.customer, S.name].filter(Boolean).join(' / ')}" geladen`); @@ -700,6 +719,7 @@ async function saveBlueprint(asNew) { await api('DELETE', '/api/blueprints/' + encodeURIComponent(S.id)); Object.assign(S, { id, name, customer, description: $('#bpDescription').value.trim() }); persist(); await api('POST', '/api/blueprints/' + encodeURIComponent(id), JSON.stringify(S, null, 2)); + markSaved(); toast(`Blaupause "${name}" gespeichert`); fillForms(); loadProfiles(); } $('#btnProfileSave').onclick = () => guard(() => saveBlueprint(false)); @@ -709,7 +729,14 @@ $('#importFile').onchange = async e => { const f = e.target.files[0]; e.target.value = ''; if (f) guard(async () => { const d = JSON.parse(await f.text()); d.id = ''; loadState(d); loadProfiles(); toast('Importiert – zum Übernehmen „Speichern“ klicken'); }); }; -$('#btnReset').onclick = () => { if (confirm('Alle Einstellungen auf Standard zurücksetzen?')) loadState(DEFAULTS()); }; +$('#btnReset').onclick = () => { if (confirm('Alle Einstellungen auf Standard zurücksetzen?')) { loadState(DEFAULTS()); markSaved(); } }; +// Speichern-Button in der Seitenleiste +$('#btnSideSave').onclick = () => guard(async () => { + if (!S.name) { showTab('profiles'); $('#profileSaveName').focus(); return toast('Bitte Kunde und Namen für die neue Blaupause angeben', true); } + if (!blueprints.length) await loadProfiles(); + $('#bpCustomer').value = S.customer || ''; $('#profileSaveName').value = S.name; $('#bpDescription').value = S.description || ''; + await saveBlueprint(false); +}); function download(name, text, type) { const a = document.createElement('a'); a.href = URL.createObjectURL(new Blob([text], { type })); a.download = name; a.click(); @@ -759,13 +786,13 @@ async function waitJob(id) { } } -$('#btnShutdown').onclick = () => { if (confirm('Agent beenden? Laufende Aufgaben werden abgebrochen.')) api('POST', '/api/shutdown', {}).finally(() => { document.body.innerHTML = '

Agent beendet.

Das Fenster kann geschlossen werden.

'; }); }; +$('#btnShutdown').onclick = () => { if (confirmDiscard() && confirm('Agent beenden? Laufende Aufgaben werden abgebrochen.')) api('POST', '/api/shutdown', {}).finally(() => { document.body.innerHTML = '

Agent beendet.

Das Fenster kann geschlossen werden.

'; }); }; // ---------------------------------------------------------------- Start function renderAll() { renderOffice(); renderSelectedApps(); renderPlanned(); renderWifi(); renderSummary(); } (async () => { if (!TOKEN) { document.body.innerHTML = '

Kein Zugriffstoken

Bitte die vollständige URL (mit ?token=…) aus dem Agent-Fenster öffnen.

'; return; } - bindForms(); renderAll(); fillForms(); scTypeUi(); + bindForms(); renderAll(); fillForms(); scTypeUi(); updateDirty(); await guard(loadStatus); await guard(loadCatalog); await guard(async () => { diff --git a/web/index.html b/web/index.html index 80f21d1..ac59bbd 100644 --- a/web/index.html +++ b/web/index.html @@ -27,6 +27,8 @@
Blaupause: (ungespeichert)
+ +
@@ -163,7 +165,10 @@ + +

setup.exe des Office Deployment Tools wird bei Bedarf automatisch von Microsoft geladen und unter office\ abgelegt. Builds liegen in office\source\.

diff --git a/web/style.css b/web/style.css index cc92b57..8ec647f 100644 --- a/web/style.css +++ b/web/style.css @@ -101,3 +101,6 @@ dialog { border: 1px solid var(--line); border-radius: 12px; background: var(--c dialog::backdrop { background: rgba(0,0,0,.45); } .okline { color: var(--ok); font-weight: 600; } .errline { color: var(--err); font-weight: 600; } .checks label input { flex: none; } +.dirty { background: rgba(178,106,0,.18); color: #ffb648; border: 1px solid rgba(255,182,72,.4); border-radius: 6px; padding: 6px 10px; font-size: 12px; font-weight: 600; } +#btnSideSave.pulse { animation: pulse 1.6s ease-in-out infinite; } +@keyframes pulse { 0%, 100% { box-shadow: 0 0 0 0 rgba(76,149,255,.6); } 50% { box-shadow: 0 0 0 6px rgba(76,149,255,0); } }