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..5cc7bd5
--- /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@v6
+ 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 d708878..fbf0711 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,10 +1,3 @@
-node_modules/
-dist/
-dist-electron/
-build/icon.ico
-build/icon.png
-build/tray-16.png
-build/icon.svg
-*.log
-.DS_Store
-Thumbs.db
+node_modules
+dist-electron
+dist
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 80f6f2a..d0f0c47 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) |
@@ -32,11 +33,24 @@
| 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) |
---
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
| | |
@@ -111,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) |
@@ -148,8 +162,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) |
@@ -238,6 +252,16 @@ blip/
| Borders | `2px solid` |
| Radius | **0** everywhere |
+
+
+| 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)**.
@@ -250,6 +274,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**.
+
Обзор
| | |
@@ -294,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) |
@@ -331,8 +367,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` |
@@ -421,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/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 0000000..6fa1455
Binary files /dev/null and b/build/icon.ico differ
diff --git a/build/icon.png b/build/icon.png
new file mode 100644
index 0000000..cadfce7
Binary files /dev/null and b/build/icon.png differ
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 0000000..e4ae0f9
Binary files /dev/null and b/build/tray-16.png differ
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/electron-builder.yml b/electron-builder.yml
index b6f736f..f80c41a 100644
--- a/electron-builder.yml
+++ b/electron-builder.yml
@@ -9,6 +9,7 @@ files:
- preload.cjs
- dist/**/*
- package.json
+ - app-metadata.json
extraResources:
- from: build/icon.png
to: icons/icon.png
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 (`` or CSS animation) gated by perf toggle and reduced-motion OS preference (`prefers-reduced-motion`).
+- Respect frameless layout and existing typography (Minecraft font stack).
+
+## Out of scope
+- Marketplace / downloadable themes beyond built-in presets.
+- Per-peer theming.
+
+## Acceptance criteria
+- [ ] User can switch at least three built-in themes; choice survives restart.
+- [ ] Accent colour or preset applies consistently across nav, borders, CTAs.
+- [ ] Turning off animated wallpaper restores static/default background instantly.
+- [ ] No CSP regression in packaged build.
+
+## Technical notes
+- Prefer `prefers-reduced-motion: reduce` → disable animations automatically.
+- Test both Vite dev and `loadFile` production paths.
+
+## Definition of done
+Manual QA checklist passed; settings survive cold start.
diff --git a/issues/002-avatar-upload-and-regenerate.md b/issues/002-avatar-upload-and-regenerate.md
new file mode 100644
index 0000000..59dc3f3
--- /dev/null
+++ b/issues/002-avatar-upload-and-regenerate.md
@@ -0,0 +1,29 @@
+# [FEATURE] Avatar upload optional path + deterministic regenerate from BLIP identity
+
+## Type
+Enhancement · Profile · Privacy
+
+## Summary
+Allow replacing the generated avatar image with user-provided PNG/WebP ≤ N MB while retaining deterministic fallback generated from `(blipId, salt)` seed; expose “Regenerate procedural avatar” reset.
+
+## Background
+Balances personalization with LAN-first anonymity and zero cloud dependency.
+
+## Scope
+- File picker IPC from renderer → validate MIME, dimensions, decode in main or renderer with capped memory.
+- Store asset under `userData` with stable filenames; migrate on ID change optionally.
+- `Regenerate` removes custom override and restores seed-based avatar.
+- Announce payload MAY include opaque “avatar fingerprint” URI or hash-only if sharing later.
+
+## Acceptance criteria
+- [ ] Uploaded avatar displays in roster, dial, chat header, call UI where peer shown.
+- [ ] Oversize / invalid mime rejected with surfaced error string (i18n).
+- [ ] Deterministic procedural avatar recreated after regenerate.
+- [ ] Works offline-only; no outbound fetch.
+
+## Technical notes
+- Cap decode size early to mitigate decompression bombs.
+- Consider CSP `blob:`/`file:` allowances when loading local previews.
+
+## Definition of done
+Visual regression spot-check across main + optional call surface.
diff --git a/issues/003-system-tray-and-background-behavior.md b/issues/003-system-tray-and-background-behavior.md
new file mode 100644
index 0000000..9da336e
--- /dev/null
+++ b/issues/003-system-tray-and-background-behavior.md
@@ -0,0 +1,28 @@
+# [FEATURE] Tray icon integration and close-to-tray semantics
+
+## Type
+Enhancement · Platform integration
+
+## Summary
+Elevate tray support from placeholder to parity expectations: contextual menu (Show / Quit), optional “minimize to tray on close”, and single-click restore / double-click semantics per platform guideline.
+
+## Background
+Power users keep LAN-first messengers docked indefinitely; quitting from X should be predictable.
+
+## Scope
+- Windows-first implementation; defer macOS `TemplateImage` tweaks behind feature flag doc.
+- Settings toggle: Close button hides window vs terminates app (`exit` semantics explicit).
+- Guard against orphaned processes on Quit from tray vs main window menu.
+
+## Acceptance criteria
+- [ ] Tray shows icon with tooltip branding.
+- [ ] Context menu restores hidden main window reliably.
+- [ ] Tray Quit terminates discovery + TCP + WebRTC cleanly.
+- [ ] User-facing copy explains close vs minimize behavior.
+
+## Technical notes
+- Reuse electron `Tray` singleton; detach from destroyed `BrowserWindow`.
+- Telemetry off by default unless later issue adds opt-in diagnostics.
+
+## Definition of done
+No zombie listeners after tray-driven quit (spot-check Activity Monitor / Process Explorer absent).
diff --git a/issues/004-audio-accessibility-controls.md b/issues/004-audio-accessibility-controls.md
new file mode 100644
index 0000000..3e6fc99
--- /dev/null
+++ b/issues/004-audio-accessibility-controls.md
@@ -0,0 +1,26 @@
+# [FEATURE] Per-channel volumes, visual speaker activity indicators, scalable UI text
+
+## Type
+Enhancement · Accessibility · Calls
+
+## Summary
+Separate master UI volume from call/session volume sliders; visualize remote speaking state if signaling allows; expose optional UI scale/font-size presets.
+
+## Background
+Lan parties and open-mic setups need fine volume control without drowning notifications.
+
+## Scope
+- Settings section for audio sliders persisting floats in config.
+- Call UI metering (local VU simplistic OK; remote only if SDP stats available).
+- `font-size`/scale presets using root `rem`.
+
+## Acceptance criteria
+- [ ] Persisted volumes apply after reconnect / new call windows.
+- [ ] Local mute/deafen state remains obvious with iconography + textual label where space allows.
+- [ ] Reduced-motion disables decorative meters if they animate heavily.
+
+## Technical notes
+Use `AnalyserNode` sparingly due to CPU; throttle paint.
+
+## Definition of done
+Smoke test mute/deaf across pop-out window + simultaneous chat receive.
diff --git a/issues/005-global-keyboard-shortcuts.md b/issues/005-global-keyboard-shortcuts.md
new file mode 100644
index 0000000..714136a
--- /dev/null
+++ b/issues/005-global-keyboard-shortcuts.md
@@ -0,0 +1,26 @@
+# [FEATURE] Configurable global hotkeys for mute/deafen/answer/end
+
+## Type
+Enhancement · Productivity · Calls
+
+## Summary
+Expose non-conflicting accelerator registration via Electron `globalShortcut` (or localized menu accelerators fallback) allowing quick mute toggle while focused outside BLIP windows.
+
+## Background
+Matches competitive UX for voice-heavy applications.
+
+## Scope
+- Default sane bindings documented; collisions detected with OS-reserved combos.
+- Store user overrides in JSON config; sanitize parse errors.
+- Unregister cleanly on blur / quit to release OS grabs.
+
+## Acceptance criteria
+- [ ] Toggle mute works when game/app foreground (Windows verified).
+- [ ] Failed registration surfaces actionable toast/snackbar once.
+- [ ] Disabling shortcuts feature releases all grabs without restart requirement.
+
+## Technical notes
+Privileged shortcut APIs differ by OS — document unsupported combos on macOS if deferred.
+
+## Definition of done
+Zero leaked registered shortcuts post `app.quit` path.
diff --git a/issues/006-native-os-notifications.md b/issues/006-native-os-notifications.md
new file mode 100644
index 0000000..6ffcc2f
--- /dev/null
+++ b/issues/006-native-os-notifications.md
@@ -0,0 +1,26 @@
+# [FEATURE] OS-level toast notifications for messages and ringing calls when unfocused
+
+## Type
+Enhancement · Notifications
+
+## Summary
+Leverage Electron `Notification` API (with permission choreography on platforms that demand it) plus optional actionable buttons where supported — message preview trimmed, call ring distinct channel.
+
+## Background
+Users multitask locally; ephemeral in-app banners alone insufficient.
+
+## Scope
+- `new Notification(...)` guarded by duplicate suppression keyed by `{peerId, tsBucket}` throttle.
+- Configurable verbosity: previews off / initials only full text.
+- Call ring persists until Accept/Reject interacted or TTL policy.
+
+## Acceptance criteria
+- [ ] Incoming chat shows OS toast when BLIP inactive / unfocused.
+- [ ] Clicking toast focuses correct conversation stub (if opened from hub).
+- [ ] Permission denial degrades gracefully to existing in-app toasts only.
+
+## Technical notes
+Windows may require packaged app registration for modern toast features — document MSI vs portable divergence.
+
+## Definition of done
+No duplicate swarm of notifications during rapid bursts (debounce QA).
diff --git a/issues/007-call-media-device-selection-and-health.md b/issues/007-call-media-device-selection-and-health.md
new file mode 100644
index 0000000..c59d2f8
--- /dev/null
+++ b/issues/007-call-media-device-selection-and-health.md
@@ -0,0 +1,25 @@
+# [FEATURE] Input/output device pickers plus lightweight call health indicators
+
+## Type
+Enhancement · Calls · QoS UX
+
+## Summary
+Enumerate `navigator.mediaDevices.enumerateDevices()` respecting permission states; expose select inputs in call UI persist last choice; derive simple latency/bitrate gauges from RTCPeerConnection stats where available.
+
+## Background
+LAN still suffers from mismatched headsets and OS default churn.
+
+## Scope
+Hot-swap constrained to before / after negotiated session with safe renegotiation path OR explicit “Reconnect media” destructive action documented.
+Expose packet loss thresholds color-coded subtly (non-alarming palette).
+
+## Acceptance criteria
+- [ ] User can bind mic + speaker distinct from OS default.
+- [ ] Selection persists restart.
+- [ ] Stats panel collapsible advanced section or compact chips.
+
+## Technical notes
+Enumerate again on `devicechange` event listeners.
+
+## Definition of done
+Manual cross-check unplug/replug USB headset mid idle call state.
diff --git a/issues/008-webrtc-screen-sharing.md b/issues/008-webrtc-screen-sharing.md
new file mode 100644
index 0000000..8794f72
--- /dev/null
+++ b/issues/008-webrtc-screen-sharing.md
@@ -0,0 +1,26 @@
+# [FEATURE] Optional screen / window capture track in WebRTC sessions
+
+## Type
+Feature · Calls · High complexity
+
+## Summary
+Add opt-in screen share alongside existing audio path using `getDisplayMedia` with explicit UX surface and hang-up semantics independent of microphone.
+
+## Background
+LAN classroom / coworking demos parity with mainstream messengers albeit bandwidth heavier.
+
+## Scope
+- Negotiation path adds video m-line gated by mutual consent handshake (offer flag already partially present — extend thoughtfully).
+- UI affordance distinguish share vs webcam future placeholder.
+- Windows capture constraints documented; DPI scaling caveats surfaced as known issues readme section.
+
+## Acceptance criteria
+- [ ] Recipient sees shared track in call window scalable surface.
+- [ ] Stopping share removes sender track without nuking mic session.
+- [ ] CPU guard (lower max frame rate presets).
+
+## Technical notes
+Plan for Electron `desktopCapturer` fallbacks vs pure web path.
+
+## Definition of done
+Stress test simultaneous audio + moderate motion screen on same machine two-instance setup.
diff --git a/issues/009-trust-pinning-peer-identity-on-first-contact.md b/issues/009-trust-pinning-peer-identity-on-first-contact.md
new file mode 100644
index 0000000..b91a3b8
--- /dev/null
+++ b/issues/009-trust-pinning-peer-identity-on-first-contact.md
@@ -0,0 +1,25 @@
+# [FEATURE] Safety affordance — optional trust / PIN confirmation for unseen BLIP identities
+
+## Type
+Enhancement · Security UX
+
+## Summary
+Presentation-only first contact modal summarizing ephemeral key fingerprint or hashed announce signature so users can verbally confirm coworker parity before sensitive chat.
+
+## Background
+Pure LAN broadcasts are spoofable inside broadcast domain by malicious insiders.
+
+## Scope
+No centralized CA; fingerprints derived locally deterministic from handshake material or hashed displayName+source IP TTL policy (document spoof limitations honestly).
+Configurable strict mode rejecting messages until acknowledgement.
+
+## Acceptance criteria
+- [ ] Modal shows reproducible fingerprint string copyable ASCII.
+- [ ] User can defer trust; suppressed until next identity tuple change detected.
+- [ ] Exportable trust store JSON migrations versioned (`trust_v1`).
+
+## Technical notes
+Avoid implying end-to-end encryption beyond WebRTC ephemeral keys unless audited — phrase as authenticity aid.
+
+## Definition of done
+Synthetic mismatch scenario tested (simulate IP swap or ID collision).
diff --git a/issues/010-network-diagnostics-overlay.md b/issues/010-network-diagnostics-overlay.md
new file mode 100644
index 0000000..67315ea
--- /dev/null
+++ b/issues/010-network-diagnostics-overlay.md
@@ -0,0 +1,26 @@
+# [FEATURE] In-app diagnostics view for LAN connectivity health
+
+## Type
+Enhancement · Diagnostics
+
+## Summary
+Read-only surfaced panel exposing selected runtime facts (bound UDP/TCP ports, reachable broadcast flag self-test TTL, firewall hints) — NOT an external port scanner replacing OS tools.
+
+## Background
+Users misattribute application bugs to Windows Defender rules.
+
+## Scope
+- Surface last discovery announce timestamp deltas.
+- Show active peer sockets count and last TCP error string if any.
+- Link to distilled troubleshooting doc excerpt.
+
+## Acceptance criteria
+- [ ] Accessible from Settings → Advanced collapsible requiring explicit expand.
+- [ ] Sensitive paths redacted (`userData` shortened).
+- [ ] Refresh button clears transient probe cache.
+
+## Technical notes
+Run lightweight self-ping optionally on user click only — never automatic aggressive scanning.
+
+## Definition of done
+Simulate blocked UDP log line surfaces human actionable string.
diff --git a/issues/011-automatic-update-channel.md b/issues/011-automatic-update-channel.md
new file mode 100644
index 0000000..ccdf40e
--- /dev/null
+++ b/issues/011-automatic-update-channel.md
@@ -0,0 +1,26 @@
+# [INFRA] Auto-update discovery via electron-updater targeting GitHub Releases
+
+## Type
+Infrastructure · Releases
+
+## Summary
+Wire `electron-updater` (or equivalent) with publish pipeline producing signed artifacts; user toggle for beta channel optional.
+
+## Background
+Manual GitHub zip friction drops retention.
+
+## Scope
+- NSIS + portable update matrix tested.
+- Code signing certificate strategy documented (self-funded vs none).
+- Silent download + prompt install pattern.
+
+## Acceptance criteria
+- [ ] Newer semver from `latest.yml` triggers prompt.
+- [ ] Failure states (no network) non-blocking.
+- [ ] SHA512 verification honored.
+
+## Technical notes
+Portable updates differ — document skip or custom flow.
+
+## Definition of done
+Dry-run against private test release bucket or staging tag.
diff --git a/issues/012-cross-platform-ci-and-artifacts.md b/issues/012-cross-platform-ci-and-artifacts.md
new file mode 100644
index 0000000..adef95f
--- /dev/null
+++ b/issues/012-cross-platform-ci-and-artifacts.md
@@ -0,0 +1,26 @@
+# [INFRA] CI matrix for Windows / macOS / Linux builds with reproducible artifacts
+
+## Type
+Infrastructure · CI/CD
+
+## Summary
+Add GitHub Actions workflow producing versioned artifacts per push tag; cache dependencies; surface artifacts for smoke QA.
+
+## Background
+Single-developer projects still benefit from regression signal and contributor friction reduction.
+
+## Scope
+- Windows job required; macOS + Linux best-effort staged.
+- Cache `npm ci` layers.
+- Optional `electron-builder` secrets for notarization placeholders.
+
+## Acceptance criteria
+- [ ] Tag `v*` triggers full release build path.
+- [ ] PRs run lint + unit placeholder (even noop) under time budget.
+- [ ] Build logs retain `dist-electron` artifact upload ≤ retention policy.
+
+## Technical notes
+macOS notarization secrets must never log.
+
+## Definition of done
+Green workflow on default branch with documented required secrets table in internal CONTRIBUTING (future).
diff --git a/issues/013-chat-history-search-and-export.md b/issues/013-chat-history-search-and-export.md
new file mode 100644
index 0000000..87f4d20
--- /dev/null
+++ b/issues/013-chat-history-search-and-export.md
@@ -0,0 +1,25 @@
+# [FEATURE] Local conversation search and JSON export (complementing clear history)
+
+## Type
+Enhancement · Chat · Data portability
+
+## Summary
+Incremental search across `localStorage` backed transcript model with debounced substring match; optional per-peer JSON export sanitized for sharing.
+
+## Background
+Parity with archival expectations absent cloud sync.
+
+## Scope
+Virtualized scrolling future optional — initial simple filter pass acceptable up to capped history lengths already enforced (`MAX_PER_PEER`).
+Export excludes binary attachments (none presently) marker field `schema: blip_chat_export_v1`.
+
+## Acceptance criteria
+- [ ] Search box filters visible transcript non-destructively.
+- [ ] Export writes valid JSON reproducible round-trip importer stub optional backlog.
+- [ ] Performance acceptable ≤10k msgs aggregate synthetic bench local only.
+
+## Technical notes
+Maintain existing storage key versioning `blip_chat_v1`; future migrations isolated.
+
+## Definition of done
+Clear + export interaction doesn't orphan partial files / temp blobs.
diff --git a/issues/014-about-links-changelog-discoverability.md b/issues/014-about-links-changelog-discoverability.md
new file mode 100644
index 0000000..a0f7724
--- /dev/null
+++ b/issues/014-about-links-changelog-discoverability.md
@@ -0,0 +1,26 @@
+# [FEATURE] First-run / About enrichment — changelog deep link & release notes UX
+
+## Type
+Enhancement · Product polish
+
+## Summary
+Extend existing About surfaces with changelog anchor (same GitHub Releases), SPDX / license succinct line, contributor CTA parity.
+
+## Background
+Reduces support duplicates asking “what changed”.
+
+## Scope
+- Derived version string already centralized — unify display in splash optional.
+- Secondary link `CHANGELOG.md` or tagged compare view.
+- Localized tooltip strings verifying external navigation uses hardened `openExternal` path.
+
+## Acceptance criteria
+- [ ] Links open externally with single user gesture.
+- [ ] Broken network does not crash UI (guard fetch if later inline notes added).
+- [ ] Mirrors EN/RU i18n keys.
+
+## Technical notes
+If inline release notes fetched later — sign or pin commit hash.
+
+## Definition of done
+Copy review with maintainer persona under 120s onboarding path video script optional.
diff --git a/issues/015-graceful-bind-eaddrinuse-startup.md b/issues/015-graceful-bind-eaddrinuse-startup.md
new file mode 100644
index 0000000..04cd535
--- /dev/null
+++ b/issues/015-graceful-bind-eaddrinuse-startup.md
@@ -0,0 +1,17 @@
+# [BUG] Uncaught EADDRINUSE on duplicate TCP/UDP bind (second instance / stale process)
+
+## Type
+Bug · Main process startup
+
+## Summary
+Second BLIP instance (or another program) binding the same TCP (`0.0.0.0:42070`) / UDP port caused an unhandled `listen` / `bind` error — Electron showed "A JavaScript error occurred in the Main process" instead of exiting cleanly.
+
+## Status
+**Fixed** — listen/bind wrapped in Promises; rollback closes partial listeners; user sees `dialog.showErrorBox` with ports and env-var hint, then `app.quit()`.
+
+## Related
+Peer list "duplicate self" mitigated by filtering own `blipId` for **any** local IPv4/NIC alias (`getLocalIpv4Set`), not only `getLocalIp()`.
+
+## Acceptance criteria (regression)
+- [ ] Second launch with same ports shows dialog, no stack trace modal, process exits.
+- [ ] Successful launch still binds TCP then UDP; order rollback on UDP failure.
diff --git a/issues/016-maint-contributing-readme-node-sync.md b/issues/016-maint-contributing-readme-node-sync.md
new file mode 100644
index 0000000..da77282
--- /dev/null
+++ b/issues/016-maint-contributing-readme-node-sync.md
@@ -0,0 +1,15 @@
+# [MAINT] Keep CONTRIBUTING.md, README, and `.nvmrc` aligned with tooling
+
+## Type
+Maintenance · Documentation
+
+## Summary
+Whenever `package.json` scripts, Node `engines`, or dev commands change, refresh **CONTRIBUTING.md**, **README** (EN + RU Quick start), and **`.nvmrc`** so first-time contributors never hit version skew.
+
+## Acceptance criteria
+- [ ] Single source of truth for Node: `package.json` `engines` + `.nvmrc` + README tables match.
+- [ ] New npm script added → CONTRIBUTING documents it or links to `package.json` discovery.
+- [ ] PR template checkbox references this policy.
+
+## Notes
+Low-effort hygiene; batch with any tooling PR.
diff --git a/issues/017-infra-ci-windows-electron-smoke.md b/issues/017-infra-ci-windows-electron-smoke.md
new file mode 100644
index 0000000..7f98b38
--- /dev/null
+++ b/issues/017-infra-ci-windows-electron-smoke.md
@@ -0,0 +1,15 @@
+# [INFRA] Extend CI with Windows smoke (packaged `dir` or minimal Electron launch)
+
+## Type
+Infrastructure · CI
+
+## Summary
+Current **CI** (`ubuntu-latest`) validates `npm ci` + `npm run build` (Vite). Add an optional **Windows** job that installs deps and runs **`electron-builder --dir`** or equivalent to catch Windows-only packaging regressions before tagging.
+
+## Acceptance criteria
+- [ ] New workflow job `windows` (or matrix) on PR + main.
+- [ ] Caches npm; runtime < ~15 min on cold start where possible.
+- [ ] Document secrets requirement if code signing introduced later.
+
+## Out of scope (for first iteration)
+Fully headless codec / WebRTC E2E.
diff --git a/issues/018-docs-policies-localized-summary.md b/issues/018-docs-policies-localized-summary.md
new file mode 100644
index 0000000..b4b61ba
--- /dev/null
+++ b/issues/018-docs-policies-localized-summary.md
@@ -0,0 +1,11 @@
+# [DOCS] Localized summaries for Conduct & Security policies (RU + short EN teaser)
+
+## Type
+Documentation · i18n
+
+## Summary
+`CODE_OF_CONDUCT.md` and `SECURITY.md` are English-first (GitHub norms). Add **short RU block** at top linking full EN doc, OR separate `CODE_OF_CONDUCT.ru.md` with canonical link back — whichever matches repo style preferences.
+
+## Acceptance criteria
+- [ ] Russian-speaking contributors see where to report and expected behavior without machine-translating nuance blindly.
+- [ ] LICENSE / legal English remains authoritative for disputes.
diff --git a/issues/019-maint-github-repo-settings-checklist.md b/issues/019-maint-github-repo-settings-checklist.md
new file mode 100644
index 0000000..1d0439a
--- /dev/null
+++ b/issues/019-maint-github-repo-settings-checklist.md
@@ -0,0 +1,22 @@
+# [MAINT] GitHub repository hygiene checklist (settings, not committed)
+
+## Type
+Maintenance · Meta
+
+## Summary
+Things **only visible in repo settings** that signal professionalism:
+
+- Enable **Discussions** vs Issues-only policy.
+- **Branch protection** on `main`: require CI passing; optional required reviewers.
+- Issue **labels**: `bug`, `enhancement`, `good first issue`, `help wanted`.
+- Repo **topics**: `electron`, `p2p`, `webrtc`, `lan`, `vite`, ...
+- Confirm **SECURITY** policy picker points to repo `SECURITY.md`.
+
+## Acceptance criteria
+- [ ] Checklist pasted in pinned Discussion or internal wiki; items ticked manually over time.
+
+## Definition of done
+
+Maintainer walkthrough screenshot / comment thread optional.
+
+
diff --git a/issues/020-quality-lint-format-ci.md b/issues/020-quality-lint-format-ci.md
new file mode 100644
index 0000000..836c754
--- /dev/null
+++ b/issues/020-quality-lint-format-ci.md
@@ -0,0 +1,12 @@
+# [QUALITY] Add formatter + linter with CI gates
+
+## Type
+Quality · Developer experience
+
+## Summary
+Introduce **Biome** or **Prettier + ESLint** (pick one ecosystem) scoped to `main/`, `renderer/`, scripts. Add **`npm run check`** wired in CI alongside `npm run build`.
+
+## Acceptance criteria
+- [ ] Config committed; autofix documented in CONTRIBUTING.
+- [ ] First PR ignores historical noise via incremental `warn`/`off` pragmas minimized — preferably one-time format commit with maintainer ACK.
+- [ ] CI fails on regression.
diff --git a/issues/021-docs-ipc-preload-reference.md b/issues/021-docs-ipc-preload-reference.md
new file mode 100644
index 0000000..4f6b59d
--- /dev/null
+++ b/issues/021-docs-ipc-preload-reference.md
@@ -0,0 +1,11 @@
+# [DOCS] Typed or tabular IPC / preload surface reference (`docs/API.md`)
+
+## Type
+Documentation · API
+
+## Summary
+Maintain `docs/API.md` listing every `ipcMain.handle`, `ipcMain.on`, and `window.blip.*` expose from `preload.cjs` — parameters, ordering expectations, privilege notes.
+
+## Acceptance criteria
+- [ ] Updating IPC requires ticking a PR checkbox or bot reminder.
+- [ ] Cross-links point to SECURITY boundaries (e.g., `open-external` allowlist rationale).
diff --git a/issues/022-infra-tagged-release-automation.md b/issues/022-infra-tagged-release-automation.md
new file mode 100644
index 0000000..201e4f0
--- /dev/null
+++ b/issues/022-infra-tagged-release-automation.md
@@ -0,0 +1,12 @@
+# [INFRA] Tagged release automation (changelog → GitHub Release body)
+
+## Type
+Infrastructure · Releases
+
+## Summary
+Manual Releases are OK for now — automate copying **CHANGELOG.md** section + attaching `dist-electron` artifacts on annotated tag push via GitHub Action (Windows runner).
+
+## Acceptance criteria
+- [ ] `v*` tag workflow publishes draft or full Release.
+- [ ] Asset checksums surfaced.
+- [ ] Signing keys handled via masked secrets documentation.
diff --git a/issues/023-docs-user-faq-troubleshooting.md b/issues/023-docs-user-faq-troubleshooting.md
new file mode 100644
index 0000000..14c6bf1
--- /dev/null
+++ b/issues/023-docs-user-faq-troubleshooting.md
@@ -0,0 +1,11 @@
+# [DOCS] USER_FAQ troubleshooting (ports, firewall, duplicate instances)
+
+## Type
+Documentation · Support
+
+## Summary
+Consolidate repeated support answers into `docs/USER_FAQ.md` (EN primary, mirror RU table in README subsection link): ports busy (**EADDRINUSE**), two instances, VM bridged networking, Defender prompts.
+
+## Acceptance criteria
+- [ ] README links prominently after Quick start sections.
+- [ ] Short enough to skim under 120 seconds.
diff --git a/issues/024-maint-stale-bot-optional.md b/issues/024-maint-stale-bot-optional.md
new file mode 100644
index 0000000..1936568
--- /dev/null
+++ b/issues/024-maint-stale-bot-optional.md
@@ -0,0 +1,11 @@
+# [MAINT] Optional automation: stale issues / needs-triage bots
+
+## Type
+Maintenance · Automation
+
+## Summary
+Evaluate GitHub Actions `stale` or custom bot tagging issues without activity > N days (`status: stalled`). Opt-in carefully to avoid chasing away casual reporters.
+
+## Acceptance criteria
+- [ ] Bot comments are polite & link to Discussion for questions.
+- [ ] Exemptions for roadmap / blocked labels documented.
diff --git a/main/config.js b/main/config.js
index 012e400..c76835a 100644
--- a/main/config.js
+++ b/main/config.js
@@ -7,6 +7,8 @@ const DEFAULT_CONFIG = {
blipId: null,
displayName: 'Anonymous',
language: 'en',
+ udpPort: 42069,
+ tcpPort: 42070,
};
let configPath = null;
@@ -36,6 +38,24 @@ export function saveConfig(config) {
return merged;
}
+export function normalizePeerIp(ip) {
+ if (!ip || typeof ip !== 'string') return '';
+ return ip.replace(/^::ffff:/i, '');
+}
+
+/** All IPv4 addresses on this machine (filter self-announcements on any NIC alias). */
+export function getLocalIpv4Set() {
+ const set = new Set(['127.0.0.1']);
+ const nets = os.networkInterfaces();
+ for (const name of Object.keys(nets)) {
+ for (const net of nets[name] || []) {
+ const v4 = net.family === 'IPv4' || net.family === 4;
+ if (v4) set.add(normalizePeerIp(net.address));
+ }
+ }
+ return set;
+}
+
export function getLocalIp() {
const nets = os.networkInterfaces();
for (const name of Object.keys(nets)) {
diff --git a/main/discovery.js b/main/discovery.js
index fd4ff2d..d646a4f 100644
--- a/main/discovery.js
+++ b/main/discovery.js
@@ -1,8 +1,8 @@
import dgram from 'dgram';
import mdns from 'multicast-dns';
-import { getLocalIp } from './config.js';
+import { getLocalIp, getLocalIpv4Set, normalizePeerIp } from './config.js';
+import { resolvePorts, getDiscoveryBroadcastPorts } from './ports.js';
-export const UDP_PORT = 42069;
const ANNOUNCE_INTERVAL = 5000;
const PEER_TIMEOUT = 30000;
@@ -16,15 +16,38 @@ export class Discovery {
this.mdnsInstance = null;
this.announceTimer = null;
this.cleanupTimer = null;
+ this.udpPort = resolvePorts(config).udpPort;
}
- start() {
+ async start() {
+ this.udpPort = resolvePorts(this.config).udpPort;
this.socket = dgram.createSocket({ type: 'udp4', reuseAddr: true });
this.socket.on('message', (msg) => 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..cf8d83d 100644
--- a/package.json
+++ b/package.json
@@ -1,18 +1,22 @@
{
"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",
+ "engines": {
+ "node": ">=20"
+ },
"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
+
+
+
+
+
+
+
+
diff --git a/renderer/call.js b/renderer/call.js
index 2d35127..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() {
@@ -307,6 +308,9 @@ export function createCallUI(config, api) {
console.error('[call] outgoing:', err);
hide();
}
+
+ // Очищаем входящий оффер при исходящем звонке
+ incomingOffer = null;
}
async function acceptIncoming() {
@@ -345,12 +349,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;
@@ -383,13 +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();
} catch (err) {
- console.error('[call] answer:', err);
+ console.error('[BLIP call] answer', err);
}
}
@@ -424,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,
@@ -438,9 +460,11 @@ export function createCallUI(config, api) {
handleCandidate,
handleRejected,
handleEnded,
+ hangupCall,
hide,
end: hide,
- isActive: () => !!pc || !!incomingOffer,
+ isActive: () => !!pc || !!(incomingOffer && activeCall?.pending),
+ getPeerId: () => peerId,
};
}
diff --git a/renderer/chat.js b/renderer/chat.js
index 582ed24..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,12 +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 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) => {
@@ -125,7 +195,19 @@ export function createChatView(peerId, config, onSend, onBack) {
block.appendChild(text);
messagesEl.appendChild(block);
});
- messagesEl.scrollTop = messagesEl.scrollHeight;
+
+ if (hasFocus) {
+ requestAnimationFrame(() => {
+ input.focus();
+ if (cursorPos !== null) input.setSelectionRange(cursorPos, cursorPos);
+ });
+ }
+
+ if (nearBottom || !hasFocus) {
+ messagesEl.scrollTop = messagesEl.scrollHeight;
+ } else {
+ messagesEl.scrollTop = scrollPos;
+ }
}
function flashNew() {
@@ -134,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 43c95bc..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 || '')}
+ ${t('toast.open_chat')} `;
+ 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,23 +655,47 @@ export function updatePeers({ peers, occupiedIds }) {
gridComponent.updateOccupied(occupiedIds.filter((id) => id !== state.config.blipId));
}
- if ((state.view === 'peers' || state.view === 'chat') && mainContent) {
- renderView(state.view);
+ /* 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' && 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);
- if (state.view !== 'chat' || state.activePeer !== peerId) {
- state.view = 'chat';
- state.activePeer = peerId;
- if (mainContent?.isConnected) renderView('chat');
+ if (state.view === 'chat' && state.activePeer === peerId) {
+ return;
+ }
+
+ const preview = typeof msg.text === 'string' ? msg.text.slice(0, 120) : '';
+ showMessageToast(peerId, preview);
+
+ 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: {