From 89b2dceb01c731ec5e3624a7c7f08bb9302b36b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=87A=C4=9EAN=20TURGUT?= Date: Sat, 3 Oct 2026 00:05:53 +0300 Subject: [PATCH] fix: Linux WebGL fallback, audio capture status, and docs test count (2577) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - K1: Gradient WebGL ctor failure falls back to silent 2D solid; admin notice + sv-gradient-webgl-fallback - M1: Platform-specific NO_DEVICES / ACCESS_DENIED strings (Windows/macOS/Linux) - M2: Visualizer draw no longer gated on audio.ready - M3: package.json description includes Linux - Audio status: always TR Yakalanıyor / EN Capturing with resolved source label - Docs: README, README.tr, ROADMAP test count badges/sections -> 2577 - Tests: layers-webgl-fallback + native-audio / packaging updates --- README.md | 8 +- README.tr.md | 4 +- ROADMAP.md | 2 +- package.json | 2 +- src/admin/admin.js | 165 ++++++++++++++-- src/admin/scene-panels.js | 13 ++ src/main/main.js | 41 ++-- src/main/native-audio.js | 32 +++- src/shared/i18n.js | 14 ++ src/visualizer/layers.js | 63 +++++- tests/layers-webgl-fallback.test.js | 288 ++++++++++++++++++++++++++++ tests/native-audio-runner.test.js | 21 ++ tests/platform-packaging.test.js | 9 + 13 files changed, 617 insertions(+), 45 deletions(-) create mode 100644 tests/layers-webgl-fallback.test.js diff --git a/README.md b/README.md index 7899ad8..d49ff72 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-2561%20passing-2ea043.svg)](#tests) +[![Tests](https://img.shields.io/badge/tests-2577%20passing-2ea043.svg)](#tests) [![cayadev.com](https://img.shields.io/badge/cayadev.com-e11d2a.svg)](https://cayadev.com) @@ -813,7 +813,7 @@ The styles the application shipped with, still one click away. The sections above show what the application looks like. This one lists what is actually in it, by name. -### Backgrounds — 31 types +### Backgrounds — 41 types **Fluid** — **Fluid Gradient** (an audio-reactive mesh gradient in a WebGL shader, in *Soft* and *Plasma* styles, with flow speed, wander, orbit, swirl, warp, scale, grain, vignette, audio burst @@ -848,7 +848,7 @@ Five colour stops, **58 built-in palettes** in seven groups (Classics, Warm, Coo Dark, Light, Monochrome Families) and your own saved palettes apply to every background type, to the Studio engine and to the 3D engine. -### Visualizer — 48 modes +### Visualizer — 60 modes **Basic** — **Bars** · **Center** · **Segments** (LED equaliser) · **Dot Matrix** · **Skyline** (buildings with lit windows) @@ -1341,7 +1341,7 @@ npm test npm start -- --smoke ``` -**2561 unit tests, all passing.** They are written to check answers, not to exercise lines: +**2577 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 e538a34..7b4203f 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-2561%20geçiyor-2ea043.svg)](#testler) +[![Test](https://img.shields.io/badge/test-2577%20geçiyor-2ea043.svg)](#testler) [![cayadev.com](https://img.shields.io/badge/cayadev.com-e11d2a.svg)](https://cayadev.com) @@ -1341,7 +1341,7 @@ npm test npm start -- --smoke ``` -**2561 birim testi, hepsi geçiyor.** Satır çalıştırmak için değil, cevap denetlemek için yazıldılar: +**2577 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 6447935..a1b8d02 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,7 +129,7 @@ npm test npm start -- --smoke ``` -- **2561 unit tests, all passing** on `main`. 703 of those shipped in v3.1.0; +- **2577 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 878 on `main` since. diff --git a/package.json b/package.json index 7d1d342..59e30a4 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "soundvisualizer", "version": "3.1.5-beta", - "description": "CAYADEV — çoklu ekran, OBS yayın çıkışı ve Studio shader editörü olan ses görselleştirici (Windows + macOS)", + "description": "CAYADEV — çoklu ekran, OBS yayın çıkışı ve Studio shader editörü olan ses görselleştirici (Windows + macOS + Linux)", "main": "src/main/main.js", "author": { "name": "CAYADEV", diff --git a/src/admin/admin.js b/src/admin/admin.js index 5db4a55..b273f63 100644 --- a/src/admin/admin.js +++ b/src/admin/admin.js @@ -542,10 +542,17 @@ return window.SVNowPlayingPanel ? window.SVNowPlayingPanel.panel() : null; case 'grouppanel': return window.SVGroupPanel ? window.SVGroupPanel.panel() : null; - case 'note': + case 'note': { // Metin işlev olabilir: içerik çizim anında hesaplanır (ör. ekran hızı) - return el('div', { class: 'ctrl settings-io-note', - text: typeof def.text === 'function' ? def.text() : def.text }); + const note = el('div', { + class: 'ctrl settings-io-note' + (def.warn ? ' warn' : ''), + text: typeof def.text === 'function' ? def.text() : def.text, + }); + if (def.attrs) { + for (const k in def.attrs) note.setAttribute(k, def.attrs[k]); + } + return note; + } case 'scenes': return scenesCtrl(def); case 'displaypicker': @@ -2097,6 +2104,12 @@ const isGradient = () => cfg.background.type === 'gradient'; // Renk paleti gradyan dışındaki 2D arkaplan modlarında da kullanılır const usesPalette = () => cfg.background.type !== 'solid'; + /* K1: WebGL gradient ctor failed → silent 2D solid. Informational only. */ + const isGradientWebGLFallback = () => !!( + window.SVLayers + && typeof window.SVLayers.isGradientWebGLFallback === 'function' + && window.SVLayers.isGradientWebGLFallback() + ); // Frekans bandı okuyan ön modlar (bar sayısı / frekans aralığı anlamlı) const isBandMode = () => MC().is('visualizer', v.type, 'bands'); // Bar benzeri geometriye sahip modlar (aralarındaki boşluk anlamlı) @@ -2183,6 +2196,13 @@ type: 'segment', path: 'background.type', label: 'Tür', rebuild: true, grouped: true, options: MC().options('background'), }, + { + type: 'note', + warn: true, + attrs: { 'data-sv-webgl-fallback-note': '1' }, + text: 'WebGL yok / desteklenmiyor: gradyan düz renge düştü', + show: () => isGradient() && isGradientWebGLFallback(), + }, { type: 'custompicker', kind: 'background', show: () => cfg.background.type === 'custom' }, { type: 'segment', @@ -3257,6 +3277,8 @@ /* Masonry sınıfı bir önceki çizimden kalırsa yeni kartlar 4 px satıra doğar. Linux o boyu içerik sanıp bütün kartları üst üste bindiriyor. */ root.classList.remove('masonry'); + // Preview first so K1 WebGL-fallback flag is current for note show(). + if (window.SVPreview) window.SVPreview.setConfig(cfg); root.innerHTML = ''; const cat = CATEGORIES.find((c) => c.id === activeCategory) || CATEGORIES[0]; @@ -3290,7 +3312,6 @@ restoreSectionsScroll(); requestAnimationFrame(restoreSectionsScroll); }); - if (window.SVPreview) window.SVPreview.setConfig(cfg); } /* KART DÜZENİ (#622). @@ -3977,6 +3998,16 @@ updateFloatingToggles(); } + /* audioUiKind tracks how the Ses Seviyesi status was last set: + idle (waiting / diagnosing), capture (source started or levels flowing), + devices (enumerate success), err (capture/diagnose failure). + Capture may already be running before the admin window subscribes to + audio-source-status, so meters can move while the label still says + "Ses bekleniyor…". Levels refresh promotes idle/devices -> capture. */ + let audioUiKind = 'idle'; + let audioCaptureDevice = null; + let audioCaptureFromStatus = false; // true after onAudioSourceStatus started + function setAudioState(text, cls, icon) { const a = $('audioState'); window.SVIcons.set(a, icon || '', text); @@ -3984,6 +4015,67 @@ a.className = 'audio-state' + (cls ? ' ' + cls : ''); } + /* Mirror loopback-helper resolveDevice + CAPTURE-START naming: + 'default' -> pickDefault device name; apps -> label; else device name. + Joined with ' + ' so the status matches onAudioSourceStatus started. */ + function pickDefaultDeviceName() { + const all = audioDevices || []; + const d = + all.find((x) => x.loopback && x.isDefault) || + all.find((x) => x.loopback) || + all.find((x) => x.kind === 'output' && x.isDefault) || + all.find((x) => x.kind === 'output') || + all.find((x) => x.isDefault) || + all[0] || + null; + return d && d.name ? d.name : null; + } + + function resolveSelectedCaptureLabel() { + const audio = cfg && cfg.audio; + const sources = (audio && Array.isArray(audio.sources) && audio.sources.length) + ? audio.sources + : (audio && audio.source ? [audio.source] : ['default']); + const AA = window.SVAppAudio; + const names = []; + const seen = new Set(); + for (const s of sources) { + let name = null; + if (AA && AA.isAppSource(s)) name = s.label || s.match || null; + else if (s === 'default') name = pickDefaultDeviceName(); + else if (s != null && s !== '') name = String(s); + if (!name || seen.has(name)) continue; + seen.add(name); + names.push(name); + } + return names.length ? names.join(' + ') : null; + } + + function markAudioCapturing(device) { + if (device) audioCaptureDevice = device; + const label = audioCaptureDevice || resolveSelectedCaptureLabel() || 'çıkış'; + audioCaptureDevice = label; + audioCaptureFromStatus = true; + audioUiKind = 'capture'; + setAudioState('Yakalanıyor: ' + label, 'ok', 'record'); + } + + /* Promote waiting/device-list status when live meters show signal. + Resolves the selected Ses Kaynakları label so we do not need a + re-select (started event) just to learn the device name. */ + function ensureAudioListeningFromLevels(d) { + if (!d || audioUiKind === 'err') return; + if (audioCaptureFromStatus && audioUiKind === 'capture') return; + const signal = Math.max(+d.level || 0, +d.bass || 0, +d.mid || 0, +d.treble || 0); + if (!(signal > 0.015)) return; + const label = resolveSelectedCaptureLabel() || audioCaptureDevice; + if (audioUiKind === 'capture' && audioCaptureDevice && audioCaptureDevice === label) return; + audioCaptureDevice = label; + audioUiKind = 'capture'; + if (label) setAudioState('Yakalanıyor: ' + label, 'ok', 'record'); + else setAudioState('Yakalanıyor: ' + (resolveSelectedCaptureLabel() || 'çıkış'), 'ok', 'record'); + } + /* Ölçek olarak yazılıyor, genişlik değil (bkz. admin.css .meter .bar i, #621). Aynı değer yeniden yazılmıyor: stil bildirimi her yazımda değişmiş sayılıyor. */ @@ -4002,14 +4094,28 @@ if (window.SVI18n?.locale !== 'tr') { return `${english} [${code}]${result?.retried ? ' Automatic retry was unsuccessful.' : ''}`; } + /* Platforma gore TR metin: Windows Ses ayarlari Linux'ta yaniltir. + loopbackAdvice / noDevicesMessage ile ayni yonlendirme. */ const tr = { - NODE_NOT_FOUND: 'Node.js bulunamadı. Node.js LTS kurun veya PATH ayarını onarın.', + NODE_NOT_FOUND: 'Node.js bulunamadı. Node.js LTS kurun veya PATH ayarınızı onarın.', HELPER_MISSING: 'Ses yardımcı dosyaları kurulumda eksik. Uygulamayı yeniden kurun veya onarın.', AUDIFY_MISSING: 'Native ses modülü eksik. Uygulamayı yeniden kurun veya onarın.', NATIVE_ABI_MISMATCH: 'Native ses modülü bu Node.js sürümüyle uyumsuz. Node.js LTS ve uygulamayı yeniden kurun.', - ACCESS_DENIED: 'Windows ses sistemine erişimi engelledi. Ses gizlilik/güvenlik ayarlarını kontrol edip uygulamayı yeniden başlatın.', - DEVICE_ENUM_TIMEOUT: 'Ses aygıtı algılama zaman aşımına uğradı. Windows Ses hizmetini ve bağlı aygıtları kontrol edin.', - NO_DEVICES: 'Etkin ses aygıtı bulunamadı. Windows Ses ayarlarını kontrol edin ve aygıtı yeniden bağlayın.', + ACCESS_DENIED: PLATFORM.isLinux + ? 'Ses alt sistemine erişim engellendi. PulseAudio/PipeWire izinlerini kontrol edip uygulamayı yeniden başlatın.' + : PLATFORM.isMac + ? 'macOS ses alt sistemine erişimi engelledi. Ses ve Gizlilik ayarlarını kontrol edip uygulamayı yeniden başlatın.' + : 'Windows ses sistemine erişimi engelledi. Ses gizlilik/güvenlik ayarlarını kontrol edip uygulamayı yeniden başlatın.', + DEVICE_ENUM_TIMEOUT: PLATFORM.isLinux + ? 'Ses aygıtı algılama zaman aşımına uğradı. PulseAudio veya PipeWire servisini ve bağlı aygıtları kontrol edin.' + : PLATFORM.isMac + ? 'Ses aygıtı algılama zaman aşımına uğradı. macOS Ses ayarlarını ve bağlı aygıtları kontrol edin.' + : 'Ses aygıtı algılama zaman aşımına uğradı. Windows Ses hizmetini ve bağlı aygıtları kontrol edin.', + NO_DEVICES: PLATFORM.isLinux + ? 'Etkin ses aygıtı bulunamadı. PulseAudio veya PipeWire çalışıyor mu ve bir monitor kaynağı görünüyor mu kontrol edin.' + : PLATFORM.isMac + ? 'Etkin ses aygıtı bulunamadı. macOS Ses ayarlarını kontrol edin ve aygıtı yeniden bağlayın.' + : 'Etkin ses aygıtı bulunamadı. Windows Ses ayarlarını kontrol edin ve aygıtı yeniden bağlayın.', INVALID_HELPER_OUTPUT: 'Ses yardımcı süreci geçersiz veri döndürdü.', HELPER_EXITED: 'Ses yardımcı süreci beklenmedik şekilde kapandı.', PROCESS_START_FAILED: 'Ses yardımcı süreci başlatılamadı.', @@ -4021,10 +4127,22 @@ function applyAudioDiagnostic(result, showSuccess = false) { audioDevices = result?.devices || []; if (result?.ok) { - if (showSuccess) setAudioState(`${audioDevices.length} ses aygıtı bulundu`, 'ok', 'check'); + // Keep an active capture label; enumerate success must not hide it. + if (showSuccess && audioUiKind !== 'capture') { + audioUiKind = 'devices'; + setAudioState(`${audioDevices.length} ses aygıtı bulundu`, 'ok', 'check'); + } else if (audioUiKind === 'capture' && !audioCaptureFromStatus) { + // Device list just refreshed — resolve default -> real name for status. + const label = resolveSelectedCaptureLabel(); + if (label && label !== audioCaptureDevice) { + audioCaptureDevice = label; + setAudioState('Yakalanıyor: ' + label, 'ok', 'record'); + } + } $('banner').classList.add('hidden'); return; } + audioUiKind = 'err'; setAudioState('Ses aygıtı tanılaması başarısız', 'err', 'warning'); $('bannerDetail').textContent = diagnosticText(result); $('banner').classList.remove('hidden'); @@ -4125,7 +4243,12 @@ actions.floatingSnapTl = () => { if (window.api && window.api.floatingSnap) window.api.floatingSnap('tl'); }; actions.refreshDevices = async () => { - setAudioState(window.SVI18n?.locale === 'tr' ? 'Ses aygıtları tanılanıyor…' : 'Diagnosing audio devices…'); + // Do not clobber an active capture status while re-enumerating devices + // (started handler may call refresh when a device name is missing). + if (audioUiKind !== 'capture') { + audioUiKind = 'idle'; + setAudioState(window.SVI18n?.locale === 'tr' ? 'Ses aygıtları tanılanıyor…' : 'Diagnosing audio devices…'); + } const result = await window.api.diagnoseAudio(); applyAudioDiagnostic(result, true); await refreshAudioApps(); @@ -4279,6 +4402,7 @@ setMeter('mBass', l.bass); setMeter('mMid', l.mid); setMeter('mTreble', l.treble); + ensureAudioListeningFromLevels(l); }, 50); const btn = $('previewToggle'); @@ -4765,6 +4889,8 @@ } const audioDiagnostic = await window.api.diagnoseAudio(); audioDevices = audioDiagnostic?.devices || []; + // Prefetch capture label from selected sources so meter promote has a name. + audioCaptureDevice = resolveSelectedCaptureLabel(); await refreshAudioApps(); try { lightingIdentity = await window.api.getLightingIdentityStatus(); @@ -5004,6 +5130,20 @@ } window.api.onVisualizerStatus((d) => setStatus(d.open, d.displayIds, d.floating)); + /* K1 admin notice: refresh when preview stack flips WebGL→solid fallback. */ + let fallbackUiRaf = 0; + window.addEventListener('sv-gradient-webgl-fallback', () => { + if (fallbackUiRaf) return; + fallbackUiRaf = requestAnimationFrame(() => { + fallbackUiRaf = 0; + const want = !!(window.SVLayers + && typeof window.SVLayers.isGradientWebGLFallback === 'function' + && window.SVLayers.isGradientWebGLFallback()); + const has = !!document.querySelector('[data-sv-webgl-fallback-note]'); + if (want !== has) render(); + }); + }); + // Farklı kontrol sistemi (Heartbeat / Durum Güvencesi): // Kaza korumasıyla pencere geri açıldığında, çökme anında veya IPC gecikmelerinde // buton durumunun ve görselleştirici durumunun gerçekle %100 uyuşmasını garanti eder. @@ -5067,10 +5207,11 @@ setMeter('mBass', d.bass); setMeter('mMid', d.mid); setMeter('mTreble', d.treble); + ensureAudioListeningFromLevels(d); }); window.api.onAudioSourceStatus((s) => { if (s.type === 'started') { - setAudioState('Yakalanıyor: ' + (s.device || 'çıkış'), 'ok', 'record'); + markAudioCapturing(s.device || 'çıkış'); $('banner').classList.add('hidden'); // başlatılan aygıtlardan herhangi biri listede yoksa listeyi tazele if (s.device) { @@ -5084,10 +5225,12 @@ kullanıcının BlackHole gibi sanal bir aygıt kurması gerekir. Sessiz kalmak, kullanıcının neden hiçbir şey görmediğini anlamaması demek olurdu. */ + audioUiKind = 'err'; setAudioState('Sistem sesi yakalanamıyor', 'err', 'warning'); $('bannerDetail').textContent = s.message || 'Bu sistemde sistem sesini veren bir aygıt bulunamadı.'; $('banner').classList.remove('hidden'); } else if (s.type === 'error') { + audioUiKind = 'err'; setAudioState('Ses yakalanamadı', 'err', 'warning'); $('bannerDetail').textContent = s.message || 'Çıkış aygıtı yakalanamadı.'; $('banner').classList.remove('hidden'); diff --git a/src/admin/scene-panels.js b/src/admin/scene-panels.js index 3fdc3ef..c330474 100644 --- a/src/admin/scene-panels.js +++ b/src/admin/scene-panels.js @@ -1264,6 +1264,19 @@ ]), ])); + /* K1: when layer stack hides the Background card, surface the same + WebGL→solid info notice here (non-blocking). */ + if (window.SVLayers + && typeof window.SVLayers.isGradientWebGLFallback === 'function' + && window.SVLayers.isGradientWebGLFallback()) { + const note = el('div', { + class: 'studio-note dim-hint', + text: 'WebGL yok / desteklenmiyor: gradyan düz renge düştü', + }); + note.setAttribute('data-sv-webgl-fallback-note', '1'); + nodes.push(note); + } + if (on) { /* Arkaplan kartı yığın açıkken gizli; şeffaflık burada, listenin en üstünde tek bir anahtar. Pencere / yayın / Spout aynı ayarı diff --git a/src/main/main.js b/src/main/main.js index 3043a6e..349a149 100644 --- a/src/main/main.js +++ b/src/main/main.js @@ -100,13 +100,10 @@ const AUTOMATION_RUN = SMOKE || SHOTS || process.argv.includes('--diag') || !!pr `lightingSet`, `syncArtnet`, `syncOpenRgb`). Tanılama (`--diag`) ışıkları gerçekten sınamak için var; ona dokunulmuyor. */ const HW_OFF = SMOKE || SHOTS; -/* Electron 35, console-message olayinin imzasini degistirdi: eskiden - (event, level, message, line, sourceId) geliyordu ve level bir sayiydi - (0 verbose, 1 info, 2 warning, 3 error); artik tum alanlar olay nesnesinin - uzerinde ve level bir metin ("debug" | "info" | "warning" | "error"). - Ikisini de okuyoruz: boylece kod hem yukseltme oncesi hem sonrasi dogru - calisir ve "level >= 2" karsilastirmasi metin gelince sessizce false - donup hatalari gizlemez. */ +/* Electron 35+, console-message: tum alanlar olay nesnesinde; level metin + ("debug"|"info"|"warning"|"error"). Dinleyici TEK arguman almali — eski + (e, level, message, ...) imzasi "arguments are deprecated" uyarisi basar. + consoleInfo olay nesnesini okur; eski konumsal arguman yedegi duruyor. */ /* Chromium, CSP’de script-src yazılmadığında her pencere yüklenişinde bu notu basar. Gerçek bir ihlal mesajı ("Refused to...") eşlik etmiyor — arandı, yok. Üç ekran açılınca üç not geliyor ve hata listesini @@ -133,8 +130,8 @@ function consoleInfo(e, level, message) { function attachSmoke(win, name) { if (!SMOKE) return; const wc = win.webContents; - wc.on('console-message', (e, level, message) => { - const c = consoleInfo(e, level, message); + wc.on('console-message', (e) => { + const c = consoleInfo(e); if (isConsoleNoise(c.message)) return; console.log(`[${name}] ${c.message}`); }); @@ -3645,8 +3642,8 @@ async function runSmoke() { if (!anyVisualizerOpen()) throw new Error('visualizer window did not open'); const wc = meterWindow().webContents; - wc.on('console-message', (e, level, message) => { - const c = consoleInfo(e, level, message); + wc.on('console-message', (e) => { + const c = consoleInfo(e); if (isConsoleNoise(c.message)) return; if (c.warnOrWorse || /error|hata|failed|undefined is not/i.test(c.message)) errors.push(c.message); }); @@ -4443,8 +4440,8 @@ async function runSmoke() { if (adminWin && !adminWin.isDestroyed()) { const awc = adminWin.webContents; const adminErrors = []; - awc.on('console-message', (e, level, message) => { - const c = consoleInfo(e, level, message); + awc.on('console-message', (e) => { + const c = consoleInfo(e); if (isConsoleNoise(c.message)) return; if (c.warnOrWorse) adminErrors.push(c.message); }); @@ -5445,8 +5442,18 @@ async function runSmoke() { if (t.hata) errors.push('texture-share: ' + t.hata); else { if (t.düğümTürü !== 1) errors.push('texture-share: the panel did not return a single element node'); - if (!(t.çocuk > 2)) errors.push('texture-share: the panel drew almost nothing (' + t.çocuk + ' children)'); - if (!/^(Spout|Syphon)/.test(t.protokol)) errors.push('texture-share: protocol name is wrong for this platform: ' + t.protokol); + /* Linux (ve Spout/Syphon'siz kurulum): panel bilerek tek uyari dugumu + dondurur (supported===false). cocuk>2 esigi Windows/macOS UX'i icindi; + burada false-positive olmasin. */ + const texAv = textureShare.available(); + if (texAv && texAv.ok === false) { + console.log('[SMOKE] texture-share panel: unsupported here (' + (texAv.reason || 'n/a') + '), children=' + t.çocuk + ' (note-only OK)'); + } else if (!(t.çocuk > 2)) { + errors.push('texture-share: the panel drew almost nothing (' + t.çocuk + ' children)'); + } + if (texAv && texAv.ok !== false) { + if (!/^(Spout|Syphon)/.test(t.protokol)) errors.push('texture-share: protocol name is wrong for this platform: ' + t.protokol); + } } } @@ -5662,8 +5669,8 @@ async function runSmoke() { webPreferences: { backgroundThrottling: false }, }); const konsol = []; - win.webContents.on('console-message', (e, level, message) => { - const c = consoleInfo(e, level, message); + win.webContents.on('console-message', (e) => { + const c = consoleInfo(e); if (isConsoleNoise(c.message)) return; if (c.level === 3 || c.level === 'error') konsol.push(c.message.slice(0, 200)); }); diff --git a/src/main/native-audio.js b/src/main/native-audio.js index bc47b25..b5fa922 100644 --- a/src/main/native-audio.js +++ b/src/main/native-audio.js @@ -145,6 +145,32 @@ function runnerOrder() { return [selfRunner(), externalRunner()]; } +/* Empty device list is platform-blind without this: Windows Sound + wording on Linux/macOS sends users to the wrong settings. Align the + actionable half with loopbackAdvice (PulseAudio/PipeWire / macOS / + Windows). */ +function noDevicesMessage(platform) { + const p = platform || process.platform; + if (p === 'linux') { + return 'No active audio devices were detected. PulseAudio or PipeWire normally exposes devices — check that the audio server is running and a monitor source is available.'; + } + if (p === 'darwin') { + return 'No active audio devices were detected. Check macOS Sound settings and reconnect the device.'; + } + return 'No active audio devices were detected. Check Windows Sound settings and reconnect the device.'; +} + +function accessDeniedMessage(platform) { + const p = platform || process.platform; + if (p === 'linux') { + return 'Access to the audio subsystem was denied. Check PulseAudio/PipeWire permissions and restart the application.'; + } + if (p === 'darwin') { + return 'macOS denied access to the audio subsystem. Check Sound and Privacy settings and restart the application.'; + } + return 'Windows denied access to the audio subsystem. Restart the application and check audio privacy/security settings.'; +} + function classifyListError({ code, signal, stderr, spawnError, timedOut, node, helperExists, runner }) { if (!helperExists) return { code: 'HELPER_MISSING', message: 'Audio helper files are missing from the installation.' }; if (timedOut) return { code: 'DEVICE_ENUM_TIMEOUT', message: 'Audio device detection timed out.' }; @@ -170,10 +196,10 @@ function classifyListError({ code, signal, stderr, spawnError, timedOut, node, h return { code: 'NATIVE_ABI_MISMATCH', message: 'The native audio module is incompatible with this Node.js version. Reinstall Node.js LTS and the application.' }; } if (/access denied|eperm|eacces/i.test(stderr)) { - return { code: 'ACCESS_DENIED', message: 'Windows denied access to the audio subsystem. Restart the application and check audio privacy/security settings.' }; + return { code: 'ACCESS_DENIED', message: accessDeniedMessage() }; } if (code !== 0) return { code: 'HELPER_EXITED', message: `The audio helper exited unexpectedly (code ${code ?? 'unknown'}${signal ? `, signal ${signal}` : ''}).` }; - return { code: 'NO_DEVICES', message: 'No active audio devices were detected. Check Windows Sound settings and reconnect the device.' }; + return { code: 'NO_DEVICES', message: noDevicesMessage() }; } function listDevicesAttempt(timeoutMs = 6000, runner = selfRunner()) { @@ -358,4 +384,4 @@ function resetNodeCache() { _runner = null; } -module.exports = { listDevices, listOutputDevices, diagnoseAudio, resetNodeCache, startCapture, stopCapture, selfRunner, externalRunner, runnerOrder, classifyListError }; +module.exports = { listDevices, listOutputDevices, diagnoseAudio, resetNodeCache, startCapture, stopCapture, selfRunner, externalRunner, runnerOrder, classifyListError, noDevicesMessage, accessDeniedMessage }; diff --git a/src/shared/i18n.js b/src/shared/i18n.js index 23a5a08..3b42871 100644 --- a/src/shared/i18n.js +++ b/src/shared/i18n.js @@ -1661,10 +1661,22 @@ 'Ses aygıtı algılanamadı.': 'Audio device could not be detected.', 'Ses aygıtı algılama zaman aşımına uğradı. Windows Ses hizmetini ve bağlı aygıtları kontrol edin.': 'Audio device detection timed out. Check Windows Audio service and connected devices.', + 'Ses aygıtı algılama zaman aşımına uğradı. PulseAudio veya PipeWire servisini ve bağlı aygıtları kontrol edin.': + 'Audio device detection timed out. Check the PulseAudio or PipeWire service and connected devices.', + 'Ses aygıtı algılama zaman aşımına uğradı. macOS Ses ayarlarını ve bağlı aygıtları kontrol edin.': + 'Audio device detection timed out. Check macOS Sound settings and connected devices.', 'Etkin ses aygıtı bulunamadı. Windows Ses ayarlarını kontrol edin ve aygıtı yeniden bağlayın.': 'No active audio device found. Check Windows Audio settings and reconnect the device.', + 'Etkin ses aygıtı bulunamadı. PulseAudio veya PipeWire çalışıyor mu ve bir monitor kaynağı görünüyor mu kontrol edin.': + 'No active audio device found. Check that PulseAudio or PipeWire is running and a monitor source is available.', + 'Etkin ses aygıtı bulunamadı. macOS Ses ayarlarını kontrol edin ve aygıtı yeniden bağlayın.': + 'No active audio device found. Check macOS Sound settings and reconnect the device.', 'Windows ses sistemine erişimi engelledi. Ses gizlilik/güvenlik ayarlarını kontrol edip uygulamayı yeniden başlatın.': 'Windows blocked access to audio system. Check audio privacy/security settings and restart the application.', + 'Ses alt sistemine erişim engellendi. PulseAudio/PipeWire izinlerini kontrol edip uygulamayı yeniden başlatın.': + 'Access to the audio subsystem was denied. Check PulseAudio/PipeWire permissions and restart the application.', + 'macOS ses alt sistemine erişimi engelledi. Ses ve Gizlilik ayarlarını kontrol edip uygulamayı yeniden başlatın.': + 'macOS blocked access to the audio subsystem. Check Sound and Privacy settings and restart the application.', 'Ses yardımcı dosyaları kurulumda eksik. Uygulamayı yeniden kurun veya onarın.': 'Audio helper files missing from installation. Reinstall or repair the application.', 'Ses yardımcı süreci başlatılamadı.': 'Could not start audio helper process.', @@ -1717,6 +1729,8 @@ 'Kamera erişimi bu ortamda kullanılamıyor.': 'Camera access is not available in this environment.', 'WebGL2 kullanılamıyor. Sürücü güncellemesi gerekebilir.': 'WebGL2 unavailable. Driver update may be required.', 'WebGL2 kullanılamıyor': 'WebGL2 unavailable', + 'WebGL yok / desteklenmiyor: gradyan düz renge düştü': + 'WebGL missing / unsupported: gradient fell back to solid color', 'bilinmeyen derleme hatası': 'unknown compile error', 'bağlama hatası': 'link error', 'Derleme hatası': 'Compilation error', diff --git a/src/visualizer/layers.js b/src/visualizer/layers.js index 45d106a..42b1457 100644 --- a/src/visualizer/layers.js +++ b/src/visualizer/layers.js @@ -693,6 +693,9 @@ this.container = container || null; this.opts = opts || {}; this.entries = []; // { layer, canvas, ctx, mode, key } + /* K1 admin signal: true while any gradient entry is on the silent + 2D solid path after WebGL ctor failure. */ + this._gradientWebGLFallback = false; this.width = 2; this.height = 2; this.sprites = null; // paylaşılan sprite motoru @@ -949,7 +952,8 @@ fresh.canvas.className = old.className; } this._disposeEntry(e); - for (const k of ['canvas', 'ctx', 'mode', 'gl', 'solid']) e[k] = fresh[k]; + for (const k of ['canvas', 'ctx', 'mode', 'gl', 'solid', 'webglFallback']) e[k] = fresh[k]; + this._syncGradientWebGLFallback(); this._sizeEntry(e); this.revived = (this.revived || 0) + 1; } @@ -1074,6 +1078,24 @@ } } this._applyStatic(); + this._syncGradientWebGLFallback(); + } + + /* Informational only: admin shows a non-blocking notice when the K1 + silent 2D solid path is active. Never throws / never dialogs. */ + _syncGradientWebGLFallback() { + const active = this.entries.some((e) => !!(e && e.webglFallback)); + if (this._gradientWebGLFallback === active) return; + this._gradientWebGLFallback = active; + try { + if (typeof window !== 'undefined' && typeof window.dispatchEvent === 'function') { + window.dispatchEvent(new CustomEvent('sv-gradient-webgl-fallback', { detail: { active } })); + } + } catch { /* DOM-less / test host */ } + } + + hasGradientWebGLFallback() { + return !!this._gradientWebGLFallback; } _create(layer, key, cfg) { @@ -1093,9 +1115,21 @@ const lcfg = layerConfig(cfg, layer); if (layer.kind === 'background') { if (layer.type === 'gradient') { - // WebGL gradyan kendi tuvalini sürer - try { e.mode = new window.SVModes.gradient(e.canvas); } catch { e.mode = null; } - e.gl = true; + // WebGL gradyan kendi tuvalini surer. WebGL yoksa (blocklist) + // kurucu firlatir: e.gl=true + e.ctx=null birakmamali — fillStyle + // TypeError spam'i olur. 2D duz renk yedegine dus. + try { + e.mode = new window.SVModes.gradient(e.canvas); + e.gl = true; + e.webglFallback = false; + } catch { + e.mode = null; + e.gl = false; + e.ctx = e.canvas.getContext('2d'); + e.solid = true; + /* Silent K1 path: solid 2D fill. Flag drives admin info notice. */ + e.webglFallback = true; + } } else if (window.SVBackgrounds && window.SVBackgrounds[layer.type]) { e.mode = new window.SVBackgrounds[layer.type](); e.ctx = e.canvas.getContext('2d'); @@ -1614,6 +1648,8 @@ if (l.kind === 'background') { if (e.gl && e.mode) { e.mode.draw(audio, lcfg, t); return; } if (e.mode) { e.mode.draw(e.ctx, audio, lcfg, t, W, H, dt); return; } + /* WebGL fallback / solid: ctx yoksa fillStyle'a dokunma. */ + if (!e.ctx) return; /* Şeffaf arkaplanda düz renk BOYANMAZ; boyasaydık pencerenin şeffaflığı bir işe yaramaz, altındaki masaüstü görünmezdi. */ if (lcfg.background && lcfg.background.transparent) { @@ -1627,8 +1663,12 @@ } if (l.kind === 'visualizer' || l.kind === 'nowplaying') { - if (!e.mode) { e.ctx.clearRect(0, 0, W, H); return; } - if (!audio || !audio.ready) { e.ctx.clearRect(0, 0, W, H); return; } + if (!e.mode) { if (e.ctx) e.ctx.clearRect(0, 0, W, H); return; } + if (!audio) { if (e.ctx) e.ctx.clearRect(0, 0, W, H); return; } + /* Ses karesi gelmeden de ciz: MilkDrop/Studio WebGL kurulumu ilk + draw'da. audio.ready yalnizca ingestFrame'de true; aygitsiz ortamda + clear+return GL'yi hic acmiyordu. AudioEngine sifir tamponlarla + baslar; gercek kare gelince ayni yol canli sinyali surer. */ e.mode.draw(audio, lcfg, t, dt); return; } @@ -1863,8 +1903,19 @@ } } + function isGradientWebGLFallback() { + try { + if (typeof window !== 'undefined' && window.SVPreview && typeof window.SVPreview.stack === 'function') { + const st = window.SVPreview.stack(); + if (st && typeof st.hasGradientWebGLFallback === 'function') return !!st.hasGradientWebGLFallback(); + } + } catch { /* preview not ready */ } + return false; + } + const api = { LayerStack, + isGradientWebGLFallback, BLEND_MODES, KINDS, normalizeLayer, diff --git a/tests/layers-webgl-fallback.test.js b/tests/layers-webgl-fallback.test.js new file mode 100644 index 0000000..1b49ce9 --- /dev/null +++ b/tests/layers-webgl-fallback.test.js @@ -0,0 +1,288 @@ +'use strict'; +/* K1 + M2 regression (Linux box / WebGL-blocklist + no-audio). + * + * K1: GradientMode constructor throws when WebGL is missing. Old code set + * e.mode=null but left e.gl=true and never assigned e.ctx → fillStyle + * TypeError every frame. + * M2: Live visualizer cleared+returned when !audio.ready, so MilkDrop never + * reached draw()/_initGL. Idle/zero buffers must still call mode.draw. + */ +global.window = global.window || {}; +const test = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const path = require('path'); +require('../src/shared/defaults.js'); +const L = require('../src/visualizer/layers.js'); + +const ROOT = path.join(__dirname, '..'); +const layersSrc = () => fs.readFileSync(path.join(ROOT, 'src/visualizer/layers.js'), 'utf8'); + +function fakeCanvas() { + const ctx2d = { + fillStyle: '#unset', + fills: 0, + clears: 0, + clearRect() { this.clears++; }, + fillRect() { this.fills++; }, + save() {}, + restore() {}, + setTransform() {}, + drawImage() {}, + }; + return { + width: 320, + height: 180, + style: {}, + classList: { add() {} }, + parentNode: null, + getContext(type) { + if (type === '2d') return ctx2d; + return null; + }, + _ctx2d: ctx2d, + }; +} + +function installDocument() { + global.document = { + createElement(tag) { + assert.strictEqual(tag, 'canvas'); + return fakeCanvas(); + }, + }; +} + +// ---------------------------------------------------------------- source lock + +test('K1 source: gradient catch falls back to 2D solid (not gl=true alone)', () => { + const s = layersSrc(); + const block = /if \(layer\.type === 'gradient'\) \{[\s\S]*?else if \(window\.SVBackgrounds/.exec(s); + assert.ok(block, 'gradient create block missing'); + const body = block[0]; + assert.match(body, /catch\s*\{/); + assert.match(body, /e\.gl\s*=\s*false/); + assert.match(body, /e\.ctx\s*=\s*e\.canvas\.getContext\('2d'\)/); + assert.match(body, /e\.solid\s*=\s*true/); + /* Old bug: e.gl=true AFTER the catch, so failure still looked like GL. */ + assert.doesNotMatch(body, /catch\s*\{\s*e\.mode\s*=\s*null;\s*\}\s*e\.gl\s*=\s*true/); +}); + +test('K1 source: background draw guards null ctx before fillStyle', () => { + const s = layersSrc(); + const i = s.indexOf('_drawEntryRaw(e, audio, cfg, t, dt, live)'); + assert.ok(i > 0); + const raw = s.slice(i, i + 1200); + assert.match(raw, /if\s*\(\s*!e\.ctx\s*\)\s*return/); + const fill = raw.indexOf('e.ctx.fillStyle'); + const guard = raw.indexOf('if (!e.ctx) return'); + assert.ok(guard > 0 && guard < fill, 'null-ctx guard must precede fillStyle'); +}); + +test('M2 source: visualizer draw is not gated on audio.ready', () => { + const src = layersSrc(); + const i = src.indexOf('_drawEntryRaw(e, audio, cfg, t, dt, live)'); + assert.ok(i > 0); + const raw = src.slice(i, i + 2200); + const start = raw.indexOf("if (l.kind === 'visualizer' || l.kind === 'nowplaying')"); + assert.ok(start >= 0, 'visualizer branch missing'); + const nextKind = raw.indexOf("if (l.kind ===", start + 10); + const vis = raw.slice(start, nextKind > start ? nextKind : start + 800); + assert.doesNotMatch(vis, /!audio\s*\|\|\s*!audio\.ready|!audio\.ready/); + assert.match(vis, /e\.mode\.draw\(audio/); +}); + +test('K1: WebGL gradient ctor throw → 2D solid entry (gl false, ctx set)', () => { + installDocument(); + window.SVModes = window.SVModes || {}; + window.SVModes.gradient = function () { + throw new Error('WebGL desteklenmiyor'); + }; + const stack = new L.LayerStack(null, {}); + stack.width = 320; + stack.height = 180; + const layer = L.normalizeLayer({ id: 'ly_bg', kind: 'background', type: 'gradient' }); + const e = stack._create(layer, 'ly_bg', { background: { type: 'gradient', solidColor: '#112233' } }); + assert.strictEqual(e.mode, null); + assert.strictEqual(e.gl, false, 'failed WebGL must not leave gl=true'); + assert.ok(e.ctx, '2D ctx fallback required'); + assert.strictEqual(e.solid, true); +}); + +test('K1: failed-gradient entry draws solid without TypeError', () => { + installDocument(); + window.SVModes = window.SVModes || {}; + window.SVModes.gradient = function () { + throw new Error('WebGL desteklenmiyor'); + }; + const stack = new L.LayerStack(null, {}); + stack.width = 320; + stack.height = 180; + const cfg = { background: { type: 'gradient', solidColor: '#abcdef' } }; + const layer = L.normalizeLayer({ id: 'ly_bg', kind: 'background', type: 'gradient' }); + const e = stack._create(layer, 'ly_bg', cfg); + assert.doesNotThrow(() => stack._drawEntryRaw(e, { ready: false }, cfg, 0, 1 / 60)); + assert.strictEqual(e.ctx.fillStyle, '#abcdef'); + assert.ok(e.ctx.fills > 0, 'solid fillRect should run'); +}); + +test('K1: pre-fix bad state (gl=true, mode=null, ctx=null) does not throw', () => { + /* Defensive path if an older revive/_create left the bad flags. */ + const stack = new L.LayerStack(null, {}); + stack.width = 320; + stack.height = 180; + const e = { + layer: L.normalizeLayer({ id: 'ly_bg', kind: 'background', type: 'gradient' }), + key: 'ly_bg', + canvas: fakeCanvas(), + ctx: null, + mode: null, + gl: true, + solid: false, + }; + assert.doesNotThrow(() => + stack._drawEntryRaw(e, { ready: true }, { background: { type: 'gradient', solidColor: '#000' } }, 0, 1 / 60) + ); +}); + +// ---------------------------------------------------------------- M2 behaviour + +test('M2: visualizer mode.draw runs when audio.ready is false', () => { + const stack = new L.LayerStack(null, {}); + stack.width = 320; + stack.height = 180; + let draws = 0; + const mode = { + draw(audio) { + draws++; + assert.ok(audio, 'audio object passed'); + assert.strictEqual(audio.ready, false); + }, + }; + const canvas = fakeCanvas(); + const e = { + layer: L.normalizeLayer({ id: 'ly_vis', kind: 'visualizer', type: 'milkdrop' }), + key: 'ly_vis', + canvas, + ctx: canvas._ctx2d, + mode, + gl: false, + }; + const audio = { ready: false, level: 0, bass: 0, mid: 0, treble: 0 }; + const cfg = { visualizer: { type: 'milkdrop' }, layerStack: { enabled: true } }; + stack._drawEntryRaw(e, audio, cfg, 0, 1 / 60); + assert.strictEqual(draws, 1, 'MilkDrop-style mode must init/draw without a real frame'); + assert.strictEqual(canvas._ctx2d.clears, 0, 'must not clear+return on !ready'); +}); + +test('M2: visualizer still draws when audio.ready is true (real path)', () => { + const stack = new L.LayerStack(null, {}); + stack.width = 64; + stack.height = 64; + let draws = 0; + const mode = { draw() { draws++; } }; + const canvas = fakeCanvas(); + const e = { + layer: L.normalizeLayer({ id: 'ly_vis', kind: 'visualizer', type: 'bars' }), + key: 'ly_vis', + canvas, + ctx: canvas._ctx2d, + mode, + gl: false, + }; + stack._drawEntryRaw(e, { ready: true, level: 0.4, bass: 0.2, mid: 0.2, treble: 0.1 }, { + visualizer: { type: 'bars' }, + }, 1, 1 / 60); + assert.strictEqual(draws, 1); +}); + +test('M2: missing audio object still clears (no crash)', () => { + const stack = new L.LayerStack(null, {}); + stack.width = 64; + stack.height = 64; + let draws = 0; + const canvas = fakeCanvas(); + const e = { + layer: L.normalizeLayer({ id: 'ly_vis', kind: 'visualizer', type: 'bars' }), + key: 'ly_vis', + canvas, + ctx: canvas._ctx2d, + mode: { draw() { draws++; } }, + gl: false, + }; + stack._drawEntryRaw(e, null, { visualizer: { type: 'bars' } }, 0, 1 / 60); + assert.strictEqual(draws, 0); + assert.ok(canvas._ctx2d.clears > 0); +}); + + +// ---------------------------------------------------------------- K1 admin signal + +test('K1 source: webglFallback flag + sync + admin event', () => { + const src = layersSrc(); + assert.match(src, /e\.webglFallback\s*=\s*true/); + assert.match(src, /_syncGradientWebGLFallback\s*\(/); + assert.match(src, /hasGradientWebGLFallback\s*\(/); + assert.match(src, /sv-gradient-webgl-fallback/); + assert.match(src, /isGradientWebGLFallback/); +}); + +test('K1: failed gradient sets hasGradientWebGLFallback after setConfig', () => { + installDocument(); + window.SVModes = window.SVModes || {}; + window.SVModes.gradient = function () { + throw new Error('WebGL desteklenmiyor'); + }; + const stack = new L.LayerStack(null, {}); + stack.width = 64; + stack.height = 64; + assert.strictEqual(stack.hasGradientWebGLFallback(), false); + stack.setConfig({ + background: { type: 'gradient', solidColor: '#112233' }, + visualizer: { type: 'none' }, + layerStack: { enabled: false }, + }); + assert.strictEqual(stack.hasGradientWebGLFallback(), true, 'fallback flag after WebGL ctor throw'); + const e = stack.entries.find((x) => x.layer && x.layer.type === 'gradient'); + assert.ok(e, 'gradient entry present'); + assert.strictEqual(e.webglFallback, true); + assert.strictEqual(e.gl, false); + assert.strictEqual(e.solid, true); +}); + +test('K1: successful gradient clears webglFallback flag', () => { + installDocument(); + window.SVModes = window.SVModes || {}; + window.SVModes.gradient = function () { + this.resize = function () {}; + this.draw = function () {}; + }; + const stack = new L.LayerStack(null, {}); + stack.width = 64; + stack.height = 64; + stack.setConfig({ + background: { type: 'gradient', solidColor: '#112233' }, + visualizer: { type: 'none' }, + layerStack: { enabled: false }, + }); + assert.strictEqual(stack.hasGradientWebGLFallback(), false); + const e = stack.entries.find((x) => x.layer && x.layer.type === 'gradient'); + assert.ok(e); + assert.strictEqual(e.gl, true); + assert.strictEqual(!!e.webglFallback, false); +}); + +test('K1 admin/i18n: notice string + keys wired', () => { + const i18n = fs.readFileSync(path.join(ROOT, 'src/shared/i18n.js'), 'utf8'); + const admin = fs.readFileSync(path.join(ROOT, 'src/admin/admin.js'), 'utf8'); + const panels = fs.readFileSync(path.join(ROOT, 'src/admin/scene-panels.js'), 'utf8'); + const tr = 'WebGL yok / desteklenmiyor: gradyan düz renge düştü'; + const en = 'WebGL missing / unsupported: gradient fell back to solid color'; + assert.ok(i18n.includes(tr), 'TR i18n key missing'); + assert.ok(i18n.includes(en), 'EN i18n value missing'); + assert.ok(admin.includes(tr), 'admin notice text missing'); + assert.ok(admin.includes('data-sv-webgl-fallback-note'), 'admin note marker missing'); + assert.ok(admin.includes('isGradientWebGLFallback'), 'admin show() helper missing'); + assert.ok(panels.includes(tr), 'layers panel notice text missing'); + assert.ok(panels.includes('data-sv-webgl-fallback-note'), 'layers panel marker missing'); +}); diff --git a/tests/native-audio-runner.test.js b/tests/native-audio-runner.test.js index 3cffeb4..0b68bcb 100644 --- a/tests/native-audio-runner.test.js +++ b/tests/native-audio-runner.test.js @@ -97,3 +97,24 @@ test('yardımcı dosyası yoksa çalıştırıcıdan bağımsız olarak bildiril const e = na.classifyListError({ helperExists: false, runner: { kind: 'self' } }); assert.strictEqual(e.code, 'HELPER_MISSING'); }); + +test('NO_DEVICES metni platforma gore (Windows Sound Linux\'ta yok)', () => { + const win = na.noDevicesMessage('win32'); + const lin = na.noDevicesMessage('linux'); + const mac = na.noDevicesMessage('darwin'); + assert.match(win, /Windows Sound/i); + assert.ok(!/Windows Sound/i.test(lin), 'Linux metni Windows dememeli: ' + lin); + assert.match(lin, /PulseAudio|PipeWire/i); + assert.ok(!/Windows Sound/i.test(mac), 'macOS metni Windows dememeli: ' + mac); + assert.match(mac, /macOS Sound/i); + const e = na.classifyListError({ helperExists: true, code: 0, stderr: '', runner: { kind: 'self' } }); + assert.strictEqual(e.code, 'NO_DEVICES'); + assert.strictEqual(e.message, na.noDevicesMessage()); +}); + +test('ACCESS_DENIED metni platforma gore', () => { + assert.match(na.accessDeniedMessage('win32'), /Windows denied/i); + assert.ok(!/Windows denied/i.test(na.accessDeniedMessage('linux'))); + assert.match(na.accessDeniedMessage('linux'), /PulseAudio|PipeWire/i); + assert.match(na.accessDeniedMessage('darwin'), /macOS denied/i); +}); diff --git a/tests/platform-packaging.test.js b/tests/platform-packaging.test.js index 04cc251..d5315ed 100644 --- a/tests/platform-packaging.test.js +++ b/tests/platform-packaging.test.js @@ -197,3 +197,12 @@ test('ses yardımcısının bağımlılıkları asar dışına çıkarılıyor', assert.deepStrictEqual(eksik, [], 'asarUnpack dışında kalan yardımcı bağımlılığı: ' + eksik.join(', ')); }); + +test('package description lists Linux alongside Windows and macOS', () => { + /* Discovery/store text used to say Windows+macOS only while dist:linux + targets existed — Linux users (and the box smoke report) treated that + as the app not supporting the platform. */ + assert.match(pkg.description, /Windows/i); + assert.match(pkg.description, /macOS/i); + assert.match(pkg.description, /Linux/i); +});