Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
14 changes: 8 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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 ГБ).
Expand Down
2 changes: 1 addition & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
5 changes: 3 additions & 2 deletions docs/ROADMAP-2.0-MORSE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
4 changes: 4 additions & 0 deletions main/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
17 changes: 9 additions & 8 deletions renderer/call.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];
Expand Down Expand Up @@ -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]);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();
Expand Down
5 changes: 2 additions & 3 deletions renderer/group-call.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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, []);

Expand Down
16 changes: 16 additions & 0 deletions renderer/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)',
Expand Down Expand Up @@ -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': 'Шумоподавление (микрофон)',
Expand Down
27 changes: 27 additions & 0 deletions renderer/ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
10 changes: 5 additions & 5 deletions renderer/voice-channel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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, []);

Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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));
Expand Down
41 changes: 41 additions & 0 deletions shared/ice-servers.js
Original file line number Diff line number Diff line change
@@ -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) };
}
48 changes: 48 additions & 0 deletions tests/ice-servers.test.js
Original file line number Diff line number Diff line change
@@ -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' }],
});
});
});
Loading