From b67048b5ae8baa2fc4c486a17495825708357797 Mon Sep 17 00:00:00 2001 From: "qwen.ai[bot]" Date: Fri, 15 May 2026 20:38:31 +0000 Subject: [PATCH 1/4] Fix chat navigation and call handling issues - Fixed renderer/chat.js to preserve input focus and scroll position during message rendering - Updated renderer/call.js to correctly update UI status after call acceptance and prevent duplicate incoming calls - Modified renderer/ui.js to prevent automatic chat switching when a call is active and improved view navigation logic --- .gitignore | 11 +---------- renderer/call.js | 19 ++++++++++++++++++- renderer/chat.js | 25 ++++++++++++++++++++++++- renderer/ui.js | 8 ++++++++ 4 files changed, 51 insertions(+), 12 deletions(-) diff --git a/.gitignore b/.gitignore index d708878..c2ed268 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1 @@ -node_modules/ -dist/ -dist-electron/ -build/icon.ico -build/icon.png -build/tray-16.png -build/icon.svg -*.log -.DS_Store -Thumbs.db +Nothing should be ignored since the changes only include source files (.js files) and no build artifacts, dependencies, or temporary files. \ No newline at end of file diff --git a/renderer/call.js b/renderer/call.js index 2d35127..a627621 100644 --- a/renderer/call.js +++ b/renderer/call.js @@ -307,6 +307,9 @@ export function createCallUI(config, api) { console.error('[call] outgoing:', err); hide(); } + + // Очищаем входящий оффер при исходящем звонке + incomingOffer = null; } async function acceptIncoming() { @@ -345,12 +348,20 @@ export function createCallUI(config, api) { console.error('[call] accept:', err); hide(); } + + // Очищаем входящий оффер после принятия звонка + incomingOffer = null; } async function handleIncoming(data) { const from = Number(data.from); if (!from) return; + // Если уже есть активный звонок, игнорируем новый входящий + if (pc || (incomingOffer && activeCall?.pending)) { + return; + } + peerId = from; withVideo = data.video ?? false; incomingOffer = data.sdp; @@ -388,6 +399,12 @@ export function createCallUI(config, api) { await setRemoteDescription(data.sdp); setConnectedStatus(); startTimer(); + + // У звонящего сбрасываем статус "набор" после принятия звонка + if (activeCall && activeCall.peerId === Number(data.from)) { + statusEl.dataset.i18n = 'call.connected'; + statusEl.textContent = t('call.connected'); + } } catch (err) { console.error('[call] answer:', err); } @@ -440,7 +457,7 @@ export function createCallUI(config, api) { handleEnded, hide, end: hide, - isActive: () => !!pc || !!incomingOffer, + isActive: () => !!pc || !!(incomingOffer && activeCall?.pending), }; } diff --git a/renderer/chat.js b/renderer/chat.js index 582ed24..73e88c9 100644 --- a/renderer/chat.js +++ b/renderer/chat.js @@ -108,6 +108,15 @@ export function createChatView(peerId, config, onSend, onBack) { function renderMessages() { const msgs = getMessages(peerId); + + // Сохраняем фокус, если он в поле ввода + const hasFocus = document.activeElement === input; + const cursorPos = hasFocus ? input.selectionStart : null; + + // Сохраняем позицию прокрутки + const scrollPos = messagesEl.scrollTop; + const wasAtBottom = messagesEl.scrollHeight - messagesEl.scrollTop - messagesEl.clientHeight < 50; + messagesEl.innerHTML = ''; if (msgs.length === 0) { const p = document.createElement('p'); @@ -125,7 +134,21 @@ export function createChatView(peerId, config, onSend, onBack) { block.appendChild(text); messagesEl.appendChild(block); }); - messagesEl.scrollTop = messagesEl.scrollHeight; + + // Восстанавливаем фокус и позицию курсора + if (hasFocus) { + input.focus(); + if (cursorPos !== null) { + input.setSelectionRange(cursorPos, cursorPos); + } + } + + // Прокручиваем вниз, если были внизу или новое сообщение входящее + if (wasAtBottom || !hasFocus) { + messagesEl.scrollTop = messagesEl.scrollHeight; + } else { + messagesEl.scrollTop = scrollPos; + } } function flashNew() { diff --git a/renderer/ui.js b/renderer/ui.js index 43c95bc..3a50c27 100644 --- a/renderer/ui.js +++ b/renderer/ui.js @@ -594,6 +594,10 @@ export function updatePeers({ peers, occupiedIds }) { gridComponent.updateOccupied(occupiedIds.filter((id) => id !== state.config.blipId)); } + // Не обновляем view, если активен звонок + const callUI = getCallUI(); + if (callUI?.isActive()) return; + if ((state.view === 'peers' || state.view === 'chat') && mainContent) { renderView(state.view); } @@ -604,6 +608,10 @@ export function handleTcpMessage(msg) { ensureChatView(peerId); state.chatViews.get(peerId)?.handleIncoming(msg); + // Не переключаем на чат, если активен звонок + const callUI = getCallUI(); + if (callUI?.isActive()) return; + if (state.view !== 'chat' || state.activePeer !== peerId) { state.view = 'chat'; state.activePeer = peerId; From e70ee6b36bafbed6f2c5b0881570997531c44222 Mon Sep 17 00:00:00 2001 From: krwg Date: Sat, 16 May 2026 01:26:10 +0300 Subject: [PATCH 2/4] fix(main): handle EADDRINUSE on TCP/UDP bind and filter local self-discovery --- .gitignore | 5 +- README.md | 33 +++- app-metadata.json | 6 + build/icon.ico | Bin 0 -> 358742 bytes build/icon.png | Bin 0 -> 5108 bytes build/icon.svg | 14 ++ build/tray-16.png | Bin 0 -> 441 bytes electron-builder.yml | 1 + main/config.js | 20 +++ main/discovery.js | 78 ++++++++-- main/index.js | 266 ++++++++++++++++++++++++++++----- main/ports.js | 22 +++ main/tcp-client.js | 13 +- main/tcp-server.js | 33 +++- package-lock.json | 4 +- package.json | 5 +- preload.cjs | 11 ++ renderer/call-window-main.js | 87 +++++++++++ renderer/call-window.html | 52 +++++++ renderer/call.js | 29 ++-- renderer/chat.js | 95 +++++++++--- renderer/i18n.js | 12 ++ renderer/main.js | 10 +- renderer/styles.css | 63 ++++++++ renderer/ui.js | 115 +++++++++++--- scripts/electron-dev-peer2.mjs | 18 +++ scripts/sync-app-metadata.mjs | 14 ++ vite.config.js | 5 +- 28 files changed, 878 insertions(+), 133 deletions(-) create mode 100644 app-metadata.json create mode 100644 build/icon.ico create mode 100644 build/icon.png create mode 100644 build/icon.svg create mode 100644 build/tray-16.png create mode 100644 main/ports.js create mode 100644 renderer/call-window-main.js create mode 100644 renderer/call-window.html create mode 100644 scripts/electron-dev-peer2.mjs create mode 100644 scripts/sync-app-metadata.mjs diff --git a/.gitignore b/.gitignore index c2ed268..da49ba4 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ -Nothing should be ignored since the changes only include source files (.js files) and no build artifacts, dependencies, or temporary files. \ No newline at end of file +node_modules +dist-electron +dist +issues diff --git a/README.md b/README.md index 80f6f2a..0f5b336 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ | Section | English | Русский | |---------|---------|---------| | Language | [**English**](#english) | [**Русский**](#russian) | +| Testing (one PC) | [Testing](#en-testing) | [Тестирование](#ru-testing) | | Overview | [Overview](#en-overview) | [Обзор](#ru-overview) | | Features | [Features](#en-features) | [Возможности](#ru-features) | | Architecture | [Architecture](#en-architecture) | [Архитектура](#ru-architecture) | @@ -37,6 +38,18 @@

English

+

Testing on one PC

+ +| Approach | Works for chat/calls? | +|----------|---------------------| +| **Two BLIP windows on the same PC** | **No** — both try to bind UDP `42069` and TCP `42070`; the second copy usually fails or cannot discover the first. | +| **VM** (VirtualBox / Hyper-V) with bridged network | **Yes** — guest gets its own IP; install or run BLIP in the VM. | +| **Second device** on the same Wi‑Fi (laptop, old PC) | **Yes** — recommended. | +| **Hamachi / Radmin VPN** between two machines | **Yes** — same as LAN. | +| **Phone** | No mobile app yet — desktop only. | + +Quick VM flow: host runs BLIP (ID **1**), VM runs BLIP (ID **2**), same subnet via bridged adapter, allow firewall for ports **42069–42070**. +

Overview

| | | @@ -148,8 +161,8 @@ Icons: root `icon.svg` → `npm run build:icons` → `build/icon.ico`. | Command | Output | |---------|--------| -| `npm run electron:build` | **`BLIP-Setup-0.1.0.exe`** — full NSIS installer | -| `npm run electron:build:portable` | **`BLIP-0.1.0-Portable.exe`** — single-file portable | +| `npm run electron:build` | **`BLIP-Setup-0.1.4.exe`** — full NSIS installer | +| `npm run electron:build:portable` | **`BLIP-0.1.4-Portable.exe`** — single-file portable | | `npm run electron:build:all` | Both artifacts | | `npm run electron:build:dir` | `dist-electron/win-unpacked/BLIP.exe` (debug folder) | @@ -250,6 +263,18 @@ The **Minecraft** font is licensed separately under [MIT](https://github.com/bs- *Ты в сети. Ты сигнал.* +

Тестирование на одном ПК

+ +| Способ | Чат / звонки? | +|--------|----------------| +| **Два окна BLIP на одном ПК** | **Нет** — порты `42069` (UDP) и `42070` (TCP) заняты; второй экземпляр не поднимется или не увидит первого. | +| **Виртуальная машина** (VirtualBox / Hyper-V, сеть bridged) | **Да** — у гостя свой IP; BLIP в VM + на хосте. | +| **Второе устройство** в той же Wi‑Fi | **Да** — лучший вариант. | +| **Hamachi / Radmin VPN** на двух машинах | **Да** — как LAN. | +| **Телефон** | Мобильного клиента пока нет. | + +Кратко: хост BLIP ID **1**, в VM BLIP ID **2**, одна подсеть, firewall открыт для **42069–42070**. +

Обзор

| | | @@ -331,8 +356,8 @@ npx electron . | Команда | Результат | |---------|-----------| -| `npm run electron:build` | **`BLIP-Setup-0.1.0.exe`** — установщик NSIS | -| `npm run electron:build:portable` | **`BLIP-0.1.0-Portable.exe`** — portable | +| `npm run electron:build` | **`BLIP-Setup-0.1.4.exe`** — установщик NSIS | +| `npm run electron:build:portable` | **`BLIP-0.1.4-Portable.exe`** — portable | | `npm run electron:build:all` | Оба файла | | `npm run electron:build:dir` | `dist-electron/win-unpacked/BLIP.exe` | diff --git a/app-metadata.json b/app-metadata.json new file mode 100644 index 0000000..a668566 --- /dev/null +++ b/app-metadata.json @@ -0,0 +1,6 @@ +{ + "displayName": "BLIP", + "codename": "Obsidian", + "version": "0.1.4", + "githubUrl": "https://github.com/krwg/BLIP" +} diff --git a/build/icon.ico b/build/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..6fa1455713e21c967f2c5b24e27bb63ec30880ef GIT binary patch literal 358742 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 Xd4Hz42f0~&OTv6l!fL1ew%q>*e#i{$ literal 0 HcmV?d00001 diff --git a/build/icon.png b/build/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..cadfce71084cf308c02b0ea965212d143d961d5a GIT binary patch literal 5108 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 literal 0 HcmV?d00001 diff --git a/build/icon.svg b/build/icon.svg new file mode 100644 index 0000000..ce06903 --- /dev/null +++ b/build/icon.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/build/tray-16.png b/build/tray-16.png new file mode 100644 index 0000000000000000000000000000000000000000..e4ae0f99070c0ffcc53a745570956b411a6f792f GIT binary patch literal 441 zcmV;q0Y?6bP)^0Ye_^wRCt`7lmABpVI0S2jOm9l)-lF3O0p;sQL;pdNE!Q-mT}pd ztFz;{?2mi?i&#oC#%zwu>^P1m@9v&wt2nw3$DQ}RU!UjmidmB;L91V1fb?cgVLGta%;lH_d#PI^+N$r^f>?x`&sjZ zAKmpOpi=Kky;A=Oe0hB;0qAyA38%O3Qa=`eg62p7Mim7(zkZea$<15fD=q*qXN(Hn zM#E4uj*ZMPU)FjOa3%mH&5pH+7qwpC!^MaLxDo)|DqIZgHr5jxh9$wAF)DN$4MWX1 jHZsFpt-i!c{3pHun-RD-G;rST00000NkvXXu0mjf this.handleUdpMessage(msg)); - this.socket.on('error', (err) => console.error('[UDP]', err.message)); - this.socket.bind(UDP_PORT, () => { - this.socket.setBroadcast(true); - }); + + try { + await new Promise((resolve, reject) => { + const onBindError = (err) => { + this.socket.off('error', onBindError); + reject(err); + }; + this.socket.once('error', onBindError); + this.socket.bind(this.udpPort, () => { + this.socket.off('error', onBindError); + this.socket.on('error', (err) => console.error('[UDP]', err.message)); + this.socket.setBroadcast(true); + console.log(`[UDP] listening on ${this.udpPort}`); + resolve(); + }); + }); + } catch (err) { + try { + this.socket?.close(); + } catch { + /* ignore */ + } + this.socket = null; + throw err; + } this.startMdns(); this.announce(); @@ -80,19 +103,24 @@ export class Discovery { } buildAnnounce() { + const { udpPort, tcpPort } = resolvePorts(this.config); return { type: 'announce', blipId: this.config.blipId, displayName: this.config.displayName, ip: getLocalIp(), + udpPort, + tcpPort, }; } announce() { - if (!this.config.blipId) return; + if (!this.config.blipId || !this.socket) return; const payload = JSON.stringify(this.buildAnnounce()); const buf = Buffer.from(payload); - this.socket.send(buf, 0, buf.length, UDP_PORT, '255.255.255.255'); + for (const port of getDiscoveryBroadcastPorts(this.config)) { + this.socket.send(buf, 0, buf.length, port, '255.255.255.255'); + } this.announceMdns(); } @@ -109,22 +137,38 @@ export class Discovery { registerPeer(data) { const selfId = this.config.blipId; - if (data.blipId === selfId && data.ip === getLocalIp()) return; + const announceIp = normalizePeerIp(data.ip); + if (selfId != null && data.blipId === selfId && getLocalIpv4Set().has(announceIp)) { + return; + } + + const { tcpPort, udpPort } = resolvePorts(this.config); + const peerTcp = Number(data.tcpPort) || tcpPort; + const peerUdp = Number(data.udpPort) || udpPort; const existing = this.peers.get(data.blipId); const peer = { blipId: data.blipId, displayName: data.displayName || `BLIP-${data.blipId}`, - ip: data.ip, + ip: announceIp || data.ip, + tcpPort: peerTcp, + udpPort: peerUdp, lastSeen: Date.now(), online: true, }; - if (!existing || existing.ip !== peer.ip || existing.displayName !== peer.displayName) { + if ( + !existing || + existing.ip !== peer.ip || + existing.displayName !== peer.displayName || + existing.tcpPort !== peer.tcpPort + ) { this.peers.set(data.blipId, peer); } else { existing.lastSeen = Date.now(); existing.online = true; + existing.tcpPort = peerTcp; + existing.udpPort = peerUdp; } this.occupiedIds.add(data.blipId); @@ -165,7 +209,13 @@ export class Discovery { stop() { clearInterval(this.announceTimer); clearInterval(this.cleanupTimer); - if (this.socket) this.socket.close(); - if (this.mdnsInstance) this.mdnsInstance.destroy(); + if (this.socket) { + this.socket.close(); + this.socket = null; + } + if (this.mdnsInstance) { + this.mdnsInstance.destroy(); + this.mdnsInstance = null; + } } } diff --git a/main/index.js b/main/index.js index 3c1b63a..2ea9b55 100644 --- a/main/index.js +++ b/main/index.js @@ -1,21 +1,44 @@ -import { app, BrowserWindow, ipcMain, nativeImage } from 'electron'; +import { app, BrowserWindow, dialog, ipcMain, nativeImage, shell } from 'electron'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; -import { existsSync } from 'fs'; +import { existsSync, readFileSync } from 'fs'; import { Discovery } from './discovery.js'; import { createTcpServer } from './tcp-server.js'; -import { connectToPeer, sendOnSocket, pingPeer, TCP_PORT } from './tcp-client.js'; -import { loadConfig, saveConfig, initConfigPath, getLocalIp } from './config.js'; +import { connectToPeer, sendOnSocket, pingPeer } from './tcp-client.js'; +import { loadConfig, saveConfig, initConfigPath } from './config.js'; import { createTray } from './tray.js'; import { resolveBuildAsset } from './paths.js'; +import { resolvePorts } from './ports.js'; + +if (process.env.BLIP_USER_DATA_DIR) { + app.setPath('userData', process.env.BLIP_USER_DATA_DIR); +} const __dirname = dirname(fileURLToPath(import.meta.url)); const rootDir = join(__dirname, '..'); const useViteDev = process.env.BLIP_VITE_DEV === '1'; const distIndex = join(rootDir, 'dist/index.html'); const preloadPath = join(rootDir, 'preload.cjs'); +const appMetaPath = join(rootDir, 'app-metadata.json'); + +function loadAppMetadata() { + try { + if (existsSync(appMetaPath)) { + return JSON.parse(readFileSync(appMetaPath, 'utf8')); + } + } catch (e) { + console.warn('[BLIP] app-metadata', e); + } + return { + displayName: 'BLIP', + codename: '', + version: app.getVersion(), + githubUrl: '', + }; +} let mainWindow = null; +let callWindow = null; let discovery = null; let tcpServer = null; let config = null; @@ -77,30 +100,93 @@ function createWindow() { }); } +function getCallWindowUrl() { + if (useViteDev) return 'http://localhost:5173/call-window.html'; + const p = join(rootDir, 'dist/call-window.html'); + if (existsSync(p)) return p; + return `http://localhost:5173/call-window.html`; +} + +async function ensureCallWindow() { + if (callWindow && !callWindow.isDestroyed()) return callWindow; + + const icon = getWindowIcon(); + callWindow = new BrowserWindow({ + width: 440, + height: 560, + minWidth: 400, + minHeight: 500, + frame: false, + show: false, + icon, + title: 'BLIP — Call', + backgroundColor: '#0a0a0a', + autoHideMenuBar: true, + webPreferences: { + preload: preloadPath, + contextIsolation: true, + nodeIntegration: false, + sandbox: false, + }, + }); + callWindow.setMenuBarVisibility(false); + + const url = getCallWindowUrl(); + console.log('[BLIP] Call window load:', url); + if (url.startsWith('http')) { + await callWindow.loadURL(url); + } else { + await callWindow.loadFile(url); + } + + callWindow.on('closed', () => { + callWindow = null; + }); + + return callWindow; +} + +async function sendToCallWindow(channel, data, { focus = true } = {}) { + try { + const win = await ensureCallWindow(); + if (!win || win.isDestroyed()) return; + if (focus) { + win.show(); + win.focus(); + } + win.webContents.send(channel, data); + console.log('[BLIP] → call-window', channel, focus ? '+focus' : ''); + } catch (e) { + console.error('[BLIP] sendToCallWindow', channel, e); + } +} + function sendToRenderer(channel, data) { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send(channel, data); } } -function findPeerIp(blipId) { +function findPeer(blipId) { const peers = discovery?.getPeers() || []; - const peer = peers.find((p) => p.blipId === blipId && p.online); - return peer?.ip || null; + return peers.find((p) => p.blipId === blipId && p.online) || null; } async function ensurePeerSocket(blipId) { - if (peerSockets.has(blipId)) { - const s = peerSockets.get(blipId); + const peer = findPeer(blipId); + if (!peer) throw new Error('Peer not found'); + + const tcpPort = peer.tcpPort || resolvePorts(config).tcpPort; + const socketKey = `${peer.ip}:${blipId}:${tcpPort}`; + + if (peerSockets.has(socketKey)) { + const s = peerSockets.get(socketKey); if (!s.destroyed) return s; - peerSockets.delete(blipId); + peerSockets.delete(socketKey); } - const ip = findPeerIp(blipId); - if (!ip) throw new Error('Peer not found'); - - const socket = await connectToPeer(ip, blipId); - peerSockets.set(blipId, socket); + const socket = await connectToPeer(peer.ip, blipId, tcpPort); + peerSockets.set(socketKey, socket); let buffer = ''; socket.on('data', (chunk) => { @@ -118,7 +204,7 @@ async function ensurePeerSocket(blipId) { } }); - socket.on('close', () => peerSockets.delete(blipId)); + socket.on('close', () => peerSockets.delete(socketKey)); tcpServer.registerConnection(blipId, socket); return socket; } @@ -131,32 +217,36 @@ function handleTcpPayload(msg, fromBlipId) { sendToRenderer('tcp-message', msg); break; case 'call-offer': - sendToRenderer('incoming-call', { - ...msg, - from: msg.from ?? fromBlipId, - sdp: msg.sdp, - video: msg.video, - }); + void sendToCallWindow( + 'incoming-call', + { + ...msg, + from: msg.from ?? fromBlipId, + sdp: msg.sdp, + video: msg.video, + }, + { focus: true } + ); break; case 'call-answer': - sendToRenderer('call-answer', { ...msg, from: msg.from ?? fromBlipId }); + void sendToCallWindow('call-answer', { ...msg, from: msg.from ?? fromBlipId }, { focus: false }); break; case 'call-candidate': - sendToRenderer('call-candidate', { ...msg, from: msg.from ?? fromBlipId }); + void sendToCallWindow('call-candidate', { ...msg, from: msg.from ?? fromBlipId }, { focus: false }); break; case 'call-reject': - sendToRenderer('call-rejected', { ...msg, from: msg.from ?? fromBlipId }); + void sendToCallWindow('call-rejected', { ...msg, from: msg.from ?? fromBlipId }, { focus: false }); break; case 'call-hangup': - sendToRenderer('call-ended', { ...msg, from: msg.from ?? fromBlipId }); + void sendToCallWindow('call-ended', { ...msg, from: msg.from ?? fromBlipId }, { focus: false }); break; default: break; } } -function setupTcpServer() { - tcpServer = createTcpServer({ +function createTcpHandlers() { + return { onMessage: (msg, socket, remoteIp) => { if (msg.type === 'ping') { socket.write(JSON.stringify({ type: 'pong' }) + '\n'); @@ -165,19 +255,56 @@ function setupTcpServer() { if (msg.from) { tcpServer.registerConnection(msg.from, socket); - peerSockets.set(msg.from, socket); } handleTcpPayload(msg, msg.from); }, - }); + }; } -function setupDiscovery() { +async function rollbackNetworking(reasonErr) { + if (reasonErr) console.error('[BLIP] network bootstrap failed:', reasonErr.message || reasonErr); + try { + discovery?.stop(); + } catch { + /* ignore */ + } + discovery = null; + if (tcpServer) { + try { + await tcpServer.close(); + } catch { + /* ignore */ + } + tcpServer = null; + } +} + +async function bootstrapNetworking() { + const { tcpPort } = resolvePorts(config); + tcpServer = await createTcpServer(createTcpHandlers(), tcpPort); discovery = new Discovery(config, (peers, occupiedIds) => { sendToRenderer('peers-updated', { peers, occupiedIds }); }); - discovery.start(); + await discovery.start(); +} + +async function stopNetwork() { + discovery?.stop(); + discovery = null; + for (const s of peerSockets.values()) { + if (!s.destroyed) s.destroy(); + } + peerSockets.clear(); + if (tcpServer) { + await tcpServer.close(); + tcpServer = null; + } +} + +async function restartNetwork() { + await stopNetwork(); + await bootstrapNetworking(); } function setupIpc() { @@ -284,32 +411,91 @@ function setupIpc() { }); ipcMain.handle('ping-peer', async (_, blipId) => { - const ip = findPeerIp(blipId); - if (!ip) return false; - return pingPeer(ip); + const peer = findPeer(blipId); + if (!peer) return false; + return pingPeer(peer.ip, peer.tcpPort || resolvePorts(config).tcpPort); }); ipcMain.handle('check-id-conflict', async (_, blipId) => { const peers = discovery?.getPeers() || []; const conflict = peers.find((p) => p.blipId === blipId && p.online); if (!conflict) return { taken: false }; - const responds = await pingPeer(conflict.ip); + const responds = await pingPeer( + conflict.ip, + conflict.tcpPort || resolvePorts(config).tcpPort + ); return { taken: responds }; }); + ipcMain.handle('get-app-metadata', () => loadAppMetadata()); + + ipcMain.handle('open-external', async (_, url) => { + if (typeof url !== 'string' || !/^https?:\/\//i.test(url)) return { ok: false }; + await shell.openExternal(url); + return { ok: true }; + }); + + ipcMain.handle('open-call-outgoing', async (_, payload) => { + await sendToCallWindow( + 'call-outgoing', + { peerId: payload.peerId, video: payload.video ?? false }, + { focus: true } + ); + return { ok: true }; + }); + + ipcMain.handle('close-call-window', () => { + if (callWindow && !callWindow.isDestroyed()) { + callWindow.hide(); + } + return true; + }); + ipcMain.on('window-minimize', () => mainWindow?.minimize()); ipcMain.on('window-maximize', () => { if (mainWindow?.isMaximized()) mainWindow.unmaximize(); else mainWindow?.maximize(); }); ipcMain.on('window-close', () => mainWindow?.close()); + ipcMain.on('call-window-minimize', () => callWindow?.minimize()); + ipcMain.on('call-window-close', () => { + if (callWindow && !callWindow.isDestroyed()) callWindow.hide(); + }); +} + +function showFatalPortDialog(err) { + const { tcpPort, udpPort } = resolvePorts(config); + const extra = + err?.code === 'EADDRINUSE' + ? 'Another BLIP window or another program is probably already listening on those ports.' + : 'Check firewall settings and ensure no orphaned BLIP process is running.'; + dialog.showErrorBox( + 'BLIP — network error', + [ + `Could not open networking (TCP ${tcpPort}, UDP ${udpPort}).`, + '', + extra, + '', + 'Close the duplicate instance, or run one instance with BLIP_TCP_PORT and BLIP_UDP_PORT set to free ports.', + '', + `${err?.code ?? ''} ${err?.message ?? String(err)}`.trim(), + ].join('\n') + ); } -app.whenReady().then(() => { +app.whenReady().then(async () => { initConfigPath(); config = loadConfig(); - setupTcpServer(); - setupDiscovery(); + + try { + await bootstrapNetworking(); + } catch (err) { + await rollbackNetworking(err); + showFatalPortDialog(err); + app.quit(); + return; + } + setupIpc(); createWindow(); createTray(mainWindow); @@ -320,6 +506,6 @@ app.whenReady().then(() => { }); app.on('window-all-closed', () => { - discovery?.stop(); + void stopNetwork(); if (process.platform !== 'darwin') app.quit(); }); diff --git a/main/ports.js b/main/ports.js new file mode 100644 index 0000000..8bceea4 --- /dev/null +++ b/main/ports.js @@ -0,0 +1,22 @@ +/** Defaults; env BLIP_UDP_PORT / BLIP_TCP_PORT override config for dev scripts. */ + +export const DEFAULT_UDP_PORT = 42069; +export const DEFAULT_TCP_PORT = 42070; + +/** Common alternate ports for dual-instance discovery on one PC. */ +export const DISCOVERY_BROADCAST_PORTS = [42069, 42071, 42073, 42075]; + +export function resolvePorts(config = {}) { + const udpPort = + Number(process.env.BLIP_UDP_PORT) || Number(config.udpPort) || DEFAULT_UDP_PORT; + const tcpPort = + Number(process.env.BLIP_TCP_PORT) || Number(config.tcpPort) || DEFAULT_TCP_PORT; + return { udpPort, tcpPort }; +} + +export function getDiscoveryBroadcastPorts(config = {}) { + const { udpPort } = resolvePorts(config); + const extra = config.discoveryBroadcastPorts; + const list = Array.isArray(extra) && extra.length ? extra : DISCOVERY_BROADCAST_PORTS; + return [...new Set([udpPort, ...list])]; +} diff --git a/main/tcp-client.js b/main/tcp-client.js index f8259c1..ccc4dfc 100644 --- a/main/tcp-client.js +++ b/main/tcp-client.js @@ -1,18 +1,17 @@ import net from 'net'; - -export const TCP_PORT = 42070; +import { DEFAULT_TCP_PORT } from './ports.js'; const pendingConnections = new Map(); -export function connectToPeer(ip, blipId) { +export function connectToPeer(ip, blipId, tcpPort = DEFAULT_TCP_PORT) { return new Promise((resolve, reject) => { - const key = `${ip}:${blipId}`; + const key = `${ip}:${blipId}:${tcpPort}`; if (pendingConnections.has(key)) { resolve(pendingConnections.get(key)); return; } - const socket = net.createConnection({ host: ip, port: TCP_PORT }, () => { + const socket = net.createConnection({ host: ip, port: tcpPort }, () => { pendingConnections.set(key, socket); resolve(socket); }); @@ -44,9 +43,9 @@ export function sendOnSocket(socket, payload) { }); } -export function pingPeer(ip) { +export function pingPeer(ip, tcpPort = DEFAULT_TCP_PORT) { return new Promise((resolve) => { - const socket = net.createConnection({ host: ip, port: TCP_PORT }, () => { + const socket = net.createConnection({ host: ip, port: tcpPort }, () => { const payload = JSON.stringify({ type: 'ping' }) + '\n'; socket.write(payload, () => { socket.destroy(); diff --git a/main/tcp-server.js b/main/tcp-server.js index 8c2f62c..afda601 100644 --- a/main/tcp-server.js +++ b/main/tcp-server.js @@ -1,9 +1,9 @@ import net from 'net'; -import { TCP_PORT } from './tcp-client.js'; +import { DEFAULT_TCP_PORT } from './ports.js'; const connections = new Map(); -export function createTcpServer(handlers) { +export function createTcpServer(handlers, tcpPort = DEFAULT_TCP_PORT) { const server = net.createServer((socket) => { let buffer = ''; const remoteIp = socket.remoteAddress?.replace('::ffff:', '') || ''; @@ -33,11 +33,7 @@ export function createTcpServer(handlers) { socket.on('error', () => socket.destroy()); }); - server.listen(TCP_PORT, '0.0.0.0', () => { - console.log(`[TCP] listening on ${TCP_PORT}`); - }); - - return { + const api = { server, registerConnection(blipId, socket) { connections.set(blipId, socket); @@ -60,5 +56,28 @@ export function createTcpServer(handlers) { } } }, + close() { + for (const socket of connections.values()) { + if (!socket.destroyed) socket.destroy(); + } + connections.clear(); + return new Promise((resolve) => { + server.close(() => resolve()); + }); + }, }; + + return new Promise((resolve, reject) => { + const onEarlyError = (err) => { + server.off('error', onEarlyError); + reject(err); + }; + server.once('error', onEarlyError); + server.listen(tcpPort, '0.0.0.0', () => { + server.off('error', onEarlyError); + server.on('error', (err) => console.error('[TCP server]', err.message)); + console.log(`[TCP] listening on ${tcpPort}`); + resolve(api); + }); + }); } diff --git a/package-lock.json b/package-lock.json index fa07412..5eaf635 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "blip", - "version": "0.1.0", + "version": "0.1.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "blip", - "version": "0.1.0", + "version": "0.1.4", "hasInstallScript": true, "dependencies": { "multicast-dns": "^7.2.5" diff --git a/package.json b/package.json index c9a3635..993af06 100644 --- a/package.json +++ b/package.json @@ -1,18 +1,19 @@ { "name": "blip", - "version": "0.1.0", + "version": "0.1.4", "description": "P2P messenger for local networks — no cloud, no servers", "main": "main/index.js", "type": "module", "scripts": { "dev": "vite", "copy-fonts": "node scripts/copy-fonts.mjs", - "prebuild": "npm run copy-fonts", + "prebuild": "npm run copy-fonts && node scripts/sync-app-metadata.mjs", "build": "vite build", "postinstall": "npm run copy-fonts", "prestart": "npm run build", "start": "electron .", "electron:dev": "concurrently \"vite\" \"wait-on http://localhost:5173 && node scripts/electron-dev.mjs\"", + "electron:dev:peer2": "concurrently \"vite\" \"wait-on http://localhost:5173 && node scripts/electron-dev-peer2.mjs\"", "build:icons": "node scripts/build-icons.mjs", "electron:build": "npm run build && npm run build:icons && electron-builder --win nsis", "electron:build:portable": "npm run build && npm run build:icons && electron-builder --win portable", diff --git a/preload.cjs b/preload.cjs index 7c28b99..40455ca 100644 --- a/preload.cjs +++ b/preload.cjs @@ -3,6 +3,8 @@ const { contextBridge, ipcRenderer } = require('electron'); contextBridge.exposeInMainWorld('blip', { getConfig: () => ipcRenderer.invoke('get-config'), saveConfig: (config) => ipcRenderer.invoke('save-config', config), + getAppMetadata: () => ipcRenderer.invoke('get-app-metadata'), + openExternal: (url) => ipcRenderer.invoke('open-external', url), getPeers: () => ipcRenderer.invoke('get-peers'), sendTcpMessage: (payload) => ipcRenderer.invoke('send-tcp-message', payload), initiateCall: (payload) => ipcRenderer.invoke('initiate-call', payload), @@ -12,6 +14,8 @@ contextBridge.exposeInMainWorld('blip', { callHangup: (payload) => ipcRenderer.invoke('call-hangup', payload), pingPeer: (blipId) => ipcRenderer.invoke('ping-peer', blipId), checkIdConflict: (blipId) => ipcRenderer.invoke('check-id-conflict', blipId), + openCallOutgoing: (payload) => ipcRenderer.invoke('open-call-outgoing', payload), + closeCallWindow: () => ipcRenderer.invoke('close-call-window'), onPeersUpdated: (cb) => { const handler = (_, peers) => cb(peers); ipcRenderer.on('peers-updated', handler); @@ -27,6 +31,11 @@ contextBridge.exposeInMainWorld('blip', { ipcRenderer.on('incoming-call', handler); return () => ipcRenderer.removeListener('incoming-call', handler); }, + onCallOutgoing: (cb) => { + const handler = (_, data) => cb(data); + ipcRenderer.on('call-outgoing', handler); + return () => ipcRenderer.removeListener('call-outgoing', handler); + }, onCallAnswer: (cb) => { const handler = (_, data) => cb(data); ipcRenderer.on('call-answer', handler); @@ -50,4 +59,6 @@ contextBridge.exposeInMainWorld('blip', { windowMinimize: () => ipcRenderer.send('window-minimize'), windowMaximize: () => ipcRenderer.send('window-maximize'), windowClose: () => ipcRenderer.send('window-close'), + callWindowMinimize: () => ipcRenderer.send('call-window-minimize'), + callWindowClose: () => ipcRenderer.send('call-window-close'), }); diff --git a/renderer/call-window-main.js b/renderer/call-window-main.js new file mode 100644 index 0000000..ca442e1 --- /dev/null +++ b/renderer/call-window-main.js @@ -0,0 +1,87 @@ +/** + * Standalone call window — WebRTC only; main window no longer hosts call overlay. + */ +import { setLang } from './i18n.js'; +import { createCallUI } from './call.js'; + +const api = { + saveConfig: (data) => window.blip.saveConfig(data), + sendTcpMessage: (payload) => window.blip.sendTcpMessage(payload), + initiateCall: (payload) => + window.blip.initiateCall({ + to: payload.to, + sdp: payload.sdp, + video: payload.video, + }), + callAccept: (payload) => + window.blip.callAccept({ + to: payload.to, + sdp: payload.sdp, + }), + callReject: (payload) => window.blip.callReject(payload), + callCandidate: (payload) => + window.blip.callCandidate({ + to: payload.to, + candidate: payload.candidate?.toJSON?.() ?? payload.candidate, + }), + callHangup: (payload) => window.blip.callHangup(payload), +}; + +function dbg(...args) { + console.log('[BLIP call-window]', ...args); +} + +async function boot() { + if (!window.blip) { + document.getElementById('call-root').innerHTML = + '

No preload bridge

'; + return; + } + + const config = await window.blip.getConfig(); + setLang(config.language || localStorage.getItem('blip_lang') || 'en'); + + const root = document.getElementById('call-root'); + let callUI = null; + + callUI = createCallUI(config, api, { + onClosed: () => { + window.blip.closeCallWindow?.(); + }, + }); + root.appendChild(callUI.el); + + document.getElementById('call-win-close')?.addEventListener('click', () => { + callUI?.hangupCall?.(); + }); + + window.blip.onCallOutgoing?.((payload) => { + dbg('call-outgoing', payload); + const peerId = payload?.peerId; + const video = !!payload?.video; + if (peerId) callUI.startOutgoing(peerId, video); + }); + + window.blip.onIncomingCall((data) => { + dbg('incoming-call', data); + callUI.handleIncoming(data); + }); + window.blip.onCallAnswer((data) => { + dbg('call-answer', data); + callUI.handleAnswer(data); + }); + window.blip.onCallCandidate((data) => { + dbg('call-candidate', data); + callUI.handleCandidate(data); + }); + window.blip.onCallRejected((data) => { + dbg('call-rejected', data); + callUI.handleRejected(data); + }); + window.blip.onCallEnded((data) => { + dbg('call-ended', data); + callUI.handleEnded(data); + }); +} + +boot().catch((e) => console.error(e)); diff --git a/renderer/call-window.html b/renderer/call-window.html new file mode 100644 index 0000000..f6ce8f3 --- /dev/null +++ b/renderer/call-window.html @@ -0,0 +1,52 @@ + + + + + + + BLIP — Call + + + + +
+
+ BLIP CALL + +
+
+
+ + + diff --git a/renderer/call.js b/renderer/call.js index a627621..dda9aa2 100644 --- a/renderer/call.js +++ b/renderer/call.js @@ -52,7 +52,7 @@ function formatDuration(ms) { return `${pad(m)}:${pad(s % 60)}`; } -export function createCallUI(config, api) { +export function createCallUI(config, api, options = {}) { const overlay = document.createElement('div'); overlay.className = 'call-overlay hidden'; @@ -166,6 +166,7 @@ export function createCallUI(config, api) { function hide() { overlay.classList.add('hidden'); cleanup(); + options.onClosed?.(); } function cleanup() { @@ -394,19 +395,21 @@ export function createCallUI(config, api) { }); async function handleAnswer(data) { - if (!isForCurrentPeer(data) || !pc) return; + if (!pc) { + console.warn('[BLIP call] handleAnswer: no peer connection', data); + return; + } + const aid = Number(data?.from); + if (aid && peerId && aid !== Number(peerId)) { + console.warn('[BLIP call] answer ignored (wrong peer)', { aid, peerId, data }); + return; + } try { await setRemoteDescription(data.sdp); setConnectedStatus(); startTimer(); - - // У звонящего сбрасываем статус "набор" после принятия звонка - if (activeCall && activeCall.peerId === Number(data.from)) { - statusEl.dataset.i18n = 'call.connected'; - statusEl.textContent = t('call.connected'); - } } catch (err) { - console.error('[call] answer:', err); + console.error('[BLIP call] answer', err); } } @@ -441,11 +444,13 @@ export function createCallUI(config, api) { deafenBtn.classList.toggle('active', deafened); }); - endBtn.addEventListener('click', async () => { + async function hangupCall() { if (peerId) await api.callHangup({ to: peerId }); sounds.callEnd(); hide(); - }); + } + + endBtn.addEventListener('click', () => hangupCall()); return { el: overlay, @@ -455,9 +460,11 @@ export function createCallUI(config, api) { handleCandidate, handleRejected, handleEnded, + hangupCall, hide, end: hide, isActive: () => !!pc || !!(incomingOffer && activeCall?.pending), + getPeerId: () => peerId, }; } diff --git a/renderer/chat.js b/renderer/chat.js index 73e88c9..b9a3a58 100644 --- a/renderer/chat.js +++ b/renderer/chat.js @@ -2,8 +2,41 @@ import { t } from './i18n.js'; import { sounds } from './audio.js'; import { createAvatarElement } from './avatar.js'; +const STORAGE_KEY = 'blip_chat_v1'; +const MAX_PER_PEER = 500; + const messagesByPeer = new Map(); +function loadFromStorage() { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return; + const o = JSON.parse(raw); + for (const [k, arr] of Object.entries(o)) { + const id = Number(k); + if (Number.isFinite(id) && Array.isArray(arr)) { + messagesByPeer.set(id, arr.slice(-MAX_PER_PEER)); + } + } + } catch (e) { + console.warn('[BLIP chat] load history', e); + } +} + +function persist() { + try { + const o = {}; + for (const [k, msgs] of messagesByPeer) { + o[k] = msgs.slice(-MAX_PER_PEER); + } + localStorage.setItem(STORAGE_KEY, JSON.stringify(o)); + } catch (e) { + console.warn('[BLIP chat] persist', e); + } +} + +loadFromStorage(); + export function getMessages(peerId) { if (!messagesByPeer.has(peerId)) messagesByPeer.set(peerId, []); return messagesByPeer.get(peerId); @@ -12,9 +45,15 @@ export function getMessages(peerId) { export function addMessage(peerId, msg) { const list = getMessages(peerId); list.push(msg); + persist(); return list; } +export function clearPeerMessages(peerId) { + messagesByPeer.delete(peerId); + persist(); +} + export function createChatView(peerId, config, onSend, onBack) { const wrap = document.createElement('div'); wrap.className = 'chat-view'; @@ -45,6 +84,22 @@ export function createChatView(peerId, config, onSend, onBack) { header.appendChild(avatar); header.appendChild(meta); + const headSpacer = document.createElement('div'); + headSpacer.style.flex = '1'; + header.appendChild(headSpacer); + + const clearBtn = document.createElement('button'); + clearBtn.type = 'button'; + clearBtn.className = 'btn btn-lang chat-clear-btn'; + clearBtn.dataset.i18n = 'chat.clear'; + clearBtn.textContent = t('chat.clear'); + clearBtn.addEventListener('click', () => { + if (!confirm(t('chat.clear_confirm'))) return; + clearPeerMessages(peerId); + renderMessages(); + }); + header.appendChild(clearBtn); + const messagesEl = document.createElement('div'); messagesEl.className = 'chat-messages glass'; @@ -85,8 +140,9 @@ export function createChatView(peerId, config, onSend, onBack) { sounds.messageSent(); const result = await onSend?.(peerId, text); if (!result?.ok) { - const last = getMessages(peerId).pop(); - if (last === msg) getMessages(peerId).pop(); + const list = getMessages(peerId); + const last = list.pop(); + if (last === msg) persist(); renderMessages(); } } @@ -108,21 +164,26 @@ export function createChatView(peerId, config, onSend, onBack) { function renderMessages() { const msgs = getMessages(peerId); - - // Сохраняем фокус, если он в поле ввода + const hasFocus = document.activeElement === input; const cursorPos = hasFocus ? input.selectionStart : null; - - // Сохраняем позицию прокрутки + const scrollPos = messagesEl.scrollTop; - const wasAtBottom = messagesEl.scrollHeight - messagesEl.scrollTop - messagesEl.clientHeight < 50; - + const nearBottom = + messagesEl.scrollHeight - messagesEl.scrollTop - messagesEl.clientHeight < 80; + messagesEl.innerHTML = ''; if (msgs.length === 0) { const p = document.createElement('p'); p.className = 'chat-empty'; p.textContent = t('chat.empty'); messagesEl.appendChild(p); + if (hasFocus) { + requestAnimationFrame(() => { + input.focus(); + if (cursorPos !== null) input.setSelectionRange(cursorPos, cursorPos); + }); + } return; } msgs.forEach((m) => { @@ -134,17 +195,15 @@ export function createChatView(peerId, config, onSend, onBack) { block.appendChild(text); messagesEl.appendChild(block); }); - - // Восстанавливаем фокус и позицию курсора + if (hasFocus) { - input.focus(); - if (cursorPos !== null) { - input.setSelectionRange(cursorPos, cursorPos); - } + requestAnimationFrame(() => { + input.focus(); + if (cursorPos !== null) input.setSelectionRange(cursorPos, cursorPos); + }); } - - // Прокручиваем вниз, если были внизу или новое сообщение входящее - if (wasAtBottom || !hasFocus) { + + if (nearBottom || !hasFocus) { messagesEl.scrollTop = messagesEl.scrollHeight; } else { messagesEl.scrollTop = scrollPos; @@ -157,6 +216,8 @@ export function createChatView(peerId, config, onSend, onBack) { messagesEl.classList.add('flash'); } + renderMessages(); + return { el: wrap, renderMessages, diff --git a/renderer/i18n.js b/renderer/i18n.js index b774d15..0e90a75 100644 --- a/renderer/i18n.js +++ b/renderer/i18n.js @@ -24,6 +24,8 @@ const locales = { 'chat.input_placeholder': 'Type a message...', 'chat.send': 'SEND', 'chat.empty': 'No messages yet.', + 'chat.clear': 'CLEAR CHAT', + 'chat.clear_confirm': 'Delete all messages in this conversation? This cannot be undone.', 'peers.title': 'PEERS', 'peers.online': 'ONLINE', 'peers.offline': 'OFFLINE', @@ -34,6 +36,8 @@ const locales = { 'settings.id': 'Your BLIP ID', 'settings.change_id': 'Change ID', 'settings.language': 'Language', + 'settings.about_title': 'About', + 'settings.github': 'GitHub', 'error.id_taken': 'ID TAKEN', 'error.id_taken_hint': 'This number is already in use. Choose another.', 'error.connection_failed': 'CONNECTION FAILED', @@ -47,6 +51,8 @@ const locales = { 'chat.pick_peer': 'Open a peer from PEERS or dial an ID.', 'chat.no_active': 'No conversation selected.', 'call.connected': 'ON CALL', + 'toast.new_message': 'NEW MESSAGE', + 'toast.open_chat': 'OPEN CHAT', }, ru: { 'app.title': 'BLIP', @@ -73,6 +79,8 @@ const locales = { 'chat.input_placeholder': 'Введи сообщение...', 'chat.send': 'ОТПР', 'chat.empty': 'Сообщений пока нет.', + 'chat.clear': 'ОЧИСТИТЬ ЧАТ', + 'chat.clear_confirm': 'Удалить все сообщения в этом чате? Это нельзя отменить.', 'peers.title': 'АБОНЕНТЫ', 'peers.online': 'В СЕТИ', 'peers.offline': 'НЕ В СЕТИ', @@ -83,6 +91,8 @@ const locales = { 'settings.id': 'Твой BLIP ID', 'settings.change_id': 'Сменить ID', 'settings.language': 'Язык', + 'settings.about_title': 'О приложении', + 'settings.github': 'GitHub', 'error.id_taken': 'ID ЗАНЯТ', 'error.id_taken_hint': 'Этот номер уже используется. Выбери другой.', 'error.connection_failed': 'ОШИБКА ПОДКЛЮЧЕНИЯ', @@ -96,6 +106,8 @@ const locales = { 'chat.pick_peer': 'Выбери абонента в АБОНЕНТЫ или набери ID.', 'chat.no_active': 'Чат не выбран.', 'call.connected': 'НА СВЯЗИ', + 'toast.new_message': 'НОВОЕ СООБЩЕНИЕ', + 'toast.open_chat': 'ОТКРЫТЬ ЧАТ', }, }; diff --git a/renderer/main.js b/renderer/main.js index c5abc80..b30f3ee 100644 --- a/renderer/main.js +++ b/renderer/main.js @@ -1,5 +1,5 @@ import { setLang } from './i18n.js'; -import { initUI, updatePeers, handleTcpMessage, getCallUI } from './ui.js'; +import { initUI, updatePeers, handleTcpMessage } from './ui.js'; const api = { saveConfig: (data) => window.blip.saveConfig(data), @@ -53,13 +53,7 @@ async function boot() { window.blip.onPeersUpdated((data) => updatePeers(data)); window.blip.onTcpMessage((msg) => handleTcpMessage(msg)); - const callUI = getCallUI(); - - window.blip.onIncomingCall((data) => callUI.handleIncoming(data)); - window.blip.onCallAnswer((data) => callUI.handleAnswer(data)); - window.blip.onCallCandidate((data) => callUI.handleCandidate(data)); - window.blip.onCallRejected((data) => callUI.handleRejected(data)); - window.blip.onCallEnded((data) => callUI.handleEnded(data)); + /* Calls run in separate BrowserWindow (call-window.html) — see main process */ } boot().catch((err) => { diff --git a/renderer/styles.css b/renderer/styles.css index f5dfb9a..6ac66be 100644 --- a/renderer/styles.css +++ b/renderer/styles.css @@ -461,6 +461,10 @@ body { padding: 6px 8px; } +.chat-clear-btn { + flex-shrink: 0; +} + .chat-peer-meta { display: flex; flex-direction: column; @@ -733,6 +737,65 @@ body { gap: 8px; } +.section-subtitle { + margin-top: 8px; + font-size: 12px; + color: #00ffc8; + text-transform: uppercase; + letter-spacing: 1px; +} + +.settings-about-line { + margin: 0; + font-size: 13px; + color: #e0e0e0; +} + +.settings-about-version { + margin: 0; + font-size: 12px; + color: rgba(0, 255, 200, 0.85); +} + +/* In-app toast (new message) */ +.app-toast { + position: fixed; + bottom: 20px; + right: 20px; + z-index: 400; + max-width: 320px; + padding: 12px 14px; + border: 2px solid #00ffc8; + background: rgba(20, 20, 20, 0.92); + backdrop-filter: blur(12px); + font-size: 12px; +} + +.app-toast strong { + display: block; + color: #00ffc8; + margin-bottom: 6px; + text-transform: uppercase; +} + +.toast-preview { + color: #e0e0e0; + margin: 0 0 10px; + word-break: break-word; + max-height: 72px; + overflow: hidden; +} + +.app-toast .toast-open { + width: 100%; + margin-top: 4px; +} + +.app-toast.toast-out { + opacity: 0; + transition: opacity 0.3s steps(1); +} + /* Error toast */ .error-toast { position: fixed; diff --git a/renderer/ui.js b/renderer/ui.js index 3a50c27..6958b94 100644 --- a/renderer/ui.js +++ b/renderer/ui.js @@ -1,7 +1,7 @@ import { t, setLang, getLang, applyLangChange, onLangChange } from './i18n.js'; import { createIdGrid } from './grid.js'; import { createChatView, getMessages, addMessage } from './chat.js'; -import { createCallUI, showSignalLost } from './call.js'; +import { showSignalLost } from './call.js'; import { createAvatarElement } from './avatar.js'; import { sounds } from './audio.js'; @@ -16,10 +16,39 @@ let state = { let rootEl = null; let mainContent = null; -let callUI = null; let gridComponent = null; let api = null; +async function openCallOutgoing(peerId, video = false) { + if (!window.blip?.openCallOutgoing) return; + try { + await window.blip.openCallOutgoing({ peerId, video }); + } catch (e) { + console.error('[BLIP] openCallOutgoing', e); + } +} + +function showMessageToast(peerId, preview) { + const el = document.createElement('div'); + el.className = 'app-toast glass'; + el.innerHTML = `${t('toast.new_message')} · #${peerId} +

${escapeHtml(preview || '')}

+ `; + el.querySelector('.toast-open')?.addEventListener('click', () => { + el.remove(); + openChat(peerId); + }); + document.body.appendChild(el); + setTimeout(() => el.classList.add('toast-out'), 8200); + setTimeout(() => el.remove(), 9000); +} + +function escapeHtml(s) { + const d = document.createElement('div'); + d.textContent = s; + return d.innerHTML; +} + function applyI18n(root = document) { root.querySelectorAll('[data-i18n]').forEach((el) => { const key = el.dataset.i18n; @@ -154,7 +183,7 @@ function renderDialView() { showSignalLost(wrap); return; } - callUI?.startOutgoing(id, false); + openCallOutgoing(id, false); }); actions.appendChild(msgBtn); @@ -248,7 +277,7 @@ function showPeerContextMenu(e, peer) { callItem.textContent = t('dial.call'); callItem.addEventListener('click', () => { menu.remove(); - if (peer.online) callUI?.startOutgoing(peer.blipId, false); + if (peer.online) openCallOutgoing(peer.blipId, false); }); menu.appendChild(msgItem); @@ -320,12 +349,45 @@ function renderSettingsView() { await api.saveConfig({ displayName: name }); }); + const aboutTitle = document.createElement('h3'); + aboutTitle.className = 'section-subtitle'; + aboutTitle.dataset.i18n = 'settings.about_title'; + aboutTitle.textContent = t('settings.about_title'); + + const aboutLine = document.createElement('p'); + aboutLine.className = 'settings-about-line'; + + const aboutVersion = document.createElement('p'); + aboutVersion.className = 'settings-about-version'; + + const githubBtn = document.createElement('button'); + githubBtn.type = 'button'; + githubBtn.className = 'btn btn-lang'; + githubBtn.dataset.i18n = 'settings.github'; + githubBtn.textContent = t('settings.github'); + + window.blip.getAppMetadata?.().then((meta) => { + const name = meta?.displayName || 'BLIP'; + const code = meta?.codename ? ` · ${meta.codename}` : ''; + aboutLine.textContent = `${name}${code}`; + aboutVersion.textContent = `v${meta?.version ?? '—'}`; + if (meta?.githubUrl) { + githubBtn.addEventListener('click', () => window.blip.openExternal?.(meta.githubUrl)); + } else { + githubBtn.disabled = true; + } + }).catch(() => {}); + wrap.appendChild(title); wrap.appendChild(nameLabel); wrap.appendChild(nameInput); wrap.appendChild(idRow); wrap.appendChild(langLabel); wrap.appendChild(langRow); + wrap.appendChild(aboutTitle); + wrap.appendChild(aboutLine); + wrap.appendChild(aboutVersion); + wrap.appendChild(githubBtn); return wrap; } @@ -562,8 +624,7 @@ export function initUI(config, blipApi) { const titleBar = createTitleBar(); rootEl.appendChild(titleBar); - callUI = createCallUI(config, blipApi); - rootEl.appendChild(callUI.el); + /* Calls use a separate BrowserWindow — see main/index.js + call-window.html */ onLangChange(() => { applyI18n(rootEl); @@ -594,31 +655,47 @@ export function updatePeers({ peers, occupiedIds }) { gridComponent.updateOccupied(occupiedIds.filter((id) => id !== state.config.blipId)); } - // Не обновляем view, если активен звонок - const callUI = getCallUI(); - if (callUI?.isActive()) return; + /* Never full re-render during active conversation (fixes scroll jump + input focus loss) */ + if (state.view === 'chat' && state.activePeer && mainContent) { + return; + } - if ((state.view === 'peers' || state.view === 'chat') && mainContent) { - renderView(state.view); + if (state.view === 'peers' && mainContent) { + renderView('peers'); + } + if (state.view === 'chat' && !state.activePeer && mainContent) { + renderView('chat'); } } export function handleTcpMessage(msg) { const peerId = msg.from === state.config.blipId ? msg.to : msg.from; + ensureChatView(peerId); state.chatViews.get(peerId)?.handleIncoming(msg); - // Не переключаем на чат, если активен звонок - const callUI = getCallUI(); - if (callUI?.isActive()) return; + if (state.view === 'chat' && state.activePeer === peerId) { + return; + } + + const preview = typeof msg.text === 'string' ? msg.text.slice(0, 120) : ''; + showMessageToast(peerId, preview); - if (state.view !== 'chat' || state.activePeer !== peerId) { - state.view = 'chat'; - state.activePeer = peerId; - if (mainContent?.isConnected) renderView('chat'); + const typingOther = + state.view === 'chat' && + state.activePeer && + state.activePeer !== peerId && + document.activeElement?.closest?.('.chat-input-row'); + + if (typingOther) { + return; } + + state.view = 'chat'; + state.activePeer = peerId; + if (mainContent?.isConnected) renderView('chat'); } export function getCallUI() { - return callUI; + return null; } diff --git a/scripts/electron-dev-peer2.mjs b/scripts/electron-dev-peer2.mjs new file mode 100644 index 0000000..1d931f2 --- /dev/null +++ b/scripts/electron-dev-peer2.mjs @@ -0,0 +1,18 @@ +import { spawn } from 'child_process'; +import { join } from 'path'; +import { homedir } from 'os'; +import electron from 'electron'; + +const userData = join(homedir(), '.blip-peer2'); + +const child = spawn(electron, ['.'], { + stdio: 'inherit', + env: { + ...process.env, + BLIP_VITE_DEV: '1', + BLIP_USER_DATA_DIR: userData, + }, + shell: true, +}); + +child.on('exit', (code) => process.exit(code ?? 0)); diff --git a/scripts/sync-app-metadata.mjs b/scripts/sync-app-metadata.mjs new file mode 100644 index 0000000..918f1f5 --- /dev/null +++ b/scripts/sync-app-metadata.mjs @@ -0,0 +1,14 @@ +/** + * Single source of truth: app-metadata.json → package.json version (for npm / electron-builder). + */ +import { readFileSync, writeFileSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +const meta = JSON.parse(readFileSync(join(root, 'app-metadata.json'), 'utf8')); +const pkgPath = join(root, 'package.json'); +const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')); +pkg.version = meta.version; +writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`, 'utf8'); +console.log('[sync-app-metadata] package.json version →', meta.version); diff --git a/vite.config.js b/vite.config.js index 144fd12..c462ac4 100644 --- a/vite.config.js +++ b/vite.config.js @@ -8,7 +8,10 @@ export default defineConfig({ outDir: '../dist', emptyOutDir: true, rollupOptions: { - input: resolve(__dirname, 'renderer/index.html'), + input: { + main: resolve(__dirname, 'renderer/index.html'), + call: resolve(__dirname, 'renderer/call-window.html'), + }, }, }, server: { From e2c9955c24dec7032fc231af7cd0fe0bdd73cd78 Mon Sep 17 00:00:00 2001 From: krwg Date: Sat, 16 May 2026 01:31:39 +0300 Subject: [PATCH 3/4] chore(repo): add OSS docs, GitHub templates, CI, and track issues on GitHub - CONTRIBUTING, SECURITY, CoC, CHANGELOG, ARCHITECTURE; README community links - GHA build (Node 20); Dependabot; issue/PR templates - engines + .nvmrc; stop ignoring issues/ for documented backlog --- .github/ISSUE_TEMPLATE/bug_report.md | 30 ++++++++ .github/ISSUE_TEMPLATE/config.yml | 5 ++ .github/ISSUE_TEMPLATE/feature_request.md | 23 ++++++ .github/dependabot.yml | 14 ++++ .github/pull_request_template.md | 24 ++++++ .github/workflows/ci.yml | 24 ++++++ .gitignore | 1 - .nvmrc | 1 + CHANGELOG.md | 42 +++++++++++ CODE_OF_CONDUCT.md | 35 +++++++++ CONTRIBUTING.md | 75 +++++++++++++++++++ README.md | 25 ++++++- SECURITY.md | 46 ++++++++++++ docs/ARCHITECTURE.md | 60 +++++++++++++++ .../001-theme-and-wallpaper-customization.md | 33 ++++++++ issues/002-avatar-upload-and-regenerate.md | 29 +++++++ ...003-system-tray-and-background-behavior.md | 28 +++++++ issues/004-audio-accessibility-controls.md | 26 +++++++ issues/005-global-keyboard-shortcuts.md | 26 +++++++ issues/006-native-os-notifications.md | 26 +++++++ ...-call-media-device-selection-and-health.md | 25 +++++++ issues/008-webrtc-screen-sharing.md | 26 +++++++ ...-pinning-peer-identity-on-first-contact.md | 25 +++++++ issues/010-network-diagnostics-overlay.md | 26 +++++++ issues/011-automatic-update-channel.md | 26 +++++++ issues/012-cross-platform-ci-and-artifacts.md | 26 +++++++ issues/013-chat-history-search-and-export.md | 25 +++++++ ...4-about-links-changelog-discoverability.md | 26 +++++++ .../015-graceful-bind-eaddrinuse-startup.md | 17 +++++ ...016-maint-contributing-readme-node-sync.md | 15 ++++ issues/017-infra-ci-windows-electron-smoke.md | 15 ++++ issues/018-docs-policies-localized-summary.md | 11 +++ ...19-maint-github-repo-settings-checklist.md | 22 ++++++ issues/020-quality-lint-format-ci.md | 12 +++ issues/021-docs-ipc-preload-reference.md | 11 +++ issues/022-infra-tagged-release-automation.md | 12 +++ issues/023-docs-user-faq-troubleshooting.md | 11 +++ issues/024-maint-stale-bot-optional.md | 11 +++ package.json | 3 + 39 files changed, 915 insertions(+), 3 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/dependabot.yml create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/ci.yml create mode 100644 .nvmrc create mode 100644 CHANGELOG.md create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 SECURITY.md create mode 100644 docs/ARCHITECTURE.md create mode 100644 issues/001-theme-and-wallpaper-customization.md create mode 100644 issues/002-avatar-upload-and-regenerate.md create mode 100644 issues/003-system-tray-and-background-behavior.md create mode 100644 issues/004-audio-accessibility-controls.md create mode 100644 issues/005-global-keyboard-shortcuts.md create mode 100644 issues/006-native-os-notifications.md create mode 100644 issues/007-call-media-device-selection-and-health.md create mode 100644 issues/008-webrtc-screen-sharing.md create mode 100644 issues/009-trust-pinning-peer-identity-on-first-contact.md create mode 100644 issues/010-network-diagnostics-overlay.md create mode 100644 issues/011-automatic-update-channel.md create mode 100644 issues/012-cross-platform-ci-and-artifacts.md create mode 100644 issues/013-chat-history-search-and-export.md create mode 100644 issues/014-about-links-changelog-discoverability.md create mode 100644 issues/015-graceful-bind-eaddrinuse-startup.md create mode 100644 issues/016-maint-contributing-readme-node-sync.md create mode 100644 issues/017-infra-ci-windows-electron-smoke.md create mode 100644 issues/018-docs-policies-localized-summary.md create mode 100644 issues/019-maint-github-repo-settings-checklist.md create mode 100644 issues/020-quality-lint-format-ci.md create mode 100644 issues/021-docs-ipc-preload-reference.md create mode 100644 issues/022-infra-tagged-release-automation.md create mode 100644 issues/023-docs-user-faq-troubleshooting.md create mode 100644 issues/024-maint-stale-bot-optional.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..afce6f1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,30 @@ +--- +name: Bug report +about: Something is broken or behaves incorrectly +title: '[BUG] ' +labels: bug +--- + +## Environment + +- OS / version: +- BLIP version (About screen or `app-metadata.json`): +- Install type (dev / NSIS / portable): + +## Steps to reproduce + +1. +2. +3. + +## Expected + +## Actual + +## Logs / screenshots + +Paste main process logs or DevTools console if relevant. Redact personal info. + +## Checklist + +- [ ] I searched existing issues for duplicates. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..faa7276 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: + - name: General question + url: https://github.com/krwg/BLIP/discussions + about: Ask the community (discussions) if you are unsure whether this is a bug. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..3428ab3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,23 @@ +--- +name: Feature request +about: Suggest an improvement or new capability +title: '[FEATURE] ' +labels: enhancement +--- + +## Problem / motivation + +What pain point does this solve? + +## Proposed solution + +## Alternatives considered + +## Scope notes + +- LAN-only / privacy constraints: +- Affects main process, renderer, or packaging: + +## Checklist + +- [ ] I am willing to help implement or test (optional). diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..fea3236 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,14 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: / + schedule: + interval: monthly + open-pull-requests-limit: 5 + labels: + - dependencies + + - package-ecosystem: github-actions + directory: / + schedule: + interval: monthly diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..adc87cf --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,24 @@ +## Summary + + + +## Type of change + +- [ ] Bug fix +- [ ] New feature +- [ ] Documentation / repo hygiene +- [ ] Refactor (no user-visible change) + +## How tested + + + +## Screenshots (UI) + + + +## Checklist + +- [ ] `npm run build` passes locally (or CI equivalent). +- [ ] User-visible strings updated in **EN + RU** if applicable (`renderer/i18n.js`). +- [ ] Linked issue: closes # diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..bf6bfc6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,24 @@ +name: CI + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Build renderer (Vite) + run: npm run build diff --git a/.gitignore b/.gitignore index da49ba4..fbf0711 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ node_modules dist-electron dist -issues diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..209e3ef --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +20 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c901f5a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,42 @@ +# Changelog + +All notable changes to this project are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +Release **version numbers** track [`app-metadata.json`](app-metadata.json) (synced into `package.json` on build). + +## [Unreleased] + +### Added + +- OSS hygiene: Contributing guide (`CONTRIBUTING.md`), Code of Conduct, security policy (`SECURITY.md`), root changelog, architecture doc (`docs/ARCHITECTURE.md`). +- `.github/workflows/ci.yml` — `npm ci` + `npm run build` on push/PR to `main`/`master`. +- Issue / PR templates, Dependabot (npm + GitHub Actions), `.nvmrc`, `engines.node` in `package.json`. +- Tracked backlog files under `issues/` (removed from `.gitignore`). + +### Changed + +- README: Community section + Node **20+** align with toolchain. + +## [0.1.4] — Obsidian + +### Added + +- Settings **About**: version from app metadata, GitHub link (`openExternal`). +- Chat history **clear conversation** action (with confirm). +- Central **`app-metadata.json`** + sync script for `package.json` version. + +### Changed + +- Main process handles **busy TCP/UDP ports** (`EADDRINUSE`): user dialog + clean exit instead of uncaught exception. +- Discovery ignores **self-announcements** on any local IPv4 alias (fewer phantom “duplicate self” peers). + +### Removed + +- In-app UDP/TCP port preset UI (profiles A/B); advanced users use env vars / config as documented. + +## Earlier + +Prior development history lives in Git commits and GitHub Releases; append older semver sections here when you cut releases. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..bdd608b --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,35 @@ +# Contributor Covenant Code of Conduct + +## Our pledge + +We pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation. + +## Our standards + +Examples of behavior that contributes to a positive environment: + +- Demonstrating empathy and kindness toward others +- Being respectful of differing opinions and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing when we affect others, and learning from the experience +- Focusing on what is best for the community + +Examples of unacceptable behavior: + +- The use of sexualized language or imagery, and sexual attention or advances of any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others’ private information without explicit permission +- Other conduct which could reasonably be considered inappropriate in a professional setting + +## Enforcement + +Maintainers are responsible for clarifying and enforcing standards of acceptable behavior. They may take appropriate and fair corrective action in response to behavior that they deem inappropriate, threatening, offensive, or harmful. + +## Reporting + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the maintainers via GitHub (issues or direct message if available). All complaints will be reviewed and investigated promptly and fairly. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 2.1. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..9a141f3 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,75 @@ +# Contributing to BLIP + +Thanks for helping improve BLIP. This project is **GPL-3.0** — your contributions will be under the same license. + +## Prerequisites + +- **Node.js** ≥ 20 (see `.nvmrc`). Use `nvm use` if you use nvm. +- **npm** (ships with Node). +- **Windows** is the primary target today; other platforms may work but are not fully validated in CI. + +## Quick setup + +```bash +git clone https://github.com/krwg/BLIP.git +cd BLIP +npm ci +``` + +## Development + +```bash +npm run electron:dev +``` + +This runs Vite and Electron with `BLIP_VITE_DEV=1`. The UI loads from `http://localhost:5173`. + +**Second instance** (separate config directory — see `scripts/electron-dev-peer2.mjs`): + +```bash +npm run electron:dev:peer2 +``` + +## Production-like run + +```bash +npm start +``` + +Builds the renderer first (`prestart` → `vite build`), then launches Electron against `dist/`. + +## Building installers + +Requires Windows for the current electron-builder targets: + +```bash +npm run electron:build # NSIS installer +npm run electron:build:portable +``` + +Outputs go to `dist-electron/` (see `electron-builder.yml`). + +## Version / metadata + +- Release version and display metadata live in **`app-metadata.json`**. +- `npm run build` runs `scripts/sync-app-metadata.mjs` so `package.json`’s `version` stays in sync. + +## Code style + +- Match existing patterns in `main/` and `renderer/`. +- Prefer small, focused PRs with a clear **what** and **why**. +- If you change user-visible strings, update **EN + RU** in `renderer/i18n.js` when applicable. + +## Pull requests + +1. Fork → branch → push → open PR against `main`. +2. Ensure **CI is green** (see `.github/workflows/ci.yml`). +3. Describe behavior change, testing done, and screenshots for UI changes. + +## Security + +Do **not** open public issues for sensitive vulnerabilities. See [SECURITY.md](SECURITY.md). + +## Questions + +Open a GitHub issue. If **Discussions** are enabled for this repo, you may ask broader questions there instead. diff --git a/README.md b/README.md index 0f5b336..d0f0c47 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ | Project layout | [Project layout](#en-layout) | [Структура](#ru-layout) | | Design tokens | [Design](#en-design) | [Дизайн](#ru-design) | | License | [License](#en-license) | [Лицензия](#ru-license) | +| Community | [Community](#en-community) | [Сообщество](#ru-community) | --- @@ -124,7 +125,7 @@ flowchart LR | | | |---|---| -| Node.js | **18+** | +| Node.js | **20+** (see `.nvmrc`) | | OS | Windows 10/11 (for `.exe` builds) | | Network | Same LAN / VPN (Hamachi, Radmin) | @@ -251,6 +252,16 @@ blip/ | Borders | `2px solid` | | Radius | **0** everywhere | +

Community

+ +| Doc | Purpose | +|-----|---------| +| [CONTRIBUTING.md](CONTRIBUTING.md) | Setup, dev workflow, PR expectations | +| [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) | Community standards | +| [SECURITY.md](SECURITY.md) | Reporting vulnerabilities | +| [CHANGELOG.md](CHANGELOG.md) | Release history | +| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | Technical map of the app | +

License

This project is licensed under **[GNU GPL v3](LICENSE)**. @@ -319,7 +330,7 @@ The **Minecraft** font is licensed separately under [MIT](https://github.com/bs- | | | |---|---| -| Node.js | **18+** | +| Node.js | **20+** (see `.nvmrc`) | | ОС | Windows 10/11 (сборка `.exe`) | | Сеть | Одна LAN / VPN (Hamachi, Radmin) | @@ -446,6 +457,16 @@ blip/ | Borders | `2px solid` | | Radius | **0** (везде) | +

Сообщество

+ +| Документ | Зачем | +|----------|--------| +| [CONTRIBUTING.md](CONTRIBUTING.md) | Сборка, dev, правила PR | +| [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) | Правила сообщества | +| [SECURITY.md](SECURITY.md) | Как сообщить об уязвимости | +| [CHANGELOG.md](CHANGELOG.md) | История версий | +| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | Архитектура кода | +

Лицензия

Проект распространяется под **[GNU GPL v3](LICENSE)**. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..bff65f1 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,46 @@ +# Security policy + +## Supported versions + +| Version | Supported | +|---------|-----------| +| Latest release on GitHub | Yes | +| Older tags | Best effort | + +BLIP is a **local-network P2P** app. Treat your LAN like a trust boundary: anyone on the same broadcast domain may attempt to interact with discovery or open TCP sessions to advertised ports. + +## Reporting a vulnerability + +**Please do not file public issues** for undisclosed security problems. + +Instead: + +1. Open a **private vulnerability report** via GitHub (**Security** → **Advisories** → **Report a vulnerability**), if enabled for the repository, **or** +2. Contact the maintainer through a private channel listed on their GitHub profile. + +Include: + +- Description and impact +- Steps to reproduce +- Affected version / commit +- Optional patch or mitigation ideas + +We aim to acknowledge within a few days; timelines depend on maintainer availability. + +## Scope (in scope) + +- Remote code execution, unsafe IPC, or unsafe `shell.openExternal` usage +- WebRTC / preload bridge weaknesses that break `contextIsolation` assumptions +- Packaging / auto-update integrity (when implemented) + +## Out of scope + +- Physical access to the machine, or malware already running as the user +- Social engineering on the local network +- Denial-of-service by flooding open ports on a hostile LAN (document hardening separately) + +## Hardening tips for users + +- Run BLIP only on networks you trust. +- Keep the app updated once releases publish security fixes. +- Use OS firewall policies if you expose unusual port overrides. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..a46247d --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,60 @@ +# BLIP — architecture overview + +High-level map of how pieces fit together. For build and contribution workflow see [CONTRIBUTING.md](../CONTRIBUTING.md). + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Electron main process │ +│ main/index.js — IPC, tray, BrowserWindows, orchestration │ +│ main/discovery.js — UDP (+ mDNS) peer presence │ +│ main/tcp-server.js | tcp-client.js — line-delimited JSON │ +└─────────────────────────────────────────────────────────────────┘ + ▲ preload.cjs (contextBridge → window.blip) + │ +┌─────────┴─────────────────────────────────────────────────────────┐ +│ Renderer (Vite bundles) │ +│ renderer/main.js · ui.js · chat.js · call.js … │ +│ renderer/call-window.html + call-window-main.js (call window) │ +└─────────────────────────────────────────────────────────────────┘ + +WebRTC signalling (SDP, ICE candidates) travels over the same TCP +connection as chat messages; media is peer-to-peer in the renderer. +``` + +## Processes & windows + +| Piece | Role | +|--------|------| +| **Main** | TCP server/client coordination, discovery, IPC to all renderers. | +| **Main window** | Chat, dial, peers, settings (`dist/index.html` or Vite dev URL). | +| **Call window** | Separate `BrowserWindow` loads `call-window.html` — WebRTC UI isolation. | + +## Networking + +| Mechanism | Default port | Purpose | +|-----------|---------------|---------| +| UDP broadcast (+ optional multi-port fan-out) | 42069 (config/env) | `announce` payloads: `blipId`, display name, IPs, advertised TCP/UDP. | +| TCP | 42070 (config/env) | Framed `\n`-delimited JSON: chat, pings, WebRTC signalling. | +| mDNS | — | Auxiliary discovery (`_blip._udp.local` TXT records). | + +Environment overrides: `BLIP_UDP_PORT`, `BLIP_TCP_PORT`. Separate user data dirs support side-by-side dev instances (`BLIP_USER_DATA_DIR`). + +## Persistence + +| Data | Location | +|------|-----------| +| User config (`blipId`, name, language, …) | Electron `userData` → `blip-config.json`. | +| Chat history | Renderer `localStorage` key `blip_chat_v1`. | +| Release metadata | `app-metadata.json` (version, codename, repo URL). | + +## Security posture (today) + +- `contextIsolation: true`, preload exposes a narrow API (`preload.cjs`). +- `openExternal` is restricted to http(s) URLs in main. +- LAN trust model: peers are whoever answers on your network segment. + +See [SECURITY.md](../SECURITY.md) for reporting expectations. + +## Future seams (tracked as GitHub issues / `issues/*.md`) + +- Auto-update channel, richer diagnostics UI, hardened trust UX, CI packaging smoke jobs. diff --git a/issues/001-theme-and-wallpaper-customization.md b/issues/001-theme-and-wallpaper-customization.md new file mode 100644 index 0000000..cf4afd4 --- /dev/null +++ b/issues/001-theme-and-wallpaper-customization.md @@ -0,0 +1,33 @@ +# [FEATURE] Theme, accent colors, and optional animated wallpaper support + +## Type +Enhancement · UX · Settings + +## Summary +Expose user-selectable themes (minimum: light/dark/high-contrast + accent hue), persisted in app config; allow optional animated or static wallpaper behind the chrome with performance-safe toggles. + +## Background +Custom appearance increases engagement and aligns expectations with polished messengers without requiring server-side infrastructure. + +## Scope +- Persist theme keys in Electron `userData` config (reuse existing pattern). +- CSS variables driven by preset tokens; avoid per-control inline colors. +- Optional wallpaper layer (`