diff --git a/CHANGELOG.md b/CHANGELOG.md index 777fda2..4988465 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,10 @@ Development line **Morse**. No GitHub Release until explicitly cut — see [`doc - **Announce authenticity** — peers with invalid/missing Ed25519 announce signatures are ignored (no peer row). - **LAN clipboard** — enabling sync from Off requires an explicit risk confirm dialog. +### Added + +- **Optional STUN/TURN** — Settings → Network; off by default; shared ICE list for 1:1, group, and voice-channel calls. + ### Fixed - **BEACON publish** — `beaconPublishFromPath` was missing from `initBeaconMesh` API (always failed with «browser file» for large ZIP); native file dialog + main-process ingest. diff --git a/README.md b/README.md index debbf12..0048125 100644 --- a/README.md +++ b/README.md @@ -371,9 +371,10 @@ blip/ 4. If you are not using a mesh VPN, try disabling unrelated VPN clients that isolate broadcast. ### Call does not connect -1. BLIP WebRTC uses **host candidates only** (no public STUN/TURN by default) — same LAN/VPN segment required. -2. Check that no corporate firewall blocks peer-to-peer UDP between the devices. -3. Retry after both peers show online in **Peers** with a fresh Mesh Pulse latency. +1. By default BLIP WebRTC uses **host candidates only** (STUN/TURN off) — same L2 / VPN segment required. +2. On Tailscale or multi-subnet VPN: **Settings → Network → STUN / TURN**, enable, and add `stun:` / `turn:` lines; start a **new** call after saving. +3. Check that no corporate firewall blocks peer-to-peer UDP between the devices. +4. Retry after both peers show online in **Peers** with a fresh Mesh Pulse latency. ### File transfer fails 1. Check **Settings → Network** size limit (1–100 GB). @@ -671,9 +672,10 @@ blip/ 4. Если mesh-VPN не нужен — отключите посторонние VPN, которые режут broadcast. ### Звонок не устанавливается -1. WebRTC в BLIP — **только host-кандидаты** (публичный STUN/TURN по умолчанию выключен): нужна одна LAN/VPN-сегмент. -2. Проверьте, что корпоративный firewall не блокирует P2P UDP между устройствами. -3. Убедитесь, что оба пира online в **Абоненты** и есть свежий Mesh Pulse. +1. По умолчанию WebRTC — **только host-кандидаты** (STUN/TURN выкл): нужна одна L2 / VPN-сегмент. +2. Tailscale или разные подсети: **Настройки → Сеть → STUN / TURN**, включите и добавьте строки `stun:` / `turn:`; начните **новый** звонок после сохранения. +3. Проверьте, что корпоративный firewall не блокирует P2P UDP между устройствами. +4. Убедитесь, что оба пира online в **Абоненты** и есть свежий Mesh Pulse. ### Файл не передаётся 1. Проверьте лимит в **Настройки → Сеть** (1–100 ГБ). diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index bd5fbe6..72fbec4 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -291,5 +291,5 @@ Static showcase: [`docs/index.html`](index.html) → [krwg.github.io/blip](https ## Future seams -- CI packaging smoke jobs, mobile client, optional STUN/TURN only if LAN host candidates fail across complex VPN topologies (host-candidate / no public STUN by default). +- CI packaging smoke jobs, mobile client. Optional STUN/TURN is available in **Settings → Network** (`iceEnabled` / `iceServerLines`, off by default) via `shared/ice-servers.js`. - macOS/Linux autostart parity beyond Windows login items. diff --git a/docs/ROADMAP-2.0-MORSE.md b/docs/ROADMAP-2.0-MORSE.md index cdd116d..852ef2f 100644 --- a/docs/ROADMAP-2.0-MORSE.md +++ b/docs/ROADMAP-2.0-MORSE.md @@ -14,8 +14,9 @@ GitHub Release / installers are **not** cut until explicitly requested — versi ## Tracked issues (living list) -- [#41](https://github.com/krwg/blip/issues/41) — core unit tests (UDP / TCP / i18n) -- [#38](https://github.com/krwg/blip/issues/38) — UDP announce HMAC + clipboard enable warning +- [#41](https://github.com/krwg/blip/issues/41) — core unit tests (UDP / TCP / i18n) ✅ +- [#38](https://github.com/krwg/blip/issues/38) — UDP announce reject + clipboard enable warning ✅ +- [#46](https://github.com/krwg/blip/issues/46) — NSIS assisted wizard ✅ - [#39](https://github.com/krwg/blip/issues/39) — optional STUN/TURN - [#40](https://github.com/krwg/blip/issues/40) — TypeScript migration (`shared/` + IPC) diff --git a/main/config.js b/main/config.js index 7c55e8d..9fb864e 100644 --- a/main/config.js +++ b/main/config.js @@ -85,6 +85,10 @@ const DEFAULT_CONFIG = { clipboardSyncMode: 'off', + /** Optional STUN/TURN for WebRTC across VPN segments. Default off = LAN host ICE only. */ + iceEnabled: false, + iceServerLines: '', + knownPeerKeys: {}, receiveBetaUpdates: false, diff --git a/renderer/call.js b/renderer/call.js index 8ecc9ee..299d140 100644 --- a/renderer/call.js +++ b/renderer/call.js @@ -13,8 +13,7 @@ import { openScreenPickerDialog } from './screen-picker-dialog.js'; import { captureDisplayStream } from './display-capture.js'; import { getVoiceMediaStream, getVoiceAudioConstraints } from './audio-capture.js'; import { dispatchReactiveAudio } from './reactive-wallpaper.js'; - -const ICE_SERVERS = []; +import { rtcConfiguration } from '../shared/ice-servers.js'; let activeCall = null; let pendingCandidates = []; @@ -49,8 +48,8 @@ function normalizeCandidate(candidate) { return null; } -function createPeerConnection(onRemoteStream) { - const pc = new RTCPeerConnection({ iceServers: ICE_SERVERS }); +function createPeerConnection(onRemoteStream, cfg) { + const pc = new RTCPeerConnection(rtcConfiguration(cfg)); pc.ontrack = (e) => { if (e.streams[0]) onRemoteStream(e.streams[0]); @@ -956,13 +955,14 @@ export function createCallUI(config, api, options = {}) { localVideo.classList.add('call-video--camera'); } + const iceCfg = (await api.getConfig?.()) || config; pc = createPeerConnection((stream) => { attachRemoteStream(stream); - }); + }, iceCfg); localStream.getTracks().forEach((tr) => pc.addTrack(tr, localStream)); const camSender = getVideoSender(); - if (camSender) void tuneVideoSender(camSender, { screenShare: false, config }); + if (camSender) void tuneVideoSender(camSender, { screenShare: false, config: iceCfg }); const offer = await pc.createOffer(); await pc.setLocalDescription(offer); @@ -1040,13 +1040,14 @@ export function createCallUI(config, api, options = {}) { localVideo.classList.add('call-video--camera'); } + const iceCfg = (await api.getConfig?.()) || config; pc = createPeerConnection((stream) => { attachRemoteStream(stream); - }); + }, iceCfg); localStream.getTracks().forEach((tr) => pc.addTrack(tr, localStream)); const camSender = getVideoSender(); - if (camSender) void tuneVideoSender(camSender, { screenShare: false, config }); + if (camSender) void tuneVideoSender(camSender, { screenShare: false, config: iceCfg }); await setRemoteDescription(offer); const answer = await pc.createAnswer(); diff --git a/renderer/group-call.js b/renderer/group-call.js index ba89b5a..c739a69 100644 --- a/renderer/group-call.js +++ b/renderer/group-call.js @@ -31,11 +31,10 @@ import { noteGroupCallStarted, clearGroupCallRoster, } from './group-call-roster.js'; +import { rtcConfiguration } from '../shared/ice-servers.js'; export { getOngoingGroupCall }; -const ICE = []; - const peers = new Map(); const pendingCandidates = new Map(); @@ -828,7 +827,7 @@ async function flushCandidates(remoteId, pc) { async function createPc(remoteId, groupId, initiator) { const rid = peerNum(remoteId); if (peers.has(rid)) return peers.get(rid); - const pc = new RTCPeerConnection({ iceServers: ICE }); + const pc = new RTCPeerConnection(rtcConfiguration(configRef)); peers.set(rid, pc); pendingCandidates.set(rid, []); diff --git a/renderer/i18n.js b/renderer/i18n.js index 6f9fb0b..617bc5a 100644 --- a/renderer/i18n.js +++ b/renderer/i18n.js @@ -691,6 +691,14 @@ const locales = { 'clipboard.enable_confirm': 'Enable LAN clipboard sync? Text you copy can be sent to peers in the active chat (passwords, tokens, secrets). Only continue on networks and peers you trust.', 'clipboard.received': 'Clipboard from #{id}', + 'settings.ice_enabled': 'STUN / TURN (WebRTC)', + 'settings.ice_hint': + 'Off by default: calls use LAN host candidates only. Enable for Tailscale / multi-subnet VPN when pure LAN ICE fails. You need reachable STUN or TURN URLs.', + 'settings.ice_lines': 'ICE server list', + 'settings.ice_lines_hint': + 'One entry per line: stun:host:port or turn:host:port|user|password. Applies to new 1:1, group, and voice-channel calls.', + 'settings.ice_lines_placeholder': + 'stun:stun.example.com:3478\nturn:turn.example.com:3478|user|pass', 'settings.call_hint': 'Stream quality sets capture resolution and LAN bitrate (higher = sharper, more CPU/bandwidth). Fullscreen resolution is the theater target when you press F.', 'settings.noise_suppression': 'Noise suppression (mic)', @@ -1683,6 +1691,14 @@ const locales = { 'clipboard.enable_confirm': 'Включить синхронизацию буфера по ЛАН? Скопированный текст (пароли, токены, секреты) может уйти абоненту в активном чате. Продолжайте только в доверенной сети и с доверенными пирами.', 'clipboard.received': 'Буфер от #{id}', + 'settings.ice_enabled': 'STUN / TURN (WebRTC)', + 'settings.ice_hint': + 'По умолчанию выкл: звонки только на LAN host-кандидатах. Включите для Tailscale / VPN на разных подсетях, если чистый LAN ICE не сходится. Нужны доступные URL STUN или TURN.', + 'settings.ice_lines': 'Список ICE-серверов', + 'settings.ice_lines_hint': + 'По одному в строке: stun:host:port или turn:host:port|user|password. Для новых звонков 1:1, группы и голосовых каналов.', + 'settings.ice_lines_placeholder': + 'stun:stun.example.com:3478\nturn:turn.example.com:3478|user|pass', 'settings.call_hint': 'Качество трансляции — разрешение захвата и битрейт по ЛАН (выше = чётче, больше нагрузка). Разрешение полноэкранное — цель в режиме театра (клавиша F).', 'settings.noise_suppression': 'Шумоподавление (микрофон)', diff --git a/renderer/ui.js b/renderer/ui.js index 37508ea..c8c91dd 100644 --- a/renderer/ui.js +++ b/renderer/ui.js @@ -2765,6 +2765,33 @@ function buildSettingsNetworkPanel() { buildSettingsFieldWithHint('clipboard.mode', 'clipboard.hint', clipSelect) ); + const iceRow = document.createElement('div'); + iceRow.className = 'settings-toggle-with-hint'; + const iceLines = document.createElement('textarea'); + iceLines.className = 'input settings-textarea'; + iceLines.rows = 4; + iceLines.placeholder = t('settings.ice_lines_placeholder'); + iceLines.dataset.i18nPlaceholder = 'settings.ice_lines_placeholder'; + iceLines.value = state.config?.iceServerLines || ''; + iceLines.disabled = !state.config?.iceEnabled; + iceLines.addEventListener('change', async () => { + state.config = await api.saveConfig({ iceServerLines: iceLines.value }); + }); + const iceToggle = createPixelToggle({ + checked: !!state.config?.iceEnabled, + labelKey: 'settings.ice_enabled', + onChange: async (checked) => { + iceLines.disabled = !checked; + state.config = await api.saveConfig({ iceEnabled: checked }); + }, + }); + iceRow.appendChild(iceToggle.el); + iceRow.appendChild(createPixelHintIcon('settings.ice_hint')); + frag.appendChild(iceRow); + frag.appendChild( + buildSettingsFieldWithHint('settings.ice_lines', 'settings.ice_lines_hint', iceLines) + ); + const projClipRow = document.createElement('div'); projClipRow.className = 'settings-toggle-with-hint'; const projClipToggle = createPixelToggle({ diff --git a/renderer/voice-channel.js b/renderer/voice-channel.js index 41adf53..7b3fd05 100644 --- a/renderer/voice-channel.js +++ b/renderer/voice-channel.js @@ -18,7 +18,7 @@ import { import { openScreenPickerDialog } from './screen-picker-dialog.js'; import { captureDisplayStream } from './display-capture.js'; import { getVoiceMediaStream } from './audio-capture.js'; -const ICE = []; +import { rtcConfiguration } from '../shared/ice-servers.js'; let clientConnectInFlight = false; @@ -424,7 +424,7 @@ async function flushClientIce(pc) { async function createHostPeer(remoteId, groupId) { const rid = peerNum(remoteId); - const pc = new RTCPeerConnection({ iceServers: ICE }); + const pc = new RTCPeerConnection(rtcConfiguration(configRef)); hostPeers.set(rid, pc); hostPendingIce.set(rid, []); @@ -619,7 +619,7 @@ function wireMeshPc(pc, remoteId, groupId) { async function createMeshOffer(remoteId, groupId) { const rid = peerNum(remoteId); if (meshPeers.has(rid) || !localStream) return; - const pc = new RTCPeerConnection({ iceServers: ICE }); + const pc = new RTCPeerConnection(rtcConfiguration(configRef)); meshPeers.set(rid, pc); meshPendingIce.set(rid, []); wireMeshPc(pc, rid, groupId); @@ -639,7 +639,7 @@ async function handleMeshOffer(remoteId, groupId, offer) { try { disconnectMeshPeer(rid); - const pc = new RTCPeerConnection({ iceServers: ICE }); + const pc = new RTCPeerConnection(rtcConfiguration(configRef)); meshPeers.set(rid, pc); meshPendingIce.set(rid, []); wireMeshPc(pc, rid, groupId); @@ -692,7 +692,7 @@ async function ensureClientToHost(group, { force = false } = {}) { } stopRemotePeerAudio(hostId); - clientPc = new RTCPeerConnection({ iceServers: ICE }); + clientPc = new RTCPeerConnection(rtcConfiguration(configRef)); clientPendingIce = []; localStream.getTracks().forEach((tr) => clientPc.addTrack(tr, localStream)); diff --git a/shared/ice-servers.js b/shared/ice-servers.js new file mode 100644 index 0000000..662f25e --- /dev/null +++ b/shared/ice-servers.js @@ -0,0 +1,41 @@ +/** + * Optional STUN/TURN for WebRTC (off by default — pure LAN host candidates). + * + * Line format (Settings → Network): + * stun:host:port + * turn:host:port|username|credential + * Blank lines and # comments ignored. Commas also split entries. + */ + +export function parseIceServerLines(text) { + const servers = []; + const raw = String(text || ''); + const chunks = raw.split(/[\r\n,]+/); + for (const chunk of chunks) { + const line = chunk.trim(); + if (!line || line.startsWith('#')) continue; + const parts = line.split('|').map((p) => p.trim()).filter(Boolean); + const urls = parts[0]; + if (!urls) continue; + if (!/^(stun|stuns|turn|turns):/i.test(urls)) continue; + if (parts.length >= 3) { + servers.push({ + urls, + username: parts[1], + credential: parts[2], + }); + } else { + servers.push({ urls }); + } + } + return servers; +} + +export function resolveIceServers(config) { + if (!config?.iceEnabled) return []; + return parseIceServerLines(config.iceServerLines); +} + +export function rtcConfiguration(config) { + return { iceServers: resolveIceServers(config) }; +} diff --git a/tests/ice-servers.test.js b/tests/ice-servers.test.js new file mode 100644 index 0000000..4f947f2 --- /dev/null +++ b/tests/ice-servers.test.js @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; +import { + parseIceServerLines, + resolveIceServers, + rtcConfiguration, +} from '../shared/ice-servers.js'; + +describe('ice-servers', () => { + it('parses STUN and TURN lines', () => { + const text = ` +# comment +stun:stun.example:3478 +turn:turn.example:3478|alice|s3cret +stuns:stun.example:5349 +`; + expect(parseIceServerLines(text)).toEqual([ + { urls: 'stun:stun.example:3478' }, + { urls: 'turn:turn.example:3478', username: 'alice', credential: 's3cret' }, + { urls: 'stuns:stun.example:5349' }, + ]); + }); + + it('ignores invalid schemes', () => { + expect(parseIceServerLines('http://bad\nstun:ok:1')).toEqual([ + { urls: 'stun:ok:1' }, + ]); + }); + + it('resolveIceServers is empty when disabled', () => { + expect( + resolveIceServers({ + iceEnabled: false, + iceServerLines: 'stun:stun.example:3478', + }), + ).toEqual([]); + }); + + it('rtcConfiguration uses resolved servers when enabled', () => { + expect( + rtcConfiguration({ + iceEnabled: true, + iceServerLines: 'stun:a:1,stun:b:2', + }), + ).toEqual({ + iceServers: [{ urls: 'stun:a:1' }, { urls: 'stun:b:2' }], + }); + }); +});