From 657379294398748901c5fe65f57bf82c8fc2bbb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=87A=C4=9EAN=20TURGUT?= Date: Tue, 22 Sep 2026 20:32:01 +0300 Subject: [PATCH 1/2] feat(milkdrop): import whole libraries from ZIP packs, folders and the machine Two steps: a scan returns a summary (presets, textures, MB and what will be skipped) while the plan stays in the main process; nothing is copied until the user confirms. - Own ZIP reader: central directory only for the scan, ZIP64, stored and deflate entries, CRC checked, a hard output cap per entry, a compression-ratio limit, at most 200,000 entries, CP437 and UTF-8 names. Only an entry's file name is used, so it cannot write outside (zip-slip). - Scans take .milk files in nested folders, images in textures/sprites folders, and images next to the presets only when a preset samples them (listed apart in the summary, not counted into its size). .milk2, oversized files, encrypted entries and macOS leftovers are skipped and reported. Same name and same content is a duplicate. - Textures go into the app's own folder, looked up after the texture folder the user chose; a counter in the settings makes every engine reload its texture list after an import. - The pack's category folders become tags (#576) unless turned off. - Search This Computer: explicit roots, known install folders, then Downloads, Desktop, Music and Documents as the system reports them. Candidates are searched breadth first, so a huge folder no longer hides a small pack next to it, and counted in the remaining time. A library whose count did not finish is listed with "+" and scanned in full before the confirmation, so nothing is imported from a partial scan; a search that ran out of time says so. Refs #574, #560 --- README.md | 17 +- README.tr.md | 17 +- ROADMAP.md | 75 +++- src/admin/milkdrop-panel.js | 211 +++++++++ src/admin/studio.css | 6 + src/main/main.js | 116 ++++- src/main/milkdrop-import.js | 529 ++++++++++++++++++++++ src/main/milkdrop-textures.js | 29 ++ src/main/preload-admin.js | 7 + src/main/zip-reader.js | 207 +++++++++ src/shared/defaults.js | 6 + src/shared/i18n.js | 51 +++ src/shared/milkdrop-library.js | 15 +- src/visualizer/modes/milkdrop.js | 11 +- tests/milkdrop-import.test.js | 572 ++++++++++++++++++++++++ tests/milkdrop-library.test.js | 2 +- tests/milkdrop-texture-surfaces.test.js | 6 +- tests/zip-reader.test.js | 195 ++++++++ 18 files changed, 2057 insertions(+), 15 deletions(-) create mode 100644 src/main/milkdrop-import.js create mode 100644 src/main/zip-reader.js create mode 100644 tests/milkdrop-import.test.js create mode 100644 tests/zip-reader.test.js diff --git a/README.md b/README.md index 9eca975..8cc0fdc 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ [![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-111997.svg)](#build--distribution) [![Electron](https://img.shields.io/badge/Electron-43-47848F.svg)](https://www.electronjs.org/) [![Downloads](https://img.shields.io/github/downloads/CaYatur/SoundVisualizer/total?label=downloads)](https://github.com/CaYatur/SoundVisualizer/releases) -[![Tests](https://img.shields.io/badge/tests-2052%20passing-2ea043.svg)](#tests) +[![Tests](https://img.shields.io/badge/tests-2081%20passing-2ea043.svg)](#tests) [![cayadev.com](https://img.shields.io/badge/cayadev.com-e11d2a.svg)](https://cayadev.com) @@ -347,6 +347,19 @@ that asserts the bar profile has no step in it. MilkDrop presets no longer restarts the MilkDrop picture on screen. Web clients get MilkDrop presets without their sources (271 KB instead of about 30 MB for 2,000 presets), and the web overlay fetches the source of a preset only when it is about to draw it. +- **Import a whole MilkDrop library.** *Import from ZIP Pack*, *Import from Folder* and *Search This + Computer* in the MilkDrop panel bring in a library in one go: nested folders, the pack's + textures, and — if you want — the pack's category folders as tags. Nothing is copied until you + have seen what will be added (presets, textures, MB, and what will be skipped: MilkDrop 3's + `.milk2` double presets, oversized files, encrypted ZIP entries) and confirmed. Presets with the + same name and content are skipped, so importing a pack twice adds nothing. Textures go into the + app's own folder and are looked up after the texture folder you chose, so a pack's + `sampler_worms` works without choosing a folder. The search looks in the usual Winamp, + foobar2000 and projectM folders and in Downloads, Desktop, Music and Documents, wherever the + system keeps them, and stops after a few seconds; a library it could not finish counting is + scanned in full before you are asked. No preset pack ships with the app. Measured with real + packs: a 9,795-preset folder was scanned in 1.1 s and imported in 6.2 s, and importing it again + added nothing. - **Sprites from `milk_img.ini`.** MilkDrop 2 draws images of your own over the picture during a show: each one is defined in `milk_img.ini` with an image, code that runs once and code that runs every frame, and launched by number. Choose the file in the MilkDrop panel and launch from @@ -1246,7 +1259,7 @@ npm test npm start -- --smoke ``` -**2052 unit tests, all passing.** They are written to check answers, not to exercise lines: +**2081 unit tests, all passing.** They are written to check answers, not to exercise lines: - **Formulas** are checked against values derived by hand from their definitions — Viviani's curve staying on its sphere, the torus tube radius, Chladni's m↔n antisymmetry, every attractor diff --git a/README.tr.md b/README.tr.md index a82ae74..605d9e3 100644 --- a/README.tr.md +++ b/README.tr.md @@ -12,7 +12,7 @@ [![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-111997.svg)](#paketleme--dağıtım) [![Electron](https://img.shields.io/badge/Electron-43-47848F.svg)](https://www.electronjs.org/) [![İndirme](https://img.shields.io/github/downloads/CaYatur/SoundVisualizer/total?label=indirme)](https://github.com/CaYatur/SoundVisualizer/releases) -[![Test](https://img.shields.io/badge/test-2052%20geçiyor-2ea043.svg)](#testler) +[![Test](https://img.shields.io/badge/test-2081%20geçiyor-2ea043.svg)](#testler) [![cayadev.com](https://img.shields.io/badge/cayadev.com-e11d2a.svg)](https://cayadev.com) @@ -340,6 +340,19 @@ profilinde basamak olmadığını doğruluyor. kullanıyor. MilkDrop presetleri eklenip silindiğinde ekrandaki MilkDrop görüntüsü artık baştan başlamıyor. Web istemcileri MilkDrop presetlerini kaynaksız alıyor (2.000 presette ~30 MB yerine 271 KB) ve web çıkışı bir presetin kaynağını ancak onu çizecekken istiyor. +- **Bütün bir MilkDrop kütüphanesini içe aktarın.** MilkDrop panelindeki *ZIP Paketinden İçe + Aktar*, *Klasörden İçe Aktar* ve *Makinede Ara* bir kütüphaneyi tek seferde alıyor: iç içe + klasörler, paketin dokuları ve istenirse paketin kategori klasörleri etiket olarak. Neyin + ekleneceğini (preset, doku, MB ve neyin atlanacağı: MilkDrop 3'ün `.milk2` çift presetleri, sınırı + aşan dosyalar, şifreli ZIP girdileri) görüp onaylamadan hiçbir şey kopyalanmıyor. Aynı ad ve + içerikteki presetler atlanıyor, yani bir paketi iki kez almak bir şey eklemiyor. Dokular + uygulamanın kendi klasörüne gidiyor ve seçtiğiniz doku klasöründen sonra aranıyor; paketin + `sampler_worms`'u klasör seçmeden de çalışıyor. Arama bilinen Winamp, foobar2000 ve projectM + klasörlerine ve İndirilenler, Masaüstü, Müzik, Belgeler'e — sistem onları nerede tutuyorsa — + bakıyor ve birkaç saniyede duruyor; sayımını bitiremediği bir kütüphane size sorulmadan önce + baştan taranıyor. Uygulamayla hiçbir preset paketi gelmiyor. Gerçek paketlerle ölçüldü: 9.795 + presetlik bir klasör 1,1 sn'de tarandı, 6,2 sn'de içe aktarıldı; ikinci kez almak hiçbir şey + eklemedi. - **`milk_img.ini`'den sprite'lar.** MilkDrop 2 gösteri sırasında kendi resimlerinizi görüntünün üstüne çiziyor: her biri `milk_img.ini`de bir resim, bir kez çalışan ve her kare çalışan koduyla tanımlı ve numarasıyla başlatılıyor. Dosyayı MilkDrop panelinde seçin; panelin listesinden, bir @@ -1243,7 +1256,7 @@ npm test npm start -- --smoke ``` -**2052 birim testi, hepsi geçiyor.** Satır çalıştırmak için değil, cevap denetlemek için yazıldılar: +**2081 birim testi, hepsi geçiyor.** Satır çalıştırmak için değil, cevap denetlemek için yazıldılar: - **Formüller**, tanımlarından elle türetilmiş değerlerle sınanıyor — Viviani eğrisinin küre üzerinde kalması, simidin boru yarıçapı, Chladni'nin m↔n antisimetrisi, her çekicinin sınırlı diff --git a/ROADMAP.md b/ROADMAP.md index 3f382a6..f27db24 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,9 +129,9 @@ npm test npm start -- --smoke ``` -- **2052 unit tests, all passing** on `main`. 703 of those shipped in v3.1.0; +- **2081 unit tests, all passing** on `main`. 703 of those shipped in v3.1.0; 105 came with v3.1.1; 163 came with v3.1.2; 157 came with v3.1.3 — 1128 at - that tag — 469 more with v3.1.4, most of them from the MilkDrop work, and 455 + that tag — 469 more with v3.1.4, most of them from the MilkDrop work, and 484 on `main` since. Formulas are checked against values derived by hand from their definitions — Viviani's curve staying on its sphere, the @@ -1663,6 +1663,77 @@ rest after. No version number yet. the visualizer's rebuild rule and the engine waiting for a source (and saying so once if a page ever gets a sourceless preset with no way to fetch it). 26 of 26 mutations are caught. +- **Large preset libraries (#574, part 2 of 2: the importer)** · done. The + MilkDrop panel imports a whole library from a ZIP pack, from a folder, or + from one found on the machine. Two steps: a scan shows what will be added — + presets, textures, MB, and what will be skipped — and nothing is copied + until the user confirms. The plan stays in the main process; the page only + gets its summary and a token. + - **What is taken.** `.milk` files in nested folders; images in `textures` + or `sprites` folders; images lying next to the presets only when a + preset asks for them by `sampler_` (MilkDrop looks in the preset's + folder too) — a preview picture beside each preset is not a texture, and + the summary lists those images apart instead of counting them into the + size. Skipped and reported: `.milk2` (#567), presets over 450 KB, + textures over 8 MB, encrypted or unsupported ZIP entries; macOS + `__MACOSX` and AppleDouble leftovers, hidden folders and `node_modules` + are not looked at. A preset with the same name and the same content is a + duplicate and skipped; the same name with other content is imported. + - **ZIP reading** is our own: only the central directory for the scan, + ZIP64, stored and deflate entries, the CRC checked, each entry inflated + with a hard output cap, a suspicious compression ratio refused, at most + 200,000 entries, CP437 and UTF-8 names. Only an entry's file name is + used, so `../../x.milk` cannot write outside (zip-slip). + - **Textures** are copied into the app's own folder (`milkdrop-textures` + in the user data), which is looked up after the texture folder the user + chose: when a name is in both, the user's wins, and a texture already in + the app's folder with other content is kept and reported. A counter in + the settings makes every engine — windows, Spout/Syphon, the preview, + the web overlay — reload its texture list after an import. + - **Tags.** The first folder below the pack's common prefix becomes a tag + (#576) unless it is a generic name (presets, milkdrop …); it can be + turned off. + - **Search.** Known install folders (Winamp's `Plugins\Milkdrop2`, + foobar2000's `milkdrop2`, projectM's preset folders) and the user + folders where portable installs get unpacked, in the order Downloads, + Desktop, Music, Documents, as the system reports them (OneDrive can move + them on Windows; the names are localized on Linux). Every subfolder and + ZIP there is a candidate, and presets lying in one of those folders + itself are one too. Candidates are searched breadth first — the first + level of each, then the second — because a depth-first walk spent the + budget inside the first huge folder and never reached a small pack next + to it; the first end-to-end run showed exactly that. Finding uses at + most 60% of an 8-second budget and counting the rest, shared between + the libraries found. One whose count did not finish is listed with "+" + (or "not counted") and scanned in full before the confirmation, so the + confirmation shows the real numbers and nothing is imported from a + partial scan. If finding ran out of time, the panel says so and points + to *Import from Folder*. + - **Measured** in an isolated store with real packs: the Cream of the Crop + folder was scanned in 1.1 s (9,795 presets, 11 category tags), imported + in 6.2 s, and imported again in 1.2 s with every preset skipped as a + duplicate; the original pack's ZIP was scanned in 6 ms and imported in + 287 ms, 14 of its presets already there from Cream. In an isolated copy + with the panel driven: the search listed a test pack and the libraries + in the user's own Downloads in 5–7 s; importing the test pack added 79 + presets tagged Dancer (74) and Fractal (5) and copied `worms.jpg` into + the app's folder, and the visualizer window loaded it with no texture + folder chosen. An 80,000-preset folder the search could count only to + 10,013 was listed as "10,013+"; *Import* scanned it again (10.8 s), the + confirmation showed 80,000 presets and 80 folder tags, and cancelling + imported nothing. The English UI was checked the same way. + - **Not done:** `.milk2` (#567); RAR and 7z packs; a ZIP inside a ZIP or + inside a chosen folder is not opened; the search does not look at other + drives or anywhere outside the places above — *Import from Folder* + reaches those. + - **Tests.** 29 tests: 7 for the ZIP reader (with a hand-written ZIP + writer, ZIP64 included) and 22 for the importer — real temporary + folders and archives, zip-slip, duplicates, textures, tags, the search + order and breadth-first finding (made deterministic with a file budget + instead of time), a library found but not counted, the rescan, the + main-process handlers, the engine's texture counter, and the panel + drawn with a fake DOM, confirmation and rescan included. 57 of 57 + mutations are caught. ## v3.1.6 — Comprehensive video export diff --git a/src/admin/milkdrop-panel.js b/src/admin/milkdrop-panel.js index 68994a0..0e25817 100644 --- a/src/admin/milkdrop-panel.js +++ b/src/admin/milkdrop-panel.js @@ -151,6 +151,152 @@ if (r && r.ok) P().toast(list.length + ' ' + tr('preset pakete yazıldı.'), 'ok'); } + /* KÜTÜPHANE İÇE AKTARIMI (#574): ZIP, klasör ve makinede bulunanlar. İKİ + AŞAMA: tarama bir özet getiriyor (kaç preset, kaç doku, kaç MB, neler + atlanacak); onaylanınca ana süreç kopyalıyor. Sormadan hiçbir şey + kopyalanmıyor. `libBusy` sürüyorsa durum metni, `libFound` makinede + bulunanlar, `libSearchDone` arama bütün adaylara bakabildi mi, + `libResult` son içe aktarımın özeti. */ + let libBusy = ''; + let libFound = null; + let libSearchDone = true; + let libResult = ''; + let libListening = false; + // Sayılar dile göre ayrılıyor: 9.795 / 9,795 + const loc = () => (window.SVI18n && window.SVI18n.locale === 'en' ? 'en' : 'tr'); + const fmt = (s, vars) => tr(s).replace(/\{(\w+)\}/g, (m, k) => { + if (!vars || !(k in vars)) return m; + return typeof vars[k] === 'number' ? vars[k].toLocaleString(loc()) : String(vars[k]); + }); + const mb = (b) => (Math.round(((+b || 0) / 1048576) * 10) / 10).toLocaleString(loc()); + + const LIB_ERR = { + NOT_ZIP: 'ZIP okunamadı: bozuk ya da ZIP değil.', + CORRUPT: 'ZIP okunamadı: bozuk ya da ZIP değil.', + READ_FAILED: 'ZIP okunamadı: bozuk ya da ZIP değil.', + TOO_MANY_ENTRIES: 'ZIP çok fazla girdi içeriyor.', + TOO_BIG: 'Kaynak çok büyük (2 GB üstü).', + NO_PLAN: 'Bu tarama artık geçerli değil; yeniden tarayın.', + }; + const libError = (code) => tr(LIB_ERR[code] || 'İçe aktarılamadı.'); + + function libSetBusy(text) { + libBusy = text; + const n = typeof document !== 'undefined' && document.getElementById('mdLibProgress'); + if (n) n.textContent = text; + } + + function listenLibrary() { + if (libListening || !window.api || !window.api.onMilkdropLibraryProgress) return; + libListening = true; + const PH = { read: 'Okunuyor: {a}/{b}', save: 'Kaydediliyor: {a}/{b}', textures: 'Dokular: {a}/{b}' }; + window.api.onMilkdropLibraryProgress((p) => { + if (!p || !libBusy) return; + libSetBusy(fmt(PH[p.phase] || 'İçe aktarılıyor…', { a: p.done, b: p.total })); + }); + } + + // Onaydan önce gösterilen özet: ne eklenecek, neler atlanacak + function libSummaryText(s) { + const lines = [fmt('“{label}”: {p} preset, {t} doku, {mb} MB.', { label: s.label, p: s.presets, t: s.textures, mb: mb(s.bytes) })]; + if (s.loose) lines.push(fmt('Presetlerin yanındaki {n} görselden yalnız presetlerin istediği kopyalanır.', { n: s.loose })); + const k = s.skipped || {}; + if (k.milk2) lines.push(fmt('{n} .milk2 dosyası (MilkDrop 3 çift preseti) desteklenmiyor, atlanacak.', { n: k.milk2 })); + if (k.tooLarge || k.textureTooLarge) lines.push(fmt('{n} dosya boyut sınırını aşıyor, atlanacak.', { n: (k.tooLarge || 0) + (k.textureTooLarge || 0) })); + if (k.encrypted || k.unsupported) lines.push(fmt('{n} şifreli ya da desteklenmeyen ZIP girdisi atlanacak.', { n: (k.encrypted || 0) + (k.unsupported || 0) })); + if (s.complete === false) lines.push(tr('Tarama yarıda kesildi: çok fazla dosya var; bulunanlar alınır.')); + if (s.tags && control(P().cfg()).importFolderTags !== false) lines.push(fmt('{n} klasör adı etiket olacak.', { n: s.tags })); + return lines.join(' '); + } + + // Son içe aktarımın sonucu + function libResultText(r) { + const lines = [fmt('{a} preset eklendi, {d} tekrar atlandı.', { a: r.added || 0, d: r.duplicates || 0 })]; + if (r.failed) lines.push(fmt('{f} preset okunamadı ya da kaydedilemedi.', { f: r.failed })); + const t = r.textures || {}; + if (t.copied) lines.push(fmt('{c} doku kopyalandı.', { c: t.copied })); + if (t.same) lines.push(fmt('{s} doku zaten vardı.', { s: t.same })); + if (t.conflicts) lines.push(fmt('{k} doku adı var olan başka bir dokuyla çakıştı; var olan kaldı.', { k: t.conflicts })); + if (r.skipped && r.skipped.milk2) lines.push(fmt('{n} .milk2 dosyası atlandı (MilkDrop 3 çift preseti).', { n: r.skipped.milk2 })); + return lines.join(' '); + } + + /* Aramanın süresi yetmeyip eksik saydığı kütüphane: onaydan önce tamamı + taranıyor, onay gerçek sayıları gösteriyor ve eksik içe aktarım olmuyor. + Yeni özet listedekinin yerini alıyor (onay verilmese de). Baştan tarama + `searchCut` taşımıyor; o da sınıra takılırsa (çok büyük klasör) olağan + onaya geçiliyor, döngü yok. */ + async function rescanFirst(s) { + if (!window.api || !window.api.rescanMilkdropLibrary) { P().toast(tr('İçe aktarma kullanılamıyor.'), 'err'); return null; } + libSetBusy(tr('Taranıyor…')); + P().rerender(); + let r = null; + try { r = await window.api.rescanMilkdropLibrary(s.token); } catch (e) { r = null; } + libSetBusy(''); + if (!r || !r.ok) { P().toast(libError(r && r.error), 'err'); P().rerender(); return null; } + if (libFound) libFound = libFound.map((x) => (x.token === s.token ? r : x)); + return r; + } + + async function confirmAndImport(s) { + if (s && s.searchCut) { + s = await rescanFirst(s); + if (!s) return; + } + if (!s || !s.presets) { P().toast(tr('Bu kaynakta MilkDrop preseti yok.'), 'err'); P().rerender(); return; } + if (s.tooBig) { P().toast(libError('TOO_BIG'), 'err'); P().rerender(); return; } + const msg = libSummaryText(s) + ' ' + tr('Aynı ad ve içerikteki presetler atlanır; dosyalar uygulamanın klasörüne kopyalanır. Devam edilsin mi?'); + if (!(await P().confirm(msg, { okText: tr('İçe Aktar') }))) { P().rerender(); return; } + listenLibrary(); + libResult = ''; + libSetBusy(tr('İçe aktarılıyor…')); + P().rerender(); + let r = null; + try { r = await window.api.importMilkdropLibrary(s.token); } catch (e) { r = null; } + libSetBusy(''); + if (!r || !r.ok) { P().toast(libError(r && r.error), 'err'); P().rerender(); return; } + // Bulunanlar listesinden çıkıyor: aynı kütüphane iki kez önerilmesin + if (libFound) libFound = libFound.filter((x) => x.token !== s.token); + /* Kaydedilen presetler değişiklik yayınıyla geliyor; etiketler ve doku + sayacı ayara buradan (ayar paneldeki kopyadan gönderiliyor). */ + const cfg = P().cfg(); + const lib = library(cfg); + const L = LB(); + const tagged = (r.saved || []).filter((x) => x && x.tag); + if (L && tagged.length && control(cfg).importFolderTags !== false) lib.tags = L.addTags(lib, tagged); + if (r.textures && r.textures.copied) lib.textureRev = (+lib.textureRev || 0) + 1; + libResult = libResultText(r); + P().apply(); + } + + async function pickLibrary(kind) { + if (!window.api || !window.api.pickMilkdropLibrary) { P().toast(tr('İçe aktarma kullanılamıyor.'), 'err'); return; } + libSetBusy(tr('Taranıyor…')); + P().rerender(); + let r = null; + try { r = await window.api.pickMilkdropLibrary(kind); } catch (e) { r = null; } + libSetBusy(''); + if (!r || !r.ok) { + P().rerender(); + if (!r || !r.canceled) P().toast(libError(r && r.error), 'err'); + return; + } + await confirmAndImport(r); + } + + async function discoverLibraries() { + if (!window.api || !window.api.discoverMilkdropLibraries) { P().toast(tr('İçe aktarma kullanılamıyor.'), 'err'); return; } + libFound = null; + libSetBusy(tr('Makinede aranıyor…')); + P().rerender(); + let r = null; + try { r = await window.api.discoverMilkdropLibraries(); } catch (e) { r = null; } + libSetBusy(''); + libFound = (r && r.ok && Array.isArray(r.libraries)) ? r.libraries : []; + libSearchDone = !(r && r.complete === false); + P().rerender(); + } + async function importPack() { const IS = window.SVPresets; const L = LB(); @@ -1067,6 +1213,71 @@ onclick: () => importPack(), }), ])); + /* KÜTÜPHANE (#574): ZIP paketi, klasör ya da makinede bulunan bir + kütüphane. Önce ne ekleneceği gösteriliyor, onaysız kopya yok. */ + nodes.push(el('div', { class: 'row' }, [ + el('button', { + class: 'btn ghost', type: 'button', text: '🗜 ZIP Paketinden İçe Aktar', disabled: !!libBusy, + onclick: () => pickLibrary('zip'), + }), + el('button', { + class: 'btn ghost', type: 'button', text: '📁 Klasörden İçe Aktar', disabled: !!libBusy, + onclick: () => pickLibrary('folder'), + }), + el('button', { + class: 'btn ghost', type: 'button', text: '🔎 Makinede Ara', disabled: !!libBusy, + title: 'Bilinen kurulum klasörlerinde ve Masaüstü, İndirilenler, Belgeler, Müzik klasörlerinde MilkDrop kütüphanesi arar', + onclick: () => discoverLibraries(), + }), + ])); + nodes.push(P().row('Klasör Adları', selOf([ + [1, 'Etiket yap'], + [0, 'Etiket yapma'], + ], control(cfg).importFolderTags === false ? 0 : 1, (v) => { control(cfg).importFolderTags = Number(v) === 1; }))); + if (libBusy) nodes.push(el('div', { id: 'mdLibProgress', class: 'studio-note', text: libBusy })); + if (libResult) nodes.push(el('div', { class: 'studio-note md-ok', text: libResult })); + if (libFound) { + if (!libFound.length) { + nodes.push(el('div', { + class: 'studio-note dim-hint', + text: 'Bilinen kurulum klasörlerinde ve Masaüstü, İndirilenler, Belgeler, Müzik klasörlerinde MilkDrop kütüphanesi bulunamadı.', + })); + } else { + const box = el('div', { class: 'md-libs' }); + for (const s of libFound) { + /* Aramanın süresi yetmediyse sayılar alt sınır ("+"); hiç + sayılamadıysa bu söyleniyor. İçe aktarmadan önce tamamı taranıyor. */ + const vars = { p: s.presets, t: s.textures, mb: mb(s.bytes) }; + const count = !s.searchCut ? fmt('{p} preset · {t} doku · {mb} MB', vars) + : s.presets ? fmt('{p}+ preset · {t}+ doku · {mb}+ MB', vars) : tr('sayılmadı'); + box.appendChild(el('div', { class: 'md-lib' }, [ + el('span', { class: 'md-lib-name', text: s.label, title: s.where || '' }), + el('span', { class: 'md-lib-count', text: count }), + el('button', { + class: 'btn ghost tiny', type: 'button', text: tr('İçe Aktar'), disabled: !!libBusy, + onclick: () => confirmAndImport(s), + }), + ])); + } + nodes.push(P().row('Bulunan Kütüphaneler', box)); + if (libFound.some((s) => s.searchCut)) { + nodes.push(el('div', { + class: 'studio-note dim-hint', + text: 'Aramanın süresi bazı kütüphaneleri saymaya yetmedi (+ ya da “sayılmadı”); içe aktarmadan önce tamamı taranır.', + })); + } + } + if (!libSearchDone) { + nodes.push(el('div', { + class: 'studio-note dim-hint', + text: 'Arama süre sınırına ulaştı, bazı klasörlere bakılamadı. Kütüphaneniz listede yoksa 📁 Klasörden İçe Aktar ile seçin.', + })); + } + } + nodes.push(el('div', { + class: 'studio-note dim-hint', + text: 'ZIP paketi, bir klasör ya da makinede bulunan bir kütüphane: önce ne ekleneceği gösterilir, onaylamadan hiçbir şey kopyalanmaz. İç içe klasörler ve dokular dahil; aynı ad ve içerikteki presetler atlanır. Dokular uygulamanın kendi klasörüne gider ve seçtiğiniz doku klasöründen sonra aranır. Uygulamayla hiçbir preset paketi gelmez. .milk2 (MilkDrop 3 çift preseti) henüz desteklenmiyor.', + })); // Arama if (presets.length > 6) { diff --git a/src/admin/studio.css b/src/admin/studio.css index e2d99f8..c47991c 100644 --- a/src/admin/studio.css +++ b/src/admin/studio.css @@ -651,6 +651,12 @@ input.p-in[type='color'] { padding: 2px; height: 28px; width: 46px; flex: none; background: rgba(124, 92, 255, 0.22); border-color: rgba(124, 92, 255, 0.55); } +/* Makinede bulunan kütüphaneler (#574): ad, sayılar, içe aktar. Yol ipucu + olarak adın üstünde. */ +.md-libs { display: flex; flex-direction: column; gap: 4px; min-width: 0; } +.md-lib { display: flex; align-items: center; gap: 8px; min-width: 0; } +.md-lib-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.md-lib-count { flex: none; font-size: 11px; color: var(--muted, #9aa); font-variant-numeric: tabular-nums; } /* ===== Kayıt paneli ===== */ diff --git a/src/main/main.js b/src/main/main.js index 52f07ef..2be2e78 100644 --- a/src/main/main.js +++ b/src/main/main.js @@ -2154,12 +2154,23 @@ ipcMain.handle('milkdrop:pick-textures', async () => { /* Doku adları ve tek bir dokunun DOĞRULANMIŞ dosyası. IPC de yayın sunucusu da bunları kullanıyor (#586): web çıkışının dosya erişimi yok, görseller ona sunucudan gidiyor ve aynı denetimden geçmeleri gerekiyor. */ +/* İçe aktarılan paketlerin dokuları (#574) uygulamanın kendi klasörüne + kopyalanıyor; kullanıcının seçtiği klasörden SONRA aranıyor, yani aynı ad + iki yerdeyse kullanıcınınki geçerli. */ +function managedTextureDir() { + return path.join(app.getPath('userData'), 'milkdrop-textures'); +} + +function textureDirs() { + return [textureDir(), managedTextureDir()]; +} + function textureNames() { - return mdTex.listTextures(textureDir()); + return mdTex.listTexturesIn(textureDirs()); } function textureFile(name) { - return mdTex.textureFileInfo(textureDir(), name, TEX_MAX_BYTES); + return mdTex.textureFileInfoIn(textureDirs(), name, TEX_MAX_BYTES); } /* Klasördeki görsel dosyalarının adları. Preset `sampler_worms` derken @@ -2176,6 +2187,107 @@ ipcMain.handle('milkdrop:texture', (e, name) => { } }); +// ---------------------------------------------------------------------------- +// MilkDrop kütüphanesi içe aktarımı (#574): ZIP, klasör, makinede bulunanlar +// +// İKİ AŞAMA: tarama bir plan üretiyor ve sayfaya yalnız özeti gidiyor (kaç +// preset, kaç doku, kaç MB, neler atlanacak); plan burada, kimliğiyle +// bekliyor. Kullanıcı onaylarsa aynı kimlikle çalıştırılıyor. Sormadan +// hiçbir şey kopyalanmıyor. Yollar ve dosya listeleri sayfaya gitmiyor. +// ---------------------------------------------------------------------------- +const mdImport = require('./milkdrop-import'); +const libraryPlans = new Map(); // kimlik → plan (son 32) + +function keepPlan(plan) { + const token = 'lib_' + require('crypto').randomBytes(8).toString('hex'); + libraryPlans.set(token, plan); + while (libraryPlans.size > 32) libraryPlans.delete(libraryPlans.keys().next().value); + return Object.assign({ token, where: plan.source }, mdImport.summary(plan)); +} + +ipcMain.handle('milkdrop:library-pick', async (e, kind) => { + const isZip = kind === 'zip'; + const r = await dialog.showOpenDialog(adminWin, isZip ? { + title: trUi('MilkDrop Paketi Seç (ZIP)', 'Choose MilkDrop Pack (ZIP)'), + properties: ['openFile'], + filters: [{ name: 'ZIP', extensions: ['zip'] }, { name: trUi('Tümü', 'All Files'), extensions: ['*'] }], + } : { + title: trUi('MilkDrop Preset Klasörü Seç', 'Choose MilkDrop Preset Folder'), + properties: ['openDirectory'], + }); + if (r.canceled || !r.filePaths[0]) return { ok: false, canceled: true }; + try { + const plan = isZip ? mdImport.scanZip(r.filePaths[0]) : await mdImport.scanFolder(r.filePaths[0]); + return Object.assign({ ok: true }, keepPlan(plan)); + } catch (err) { + return { ok: false, error: (err && err.code) || 'READ_FAILED' }; + } +}); + +/* Makinede arama: bilinen kurulum klasörleri ve kullanıcı klasörleri + (İndirilenler, Masaüstü, Müzik, Belgeler). Yalnız düğmeyle, sonuçlar + yalnız bu makinede. Kullanıcı klasörlerinin yeri sistemden: OneDrive + taşımış olabilir, Linux'ta adları yerel dilde. */ +function knownFolder(name) { + try { return app.getPath(name); } catch { return ''; } +} + +ipcMain.handle('milkdrop:library-discover', async () => { + const env = { + platform: process.platform, + home: app.getPath('home'), + appData: process.env.APPDATA || app.getPath('appData'), + programFiles: process.env.ProgramFiles || '', + programFilesX86: process.env['ProgramFiles(x86)'] || '', + downloads: knownFolder('downloads'), + desktop: knownFolder('desktop'), + music: knownFolder('music'), + documents: knownFolder('documents'), + }; + /* Ölçüm kancası: SV_LIBRARY_ROOTS (yol ayırıcısıyla birden çok) aramaya + ek kök veriyor, ilk onlara bakılıyor; yalıtılmış uçtan uca denemeler + kullanıcının kendi klasörlerine dosya koymadan sınansın diye. */ + const roots = (process.env.SV_LIBRARY_ROOTS || '').split(path.delimiter).filter(Boolean); + const r = await mdImport.discover(env, { budgetMs: 8000, roots }); + return { ok: true, complete: r.complete, libraries: r.libraries.map(keepPlan) }; +}); + +/* Aramada süresi yetmeyip eksik sayılan kütüphane: onaydan önce baştan + sona taranıyor; onay gerçek sayıları gösteriyor, eksik içe aktarım + olmuyor. Yeni plan eskisinin yerini alıyor. */ +ipcMain.handle('milkdrop:library-rescan', async (e, token) => { + const key = String(token || ''); + const old = libraryPlans.get(key); + if (!old) return { ok: false, error: 'NO_PLAN' }; + try { + const plan = await mdImport.rescan(old); + libraryPlans.delete(key); + return Object.assign({ ok: true }, keepPlan(plan)); + } catch (err) { + return { ok: false, error: (err && err.code) || 'READ_FAILED' }; + } +}); + +ipcMain.handle('milkdrop:library-import', async (e, token) => { + const plan = libraryPlans.get(String(token || '')); + if (!plan) return { ok: false, error: 'NO_PLAN' }; + if (plan.tooBig) return { ok: false, error: 'TOO_BIG' }; + const sender = e.sender; + const r = await mdImport.runImport(plan, { + existing: () => presetsStore.list(), + saveManyAsync: presetsStore.saveManyAsync, + textureDir: managedTextureDir(), + onProgress: (phase, done, total) => { + if (sender && !sender.isDestroyed()) sender.send('milkdrop:library-progress', { phase, done, total }); + }, + }); + libraryPlans.delete(String(token)); + // Yeni presetler bir kez ve yalnız onlar yayınlanıyor; sayfaya kimlik ve etiket dönüyor + if (r.presets && r.presets.length) broadcastPresetDelta(r.presets, []); + delete r.presets; + return r; +}); + // ---------------------------------------------------------------------------- // MilkDrop sprite'ları (#577): milk_img.ini // diff --git a/src/main/milkdrop-import.js b/src/main/milkdrop-import.js new file mode 100644 index 0000000..49c7ca3 --- /dev/null +++ b/src/main/milkdrop-import.js @@ -0,0 +1,529 @@ +'use strict'; +/* MILKDROP KÜTÜPHANESİ İÇE AKTARIMI (#574). + + Preset paketleri ZIP olarak dolaşıyor ve birçok kişinin makinesinde + Winamp'tan, MilkDrop 3'ten, BeatDrop'tan ya da projectM'den kalma bir + kütüphane zaten var. Bu modül ikisini de alıyor. Electron'a bağlı değil: + klasörler ve dosyalar parametre, testler gerçek dosyalarla koşuyor. + + İKİ AŞAMA. Önce TARAMA (`scanFolder`, `scanZip`, `discover`): ne + eklenecek, kaç MB, neler atlanacak — hiçbir şey kopyalanmıyor. Kullanıcı + onaylarsa ÇALIŞTIRMA (`runImport`). "Sormadan hiçbir şey kopyalanmıyor." + + NE ALINIYOR + - `.milk` dosyaları, iç içe klasörler dahil. Ad dosyanın adı. + - Dokular: `textures` ya da `sprites` adlı klasörlerdeki görseller + (MilkDrop 3 ikisini aynı sayıyor) ve bir presetin `sampler_` ile + istediği, presetlerle aynı klasörde duran görseller — MilkDrop dokuyu + bulamayınca presetin klasörüne de bakıyor. Ötekiler (önizleme ekran + görüntüleri gibi) doku sayılmıyor. + NE ALINMIYOR (sayılıp raporlanıyor) + - `.milk2` (MilkDrop 3'ün çift preseti): motor bugün okumuyor (#567). + - Sınırı aşan preset ve doku, şifreli ya da desteklenmeyen ZIP girdisi. + - Aynı ad ve aynı içerikte zaten var olan preset (tekrar). + + Klasör adları istenirse ETİKET oluyor (#576): paketin kendi kategori + klasörleri ("Fractal", "Dancer" …) — bütün presetlerin ortak ön eki + atıldıktan sonraki ilk klasör. */ +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); +const zip = require('./zip-reader'); +const tex = require('./milkdrop-textures'); + +const PRESET_MAX_BYTES = 450 * 1024; // depo sınırı 512 KB JSON; kaçışlara pay +const TEXTURE_MAX_BYTES = 8 * 1024 * 1024; // görselleştiricinin doku sınırıyla aynı +const MAX_FILES = 200000; +const MAX_DEPTH = 12; +const MAX_TOTAL_BYTES = 2 * 1024 * 1024 * 1024; +const TEX_DIRS = ['textures', 'sprites']; +/* Motorun kendi örnekleyicileri: preset bunları istese de dosya değil. */ +const BUILTIN_SAMPLERS = /^(main|fw_main|fc_main|pw_main|pc_main|noise_[lmh]q|noisevol_[lh]q|blur[123]|rand\d\d)(_|$)/i; + +const lower = (s) => String(s || '').toLowerCase(); +const extOf = (n) => path.extname(String(n || '')).toLowerCase(); +const stem = (n) => path.basename(String(n || ''), path.extname(String(n || ''))); + +/* Doku adı güvenli mi: düz bir dosya adı, görsel uzantılı. Paketten gelen + adlar güvenilmez; yalnız son parça kullanılıyor ve yine de denetleniyor. */ +function safeTextureName(n) { + const b = String(n || ''); + if (!b || b === '.' || b === '..' || /[\\/:]/.test(b) || b.length > 120) return false; + return tex.isTextureFile(b); +} + +/* Alınmayan yollar, iki tarayıcıda da aynı: macOS'un ZIP'e koyduğu + `__MACOSX/` ve `._ad.milk` (AppleDouble — preset değil, ikili veri; + alınsaydı bozuk preset olarak listede görünürdü), gizli klasörler ve + `node_modules`. */ +function skipPath(parts) { + const name = parts[parts.length - 1] || ''; + if (name.indexOf('._') === 0) return true; + for (let i = 0; i < parts.length - 1; i++) { + const d = parts[i]; + if (d === '__MACOSX' || d === 'node_modules' || d[0] === '.') return true; + } + return false; +} + +/* Parça listesi (klasörler + dosya adı) → preset kaydı. `parts` kökten + göreli yolun parçaları. */ +function classify(parts, size) { + const name = parts[parts.length - 1]; + const dirs = parts.slice(0, -1); + const ext = extOf(name); + if (ext === '.milk') return { type: 'preset', name: stem(name), dirs, size }; + if (ext === '.milk2') return { type: 'milk2' }; + if (tex.isTextureFile(name)) { + const inTexDir = dirs.some((d) => TEX_DIRS.indexOf(lower(d)) >= 0); + return { type: inTexDir ? 'texture' : 'loose', name, dirs, size }; + } + return { type: 'other' }; +} + +/* Bütün presetlerin ortak klasör ön eki atıldıktan sonra ilk klasör: + paketin kategori klasörü. Genel adlar etiket olmuyor. */ +const GENERIC = /^(presets?|milkdrop\d?|milkdrop ?presets?|resources|plugins)$/i; +function assignTags(presets) { + if (!presets.length) return; + let common = presets[0].dirs.slice(); + for (const p of presets) { + let i = 0; + while (i < common.length && i < p.dirs.length && common[i] === p.dirs[i]) i++; + common = common.slice(0, i); + if (!common.length) break; + } + for (const p of presets) { + const rest = p.dirs.slice(common.length); + const t = rest.find((d) => !GENERIC.test(d.trim())); + p.tag = t ? t.trim().slice(0, 32) : ''; + } +} + +/* `bytes` alınacak olanlar (presetler ve doku klasörlerindekiler); + presetlerin yanındaki görseller `looseBytes`: yalnız bir preset isterse + kopyalanıyor, hangisinin istendiği presetler okunmadan bilinmiyor. Birçok + pakette her presetin yanında bir önizleme görüntüsü var; bunlar "doku" + diye sayılsaydı özet olduğundan kat kat büyük görünürdü. */ +function emptyPlan(kind, source, label) { + return { + kind, source, label, + presets: [], textures: [], loose: [], + bytes: 0, looseBytes: 0, complete: true, + skipped: { milk2: 0, tooLarge: 0, encrypted: 0, unsupported: 0, textureTooLarge: 0 }, + }; +} + +function addEntry(plan, parts, size, ref) { + if (skipPath(parts)) return; + const c = classify(parts, size); + if (c.type === 'milk2') { plan.skipped.milk2++; return; } + if (c.type === 'preset') { + if (size > PRESET_MAX_BYTES) { plan.skipped.tooLarge++; return; } + plan.presets.push({ name: c.name, dirs: c.dirs, size, ref, tag: '' }); + plan.bytes += size; + } else if (c.type === 'texture' || c.type === 'loose') { + if (!safeTextureName(c.name)) return; + if (size > TEXTURE_MAX_BYTES) { plan.skipped.textureTooLarge++; return; } + if (c.type === 'texture') { + plan.textures.push({ name: c.name, dirs: c.dirs, size, ref }); + plan.bytes += size; + } else { + plan.loose.push({ name: c.name, dirs: c.dirs, size, ref }); + plan.looseBytes += size; + } + } +} + +/* KLASÖR. Eşzamansız ve sınırlı: `budget` { files, deadline }. `shallow`: + alt klasörlere inilmiyor (makinede aramada İndirilenler'in kendisinde + duran tek tek presetler — alt klasörler ayrı kütüphane). */ +async function scanFolder(dir, opts) { + const o = opts || {}; + const plan = emptyPlan('folder', dir, o.label || path.basename(dir)); + if (o.shallow) plan.shallow = true; + const budget = o.budget || { files: MAX_FILES, deadline: Infinity }; + const walk = async (d, parts, depth) => { + if (depth > MAX_DEPTH) return; + let items; + try { items = await fs.promises.readdir(d, { withFileTypes: true }); } catch { return; } + for (const it of items) { + if (budget.files <= 0 || Date.now() > budget.deadline) { plan.complete = false; return; } + budget.files--; + const full = path.join(d, it.name); + if (it.isDirectory()) { + // Alınmayacak klasöre hiç inilmiyor (bkz. `skipPath`) + if (o.shallow || skipPath(parts.concat(it.name, 'x'))) continue; + await walk(full, parts.concat(it.name), depth + 1); + } else if (it.isFile()) { + const ext = extOf(it.name); + if (ext !== '.milk' && ext !== '.milk2' && !tex.isTextureFile(it.name)) continue; + let size = 0; + try { size = (await fs.promises.stat(full)).size; } catch { continue; } + addEntry(plan, parts.concat(it.name), size, full); + } + } + }; + await walk(dir, [], 0); + finish(plan); + return plan; +} + +/* ZIP. Yalnız merkezi dizin okunuyor; içerik çalıştırmada. */ +function scanZip(file, opts) { + const o = opts || {}; + const plan = emptyPlan('zip', file, o.label || stem(file)); + const z = zip.open(file); + try { + for (const e of z.entries) { + if (e.dir) continue; + const parts = e.name.split(/[\\/]+/).filter((s) => s && s !== '.' && s !== '..'); + if (!parts.length || skipPath(parts)) continue; + const c = classify(parts, e.size); + if (c.type === 'other') continue; + if (e.encrypted) { plan.skipped.encrypted++; continue; } + if (e.method !== 0 && e.method !== 8) { plan.skipped.unsupported++; continue; } + addEntry(plan, parts, e.size, e.index); + } + } finally { + z.close(); + } + finish(plan); + return plan; +} + +function finish(plan) { + assignTags(plan.presets); + if (plan.bytes > MAX_TOTAL_BYTES) plan.tooBig = true; +} + +/* Özet: sayfaya giden; yollar ve dosya listeleri ana süreçte kalıyor. + `textures` kesin alınacaklar, `loose` presetlerin yanındaki görseller + (yalnız istenenleri). `searchCut`: makinede aramanın süresi yetmedi, + sayılar eksik — içe aktarmadan önce `rescan`. */ +function summary(plan) { + return { + kind: plan.kind, + label: plan.label, + presets: plan.presets.length, + textures: plan.textures.length, + loose: plan.loose.length, + bytes: plan.bytes, + complete: plan.complete, + searchCut: !!plan.searchCut, + tooBig: !!plan.tooBig, + skipped: Object.assign({}, plan.skipped), + tags: Array.from(new Set(plan.presets.map((p) => p.tag).filter(Boolean))).length, + }; +} + +/* Aynı kaynağın baştan sona taraması: aramada süresi yetmeyen kütüphane + onaydan önce bununla sayılıyor. Aramanın bütçesi yok; yalnız her taramanın + kendi sınırları (MAX_FILES). */ +async function rescan(plan) { + if (plan.kind === 'zip') return scanZip(plan.source, { label: plan.label }); + return scanFolder(plan.source, { label: plan.label, shallow: !!plan.shallow }); +} + +/* Preset metni: önce katı UTF-8, olmazsa Latin-1 (MilkDrop dosyaları + çoğunlukla ANSI). Kod ASCII; yalnız yorumlar ve adlar etkileniyor. */ +const UTF8 = new TextDecoder('utf-8', { fatal: true }); +function decodeText(buf) { + try { return UTF8.decode(buf); } catch { return buf.toString('latin1'); } +} + +// Ad ile içerik özeti arasında dosya adında olamayacak bir ayırıcı +const SEP = String.fromCharCode(0); +const keyOf = (name, source) => name + SEP + crypto.createHash('sha1').update(source).digest('hex'); + +/* Presetin istediği dosya dokuları: `sampler_worms` → "worms". */ +function samplerNames(source) { + const out = new Set(); + const re = /\bsampler_([A-Za-z0-9_]+)/g; + let m; + while ((m = re.exec(source))) { + const n = m[1]; + if (!BUILTIN_SAMPLERS.test(n)) out.add(lower(n)); + } + return out; +} + +/* ÇALIŞTIRMA. `deps`: + existing() — depodaki presetler (tekrar denetimi için) + saveManyAsync(list, onProgress) — depoya parça parça kayıt + textureDir — dokuların kopyalanacağı klasör (yönetilen) + onProgress(phase, done, total) + newId() — preset kimliği + Dönüş: eklenen, atlanan ve başarısız sayıları; kaydedilenlerin kimliği ve + etiketi (etiketleri ayara panel yazıyor). */ +async function runImport(plan, deps) { + const d = deps || {}; + const prog = typeof d.onProgress === 'function' ? d.onProgress : () => {}; + const result = { + ok: true, added: 0, duplicates: 0, failed: 0, + textures: { copied: 0, same: 0, conflicts: 0, failed: 0 }, + skipped: Object.assign({}, plan.skipped), saved: [], + }; + const seen = new Set(); + for (const p of (typeof d.existing === 'function' ? d.existing() : []) || []) { + if (p && p.kind === 'milkdrop' && typeof p.source === 'string') seen.add(keyOf(p.name || '', p.source)); + } + let z = null; + const readRef = (ref, max) => { + if (plan.kind === 'zip') { + if (!z) z = zip.open(plan.source); + return z.read(z.entries[ref], max); + } + const st = fs.statSync(ref); + if (st.size > max) throw new Error('TOO_LARGE'); + return fs.readFileSync(ref); + }; + try { + // 1) Presetler: oku, tekrarı ayıkla, parça parça kaydet + const items = []; + const tagOf = new Map(); + const wanted = new Set(); + const total = plan.presets.length; + for (let i = 0; i < total; i++) { + const it = plan.presets[i]; + let source; + try { source = decodeText(readRef(it.ref, PRESET_MAX_BYTES)); } catch { result.failed++; continue; } + /* İstenen dokular TEKRAR olan presetlerden de: kullanıcı dokuyu + silmişse aynı paketi yeniden almak onu geri getirmeli. */ + for (const s of samplerNames(source)) wanted.add(s); + const k = keyOf(it.name, source); + if (seen.has(k)) { result.duplicates++; continue; } + seen.add(k); + const id = typeof d.newId === 'function' ? d.newId() : 'md_' + crypto.randomBytes(6).toString('hex'); + items.push({ id, kind: 'milkdrop', name: it.name, source }); + if (it.tag) tagOf.set(id, it.tag); + if (i % 200 === 199) { prog('read', i + 1, total); await new Promise((r) => setImmediate(r)); } + } + prog('read', total, total); + const saved = typeof d.saveManyAsync === 'function' + ? await d.saveManyAsync(items, (done, all) => prog('save', done, all)) + : []; + result.added = saved.length; + result.failed += items.length - saved.length; + result.saved = saved.map((p) => ({ id: p.id, tag: tagOf.get(p.id) || '' })); + // Ana süreç yayın için alıyor; sayfaya dönmeden siliniyor (kaynaklar ağır) + result.presets = saved; + // 2) Dokular: klasördekiler hep, presetin yanındakiler yalnız istenirse + const texList = plan.textures.concat(plan.loose.filter((t) => wanted.has(lower(stem(t.name))))); + if (texList.length && d.textureDir) { + fs.mkdirSync(d.textureDir, { recursive: true }); + for (let i = 0; i < texList.length; i++) { + const t = texList[i]; + const dest = path.join(d.textureDir, t.name); + try { + const buf = readRef(t.ref, TEXTURE_MAX_BYTES); + if (fs.existsSync(dest)) { + const cur = fs.readFileSync(dest); + if (cur.equals(buf)) result.textures.same++; + else result.textures.conflicts++; // var olan kalıyor + } else { + await fs.promises.writeFile(dest, buf); + result.textures.copied++; + } + } catch { result.textures.failed++; } + if (i % 50 === 49) { prog('textures', i + 1, texList.length); await new Promise((r) => setImmediate(r)); } + } + prog('textures', texList.length, texList.length); + } + } finally { + if (z) z.close(); + } + return result; +} + +/* MAKİNEDE ARAMA. Her aday bir kütüphane adayı: bilinen kurulum klasörleri + (Winamp, foobar2000, projectM) ve taşınabilir kurulumların açıldığı + kullanıcı klasörlerinin (İndirilenler, Masaüstü, Müzik, Belgeler) her alt + klasörü ve yanlarındaki her ZIP. Kullanıcı klasörünün kendisinde tek tek + duran presetler de bir aday; onun alt klasörlerine inilmiyor (onlar zaten + ayrı aday). + + İKİ ADIM, ikisi de süre bütçesiyle (`budgetMs`; bulma en çok %60'ını + kullanıyor, kalanı saymaya): + 1) BULMA (`findLibraries`) — genişlik öncelikli: bütün adayların ilk + düzeyi, sonra ikincisi… Derinlik öncelikli tarama bütçeyi ilk dev + klasöre (oyun, proje, fotoğraf arşivi) harcıyor ve yanındaki küçük + pakete hiç varamıyordu; şimdi sığ duran bir kütüphane, komşusunun + dibine inilmeden bulunuyor. İçinde bir `.milk` görülen aday kütüphane, + altına artık inilmiyor. ZIP'in yalnız merkezi dizini okunuyor, sayımı da + o veriyor; ZIP'ler arasında süre denetleniyor ve olay döngüsüne yol + veriliyor (ana süreç yayını ve ışıkları da sürüyor). + 2) SAYMA (`countLibraries`) — bulunan klasörler baştan sona taranıyor, + kalan süre aralarında paylaşılıyor; erken biten payını sonrakine + bırakıyor. Süresi yetmeyen `searchCut`: sayılar eksik, panel içe + aktarmadan önce `rescan` istiyor. Hiç süre kalmadıysa sayılmadan + listeleniyor: bulunan kütüphane listeden düşmüyor, süre de aşılmıyor. + Sıra: açıkça verilen kökler (`opts.roots`), bilinen kurulum klasörleri, + sonra İndirilenler, Masaüstü, Müzik, Belgeler — paketin en çok indiği + yerden en kalabalık olana. Kullanıcı klasörlerinin gerçek yeri `env`'den: + Windows'ta OneDrive onları taşıyabiliyor, Linux'ta adları yerel dilde + (~/İndirilenler); verilmezse ev klasörü + İngilizce ad. + Sonuç `{ libraries, complete }`: bulma adımı bütçeye takılıp bütün + adaylara bakamadıysa `complete: false`. + `env`: { platform, home, appData, programFiles, programFilesX86, + downloads, desktop, music, documents }. */ +function candidates(env) { + const e = env || {}; + const out = []; + const add = (label, dir) => { if (dir) out.push({ label, dir }); }; + if (e.platform === 'win32') { + for (const pf of [e.programFiles, e.programFilesX86]) { + if (!pf) continue; + add('Winamp (MilkDrop 2)', path.join(pf, 'Winamp', 'Plugins', 'Milkdrop2')); + add('projectM', path.join(pf, 'projectM', 'presets')); + } + if (e.appData) { + add('foobar2000 (MilkDrop 2)', path.join(e.appData, 'foobar2000-v2', 'milkdrop2')); + add('foobar2000 (MilkDrop 2)', path.join(e.appData, 'foobar2000', 'milkdrop2')); + } + } else if (e.platform === 'darwin') { + add('projectM', '/Applications/projectM.app/Contents/Resources/presets'); + if (e.home) add('projectM', path.join(e.home, 'Library', 'Application Support', 'projectM', 'presets')); + } else { + add('projectM', '/usr/share/projectM/presets'); + add('projectM', '/usr/local/share/projectM/presets'); + if (e.home) { + add('projectM', path.join(e.home, '.projectM', 'presets')); + add('projectM', path.join(e.home, '.local', 'share', 'projectM', 'presets')); + } + } + return out; +} + +// Kullanıcı klasörleri, paketin en çok indiği yerden en kalabalık olana +const HOME_ROOTS = [['downloads', 'Downloads'], ['desktop', 'Desktop'], ['music', 'Music'], ['documents', 'Documents']]; +function homeRoots(env) { + const e = env || {}; + const out = []; + for (const [key, name] of HOME_ROOTS) { + const dir = e[key] || (e.home ? path.join(e.home, name) : ''); + if (dir) out.push(dir); + } + return out; +} + +const FIND_SHARE = 0.6; +/* Adayın İÇİNDE inilmeyen klasörler, yalnız ada bakılarak; adayın kendi + yolu denetlenmiyor (~/.projectM/presets gizli bir klasörün altında). */ +const skipDirName = (n) => n === '__MACOSX' || n === 'node_modules' || n[0] === '.'; +const isPresetFile = (n) => extOf(n) === '.milk' && n.indexOf('._') !== 0; +const tick = () => new Promise((r) => setImmediate(r)); + +async function isDir(p) { + try { return (await fs.promises.stat(p)).isDirectory(); } catch { return false; } +} + +/* 1) BULMA. Dönüş `{ found, complete }`; `found` sırayla { label, dir, + shallow?, plan? } — ZIP'lerin planı bulunurken hazır. `maxFiles` bakılan + girdi sayısını sınırlıyor (testler süreye değil buna dayanıyor). */ +async function findLibraries(env, opts) { + const e = env || {}; + const o = opts || {}; + const until = Date.now() + Math.round((o.budgetMs || 8000) * FIND_SHARE); + let files = o.maxFiles || MAX_FILES; + let complete = true; + const over = () => { + if (files > 0 && Date.now() <= until) return false; + complete = false; + return true; + }; + const tops = []; + const seen = new Set(); + const add = (t) => { + const key = lower(path.resolve(t.dir)) + (t.shallow ? '|kendisi' : ''); + if (seen.has(key)) return; + seen.add(key); + tops.push(t); + }; + // Kök: alt klasörleri ve ZIP'leri aday; kendisinde preset varsa o da + const expand = async (root) => { + let items; + try { items = await fs.promises.readdir(root, { withFileTypes: true }); } catch { return; } + files -= items.length; + let loose = false; + for (const it of items) { + if (it.isDirectory()) { + if (!skipDirName(it.name)) add({ label: it.name, dir: path.join(root, it.name) }); + } else if (it.isFile()) { + if (extOf(it.name) === '.zip') add({ label: it.name, dir: path.join(root, it.name), zip: true }); + else if (isPresetFile(it.name)) loose = true; + } + } + if (loose) add({ label: path.basename(root), dir: root, shallow: true, found: true }); + }; + for (const r of o.roots || []) { if (over()) break; await expand(r); } + for (const c of candidates(e)) if (await isDir(c.dir)) add({ label: c.label, dir: c.dir }); + for (const r of homeRoots(e)) { if (over()) break; await expand(r); } + + // Genişlik öncelikli: `level` bu derinlikte bakılacak klasörler, aday sırasıyla + let level = tops.filter((t) => !t.found).map((t) => ({ top: t, dir: t.dir })); + for (let depth = 0; level.length && complete; depth++) { + const next = []; + for (const q of level) { + if (q.top.found) continue; + if (over()) break; + if (q.top.zip) { + try { + const plan = scanZip(q.dir, { label: q.top.label }); + if (plan.presets.length) { q.top.found = true; q.top.plan = plan; } + } catch { /* ZIP değil ya da bozuk: listelenmiyor */ } + files--; + await tick(); + continue; + } + let items; + try { items = await fs.promises.readdir(q.dir, { withFileTypes: true }); } catch { continue; } + files -= items.length; + for (const it of items) { + if (it.isFile() && isPresetFile(it.name)) { q.top.found = true; break; } + if (it.isDirectory() && depth < MAX_DEPTH && !skipDirName(it.name)) next.push({ top: q.top, dir: path.join(q.dir, it.name) }); + } + } + level = next; + } + return { found: tops.filter((t) => t.found), complete }; +} + +/* 2) SAYMA. Bulunan klasörler `deadline`a kadar, kalan süre aralarında + paylaşılarak; ZIP'ler zaten sayılı. Süresi yetmeyen ya da hiç süre + kalmayan `searchCut` diyor ve yine listeleniyor. */ +async function countLibraries(found, deadline) { + const out = []; + let left = found.filter((t) => !t.plan).length; + for (const t of found) { + let plan = t.plan; + if (!plan) { + const ms = deadline - Date.now(); + if (ms > 0) { + const budget = { files: MAX_FILES, deadline: Date.now() + Math.max(1, Math.floor(ms / left)) }; + plan = await scanFolder(t.dir, { label: t.label, shallow: t.shallow, budget }); + } else { + plan = emptyPlan('folder', t.dir, t.label); + if (t.shallow) plan.shallow = true; + plan.complete = false; + } + left--; + if (!plan.complete) plan.searchCut = true; + } + if (plan.presets.length || plan.searchCut) out.push(plan); + } + return out; +} + +async function discover(env, opts) { + const o = opts || {}; + const deadline = Date.now() + (o.budgetMs || 8000); + const r = await findLibraries(env, o); + return { libraries: await countLibraries(r.found, deadline), complete: r.complete }; +} + +module.exports = { + PRESET_MAX_BYTES, TEXTURE_MAX_BYTES, MAX_FILES, MAX_TOTAL_BYTES, + scanFolder, scanZip, rescan, runImport, summary, discover, findLibraries, countLibraries, + candidates, homeRoots, samplerNames, decodeText, assignTags, +}; diff --git a/src/main/milkdrop-textures.js b/src/main/milkdrop-textures.js index 4bf2379..afc886f 100644 --- a/src/main/milkdrop-textures.js +++ b/src/main/milkdrop-textures.js @@ -87,6 +87,35 @@ function textureFileInfo(dir, name, maxBytes) { } } +/* BİRDEN ÇOK KLASÖR (#574). Kullanıcının seçtiği doku klasörü ve içe + aktarılan paketlerin dokularının kopyalandığı, uygulamaya ait klasör. + Aynı ad iki yerde varsa kullanıcınınki geçerli: sırayla bakılıyor. + Adlar birleşiyor, her ad bir kez. */ +function listTexturesIn(dirs) { + const names = []; + const seen = new Set(); + for (const d of Array.isArray(dirs) ? dirs : []) { + if (!d) continue; + for (const n of listTextures(d).names) { + const k = n.toLowerCase(); + if (seen.has(k)) continue; + seen.add(k); + names.push(n); + } + } + return { dir: (Array.isArray(dirs) && dirs[0]) || '', names }; +} + +function textureFileInfoIn(dirs, name, maxBytes) { + for (const d of Array.isArray(dirs) ? dirs : []) { + if (!d) continue; + const t = textureFileInfo(d, name, maxBytes); + if (t) return t; + } + return null; +} + module.exports = { TEXTURE_EXT, TEXTURE_MIME, isTextureFile, mimeFor, resolveTexture, listTextures, textureFileInfo, + listTexturesIn, textureFileInfoIn, }; diff --git a/src/main/preload-admin.js b/src/main/preload-admin.js index d965248..ab13565 100644 --- a/src/main/preload-admin.js +++ b/src/main/preload-admin.js @@ -110,6 +110,13 @@ contextBridge.exposeInMainWorld('api', { // Değişiklik yayını ve toplu kaydın ilerlemesi (#574) onPresetsDelta: (cb) => ipcRenderer.on('presets-delta', (e, d) => cb(d)), onPresetsProgress: (cb) => ipcRenderer.on('presets-progress', (e, p) => cb(p)), + /* MilkDrop kütüphanesi (#574): tarama bir özet ve kimlik döndürüyor, + içe aktarım aynı kimlikle; yollar ve dosya listeleri ana süreçte. */ + pickMilkdropLibrary: (kind) => ipcRenderer.invoke('milkdrop:library-pick', kind), + discoverMilkdropLibraries: () => ipcRenderer.invoke('milkdrop:library-discover'), + rescanMilkdropLibrary: (token) => ipcRenderer.invoke('milkdrop:library-rescan', token), + importMilkdropLibrary: (token) => ipcRenderer.invoke('milkdrop:library-import', token), + onMilkdropLibraryProgress: (cb) => ipcRenderer.on('milkdrop:library-progress', (e, p) => cb(p)), // Yayın çıkışı (OBS tarayıcı kaynağı + mobil kumanda) streamStatus: () => ipcRenderer.invoke('stream:status'), diff --git a/src/main/zip-reader.js b/src/main/zip-reader.js new file mode 100644 index 0000000..d43abe0 --- /dev/null +++ b/src/main/zip-reader.js @@ -0,0 +1,207 @@ +'use strict'; +/* ZIP OKUYUCU (#574) — preset paketleri için, bağımlılıksız. + + MilkDrop preset paketleri ZIP olarak dolaşıyor. Bu okuyucu yalnız ihtiyaç + duyulanı yapıyor: arşivin MERKEZİ DİZİNİNİ okuyor (yerel başlıklar akışla + yazılmış arşivlerde boyutları sıfır taşıyor, güvenilmez) ve istenen + girdiyi açıyor. Arşiv belleğe bütün alınmıyor; girdiler dosyadaki + konumlarından okunuyor. + + Desteklenen: saklanmış (0) ve deflate (8) girdiler, ZIP64 (65.535'ten + fazla girdi ya da 4 GB'tan büyük arşiv — 73 binlik preset derlemeleri + var), UTF-8 ve CP437 adlar. Desteklenmeyen her durum kendi adıyla bir + hata: şifreli girdi, başka sıkıştırma yöntemi, bozuk arşiv. + + Girdi ADLARI güvenilmez. Bu modül adları yol olarak hiçbir yere yazmıyor; + çağıran yalnız adın son parçasını (dosya adı) ve klasör adlarını etiket + olarak kullanıyor. + + Güvenlik sınırları: + - girdi sayısı (`MAX_ENTRIES`), + - açılmış boyut, çağıranın verdiği sınırla; açma `maxOutputLength` ile + BİLDİRİLEN boyuta kısılıyor — başlığı yalan söyleyen bir girdi o + boyutun ötesine açılamıyor, + - sıkıştırma oranı (`MAX_RATIO`): 1 MB'ı aşan ve bundan daha fazla + sıkışmış bir girdi bomba sayılıyor, + - CRC32 denetimi. */ +const fs = require('fs'); +const zlib = require('zlib'); + +const MAX_ENTRIES = 200000; +const MAX_RATIO = 200; +const EOCD_SIG = 0x06054b50; +const EOCD64_SIG = 0x06064b50; +const EOCD64_LOC_SIG = 0x07064b50; +const CDH_SIG = 0x02014b50; +const LFH_SIG = 0x04034b50; + +class ZipError extends Error { + constructor(code, detail) { + super(code + (detail ? ': ' + detail : '')); + this.code = code; + } +} + +// CRC32 (IEEE). Node'un `zlib.crc32`si 20.15 ve 22.2'de geldi; CI 20'de de koşuyor. +const CRC_TABLE = (() => { + const t = new Uint32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + t[n] = c >>> 0; + } + return t; +})(); +function crc32(buf) { + let c = 0xffffffff; + for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8); + return (c ^ 0xffffffff) >>> 0; +} + +// CP437: bayrak 11 kapalıyken adlar bu kod sayfasında (DOS'un ve eski araçların) +const CP437_HIGH = 'ÇüéâäàåçêëèïîìÄÅÉæÆôöòûùÿÖÜ¢£¥₧ƒáíóúñѪº¿⌐¬½¼¡«»░▒▓│┤╡╢╖╕╣║╗╝╜╛┐└┴┬├─┼╞╟╚╔╩╦╠═╬╧╨╤╥╙╘╒╓╫╪┘┌█▄▌▐▀αßΓπΣσµτΦΘΩδ∞φε∩≡±≥≤⌠⌡÷≈°∙·√ⁿ²■ '; +function decodeName(buf, utf8) { + if (utf8) return buf.toString('utf8'); + let s = ''; + for (let i = 0; i < buf.length; i++) { + const b = buf[i]; + s += b < 0x80 ? String.fromCharCode(b) : CP437_HIGH[b - 0x80]; + } + return s; +} + +function readAt(fd, pos, len) { + const buf = Buffer.alloc(len); + let off = 0; + while (off < len) { + const n = fs.readSync(fd, buf, off, len - off, pos + off); + if (n <= 0) throw new ZipError('CORRUPT', 'kısa okuma'); + off += n; + } + return buf; +} + +/* Arşivi aç ve merkezi dizini oku. Dönüş: { entries, close, read }. + `entries[i]`: { name, dir, method, flags, encrypted, compSize, size, + crc, offset, index }. */ +function open(file) { + const fd = fs.openSync(file, 'r'); + try { + const st = fs.fstatSync(fd); + const total = st.size; + if (total < 22) throw new ZipError('NOT_ZIP'); + // Arşiv sonu kaydı: sondaki yorum en çok 65.535 bayt + const tailLen = Math.min(total, 22 + 65535); + const tail = readAt(fd, total - tailLen, tailLen); + let eocd = -1; + for (let i = tail.length - 22; i >= 0; i--) { + if (tail.readUInt32LE(i) === EOCD_SIG) { eocd = i; break; } + } + if (eocd < 0) throw new ZipError('NOT_ZIP'); + let count = tail.readUInt16LE(eocd + 10); + let cdSize = tail.readUInt32LE(eocd + 12); + let cdOffset = tail.readUInt32LE(eocd + 16); + if (count === 0xffff || cdSize === 0xffffffff || cdOffset === 0xffffffff) { + // ZIP64: konum kaydı arşiv sonu kaydının hemen önünde + const locPos = total - tailLen + eocd - 20; + if (locPos < 0) throw new ZipError('CORRUPT', 'zip64 konum kaydı yok'); + const loc = readAt(fd, locPos, 20); + if (loc.readUInt32LE(0) !== EOCD64_LOC_SIG) throw new ZipError('CORRUPT', 'zip64 konum kaydı yok'); + const recPos = Number(loc.readBigUInt64LE(8)); + if (recPos < 0 || recPos + 56 > total) throw new ZipError('CORRUPT', 'zip64 kaydı dışarıda'); + const rec = readAt(fd, recPos, 56); + if (rec.readUInt32LE(0) !== EOCD64_SIG) throw new ZipError('CORRUPT', 'zip64 kaydı yok'); + count = Number(rec.readBigUInt64LE(32)); + cdSize = Number(rec.readBigUInt64LE(40)); + cdOffset = Number(rec.readBigUInt64LE(48)); + } + if (count > MAX_ENTRIES) throw new ZipError('TOO_MANY_ENTRIES', String(count)); + if (cdOffset + cdSize > total) throw new ZipError('CORRUPT', 'merkezi dizin dışarıda'); + const cd = readAt(fd, cdOffset, cdSize); + const entries = []; + let p = 0; + for (let i = 0; i < count; i++) { + if (p + 46 > cd.length || cd.readUInt32LE(p) !== CDH_SIG) throw new ZipError('CORRUPT', 'merkezi dizin girdisi'); + const flags = cd.readUInt16LE(p + 8); + const method = cd.readUInt16LE(p + 10); + const crc = cd.readUInt32LE(p + 16); + let compSize = cd.readUInt32LE(p + 20); + let size = cd.readUInt32LE(p + 24); + const nameLen = cd.readUInt16LE(p + 28); + const extraLen = cd.readUInt16LE(p + 30); + const commentLen = cd.readUInt16LE(p + 32); + let offset = cd.readUInt32LE(p + 42); + const end = p + 46 + nameLen + extraLen + commentLen; + if (end > cd.length) throw new ZipError('CORRUPT', 'merkezi dizin girdisi'); + const name = decodeName(cd.subarray(p + 46, p + 46 + nameLen), (flags & 0x800) !== 0); + // ZIP64 ek alanı: 0xFFFFFFFF olan alanlar sırayla burada + let e = p + 46 + nameLen; + const eEnd = e + extraLen; + while (e + 4 <= eEnd) { + const id = cd.readUInt16LE(e); + const len = cd.readUInt16LE(e + 2); + if (id === 0x0001) { + let q = e + 4; + const qEnd = Math.min(eEnd, q + len); + if (size === 0xffffffff && q + 8 <= qEnd) { size = Number(cd.readBigUInt64LE(q)); q += 8; } + if (compSize === 0xffffffff && q + 8 <= qEnd) { compSize = Number(cd.readBigUInt64LE(q)); q += 8; } + if (offset === 0xffffffff && q + 8 <= qEnd) { offset = Number(cd.readBigUInt64LE(q)); q += 8; } + } + e += 4 + len; + } + entries.push({ + index: i, name, dir: /\/$/.test(name), method, flags, encrypted: (flags & 1) !== 0, + compSize, size, crc, offset, + }); + p = end; + } + let closed = false; + return { + entries, + total, + /* Girdinin içeriği. `maxBytes` açılmış boyutun üst sınırı. */ + read(entry, maxBytes) { + if (closed) throw new ZipError('CLOSED'); + if (entry.dir) throw new ZipError('IS_DIR'); + if (entry.encrypted) throw new ZipError('ENCRYPTED', entry.name); + if (entry.method !== 0 && entry.method !== 8) throw new ZipError('UNSUPPORTED_METHOD', String(entry.method)); + if (typeof maxBytes === 'number' && entry.size > maxBytes) throw new ZipError('TOO_LARGE', entry.name); + if (entry.size > 1024 * 1024 && entry.compSize > 0 && entry.size / entry.compSize > MAX_RATIO) { + throw new ZipError('SUSPICIOUS_RATIO', entry.name); + } + if (entry.offset + 30 > total) throw new ZipError('CORRUPT', 'yerel başlık dışarıda'); + const lh = readAt(fd, entry.offset, 30); + if (lh.readUInt32LE(0) !== LFH_SIG) throw new ZipError('CORRUPT', 'yerel başlık'); + const start = entry.offset + 30 + lh.readUInt16LE(26) + lh.readUInt16LE(28); + if (start + entry.compSize > total) throw new ZipError('CORRUPT', 'girdi dışarıda'); + const raw = readAt(fd, start, entry.compSize); + let out; + if (entry.method === 0) { + if (entry.compSize !== entry.size) throw new ZipError('CORRUPT', 'saklanmış boyut'); + out = raw; + } else { + try { + // Bildirilen boyutun ötesine açılamıyor: yalan söyleyen başlık hata veriyor + out = zlib.inflateRawSync(raw, { maxOutputLength: Math.max(1, entry.size) }); + } catch (err) { + throw new ZipError('CORRUPT', 'açılamadı'); + } + if (out.length !== entry.size) throw new ZipError('CORRUPT', 'boyut uyuşmuyor'); + } + if (crc32(out) !== entry.crc) throw new ZipError('CRC', entry.name); + return out; + }, + close() { + if (closed) return; + closed = true; + try { fs.closeSync(fd); } catch { /* kapanmış */ } + }, + }; + } catch (err) { + try { fs.closeSync(fd); } catch { /* kapanmış */ } + if (err instanceof ZipError) throw err; + throw new ZipError('READ_FAILED', err && err.message); + } +} + +module.exports = { open, crc32, decodeName, ZipError, MAX_ENTRIES, MAX_RATIO }; diff --git a/src/shared/defaults.js b/src/shared/defaults.js index 1166c3c..8834d3b 100644 --- a/src/shared/defaults.js +++ b/src/shared/defaults.js @@ -755,6 +755,10 @@ ratings: {}, favorites: {}, tags: {}, + /* İçe aktarılan paketlerin dokuları (#574) uygulamanın klasörüne + kopyalanıyor; her içe aktarım bu sayacı artırıyor ve motorlar doku + listesini yeniden istiyor. */ + textureRev: 0, }, milkdropControl: { locked: false, @@ -762,6 +766,8 @@ independent: false, spriteFile: '', reduceMotion: 'system', + // İçe aktarımda paketin kategori klasörü etiket olsun (#574, #576) + importFolderTags: true, }, // ------------------------------------------------------------------ diff --git a/src/shared/i18n.js b/src/shared/i18n.js index 26a1d99..4ac2d61 100644 --- a/src/shared/i18n.js +++ b/src/shared/i18n.js @@ -1670,6 +1670,57 @@ 'preset pakete yazıldı.': 'presets written to the pack.', 'Paket okunamadı (.svpack ya da .svpreset bekleniyordu).': 'The pack could not be read (.svpack or .svpreset expected).', 'preset içe aktarıldı.': 'presets imported.', + // ---- Kütüphane içe aktarımı: ZIP, klasör, makinede arama (#574) ---- + '🗜 ZIP Paketinden İçe Aktar': '🗜 Import from ZIP Pack', + '📁 Klasörden İçe Aktar': '📁 Import from Folder', + '🔎 Makinede Ara': '🔎 Search This Computer', + 'Bilinen kurulum klasörlerinde ve Masaüstü, İndirilenler, Belgeler, Müzik klasörlerinde MilkDrop kütüphanesi arar': + 'Looks for MilkDrop libraries in the usual install folders and in Desktop, Downloads, Documents and Music', + 'Klasör Adları': 'Folder Names', + 'Etiket yap': 'Make tags', + 'Etiket yapma': 'Don’t make tags', + 'Bulunan Kütüphaneler': 'Libraries Found', + 'İçe Aktar': 'Import', + 'Taranıyor…': 'Scanning…', + 'Makinede aranıyor…': 'Searching this computer…', + 'İçe aktarılıyor…': 'Importing…', + 'Okunuyor: {a}/{b}': 'Reading: {a}/{b}', + 'Kaydediliyor: {a}/{b}': 'Saving: {a}/{b}', + 'Dokular: {a}/{b}': 'Textures: {a}/{b}', + '“{label}”: {p} preset, {t} doku, {mb} MB.': '“{label}”: {p} presets, {t} textures, {mb} MB.', + '{n} .milk2 dosyası (MilkDrop 3 çift preseti) desteklenmiyor, atlanacak.': + '{n} .milk2 files (MilkDrop 3 double presets) are not supported and will be skipped.', + '{n} dosya boyut sınırını aşıyor, atlanacak.': '{n} files exceed the size limit and will be skipped.', + '{n} şifreli ya da desteklenmeyen ZIP girdisi atlanacak.': '{n} encrypted or unsupported ZIP entries will be skipped.', + 'Tarama yarıda kesildi: çok fazla dosya var; bulunanlar alınır.': 'The scan stopped early: too many files; what was found will be imported.', + '{n} klasör adı etiket olacak.': '{n} folder names will become tags.', + 'Aynı ad ve içerikteki presetler atlanır; dosyalar uygulamanın klasörüne kopyalanır. Devam edilsin mi?': + 'Presets with the same name and content are skipped; files are copied into the app’s own folder. Continue?', + '{a} preset eklendi, {d} tekrar atlandı.': '{a} presets added, {d} duplicates skipped.', + '{f} preset okunamadı ya da kaydedilemedi.': '{f} presets could not be read or saved.', + '{c} doku kopyalandı.': '{c} textures copied.', + '{s} doku zaten vardı.': '{s} textures were already there.', + '{k} doku adı var olan başka bir dokuyla çakıştı; var olan kaldı.': + '{k} texture names clashed with a different existing texture; the existing one was kept.', + '{n} .milk2 dosyası atlandı (MilkDrop 3 çift preseti).': '{n} .milk2 files were skipped (MilkDrop 3 double presets).', + 'Bu kaynakta MilkDrop preseti yok.': 'There are no MilkDrop presets in this source.', + 'ZIP okunamadı: bozuk ya da ZIP değil.': 'The ZIP could not be read: it is damaged or not a ZIP.', + 'ZIP çok fazla girdi içeriyor.': 'The ZIP has too many entries.', + 'Kaynak çok büyük (2 GB üstü).': 'The source is too large (over 2 GB).', + 'Bu tarama artık geçerli değil; yeniden tarayın.': 'This scan is no longer valid; scan again.', + 'İçe aktarılamadı.': 'Could not import.', + 'Bilinen kurulum klasörlerinde ve Masaüstü, İndirilenler, Belgeler, Müzik klasörlerinde MilkDrop kütüphanesi bulunamadı.': + 'No MilkDrop library was found in the usual install folders or in Desktop, Downloads, Documents and Music.', + '{p} preset · {t} doku · {mb} MB': '{p} presets · {t} textures · {mb} MB', + '{p}+ preset · {t}+ doku · {mb}+ MB': '{p}+ presets · {t}+ textures · {mb}+ MB', + 'sayılmadı': 'not counted', + 'Aramanın süresi bazı kütüphaneleri saymaya yetmedi (+ ya da “sayılmadı”); içe aktarmadan önce tamamı taranır.': + 'The search ran out of time before it could count some libraries (+ or “not counted”); they are scanned in full before importing.', + 'Arama süre sınırına ulaştı, bazı klasörlere bakılamadı. Kütüphaneniz listede yoksa 📁 Klasörden İçe Aktar ile seçin.': + 'The search reached its time limit and some folders were not checked. If your library is not listed, choose it with 📁 Import from Folder.', + 'Presetlerin yanındaki {n} görselden yalnız presetlerin istediği kopyalanır.': 'Of the {n} images next to the presets, only the ones the presets ask for are copied.', + 'ZIP paketi, bir klasör ya da makinede bulunan bir kütüphane: önce ne ekleneceği gösterilir, onaylamadan hiçbir şey kopyalanmaz. İç içe klasörler ve dokular dahil; aynı ad ve içerikteki presetler atlanır. Dokular uygulamanın kendi klasörüne gider ve seçtiğiniz doku klasöründen sonra aranır. Uygulamayla hiçbir preset paketi gelmez. .milk2 (MilkDrop 3 çift preseti) henüz desteklenmiyor.': + 'A ZIP pack, a folder or a library found on this computer: what will be added is shown first, and nothing is copied until you confirm. Nested folders and textures are included; presets with the same name and content are skipped. Textures go into the app’s own folder and are looked up after the texture folder you chose. No preset pack ships with the app. .milk2 (MilkDrop 3 double presets) is not supported yet.', 'Sil': 'Delete', diff --git a/src/shared/milkdrop-library.js b/src/shared/milkdrop-library.js index 30a7102..da057dc 100644 --- a/src/shared/milkdrop-library.js +++ b/src/shared/milkdrop-library.js @@ -116,6 +116,19 @@ return all; } + /* Toplu etiket (#574, içe aktarımda klasör adları): her `{ id, tag }` için + etiket ekleniyor, eşlem BİR kez kopyalanıyor — 10 bin presette tek tek + `withTags` eşlemi 10 bin kez kopyalardı. */ + function addTags(lib, pairs) { + const all = Object.assign({}, lib && lib.tags); + for (const pr of Array.isArray(pairs) ? pairs : []) { + if (!pr || !safeKey(pr.id) || !pr.tag) continue; + const t = normTags((Array.isArray(all[pr.id]) ? all[pr.id] : []).concat([pr.tag])); + if (t.length) all[pr.id] = t; + } + return all; + } + /* Silinen presetin izi: puan, favori ve etiketler birlikte gidiyor. */ function forget(lib, id) { const drop = (o) => { @@ -287,7 +300,7 @@ } const api = { - MAX_TAGS, MAX_TAG_LEN, fold, authorsOf, normTags, isFavorite, tagsOf, withFavorite, withTags, forget, + MAX_TAGS, MAX_TAG_LEN, fold, authorsOf, normTags, isFavorite, tagsOf, withFavorite, withTags, addTags, forget, parseQuery, matches, filter, poolOf, pool, tagCounts, authorCounts, packEntry, mergeEntries, adopt, }; if (typeof module !== 'undefined' && module.exports) module.exports = api; diff --git a/src/visualizer/modes/milkdrop.js b/src/visualizer/modes/milkdrop.js index 35e463b..464d6a4 100644 --- a/src/visualizer/modes/milkdrop.js +++ b/src/visualizer/modes/milkdrop.js @@ -2588,8 +2588,13 @@ void main(){ bugün yanlış-ama-çalışan 1.748 preseti siyaha çevirirdi. */ _ensureTextureLib(cfg) { const dir = (cfg.milkdrop && cfg.milkdrop.textureDir) || ''; - if (dir === this._texDir) return; - this._texDir = dir; + /* İçe aktarılan paketlerin dokuları (#574) uygulamanın kendi + klasöründe; her içe aktarım sayacı artırıyor ve liste yeniden + isteniyor. Klasör seçilmemiş olsa da o dokular kullanılıyor. */ + const rev = (cfg.milkdropLibrary && +cfg.milkdropLibrary.textureRev) || 0; + const key = dir + '#' + rev; + if (key === this._texDir) return; + this._texDir = key; this._texNames = []; /* Jeton her klasör değişiminde artıyor: uçuşta olan istekler geri döndüğünde artık geçersiz oldukları buradan anlaşılıyor. Klasörü @@ -2600,7 +2605,7 @@ void main(){ this._texWanted = null; this._texListing = false; const api = typeof window !== 'undefined' ? window.api : null; - if (!dir || !api || !api.milkdropTextures) return; + if ((!dir && !rev) || !api || !api.milkdropTextures) return; this._texListing = true; this._texBusy(1); Promise.resolve(api.milkdropTextures()).then((r) => { diff --git a/tests/milkdrop-import.test.js b/tests/milkdrop-import.test.js new file mode 100644 index 0000000..fd08480 --- /dev/null +++ b/tests/milkdrop-import.test.js @@ -0,0 +1,572 @@ +'use strict'; +/* MILKDROP KÜTÜPHANESİ İÇE AKTARIMI (#574, kısım 2). ZIP paketi, klasör ve + * makinede bulunan kütüphaneler; iki aşama: tarama özet veriyor, onaylanınca + * kopyalanıyor. Klasörler ve arşivler burada gerçekten kuruluyor. + * + * Gerçek veriyle ölçüldü (korpus, yalıtılmış depo): Cream of the Crop + * klasörü 1,1 sn'de tarandı (9.795 preset, 11 kategori etiketi), 6,2 sn'de + * içe aktarıldı; aynı paket ikinci kez 1,2 sn'de hepsi tekrar diye atlandı. + * Orijinal paketin ZIP'i 6 ms'de tarandı; içe aktarımda 14 preset Cream'de + * aynı ad ve içerikle zaten vardı. + */ +global.window = global.window || {}; +const test = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const vm = require('vm'); +const zlib = require('zlib'); +const I = require('../src/main/milkdrop-import.js'); +const Z = require('../src/main/zip-reader.js'); +const TEX = require('../src/main/milkdrop-textures.js'); +require('../src/shared/defaults.js'); +const L = require('../src/shared/milkdrop-library.js'); + +const ROOT = path.join(__dirname, '..'); +const read = (p) => fs.readFileSync(path.join(ROOT, p), 'utf-8'); +const bare = (s) => s.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/(^|[^:])\/\/[^\n]*/g, '$1 '); + +const made = []; +test.after(() => { for (const d of made) fs.rmSync(d, { recursive: true, force: true }); }); +function tmpDir() { + const d = fs.mkdtempSync(path.join(os.tmpdir(), 'sv-imp-')); + made.push(d); + return d; +} +const put = (base, rel, data) => { + const f = path.join(base, ...rel.split('/')); + fs.mkdirSync(path.dirname(f), { recursive: true }); + fs.writeFileSync(f, data); +}; + +const MILK_A = '[preset00]\nfDecay=0.98\nwarp_1=`ret = tex2D(sampler_worms, uv).xyz + tex2D(sampler_noise_lq, uv).xyz;\n'; +const MILK_B = '[preset00]\nfDecay=0.95\nper_frame_1=zoom = 1.01;\n'; +const MILK_C = '[preset00]\nfDecay=0.90\ncomp_1=`ret = tex2D(sampler_main, uv).xyz;\n'; +// Paketin yapısı: kategoriler, doku klasörleri, gevşek görseller +const LAYOUT = { + 'Fraktal/Geiss - A.milk': MILK_A, + 'Fraktal/Martin - B.milk': MILK_B, + 'Dans/Flexi - C.milk': MILK_C, + 'Dans/Flexi - C.jpg': 'ÖNİZLEME', // gevşek, hiçbir preset istemiyor: doku değil + 'Dans/worms.jpg': 'SOLUCAN', // gevşek, A istiyor: doku + 'textures/clouds.png': 'BULUT', // doku klasörü: hep + 'sprites/logo.png': 'LOGO', // MilkDrop 3'te sprites da doku klasörü + 'cift.milk2': 'MD3', + 'buyuk.milk': 'x'.repeat(I.PRESET_MAX_BYTES + 1), + 'node_modules/gizli.milk': MILK_B, + 'okubeni.txt': 'metin', +}; +function packFolder() { + const d = tmpDir(); + for (const [rel, data] of Object.entries(LAYOUT)) put(d, 'Paket/' + rel, data); + return path.join(d, 'Paket'); +} + +// Elle ZIP (tests/zip-reader.test.js'teki yazıcının kısası): deflate, UTF-8 ad +function makeZip(files) { + const locals = []; + const central = []; + let offset = 0; + for (const f of files) { + const data = Buffer.from(f.data); + const method = f.method == null ? 8 : f.method; + const comp = method === 8 ? zlib.deflateRawSync(data) : data; + const name = Buffer.from(f.name, 'utf8'); + const flags = 0x800 | (f.flags || 0); + const lh = Buffer.alloc(30); + lh.writeUInt32LE(0x04034b50, 0); + lh.writeUInt16LE(flags, 6); + lh.writeUInt16LE(method, 8); + lh.writeUInt32LE(Z.crc32(data), 14); + lh.writeUInt32LE(comp.length, 18); + lh.writeUInt32LE(data.length, 22); + lh.writeUInt16LE(name.length, 26); + const cd = Buffer.alloc(46); + cd.writeUInt32LE(0x02014b50, 0); + cd.writeUInt16LE(flags, 8); + cd.writeUInt16LE(method, 10); + cd.writeUInt32LE(Z.crc32(data), 16); + cd.writeUInt32LE(comp.length, 20); + cd.writeUInt32LE(data.length, 24); + cd.writeUInt16LE(name.length, 28); + cd.writeUInt32LE(offset, 42); + locals.push(lh, name, comp); + central.push(cd, name); + offset += 30 + name.length + comp.length; + } + const cdBuf = Buffer.concat(central); + const eocd = Buffer.alloc(22); + eocd.writeUInt32LE(0x06054b50, 0); + eocd.writeUInt16LE(files.length, 8); + eocd.writeUInt16LE(files.length, 10); + eocd.writeUInt32LE(cdBuf.length, 12); + eocd.writeUInt32LE(offset, 16); + return Buffer.concat(locals.concat([cdBuf, eocd])); +} +function packZip(extra) { + const files = Object.entries(LAYOUT).map(([rel, data]) => ({ name: 'Paket/' + rel, data })); + const d = tmpDir(); + const f = path.join(d, 'paket.zip'); + fs.writeFileSync(f, makeZip(files.concat(extra || []))); + return f; +} + +// Sahte depo: kaydedileni tutuyor, toplu kaydı ilerlemeyle bildiriyor +function fakeStore(existing) { + const all = (existing || []).slice(); + let n = 0; + return { + all, + existing: () => all, + saveManyAsync: async (items, onProgress) => { + const saved = items.map((p) => Object.assign({}, p, { id: p.id || 'md_t' + (n++) })); + all.push(...saved); + if (onProgress) onProgress(saved.length, items.length); + return saved; + }, + }; +} + +// --------------------------------------------------------------- tarama + +test('klasör taraması: presetler, dokular, atlananlar, kategori etiketleri', async () => { + const plan = await I.scanFolder(packFolder()); + const s = I.summary(plan); + assert.deepStrictEqual([s.kind, s.label, s.presets, s.textures, s.loose, s.complete, s.searchCut], ['folder', 'Paket', 3, 2, 2, true, false]); + // Boyut: presetler ve doku klasörü; yanlardaki görseller ayrı (yalnız istenirse kopyalanıyor) + const size = (list) => list.reduce((n, x) => n + x.size, 0); + assert.strictEqual(s.bytes, size(plan.presets) + size(plan.textures)); + assert.strictEqual(plan.looseBytes, size(plan.loose)); + assert.deepStrictEqual(s.skipped, { milk2: 1, tooLarge: 1, encrypted: 0, unsupported: 0, textureTooLarge: 0 }); + assert.strictEqual(s.tags, 2); + const byName = Object.fromEntries(plan.presets.map((p) => [p.name, p.tag])); + assert.deepStrictEqual(byName, { 'Geiss - A': 'Fraktal', 'Martin - B': 'Fraktal', 'Flexi - C': 'Dans' }, 'node_modules atlandı'); + assert.deepStrictEqual(plan.textures.map((t) => t.name).sort(), ['clouds.png', 'logo.png']); + assert.deepStrictEqual(plan.loose.map((t) => t.name).sort(), ['Flexi - C.jpg', 'worms.jpg']); + assert.strictEqual(s.where, undefined, 'özette yol yok'); +}); + +test('ZIP taraması aynı planı veriyor; şifreli ve desteklenmeyen girdi sayılıyor', () => { + const f = packZip([ + { name: 'Paket/Dans/sifreli.milk', data: 'x', flags: 1 }, + // macOS'un ZIP'e koyduğu AppleDouble kalıntıları: preset değil + { name: '__MACOSX/Paket/Fraktal/._Geiss - A.milk', data: 'ikili' }, + { name: '__MACOSX/Paket/Fraktal/meta.milk', data: 'ikili' }, + { name: 'Paket/Dans/._Flexi - C.milk', data: 'ikili' }, + { name: 'Paket/.git/eski.milk', data: MILK_B }, + ]); + const s = I.summary(I.scanZip(f)); + assert.deepStrictEqual([s.kind, s.label, s.presets, s.textures, s.loose, s.tags], ['zip', 'paket', 3, 2, 2, 2]); + assert.strictEqual(s.skipped.encrypted, 1); + assert.strictEqual(s.skipped.milk2, 1); +}); + +test('etiket: ortak ön ek atılıyor, genel klasör adları etiket değil', () => { + const P = (dirs) => ({ dirs, tag: '' }); + const a = [P(['cream-master', 'Fractal', 'Nested']), P(['cream-master', 'Dancer']), P(['cream-master'])]; + I.assignTags(a); + assert.deepStrictEqual(a.map((p) => p.tag), ['Fractal', 'Dancer', '']); + const b = [P(['Milkdrop2', 'presets', 'Geiss']), P(['Milkdrop2', 'presets', 'Martin'])]; + I.assignTags(b); + assert.deepStrictEqual(b.map((p) => p.tag), ['Geiss', 'Martin']); + const c = [P(['tek'])]; + I.assignTags(c); + assert.deepStrictEqual(c.map((p) => p.tag), [''], 'tek klasörlü paket etiketsiz'); + const d = [P(['x', 'presets']), P(['x', 'resources'])]; + I.assignTags(d); + assert.deepStrictEqual(d.map((p) => p.tag), ['', ''], 'genel adlar'); +}); + +// ------------------------------------------------------------ çalıştırma + +test('içe aktarım: presetler kaydediliyor, dokular yalnız gerekenler, etiketler dönüyor', async () => { + const plan = await I.scanFolder(packFolder()); + const store = fakeStore(); + const texDir = path.join(tmpDir(), 'dokular'); + const phases = new Set(); + const r = await I.runImport(plan, { existing: store.existing, saveManyAsync: store.saveManyAsync, textureDir: texDir, onProgress: (ph) => phases.add(ph) }); + assert.strictEqual(r.ok, true); + assert.deepStrictEqual([r.added, r.duplicates, r.failed], [3, 0, 0]); + assert.deepStrictEqual(fs.readdirSync(texDir).sort(), ['clouds.png', 'logo.png', 'worms.jpg'], 'önizleme görüntüsü doku değil'); + assert.strictEqual(fs.readFileSync(path.join(texDir, 'worms.jpg'), 'utf8'), 'SOLUCAN'); + assert.deepStrictEqual(r.textures, { copied: 3, same: 0, conflicts: 0, failed: 0 }); + assert.deepStrictEqual(r.saved.map((x) => x.tag).sort(), ['Dans', 'Fraktal', 'Fraktal']); + assert.ok(r.presets.length === 3 && r.presets.every((p) => p.kind === 'milkdrop' && p.source), 'ana süreç yayın için alıyor'); + assert.deepStrictEqual([...phases].sort(), ['read', 'save', 'textures']); + assert.deepStrictEqual(r.skipped.milk2, 1); + // İkinci kez: hepsi tekrar, dokular zaten var + const r2 = await I.runImport(plan, { existing: store.existing, saveManyAsync: store.saveManyAsync, textureDir: texDir }); + assert.deepStrictEqual([r2.added, r2.duplicates], [0, 3]); + assert.deepStrictEqual(r2.textures, { copied: 0, same: 3, conflicts: 0, failed: 0 }); +}); + +test('içe aktarım: aynı ad başka içerik tekrar değil; var olan dokunun üstüne yazılmıyor', async () => { + const plan = await I.scanFolder(packFolder()); + const store = fakeStore([{ id: 'x', kind: 'milkdrop', name: 'Geiss - A', source: 'BAŞKA' }, { id: 'y', kind: 'milkdrop', name: 'Martin - B', source: MILK_B }]); + const texDir = path.join(tmpDir(), 'dokular'); + put(texDir, 'clouds.png', 'KULLANICININ'); + const r = await I.runImport(plan, { existing: store.existing, saveManyAsync: store.saveManyAsync, textureDir: texDir }); + assert.deepStrictEqual([r.added, r.duplicates], [2, 1], 'yalnız Martin - B aynı'); + assert.strictEqual(r.textures.conflicts, 1); + assert.strictEqual(fs.readFileSync(path.join(texDir, 'clouds.png'), 'utf8'), 'KULLANICININ', 'var olan kaldı'); +}); + +test('ZIP içe aktarımı; ad yol dışına çıkamıyor (zip-slip)', async () => { + const f = packZip([ + { name: '../../kacak/Kacak - D.milk', data: MILK_B + ';kaçak' }, + { name: 'Paket/../../../textures/kacak.png', data: 'KAÇAK' }, + ]); + const plan = I.scanZip(f); + const store = fakeStore(); + const base = tmpDir(); + const texDir = path.join(base, 'a', 'b', 'dokular'); + const r = await I.runImport(plan, { existing: store.existing, saveManyAsync: store.saveManyAsync, textureDir: texDir }); + assert.strictEqual(r.added, 4); + assert.ok(store.all.some((p) => p.name === 'Kacak - D'), 'yalnız dosya adı alındı'); + assert.deepStrictEqual(fs.readdirSync(texDir).sort(), ['clouds.png', 'kacak.png', 'logo.png', 'worms.jpg']); + assert.ok(!fs.existsSync(path.join(base, 'kacak')) && !fs.existsSync(path.join(base, 'a', 'kacak.png')), 'klasör dışına yazılmadı'); +}); + +test('metin çözümü ve örnekleyici adları', () => { + assert.strictEqual(I.decodeText(Buffer.from('ğüş', 'utf8')), 'ğüş'); + assert.strictEqual(I.decodeText(Buffer.from([0x63, 0x61, 0x66, 0xe9])), 'café', 'geçersiz UTF-8: Latin-1'); + assert.deepStrictEqual([...I.samplerNames(MILK_A + 'sampler_blur2 sampler_rand03 sampler_rand00_smalltiled sampler_Clouds2 sampler_fw_main')].sort(), ['clouds2', 'worms']); +}); + +// ----------------------------------------------------------- makinede arama + +test('makinede arama: bilinen yer, kullanıcı klasörleri, ZIP; boş olan listelenmiyor', async () => { + const home = tmpDir(); + put(home, '.projectM/presets/Geiss - P.milk', MILK_B); + put(home, 'Downloads/cream-master/Fractal/X - Y.milk', MILK_A); + put(home, 'Downloads/cream-master/textures/worms.jpg', 'W'); + put(home, 'Documents/Adobe/Presets/ayar.txt', 'değil'); + // Bulunan ama alınacak preseti olmayan (tek preseti sınırı aşıyor): listelenmiyor + put(home, 'Desktop/dev/buyuk.milk', 'x'.repeat(I.PRESET_MAX_BYTES + 1)); + fs.writeFileSync(path.join(home, 'Downloads', 'paket.zip'), makeZip([{ name: 'p/a.milk', data: MILK_C }])); + fs.writeFileSync(path.join(home, 'Downloads', 'bozuk.zip'), 'ZIP değil'); + const r = await I.discover({ platform: 'linux', home }); + assert.strictEqual(r.complete, true, 'bütün adaylara bakıldı'); + // Yalnız bu testin ev klasörü: CI makinesinde projectM kurulu olsa da sonuç değişmesin + const rows = r.libraries.filter((p) => path.resolve(p.source).indexOf(path.resolve(home)) === 0) + .map((p) => [p.label, p.kind, p.presets.length, !!p.searchCut]); + assert.deepStrictEqual(rows.sort(), [['cream-master', 'folder', 1, false], ['paket.zip', 'zip', 1, false], ['projectM', 'folder', 1, false]]); + // Bütçe biterse yarıda kalıyor ve bunu söylüyor + const cut = await I.scanFolder(path.join(home, 'Downloads'), { budget: { files: 2, deadline: Infinity } }); + assert.strictEqual(cut.complete, false); +}); + +test('makinede arama: genişlik öncelikli — dev bir komşu küçük paketi gizlemiyor; açıkça verilen kök önce', async () => { + const home = tmpDir(); + /* İndirilenler'de dev bir klasör: dar başlıyor, dipte geniş. Derinlik + öncelikli tarama bütçeyi burada bitirip yanındaki pakete varamıyordu + (Windows'ta klasörler ada göre sıralı geliyor, "aaa" önce). */ + for (let i = 0; i < 150; i++) put(home, 'Downloads/aaa-dev/d/d/d/k' + i + '/x.txt', 'x'); + put(home, 'Downloads/zzz-paket/Kategori/A - B.milk', MILK_B); + const extra = tmpDir(); + put(extra, 'deneme/C - D.milk', MILK_C); + /* Sistem klasörü adayı yok (Program Files verilmedi): CI makinesinde + projectM kurulu olsa bile bütçeden yemesin, sonuç makineye bağlı olmasın */ + const r = await I.discover({ platform: 'win32', home }, { maxFiles: 100, roots: [extra] }); + const labels = r.libraries.map((p) => p.label); + assert.deepStrictEqual(labels, ['deneme', 'zzz-paket'], 'açıkça verilen kök önce, komşusunun dibine inilmeden bulundu'); + assert.strictEqual(r.complete, false, 'bütçe bitti: bazı klasörlere bakılamadığı söyleniyor'); + const pack = r.libraries[1]; + assert.deepStrictEqual([pack.presets.length, pack.complete, !!pack.searchCut], [1, true, false], 'bulunan baştan sona sayıldı'); +}); + +test('makinede arama: süresi yetmeyen kütüphane sayılmadan listeleniyor; baştan tarama tamamını sayıyor', async () => { + const dir = packFolder(); + const [cut] = await I.countLibraries([{ label: 'Paket', dir }], Date.now() - 1); + assert.deepStrictEqual([cut.presets.length, cut.complete, cut.searchCut], [0, false, true], 'bulunan listeden düşmedi'); + assert.strictEqual(I.summary(cut).searchCut, true); + const full = I.summary(await I.rescan(cut)); + assert.deepStrictEqual([full.presets, full.textures, full.loose, full.complete, full.searchCut], [3, 2, 2, true, false]); + // Süre varsa sayılıyor; ZIP'in planı bulunurken hazır, yeniden sayılmıyor + const zipPlan = I.scanZip(packZip()); + const both = await I.countLibraries([{ label: 'Paket', dir }, { label: 'paket.zip', dir: zipPlan.source, plan: zipPlan }], Date.now() + 60000); + assert.deepStrictEqual(both.map((p) => [p.kind, p.presets.length, !!p.searchCut]), [['folder', 3, false], ['zip', 3, false]]); + assert.strictEqual(both[1], zipPlan); +}); + +test('makinede arama: kullanıcı klasörünün kendisindeki presetler ayrı kütüphane, alt klasörlerine inilmeden', async () => { + const home = tmpDir(); + // Sistemin verdiği yerel adlı klasör (Linux: ~/İndirilenler) + const dl = path.join(home, 'İndirilenler'); + put(dl, 'Tek - Bir.milk', MILK_B); + put(dl, '._Tek - Bir.milk', 'ikili'); // AppleDouble: preset değil + put(dl, 'paket/Kat/Iki - Uc.milk', MILK_C); + const r = await I.discover({ platform: 'linux', home, downloads: dl }); + const rows = r.libraries.filter((p) => path.resolve(p.source).indexOf(path.resolve(home)) === 0) + .map((p) => [p.label, p.presets.map((x) => x.name).join(',')]); + assert.deepStrictEqual(rows.sort(), [['paket', 'Iki - Uc'], ['İndirilenler', 'Tek - Bir']]); + const own = r.libraries.find((p) => p.label === 'İndirilenler'); + assert.strictEqual(own.shallow, true); + assert.deepStrictEqual((await I.rescan(own)).presets.map((x) => x.name), ['Tek - Bir'], 'baştan tarama da alt klasörlere inmiyor'); +}); + +test('kullanıcı klasörleri: sistemin verdiği yer önce; sıra İndirilenler → Masaüstü → Müzik → Belgeler', () => { + const n = (list) => list.map((d) => d.replace(/\\/g, '/')); + assert.deepStrictEqual(n(I.homeRoots({ home: '/h' })), ['/h/Downloads', '/h/Desktop', '/h/Music', '/h/Documents']); + // Linux'ta yerel adlar, Windows'ta OneDrive'a taşınmış Belgeler + assert.deepStrictEqual(n(I.homeRoots({ home: '/h', downloads: '/h/İndirilenler', documents: '/h/OneDrive/Belgeler' })), + ['/h/İndirilenler', '/h/Desktop', '/h/Music', '/h/OneDrive/Belgeler']); + assert.deepStrictEqual(I.homeRoots({}), []); +}); + +test('bilinen yerler: Windows, macOS ve Linux kurulum klasörleri', () => { + const w = I.candidates({ platform: 'win32', home: 'C:/U', appData: 'C:/U/AppData/Roaming', programFiles: 'C:/PF', programFilesX86: 'C:/PF86' }); + const ws = w.map((c) => c.dir.replace(/\\/g, '/')); + assert.ok(ws.includes('C:/PF86/Winamp/Plugins/Milkdrop2')); + assert.ok(ws.includes('C:/U/AppData/Roaming/foobar2000-v2/milkdrop2')); + assert.ok(I.candidates({ platform: 'linux', home: '/h' }).some((c) => c.dir.replace(/\\/g, '/') === '/usr/share/projectM/presets')); + assert.ok(I.candidates({ platform: 'darwin', home: '/h' }).some((c) => /projectM\.app/.test(c.dir))); +}); + +// --------------------------------------------------------------- dokular + +test('dokular iki klasörden: önce kullanıcınınki, ad bir kez', () => { + const a = tmpDir(); + const b = tmpDir(); + put(a, 'clouds.png', 'KULLANICI'); + put(b, 'Clouds.png', 'PAKET'); + put(b, 'worms.jpg', 'SOLUCAN'); + const names = TEX.listTexturesIn([a, b]).names; + assert.deepStrictEqual(names.sort(), ['clouds.png', 'worms.jpg']); + assert.strictEqual(fs.readFileSync(TEX.textureFileInfoIn([a, b], 'clouds.png', 1024).file, 'utf8'), 'KULLANICI'); + assert.strictEqual(fs.readFileSync(TEX.textureFileInfoIn(['', b], 'worms.jpg', 1024).file, 'utf8'), 'SOLUCAN', 'klasör seçilmemişse de'); + assert.strictEqual(TEX.textureFileInfoIn([a, b], '../x.png', 1024), null); +}); + +test('motor: içe aktarılan dokular için liste yeniden isteniyor, klasör seçilmemişse de', () => { + let asked = 0; + const canvas = () => ({ width: 64, height: 64, getContext: (k) => (k === '2d' ? {} : null), addEventListener() {} }); + const ctx = { window: {}, document: { createElement: canvas }, console, performance }; + ctx.window.document = ctx.document; + ctx.window.api = { milkdropTextures: () => { asked++; return Promise.resolve({ names: [] }); } }; + vm.createContext(ctx); + for (const f of ['src/shared/milkdrop.js', 'src/shared/milkdrop-cycle.js', 'src/visualizer/modes/milkdrop.js']) { + vm.runInContext(read(f), ctx, { filename: f }); + } + const m = new ctx.window.SVModes.milkdrop(canvas()); + const at = (dir, rev) => m._ensureTextureLib({ milkdrop: { textureDir: dir }, milkdropLibrary: { textureRev: rev } }); + at('', 0); + assert.strictEqual(asked, 0, 'hiçbir şey yokken istek yok'); + at('', 1); + assert.strictEqual(asked, 1, 'içe aktarımdan sonra'); + at('', 1); + assert.strictEqual(asked, 1); + at('', 2); + assert.strictEqual(asked, 2, 'her içe aktarım yeniden'); + at('C:/dokular', 2); + assert.strictEqual(asked, 3); +}); + +// ------------------------------------------------------------ ana süreç + +test('ana süreç: tarama planı burada, sayfaya özet; içe aktarım tek yayın', () => { + const M = bare(read('src/main/main.js')); + assert.match(M, /function managedTextureDir\(\) \{\s*return path\.join\(app\.getPath\('userData'\), 'milkdrop-textures'\);/); + const imp = /ipcMain\.handle\('milkdrop:library-import', async \(e, token\) => \{[\s\S]*?\n\}\);/.exec(M); + assert.ok(imp, 'içe aktarım işleyicisi yok'); + assert.match(imp[0], /textureDir: managedTextureDir\(\),/); + assert.match(imp[0], /sender\.send\('milkdrop:library-progress', \{ phase, done, total \}\)/); + assert.match(imp[0], /if \(r\.presets && r\.presets\.length\) broadcastPresetDelta\(r\.presets, \[\]\);\s*delete r\.presets;/); + assert.match(M, /return Object\.assign\(\{ token, where: plan\.source \}, mdImport\.summary\(plan\)\);/); + assert.match(M, /ipcMain\.handle\('milkdrop:library-pick', async \(e, kind\) => \{/); + // Arama: kullanıcı klasörlerinin gerçek yeri sistemden, sonuç bütün adaylara bakılıp bakılmadığını taşıyor + const disc = /ipcMain\.handle\('milkdrop:library-discover', async \(\) => \{[\s\S]*?\n\}\);/.exec(M); + assert.ok(disc, 'arama işleyicisi yok'); + for (const k of ['downloads', 'desktop', 'music', 'documents']) assert.ok(disc[0].includes(k + ": knownFolder('" + k + "'),"), k); + assert.match(disc[0], /return \{ ok: true, complete: r\.complete, libraries: r\.libraries\.map\(keepPlan\) \};/); + // Eksik sayılan: baştan taranıp eski planın yerine + const re = /ipcMain\.handle\('milkdrop:library-rescan', async \(e, token\) => \{[\s\S]*?\n\}\);/.exec(M); + assert.ok(re, 'baştan tarama işleyicisi yok'); + assert.match(re[0], /const plan = await mdImport\.rescan\(old\);\s*libraryPlans\.delete\(key\);\s*return Object\.assign\(\{ ok: true \}, keepPlan\(plan\)\);/); + const A = read('src/main/preload-admin.js'); + for (const k of ['pickMilkdropLibrary', 'discoverMilkdropLibraries', 'rescanMilkdropLibrary', 'importMilkdropLibrary', 'onMilkdropLibraryProgress']) assert.ok(A.includes(k + ':'), k); +}); + +// ----------------------------------------------------------------- panel + +function el(tag, props, kids) { + const n = { tag, props: props || {}, text: (props && props.text) || '', kids: [], on: {}, value: '', attrs: {} }; + n.className = (props && props.class) || ''; + n.appendChild = (c) => { n.kids.push(c); return c; }; + n.addEventListener = (ev, f) => { n.on[ev] = f; }; + n.setAttribute = (k, v) => { n.attrs[k] = v; }; + n.getAttribute = (k) => n.attrs[k]; + n.removeAttribute = () => {}; + Object.defineProperty(n, 'textContent', { get: () => n.text, set: (v) => { n.text = v; n.kids = []; } }); + for (const k of Object.keys(n.props)) if (k.indexOf('on') === 0 && typeof n.props[k] === 'function') n.on[k.slice(2)] = n.props[k]; + (kids || []).forEach((c) => c && n.kids.push(c)); + return n; +} +const walk = (n, f) => { if (!n || typeof n !== 'object') return; f(n); (n.kids || []).forEach((k) => walk(k, f)); if (n.node) walk(n.node, f); }; +const find = (root, text) => { let b = null; walk(root, (n) => { if (!b && n.props && n.props.text === text) b = n; }); return b; }; + +async function panelWith(api, setup) { + const key = require.resolve('../src/admin/milkdrop-panel.js'); + delete require.cache[key]; + const cfg = global.window.SV.defaultConfig(); + if (setup) setup(cfg); + const calls = { apply: 0, confirms: [], toasts: [] }; + global.document = { querySelector: () => null, getElementById: () => null, activeElement: null }; + window.SVMilkdropCycle = require('../src/shared/milkdrop-cycle.js'); + window.SVMilkdropLibrary = L; + window.SVPresets = undefined; + window.api = Object.assign({ listPresets: () => Promise.resolve([]) }, api); + window.SVPanel = { + cfg: () => cfg, apply() { calls.apply++; }, rerender() {}, el, + row: (label, node) => ({ label, node }), confirm: (m) => { calls.confirms.push(m); return Promise.resolve(true); }, + toast: (m) => calls.toasts.push(m), + }; + window.SVScenePanels = { miniSlider: (label) => ({ label, kids: [] }) }; + const M = require('../src/admin/milkdrop-panel.js'); + M.init(); + await new Promise((r) => setImmediate(r)); + return { M, cfg, calls, render: () => M.panel() }; +} + +const SUMMARY = { ok: true, token: 'lib_1', where: 'C:/x', kind: 'zip', label: 'cream', presets: 3, textures: 2, bytes: 3 * 1048576, complete: true, tooBig: false, skipped: { milk2: 1, tooLarge: 0, encrypted: 0, unsupported: 0, textureTooLarge: 0 }, tags: 2 }; +const RESULT = { ok: true, added: 3, duplicates: 1, failed: 0, textures: { copied: 2, same: 0, conflicts: 1, failed: 0 }, skipped: { milk2: 1 }, saved: [{ id: 'n1', tag: 'Fraktal' }, { id: 'n2', tag: 'Fraktal' }, { id: 'n3', tag: '' }] }; + +test('panel: ZIP seçimi → özet onayı → içe aktarım → etiketler ve doku sayacı', async () => { + let imported = null; + const p = await panelWith({ + pickMilkdropLibrary: (kind) => Promise.resolve(Object.assign({ kind }, SUMMARY)), + importMilkdropLibrary: (token) => { imported = token; return Promise.resolve(RESULT); }, + }); + await find(p.render(), '🗜 ZIP Paketinden İçe Aktar').on.click(); + assert.strictEqual(imported, 'lib_1'); + assert.strictEqual(p.calls.confirms.length, 1, 'kopyalamadan önce soruluyor'); + const msg = p.calls.confirms[0]; + for (const part of ['“cream”: 3 preset, 2 doku, 3 MB.', '1 .milk2 dosyası', '2 klasör adı etiket olacak.', 'Devam edilsin mi?']) { + assert.ok(msg.includes(part), 'özet: ' + part + ' — ' + msg); + } + const lib = p.cfg.milkdropLibrary; + assert.deepStrictEqual(lib.tags, { n1: ['Fraktal'], n2: ['Fraktal'] }); + assert.strictEqual(lib.textureRev, 1, 'dokular kopyalandı: motorlar listeyi yeniden istesin'); + assert.ok(p.calls.apply > 0); + const note = []; + walk(p.render(), (n) => { if (/md-ok/.test(n.className || '') && n.text) note.push(n.text); }); + assert.ok(note.some((t) => t.startsWith('3 preset eklendi, 1 tekrar atlandı.') && t.includes('2 doku kopyalandı.') && t.includes('1 doku adı')), note.join(' | ')); +}); + +test('panel: onay verilmezse içe aktarım yok; klasör adları kapalıysa etiket yok', async () => { + let imported = 0; + const p = await panelWith({ + pickMilkdropLibrary: () => Promise.resolve(SUMMARY), + importMilkdropLibrary: () => { imported++; return Promise.resolve(RESULT); }, + }, (cfg) => { cfg.milkdropControl.importFolderTags = false; }); + window.SVPanel.confirm = () => Promise.resolve(false); + await find(p.render(), '📁 Klasörden İçe Aktar').on.click(); + assert.strictEqual(imported, 0, 'sormadan kopya yok'); + window.SVPanel.confirm = () => Promise.resolve(true); + await find(p.render(), '📁 Klasörden İçe Aktar').on.click(); + assert.strictEqual(imported, 1); + assert.deepStrictEqual(p.cfg.milkdropLibrary.tags, {}, 'etiket yapılmadı'); +}); + +test('panel: makinede arama listesi ve hata metinleri', async () => { + const p = await panelWith({ + discoverMilkdropLibraries: () => Promise.resolve({ ok: true, libraries: [SUMMARY, Object.assign({}, SUMMARY, { token: 'lib_2', label: 'Winamp (MilkDrop 2)', presets: 552, textures: 0, bytes: 4777905 })] }), + importMilkdropLibrary: () => Promise.resolve({ ok: false, error: 'NO_PLAN' }), + pickMilkdropLibrary: () => Promise.resolve({ ok: false, error: 'NOT_ZIP' }), + }); + await find(p.render(), '🔎 Makinede Ara').on.click(); + const r = p.render(); + const row = r.kids.find((n) => n && n.label === 'Bulunan Kütüphaneler'); + assert.ok(row, 'liste yok'); + assert.deepStrictEqual(row.node.kids.map((it) => it.kids[1].text), ['3 preset · 2 doku · 3 MB', '552 preset · 0 doku · 4,6 MB']); + await row.node.kids[1].kids[2].on.click(); + assert.ok(p.calls.toasts.includes('Bu tarama artık geçerli değil; yeniden tarayın.')); + await find(p.render(), '🗜 ZIP Paketinden İçe Aktar').on.click(); + assert.ok(p.calls.toasts.includes('ZIP okunamadı: bozuk ya da ZIP değil.')); + // Boş arama sonucu söyleniyor + window.api.discoverMilkdropLibraries = () => Promise.resolve({ ok: true, libraries: [] }); + await find(p.render(), '🔎 Makinede Ara').on.click(); + const texts = []; + walk(p.render(), (n) => { if (n.text) texts.push(n.text); }); + assert.ok(texts.some((t) => t.startsWith('Bilinen kurulum klasörlerinde'))); +}); + +test('panel: süresi yetmeyen kütüphane "+" ile listeleniyor, onaydan önce tamamı taranıyor', async () => { + const rescans = []; + let imported = null; + const CUT = Object.assign({}, SUMMARY, { token: 'lib_c', kind: 'folder', presets: 120, complete: false, searchCut: true }); + const NONE = Object.assign({}, SUMMARY, { token: 'lib_n', kind: 'folder', label: 'Winamp (MilkDrop 2)', presets: 0, textures: 0, bytes: 0, complete: false, searchCut: true }); + const FULL = Object.assign({}, SUMMARY, { token: 'lib_f', kind: 'folder', presets: 9795, textures: 4, loose: 12, bytes: 60 * 1048576 }); + const p = await panelWith({ + discoverMilkdropLibraries: () => Promise.resolve({ ok: true, complete: false, libraries: [CUT, NONE] }), + rescanMilkdropLibrary: (token) => { rescans.push(token); return Promise.resolve(Object.assign({ ok: true }, FULL)); }, + importMilkdropLibrary: (token) => { imported = token; return Promise.resolve(RESULT); }, + }); + await find(p.render(), '🔎 Makinede Ara').on.click(); + const rows = () => p.render().kids.find((n) => n && n.label === 'Bulunan Kütüphaneler').node.kids; + assert.deepStrictEqual(rows().map((it) => it.kids[1].text), ['120+ preset · 2+ doku · 3+ MB', 'sayılmadı']); + const texts = []; + walk(p.render(), (n) => { if (n.text) texts.push(n.text); }); + assert.ok(texts.some((t) => t.startsWith('Aramanın süresi bazı kütüphaneleri')), 'eksik sayım açıklanıyor'); + assert.ok(texts.some((t) => t.startsWith('Arama süre sınırına ulaştı')), 'bakılamayan klasörler söyleniyor'); + await rows()[0].kids[2].on.click(); + assert.deepStrictEqual(rescans, ['lib_c'], 'onaydan önce baştan tarandı'); + assert.strictEqual(imported, 'lib_f', 'eksik plan içe aktarılmadı'); + const msg = p.calls.confirms[0]; + assert.ok(msg.includes('“cream”: 9.795 preset, 4 doku, 60 MB.'), msg + ' — sayı Türkçe ayrılıyor'); + assert.ok(msg.includes('Presetlerin yanındaki 12 görselden yalnız presetlerin istediği kopyalanır.'), msg); + assert.ok(!msg.includes('Tarama yarıda kesildi'), 'tam sayıldı: yarıda uyarısı yok'); + // Baştan tarama da sınıra takılırsa (çok büyük klasör) olağan onay; ikinci kez taranmıyor + window.api.rescanMilkdropLibrary = (token) => { rescans.push(token); return Promise.resolve(Object.assign({ ok: true }, FULL, { token: 'lib_g', complete: false, searchCut: false })); }; + await rows()[0].kids[2].on.click(); + assert.deepStrictEqual(rescans, ['lib_c', 'lib_n']); + assert.strictEqual(imported, 'lib_g'); + assert.ok(p.calls.confirms[1].includes('Tarama yarıda kesildi'), p.calls.confirms[1]); + // Baştan tarama başarısızsa içe aktarım yok + imported = null; + window.api.discoverMilkdropLibraries = () => Promise.resolve({ ok: true, complete: true, libraries: [CUT] }); + window.api.rescanMilkdropLibrary = () => Promise.resolve({ ok: false, error: 'NO_PLAN' }); + await find(p.render(), '🔎 Makinede Ara').on.click(); + await rows()[0].kids[2].on.click(); + assert.strictEqual(imported, null); + assert.strictEqual(p.calls.confirms.length, 2, 'sorulmadı bile'); + assert.ok(p.calls.toasts.includes('Bu tarama artık geçerli değil; yeniden tarayın.')); + const after = []; + walk(p.render(), (n) => { if (n.text) after.push(n.text); }); + assert.ok(!after.some((t) => t.startsWith('Arama süre sınırına ulaştı')), 'tam aramada o not yok'); +}); + +test('toplu etiket: eşlem bir kez kopyalanıyor, var olan etiketler kalıyor', () => { + const lib = { tags: { a: ['Sakin'] } }; + const out = L.addTags(lib, [{ id: 'a', tag: 'Fraktal' }, { id: 'b', tag: 'Dans' }, { id: 'c', tag: '' }, { id: '__proto__', tag: 'x' }]); + assert.deepStrictEqual(out, { a: ['Sakin', 'Fraktal'], b: ['Dans'] }); + assert.notStrictEqual(out, lib.tags); +}); + +test('metinlerin İngilizcesi var', () => { + const I18N = read('src/shared/i18n.js'); + const PANEL = read('src/admin/milkdrop-panel.js'); + for (const k of [ + '🗜 ZIP Paketinden İçe Aktar', '📁 Klasörden İçe Aktar', '🔎 Makinede Ara', 'Klasör Adları', 'Etiket yap', 'Etiket yapma', + 'Bulunan Kütüphaneler', 'İçe Aktar', 'Taranıyor…', 'Makinede aranıyor…', 'İçe aktarılıyor…', + 'Okunuyor: {a}/{b}', 'Kaydediliyor: {a}/{b}', 'Dokular: {a}/{b}', '“{label}”: {p} preset, {t} doku, {mb} MB.', + '{a} preset eklendi, {d} tekrar atlandı.', '{c} doku kopyalandı.', '{p} preset · {t} doku · {mb} MB', + 'Bu kaynakta MilkDrop preseti yok.', 'ZIP okunamadı: bozuk ya da ZIP değil.', 'İçe aktarılamadı.', + '{p}+ preset · {t}+ doku · {mb}+ MB', 'sayılmadı', 'Presetlerin yanındaki {n} görselden yalnız presetlerin istediği kopyalanır.', + 'Aramanın süresi bazı kütüphaneleri saymaya yetmedi (+ ya da “sayılmadı”); içe aktarmadan önce tamamı taranır.', + 'Arama süre sınırına ulaştı, bazı klasörlere bakılamadı. Kütüphaneniz listede yoksa 📁 Klasörden İçe Aktar ile seçin.', + ]) { + assert.ok(PANEL.includes("'" + k + "'"), k + ' panelde yok'); + assert.ok(I18N.includes("'" + k + "':"), k + ' sözlükte yok'); + } + // Şablonların İngilizcesi aynı yer tutucuları taşıyor + for (const [tr, en] of [ + ['“{label}”: {p} preset, {t} doku, {mb} MB.', '{label}{p}{t}{mb}'], ['{p} preset · {t} doku · {mb} MB', '{p}{t}{mb}'], + ['{p}+ preset · {t}+ doku · {mb}+ MB', '{p}{t}{mb}'], ['Presetlerin yanındaki {n} görselden yalnız presetlerin istediği kopyalanır.', '{n}'], + ]) { + const m = new RegExp("'" + tr.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + "': '([^']*)'").exec(I18N); + assert.ok(m, tr); + assert.deepStrictEqual((m[1].match(/\{\w+\}/g) || []).join(''), en); + } +}); diff --git a/tests/milkdrop-library.test.js b/tests/milkdrop-library.test.js index 4ff3a89..b1091d4 100644 --- a/tests/milkdrop-library.test.js +++ b/tests/milkdrop-library.test.js @@ -337,7 +337,7 @@ test('varsayılanlar: havuz "hepsi"; favori ve etiketler kitaplıkta, sahnenin d const d = SV.defaultConfig(); assert.strictEqual(d.milkdrop.autoFrom, 'all'); assert.strictEqual(d.milkdrop.autoTag, ''); - assert.deepStrictEqual(d.milkdropLibrary, { ratings: {}, favorites: {}, tags: {} }); + assert.deepStrictEqual(d.milkdropLibrary, { ratings: {}, favorites: {}, tags: {}, textureRev: 0 }); for (const f of ['src/main/main.js', 'src/admin/control.js', 'src/admin/admin.js']) { const m = /const SCENE_KEYS = \[([\s\S]*?)\];/.exec(read(f)); assert.ok(m && !m[1].includes('milkdropLibrary'), f); diff --git a/tests/milkdrop-texture-surfaces.test.js b/tests/milkdrop-texture-surfaces.test.js index 3665614..b653146 100644 --- a/tests/milkdrop-texture-surfaces.test.js +++ b/tests/milkdrop-texture-surfaces.test.js @@ -73,8 +73,10 @@ test('doku dosyası: doğrulanmış yol, tür ve boyut; sınırın üstü ve dı test('ana süreç: IPC ile yayın sunucusu AYNI iki yardımcıyı kullanıyor', () => { const main = strip(read('src/main/main.js')); - assert.match(main, /function textureNames\(\) \{\s*return mdTex\.listTextures\(textureDir\(\)\);\s*\}/); - assert.match(main, /function textureFile\(name\) \{\s*return mdTex\.textureFileInfo\(textureDir\(\), name, TEX_MAX_BYTES\);\s*\}/); + // Kullanıcının klasörü ve içe aktarılan dokuların klasörü, bu sırayla (#574) + assert.match(main, /function textureDirs\(\) \{\s*return \[textureDir\(\), managedTextureDir\(\)\];\s*\}/); + assert.match(main, /function textureNames\(\) \{\s*return mdTex\.listTexturesIn\(textureDirs\(\)\);\s*\}/); + assert.match(main, /function textureFile\(name\) \{\s*return mdTex\.textureFileInfoIn\(textureDirs\(\), name, TEX_MAX_BYTES\);\s*\}/); assert.match(main, /ipcMain\.handle\('milkdrop:textures', \(\) => textureNames\(\)\)/); assert.match(main, /ipcMain\.handle\('milkdrop:texture', \(e, name\) => \{\s*const t = textureFile\(name\);/); assert.match(main, /mdTextureNames: \(\) => textureNames\(\)\.names,/); diff --git a/tests/zip-reader.test.js b/tests/zip-reader.test.js new file mode 100644 index 0000000..8b496b4 --- /dev/null +++ b/tests/zip-reader.test.js @@ -0,0 +1,195 @@ +'use strict'; +/* ZIP OKUYUCU (#574). Preset paketleri ZIP olarak dolaşıyor; arşiv + * kullanıcının indirdiği, güvenilmez bir dosya. Testler arşivleri burada, + * elle yazıyor: saklanmış ve deflate girdiler, UTF-8 ve CP437 adlar, ZIP64, + * şifreli girdi, başka sıkıştırma yöntemi, bozuk CRC, başlığı yalan + * söyleyen girdi, sıkıştırma bombası, bozuk merkezi dizin. + */ +const test = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const zlib = require('zlib'); +const Z = require('../src/main/zip-reader.js'); + +const made = []; +test.after(() => { for (const d of made) fs.rmSync(d, { recursive: true, force: true }); }); +function tmpFile(buf) { + const d = fs.mkdtempSync(path.join(os.tmpdir(), 'sv-zip-')); + made.push(d); + const f = path.join(d, 'paket.zip'); + fs.writeFileSync(f, buf); + return f; +} + +/* Elle ZIP. `files`: { name, data, method (0|8|başka), utf8 (varsayılan + true), flags, crc, size, nameBuf }. `zip64`: sonu ZIP64 kayıtlarıyla ve + girdilerin boyut/konumu ZIP64 ek alanında. `declaredCount`: sonda + bildirilen girdi sayısı (yalan söylemek için). */ +function makeZip(files, opts) { + const o = opts || {}; + const locals = []; + const central = []; + let offset = 0; + for (const f of files) { + const data = Buffer.isBuffer(f.data) ? f.data : Buffer.from(f.data == null ? '' : f.data); + const method = f.method == null ? 8 : f.method; + const comp = f.comp || (method === 8 ? zlib.deflateRawSync(data) : data); + const nameBuf = f.nameBuf || Buffer.from(f.name, 'utf8'); + const flags = (f.flags || 0) | (f.utf8 === false ? 0 : 0x800); + const crc = f.crc != null ? f.crc : Z.crc32(data); + const size = f.size != null ? f.size : data.length; + const lh = Buffer.alloc(30); + lh.writeUInt32LE(0x04034b50, 0); + lh.writeUInt16LE(20, 4); + lh.writeUInt16LE(flags, 6); + lh.writeUInt16LE(method, 8); + lh.writeUInt32LE(crc, 14); + lh.writeUInt32LE(comp.length, 18); + lh.writeUInt32LE(size, 22); + lh.writeUInt16LE(nameBuf.length, 26); + const extra = o.zip64 ? Buffer.alloc(28) : Buffer.alloc(0); + if (o.zip64) { + extra.writeUInt16LE(1, 0); + extra.writeUInt16LE(24, 2); + extra.writeBigUInt64LE(BigInt(size), 4); + extra.writeBigUInt64LE(BigInt(comp.length), 12); + extra.writeBigUInt64LE(BigInt(offset), 20); + } + const cd = Buffer.alloc(46); + cd.writeUInt32LE(0x02014b50, 0); + cd.writeUInt16LE(45, 4); + cd.writeUInt16LE(20, 6); + cd.writeUInt16LE(flags, 8); + cd.writeUInt16LE(method, 10); + cd.writeUInt32LE(crc, 16); + cd.writeUInt32LE(o.zip64 ? 0xffffffff : comp.length, 20); + cd.writeUInt32LE(o.zip64 ? 0xffffffff : size, 24); + cd.writeUInt16LE(nameBuf.length, 28); + cd.writeUInt16LE(extra.length, 30); + cd.writeUInt32LE(o.zip64 ? 0xffffffff : offset, 42); + locals.push(lh, nameBuf, comp); + central.push(cd, nameBuf, extra); + offset += 30 + nameBuf.length + comp.length; + } + const cdBuf = Buffer.concat(central); + const count = o.declaredCount != null ? o.declaredCount : files.length; + const tail = []; + if (o.zip64) { + const rec = Buffer.alloc(56); + rec.writeUInt32LE(0x06064b50, 0); + rec.writeBigUInt64LE(44n, 4); + rec.writeUInt16LE(45, 12); + rec.writeUInt16LE(45, 14); + rec.writeBigUInt64LE(BigInt(count), 24); + rec.writeBigUInt64LE(BigInt(count), 32); + rec.writeBigUInt64LE(BigInt(cdBuf.length), 40); + rec.writeBigUInt64LE(BigInt(offset), 48); + const loc = Buffer.alloc(20); + loc.writeUInt32LE(0x07064b50, 0); + loc.writeBigUInt64LE(BigInt(offset + cdBuf.length), 8); + loc.writeUInt32LE(1, 16); + tail.push(rec, loc); + } + const eocd = Buffer.alloc(22); + eocd.writeUInt32LE(0x06054b50, 0); + eocd.writeUInt16LE(o.zip64 ? 0xffff : count, 8); + eocd.writeUInt16LE(o.zip64 ? 0xffff : count, 10); + eocd.writeUInt32LE(o.zip64 ? 0xffffffff : cdBuf.length, 12); + eocd.writeUInt32LE(o.zip64 ? 0xffffffff : offset, 16); + return Buffer.concat(locals.concat([cdBuf], tail, [eocd])); +} + +const code = (fn) => { try { fn(); return 'hata yok'; } catch (e) { return e.code || e.message; } }; + +test('CRC32 standart doğrulama değeri', () => { + assert.strictEqual(Z.crc32(Buffer.from('123456789')).toString(16), 'cbf43926'); +}); + +test('saklanmış ve deflate girdiler, klasörler, UTF-8 ad', () => { + const milk = '[preset00]\nfDecay=0.98\nper_frame_1=zoom = 1.01;\n'.repeat(40); + const f = tmpFile(makeZip([ + { name: 'Paket/', data: '', method: 0 }, + { name: 'Paket/Fraktal/Geiss - Kazan.milk', data: milk }, + { name: 'Paket/doku/ışık.png', data: Buffer.from([1, 2, 3, 4]), method: 0 }, + ])); + const z = Z.open(f); + try { + assert.deepStrictEqual(z.entries.map((e) => [e.name, e.dir, e.method]), [ + ['Paket/', true, 0], ['Paket/Fraktal/Geiss - Kazan.milk', false, 8], ['Paket/doku/ışık.png', false, 0], + ]); + assert.strictEqual(z.read(z.entries[1]).toString('utf8'), milk); + assert.ok(z.entries[1].compSize < milk.length, 'gerçekten sıkışmış'); + assert.deepStrictEqual([...z.read(z.entries[2])], [1, 2, 3, 4]); + assert.strictEqual(code(() => z.read(z.entries[0])), 'IS_DIR'); + assert.strictEqual(code(() => z.read(z.entries[1], 10)), 'TOO_LARGE', 'çağıranın sınırı'); + } finally { + z.close(); + } + assert.strictEqual(code(() => z.read(z.entries[1])), 'CLOSED'); +}); + +test('CP437 ad: UTF-8 bayrağı yokken DOS kod sayfası', () => { + const f = tmpFile(makeZip([{ name: 'x', nameBuf: Buffer.from([0x80, 0x81, 0x65, 0x2e, 0x6d, 0x69, 0x6c, 0x6b]), utf8: false, data: 'a' }])); + const z = Z.open(f); + assert.strictEqual(z.entries[0].name, 'Çüe.milk'); + z.close(); +}); + +test('ZIP64: girdi sayısı, boyut ve konum ZIP64 kayıtlarından', () => { + const f = tmpFile(makeZip([ + { name: 'a.milk', data: 'birinci' }, + { name: 'b.milk', data: 'ikinci', method: 0 }, + ], { zip64: true })); + const z = Z.open(f); + assert.deepStrictEqual(z.entries.map((e) => [e.name, e.size]), [['a.milk', 7], ['b.milk', 6]]); + assert.strictEqual(z.read(z.entries[0]).toString(), 'birinci'); + assert.strictEqual(z.read(z.entries[1]).toString(), 'ikinci'); + z.close(); + // Çok girdi bildiren arşiv, merkezi dizin okunmadan reddediliyor + assert.strictEqual(code(() => Z.open(tmpFile(makeZip([], { zip64: true, declaredCount: Z.MAX_ENTRIES + 1 })))), 'TOO_MANY_ENTRIES'); +}); + +test('şifreli girdi ve başka sıkıştırma yöntemi kendi adıyla', () => { + const f = tmpFile(makeZip([ + { name: 'gizli.milk', data: 'x', flags: 1 }, + { name: 'lzma.milk', data: 'y', method: 14, comp: Buffer.from('y') }, + ])); + const z = Z.open(f); + assert.strictEqual(z.entries[0].encrypted, true); + assert.strictEqual(code(() => z.read(z.entries[0])), 'ENCRYPTED'); + assert.strictEqual(code(() => z.read(z.entries[1])), 'UNSUPPORTED_METHOD'); + z.close(); +}); + +test('bozuk içerik: CRC, yalan söyleyen boyut, bomba', () => { + const data = Buffer.alloc(3 * 1024 * 1024, 0x41); + const f = tmpFile(makeZip([ + { name: 'crc.milk', data: 'abc', crc: 12345 }, + // Başlık 10 bayt diyor, içerik 3 MB açılıyor: bildirilenin ötesine açılamıyor + { name: 'yalan.milk', data, size: 10 }, + // 3 MB, ~3 KB'a sıkışıyor: oran sınırın üstünde + { name: 'bomba.png', data }, + ])); + const z = Z.open(f); + assert.strictEqual(code(() => z.read(z.entries[0])), 'CRC'); + assert.strictEqual(code(() => z.read(z.entries[1])), 'CORRUPT'); + assert.ok(z.entries[2].size / z.entries[2].compSize > Z.MAX_RATIO); + assert.strictEqual(code(() => z.read(z.entries[2])), 'SUSPICIOUS_RATIO'); + z.close(); +}); + +test('ZIP olmayan ve bozuk arşiv', () => { + assert.strictEqual(code(() => Z.open(tmpFile(Buffer.from('MILKDROP_PRESET_VERSION=201\n'.repeat(3))))), 'NOT_ZIP'); + assert.strictEqual(code(() => Z.open(tmpFile(Buffer.alloc(5)))), 'NOT_ZIP'); + const good = makeZip([{ name: 'a.milk', data: 'abc' }]); + // Merkezi dizinin imzası bozuk + const bad = Buffer.from(good); + bad.writeUInt32LE(0xdeadbeef, good.length - 22 - 46 - 6); + assert.strictEqual(code(() => Z.open(tmpFile(bad))), 'CORRUPT'); + // Merkezi dizin dosyanın dışını gösteriyor + const out = Buffer.from(good); + out.writeUInt32LE(0x7fffffff, good.length - 22 + 16); + assert.strictEqual(code(() => Z.open(tmpFile(out))), 'CORRUPT'); +}); From f992e143437c5dd3e2f911836e8e93c91d66416c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=87A=C4=9EAN=20TURGUT?= Date: Tue, 22 Sep 2026 20:35:28 +0300 Subject: [PATCH 2/2] docs(milkdrop): note the web overlay texture check; say what reaches the page A web overlay opened before an import asked for its texture list again after it and loaded the imported texture from the server. The comments now say that only file lists stay in the main process: the source's own path goes to the panel as a tooltip. Refs #574 --- ROADMAP.md | 11 +++++++---- src/main/main.js | 3 ++- src/main/milkdrop-import.js | 2 +- src/main/preload-admin.js | 2 +- 4 files changed, 11 insertions(+), 7 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index f27db24..d0af202 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1718,10 +1718,13 @@ rest after. No version number yet. in the user's own Downloads in 5–7 s; importing the test pack added 79 presets tagged Dancer (74) and Fractal (5) and copied `worms.jpg` into the app's folder, and the visualizer window loaded it with no texture - folder chosen. An 80,000-preset folder the search could count only to - 10,013 was listed as "10,013+"; *Import* scanned it again (10.8 s), the - confirmation showed 80,000 presets and 80 folder tags, and cancelling - imported nothing. The English UI was checked the same way. + folder chosen. A web overlay opened in a real browser before the import + asked for its texture list again after it and loaded `worms.jpg` from + the server when a preset needing it was chosen. An 80,000-preset folder + the search could count only to 10,013 was listed as "10,013+"; *Import* + scanned it again (10.8 s), the confirmation showed 80,000 presets and 80 + folder tags, and cancelling imported nothing. The English UI was checked + the same way. - **Not done:** `.milk2` (#567); RAR and 7z packs; a ZIP inside a ZIP or inside a chosen folder is not opened; the search does not look at other drives or anywhere outside the places above — *Import from Folder* diff --git a/src/main/main.js b/src/main/main.js index 2be2e78..8f35b86 100644 --- a/src/main/main.js +++ b/src/main/main.js @@ -2193,7 +2193,8 @@ ipcMain.handle('milkdrop:texture', (e, name) => { // İKİ AŞAMA: tarama bir plan üretiyor ve sayfaya yalnız özeti gidiyor (kaç // preset, kaç doku, kaç MB, neler atlanacak); plan burada, kimliğiyle // bekliyor. Kullanıcı onaylarsa aynı kimlikle çalıştırılıyor. Sormadan -// hiçbir şey kopyalanmıyor. Yollar ve dosya listeleri sayfaya gitmiyor. +// hiçbir şey kopyalanmıyor. Dosya listeleri sayfaya gitmiyor; kaynağın +// kendi yolu (`where`) yalnız listede ipucu olarak gidiyor. // ---------------------------------------------------------------------------- const mdImport = require('./milkdrop-import'); const libraryPlans = new Map(); // kimlik → plan (son 32) diff --git a/src/main/milkdrop-import.js b/src/main/milkdrop-import.js index 49c7ca3..1bc092f 100644 --- a/src/main/milkdrop-import.js +++ b/src/main/milkdrop-import.js @@ -197,7 +197,7 @@ function finish(plan) { if (plan.bytes > MAX_TOTAL_BYTES) plan.tooBig = true; } -/* Özet: sayfaya giden; yollar ve dosya listeleri ana süreçte kalıyor. +/* Özet: sayfaya giden; dosya listeleri ana süreçte kalıyor. `textures` kesin alınacaklar, `loose` presetlerin yanındaki görseller (yalnız istenenleri). `searchCut`: makinede aramanın süresi yetmedi, sayılar eksik — içe aktarmadan önce `rescan`. */ diff --git a/src/main/preload-admin.js b/src/main/preload-admin.js index ab13565..6de8d02 100644 --- a/src/main/preload-admin.js +++ b/src/main/preload-admin.js @@ -111,7 +111,7 @@ contextBridge.exposeInMainWorld('api', { onPresetsDelta: (cb) => ipcRenderer.on('presets-delta', (e, d) => cb(d)), onPresetsProgress: (cb) => ipcRenderer.on('presets-progress', (e, p) => cb(p)), /* MilkDrop kütüphanesi (#574): tarama bir özet ve kimlik döndürüyor, - içe aktarım aynı kimlikle; yollar ve dosya listeleri ana süreçte. */ + içe aktarım aynı kimlikle; dosya listeleri ana süreçte kalıyor. */ pickMilkdropLibrary: (kind) => ipcRenderer.invoke('milkdrop:library-pick', kind), discoverMilkdropLibraries: () => ipcRenderer.invoke('milkdrop:library-discover'), rescanMilkdropLibrary: (token) => ipcRenderer.invoke('milkdrop:library-rescan', token),