From 29efc12e2a761a9efe551b0db0c22928019e4dcb Mon Sep 17 00:00:00 2001 From: krwg Date: Tue, 19 May 2026 13:57:27 -0700 Subject: [PATCH 01/25] add project header to README.md Added a header for the BLIP project in README. --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index c3df5ab..cfac12d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@
icon + # BLIP **P2P messenger for local networks — no cloud, no servers, no internet.** From c37e47108dc0ff43e910f82bd80102b17ddd1ea7 Mon Sep 17 00:00:00 2001 From: krwg Date: Wed, 20 May 2026 23:41:02 +0300 Subject: [PATCH 02/25] add testing section for English in README --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index cfac12d..56fd8b7 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@

English

+

Testing on one PC

| Approach | Works for chat/calls? | From 34698ccdccea450f021655074fa1f3ee1150013e Mon Sep 17 00:00:00 2001 From: krwg Date: Thu, 21 May 2026 10:51:36 +0300 Subject: [PATCH 03/25] Merge remote repository --- renderer/settings-ui.js | 17 +++++++++++++++++ renderer/ui.js | 14 ++++++++------ 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/renderer/settings-ui.js b/renderer/settings-ui.js index 108514b..3ba1bf0 100644 --- a/renderer/settings-ui.js +++ b/renderer/settings-ui.js @@ -6,6 +6,23 @@ export function buildThemedSelect(className = 'blip-select settings-dropdown') { return sel; } +/** Scrollable glass panel for settings lists (blocked peers, shortcuts, network, releases). */ +export function createSettingsListPanel(extraClass = '') { + const panel = document.createElement('div'); + panel.className = ['settings-list-panel', extraClass].filter(Boolean).join(' '); + return panel; +} + +/** + * @param {HTMLElement} content + * @param {string} [extraClass] + */ +export function wrapInSettingsListPanel(content, extraClass = '') { + const panel = createSettingsListPanel(extraClass); + panel.appendChild(content); + return panel; +} + /** * @param {{ value: string, label: string }[]} options * @param {string} current diff --git a/renderer/ui.js b/renderer/ui.js index 307ef7b..bd48cb4 100644 --- a/renderer/ui.js +++ b/renderer/ui.js @@ -93,6 +93,8 @@ import { createPixelToggle, createPixelHintIcon, copyTextToClipboard, + createSettingsListPanel, + wrapInSettingsListPanel, } from './settings-ui.js'; import { buildMicTestPanel } from './mic-test-panel.js'; import { @@ -1766,12 +1768,12 @@ function buildSettingsPrivacyPanel() { hint.textContent = t('settings.privacy_hint'); frag.appendChild(hint); - const list = document.createElement('div'); - list.className = 'settings-blocked-list'; + const list = createSettingsListPanel('settings-blocked-list'); function renderList() { list.innerHTML = ''; const blocked = getBlockedPeerIds(); + list.classList.toggle('settings-list-panel--empty', blocked.length === 0); if (blocked.length === 0) { const empty = document.createElement('p'); empty.className = 'hint'; @@ -1782,7 +1784,7 @@ function buildSettingsPrivacyPanel() { } for (const id of blocked) { const row = document.createElement('div'); - row.className = 'settings-blocked-row glass'; + row.className = 'settings-blocked-row settings-list-panel__row'; const peer = state.peers.find((p) => p.blipId === id); const meta = document.createElement('div'); @@ -2314,7 +2316,7 @@ function buildSettingsNetworkPanel() { frag.appendChild(actions); const bodyHost = document.createElement('div'); - bodyHost.className = 'settings-network-body'; + bodyHost.className = 'settings-network-body settings-list-panel settings-list-panel--auto'; frag.appendChild(bodyHost); let lastDiagnostics = null; @@ -2493,7 +2495,7 @@ function buildSettingsShortcutsPanel() { list.appendChild(dt); list.appendChild(dd); } - frag.appendChild(list); + frag.appendChild(wrapInSettingsListPanel(list, 'settings-list-panel--compact')); } addShortcutBlock('settings.shortcuts_main_scope', [ @@ -2881,7 +2883,7 @@ function buildSettingsUpdatesPanel() { frag.appendChild(releasesTitle); const releasesFeed = document.createElement('div'); - releasesFeed.className = 'settings-releases-feed'; + releasesFeed.className = 'settings-releases-feed settings-list-panel settings-list-panel--tall'; releasesFeed.textContent = '…'; frag.appendChild(releasesFeed); From 1e92de51310556b0cc1f6b980ec09626d2cd79f5 Mon Sep 17 00:00:00 2001 From: krwg Date: Thu, 21 May 2026 11:53:49 +0300 Subject: [PATCH 04/25] fix(settings): improve list containers, selects, and headings - Add shared list panels (.settings-list-panel) for blocked peers, network diagnostics, shortcuts, and release feed - Use row dividers inside panels instead of a glass border on every row - Increase dropdown height and readability in settings (padding, min-height, no uppercase for long labels) - Align section titles and field labels (line-height, flex in title rows) - Style mic test panel and .settings-text-input consistently - Add createSettingsListPanel / wrapInSettingsListPanel in settings-ui.js --- renderer/styles.css | 200 +++++++++++++++++++++++++++++++++++++------- renderer/ui.js | 2 +- 2 files changed, 169 insertions(+), 33 deletions(-) diff --git a/renderer/styles.css b/renderer/styles.css index c0d8ef3..b4060fe 100644 --- a/renderer/styles.css +++ b/renderer/styles.css @@ -376,6 +376,7 @@ textarea { select.blip-select { width: 100%; max-width: 100%; + min-height: 40px; padding: 10px 36px 10px 12px; border: 2px solid var(--blip-accent); background-color: var(--blip-input-bg); @@ -384,7 +385,7 @@ select.blip-select { font-size: 12px; text-transform: uppercase; letter-spacing: 0.06em; - line-height: 1.2; + line-height: 1.35; outline: none; cursor: pointer; border-radius: 0; @@ -402,6 +403,16 @@ select.blip-select { background-repeat: no-repeat; } +.settings-panel .blip-select, +.settings-panel select.blip-select { + min-height: 44px; + padding: 12px 40px 12px 14px; + font-size: 13px; + line-height: 1.4; + text-transform: none; + letter-spacing: 0.02em; +} + .blip-select:focus, select.blip-select:focus { box-shadow: 0 0 0 1px var(--blip-accent); @@ -1469,7 +1480,6 @@ select.blip-select:focus { .settings-call-select { max-width: 100%; - margin-top: 6px; } .settings-avatar-file-input { @@ -1490,11 +1500,9 @@ select.blip-select:focus { .settings-releases-feed { display: flex; flex-direction: column; - gap: 10px; + gap: 8px; flex: 1; min-height: 140px; - max-height: none; - overflow-y: auto; margin-top: 8px; } @@ -1506,6 +1514,12 @@ select.blip-select:focus { .settings-release-card { padding: 10px 12px; + margin: 0; + border-bottom: 1px solid color-mix(in srgb, var(--blip-glass-border) 30%, transparent); +} + +.settings-releases-feed .settings-release-card:last-child { + border-bottom: none; } .settings-release-head { @@ -1548,11 +1562,12 @@ select.blip-select:focus { } .settings-network-list { - margin: 12px 0 0; + margin: 0; display: grid; - grid-template-columns: minmax(120px, 40%) 1fr; - gap: 8px 16px; + grid-template-columns: minmax(128px, 38%) 1fr; + gap: 10px 18px; font-size: 12px; + line-height: 1.4; } .settings-network-list dt { @@ -1851,10 +1866,11 @@ select.blip-select:focus { } .mic-test-panel { - margin: 12px 0 20px; - padding: 12px; - border-radius: 8px; - background: rgba(0, 0, 0, 0.18); + margin: 4px 0 16px; + padding: 14px; + background: color-mix(in srgb, var(--blip-glass) 94%, var(--blip-bg)); + border: 2px solid var(--blip-glass-border); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--blip-accent) 10%, transparent); } .mic-test-meter { @@ -2162,10 +2178,21 @@ select.blip-select:focus { align-self: stretch; } -.settings-view label { +.settings-view > label, +.settings-panel > label:not(.pixel-toggle-row) { + display: block; text-transform: uppercase; font-size: 11px; + line-height: 1.35; color: var(--blip-accent); + margin-bottom: 4px; +} + +.settings-field label, +.pixel-toggle-row { + text-transform: none; + font-size: inherit; + color: inherit; } .settings-sub-label { @@ -2282,26 +2309,31 @@ select.blip-select:focus { flex-direction: column; gap: 8px; padding: 12px; - border-radius: 8px; + align-self: flex-start; + position: sticky; + top: 0; } .settings-field { display: flex; flex-direction: column; - gap: 6px; + gap: 8px; + margin-bottom: 2px; } .settings-field-label { - font-size: 10px; + display: block; + font-size: 11px; + line-height: 1.35; text-transform: uppercase; - letter-spacing: 0.08em; + letter-spacing: 0.06em; color: var(--blip-accent); font-family: var(--blip-font, 'Minecraft', monospace); } .settings-dropdown { width: 100%; - max-width: 400px; + max-width: 100%; } .settings-panel .blip-select:active { @@ -2328,22 +2360,27 @@ select.blip-select:focus { } .settings-panel-title { - margin: 0 0 4px 0; - font-size: 18px; + margin: 0 0 8px 0; + font-size: 19px; + line-height: 1.35; color: var(--blip-accent); text-transform: uppercase; - letter-spacing: 1px; + letter-spacing: 0.06em; + flex-shrink: 0; } .settings-panel-title-row { display: flex; align-items: center; - gap: 8px; - margin-bottom: 12px; + gap: 10px; + margin-bottom: 16px; + flex-shrink: 0; } .settings-panel-title-row .settings-panel-title { margin: 0; + flex: 1; + min-width: 0; } .settings-panel--empty .hint { @@ -2352,10 +2389,68 @@ select.blip-select:focus { line-height: 1.5; } -.settings-blocked-list { +.settings-list-panel { display: flex; flex-direction: column; - gap: 8px; + gap: 0; + padding: 2px; + background: color-mix(in srgb, var(--blip-glass) 94%, var(--blip-bg)); + border: 2px solid var(--blip-glass-border); + box-shadow: + inset 0 0 0 1px color-mix(in srgb, var(--blip-accent) 12%, transparent), + 3px 3px 0 rgba(0, 0, 0, 0.45); + overflow: hidden; +} + +.settings-list-panel--compact { + max-width: 420px; +} + +.settings-list-panel--auto { + max-height: none; + overflow: visible; + padding: 12px 14px; +} + +.settings-blocked-list.settings-list-panel { + max-height: min(40vh, 360px); + overflow-y: auto; +} + +.settings-releases-feed.settings-list-panel { + max-height: min(52vh, 520px); + overflow-y: auto; +} + +.settings-list-panel--empty { + min-height: 72px; + padding: 14px 16px; + justify-content: center; + align-items: stretch; +} + +.settings-list-panel--empty .hint { + margin: 0; + text-align: center; + line-height: 1.45; +} + +.settings-list-panel__row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 12px; + border-bottom: 1px solid color-mix(in srgb, var(--blip-glass-border) 35%, transparent); +} + +.settings-list-panel__row:last-child { + border-bottom: none; +} + +.settings-blocked-list { + width: 100%; + max-width: 520px; } .settings-blocked-row { @@ -2642,42 +2737,59 @@ select.blip-select:focus { color: var(--blip-text); } +.settings-network-body .hint { + margin: 0; +} + .settings-shortcuts-sub { - margin: 0 0 8px 0; + margin: 12px 0 8px 0; font-size: 11px; + line-height: 1.35; text-transform: uppercase; color: color-mix(in srgb, var(--blip-accent) 80%, transparent); - letter-spacing: 0.5px; + letter-spacing: 0.04em; } .settings-shortcuts-list { margin: 0; display: grid; - grid-template-columns: 1fr auto; - gap: 8px 24px; - max-width: 360px; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px 20px; + padding: 10px 12px; + align-items: center; } .settings-shortcuts-list dt { margin: 0; font-size: 13px; + line-height: 1.35; color: var(--blip-text); + min-width: 0; } .settings-shortcuts-list dd { margin: 0; font-size: 13px; + line-height: 1.35; font-family: inherit; color: var(--blip-accent); text-align: right; + white-space: nowrap; +} + +.settings-list-panel--compact { + max-height: none; + overflow: visible; } .section-subtitle { - margin-top: 8px; + margin: 14px 0 8px; font-size: 12px; + line-height: 1.35; color: var(--blip-accent); text-transform: uppercase; - letter-spacing: 1px; + letter-spacing: 0.06em; + flex-shrink: 0; } .settings-about-line { @@ -3645,6 +3757,30 @@ body.media-viewer-open { flex-wrap: wrap; } +.settings-label-row .settings-field-label, +.settings-label-row .section-subtitle { + flex: 1; + min-width: 0; + margin: 0; +} + +.settings-text-input { + width: 100%; + max-width: 360px; + padding: 10px 12px; + border: 2px solid var(--blip-accent); + background: var(--blip-input-bg); + color: var(--blip-text); + font-family: inherit; + font-size: 14px; + line-height: 1.35; + outline: none; +} + +.settings-text-input:focus { + box-shadow: 0 0 0 1px var(--blip-accent); +} + .section-title-row { margin-bottom: 8px; } diff --git a/renderer/ui.js b/renderer/ui.js index bd48cb4..f3005ee 100644 --- a/renderer/ui.js +++ b/renderer/ui.js @@ -2916,7 +2916,7 @@ function buildSettingsUpdatesPanel() { const feed = filterReleasesForChannel(result.releases, !!state.config?.receiveBetaUpdates); for (const r of feed) { const card = document.createElement('article'); - card.className = 'settings-release-card glass'; + card.className = 'settings-release-card'; const head = document.createElement('div'); head.className = 'settings-release-head'; From 11f751a13682237889a964b3fcaef58846e39d66 Mon Sep 17 00:00:00 2001 From: krwg Date: Thu, 21 May 2026 15:31:59 +0300 Subject: [PATCH 05/25] feat(mesh-plus): licensing, app icons, and settings UX (0.7.10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Ed25519 MESH+ activation (main IPC, public config, UDP announce badge) and settings tab with benefits carousel and license management. Add app icon picker: free variants (main + dop) and MESH+ mesh icons with live tray/window updates; rasterize all SVGs via build:icons for installer. Polish settings UI: larger About header, section hints on ?, remove icon captions and tray subsection titles, larger icon tiles, theme-aware ID grid. Extend RU strings (АФК, АЙДИ, mesh feature names, ГИТХАБ, ЧТО НОВОГО). Includes mesh-plus-keygen script, config-public split, and electron-builder icon/installer updates. --- .gitignore | 4 + CHANGELOG.md | 38 ++++ CONTRIBUTING.md | 6 +- README.md | 4 +- app-metadata.json | 4 +- build/icon.ico | Bin 358742 -> 358742 bytes build/icon.png | Bin 5108 -> 9659 bytes build/icon.svg | 88 +++++++- build/icons/dop-1-tray.png | Bin 0 -> 437 bytes build/icons/dop-1.png | Bin 0 -> 9746 bytes build/icons/dop-2-tray.png | Bin 0 -> 735 bytes build/icons/dop-2.png | Bin 0 -> 16147 bytes build/icons/dop-3-tray.png | Bin 0 -> 486 bytes build/icons/dop-3.png | Bin 0 -> 9739 bytes build/icons/dop-4-tray.png | Bin 0 -> 504 bytes build/icons/dop-4.png | Bin 0 -> 9820 bytes build/icons/main-tray.png | Bin 0 -> 482 bytes build/icons/main.png | Bin 0 -> 9659 bytes build/icons/mesh-1-tray.png | Bin 0 -> 559 bytes build/icons/mesh-1.png | Bin 0 -> 16046 bytes build/icons/mesh-2-tray.png | Bin 0 -> 481 bytes build/icons/mesh-2.png | Bin 0 -> 13956 bytes build/icons/mesh-3-tray.png | Bin 0 -> 337 bytes build/icons/mesh-3.png | Bin 0 -> 8404 bytes build/icons/mesh-4-tray.png | Bin 0 -> 265 bytes build/icons/mesh-4.png | Bin 0 -> 6661 bytes build/icons/mesh-5-tray.png | Bin 0 -> 268 bytes build/icons/mesh-5.png | Bin 0 -> 6847 bytes build/icons/mesh-6-tray.png | Bin 0 -> 276 bytes build/icons/mesh-6.png | Bin 0 -> 6885 bytes build/installer.nsh | 11 + build/tray-16.png | Bin 441 -> 482 bytes docs/ARCHITECTURE.md | 3 +- electron-builder.yml | 19 +- icon-dop-1.svg | 67 ++++++ icon-dop-2.svg | 71 +++++++ icon-dop-3.svg | 67 ++++++ icon-dop-4.svg | 67 ++++++ icon-main.svg | 86 ++++++++ icon-mesh1.svg | 81 +++++++ icon-mesh2.svg | 89 ++++++++ icon-mesh3.svg | 59 ++++++ icon-mesh4.svg | 131 ++++++++++++ icon-mesh5.svg | 127 +++++++++++ icon-mesh6.svg | 126 +++++++++++ icon.svg | 88 +++++++- main/app-icons.js | 61 ++++++ main/apply-app-icons.js | 36 ++++ main/config-public.js | 22 ++ main/config.js | 8 + main/discovery.js | 9 +- main/index.js | 119 +++++++++-- main/mesh-plus-license.js | 138 ++++++++++++ main/mesh-plus-public-key.js | 6 + main/paths.js | 7 +- main/tray.js | 31 ++- package-lock.json | 4 +- package.json | 8 +- preload.cjs | 4 + renderer/app-icon-picker.js | 105 +++++++++ renderer/i18n.js | 118 ++++++++++- renderer/mesh-plus-settings.js | 272 ++++++++++++++++++++++++ renderer/mesh-plus.js | 36 ++++ renderer/settings-ui.js | 26 +++ renderer/styles.css | 376 +++++++++++++++++++++++++++++++-- renderer/ui.js | 61 ++++-- scripts/build-icons.mjs | 93 ++++++-- scripts/mesh-plus-keygen.mjs | 54 +++++ 68 files changed, 2717 insertions(+), 113 deletions(-) create mode 100644 build/icons/dop-1-tray.png create mode 100644 build/icons/dop-1.png create mode 100644 build/icons/dop-2-tray.png create mode 100644 build/icons/dop-2.png create mode 100644 build/icons/dop-3-tray.png create mode 100644 build/icons/dop-3.png create mode 100644 build/icons/dop-4-tray.png create mode 100644 build/icons/dop-4.png create mode 100644 build/icons/main-tray.png create mode 100644 build/icons/main.png create mode 100644 build/icons/mesh-1-tray.png create mode 100644 build/icons/mesh-1.png create mode 100644 build/icons/mesh-2-tray.png create mode 100644 build/icons/mesh-2.png create mode 100644 build/icons/mesh-3-tray.png create mode 100644 build/icons/mesh-3.png create mode 100644 build/icons/mesh-4-tray.png create mode 100644 build/icons/mesh-4.png create mode 100644 build/icons/mesh-5-tray.png create mode 100644 build/icons/mesh-5.png create mode 100644 build/icons/mesh-6-tray.png create mode 100644 build/icons/mesh-6.png create mode 100644 build/installer.nsh create mode 100644 icon-dop-1.svg create mode 100644 icon-dop-2.svg create mode 100644 icon-dop-3.svg create mode 100644 icon-dop-4.svg create mode 100644 icon-main.svg create mode 100644 icon-mesh1.svg create mode 100644 icon-mesh2.svg create mode 100644 icon-mesh3.svg create mode 100644 icon-mesh4.svg create mode 100644 icon-mesh5.svg create mode 100644 icon-mesh6.svg create mode 100644 main/app-icons.js create mode 100644 main/apply-app-icons.js create mode 100644 main/config-public.js create mode 100644 main/mesh-plus-license.js create mode 100644 main/mesh-plus-public-key.js create mode 100644 renderer/app-icon-picker.js create mode 100644 renderer/mesh-plus-settings.js create mode 100644 renderer/mesh-plus.js create mode 100644 scripts/mesh-plus-keygen.mjs diff --git a/.gitignore b/.gitignore index f1db1a0..d615f87 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,7 @@ SECURITY.local.md # GitHub profile repo draft (publish to github.com/krwg/krwg separately) github-profile/ +MESH-PLUS-PLAN.md +scripts/.mesh-plus-private.b64 +mesh-plus-private.b64 +keysandlicense.md \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 42bece3..3b11b1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,44 @@ Release **version numbers** track [`app-metadata.json`](app-metadata.json) (sync _Nothing yet._ +## [0.7.10] — MESH+ Gate + +### Added + +- **App icon picker** (Appearance) — free: main + dop-1…4; MESH+: mesh-1…6 with lock toast when inactive. +- **Icon pipeline** — `icon-main.svg` drives installer/`.exe`; all variants rasterized to `build/icons/` via `npm run build:icons`. + +### Changed + +- **МЭШ ПЛЮС** settings UX — status card, carousel counter, activation card, **Удалить ключ**. +- **Russian localization** — full RU strings for МЭШ ПЛЮС tab (status «БЕСПЛАТНО» / «МЭШ ПЛЮС», toasts, carousel). +- **Developer** — **Удалить подписку** clears MESH+ license (with toast if already FREE). +- Tray/window/About icon updates live when changing variant (Start Menu shortcut updates on reinstall). + +## [0.7.9] — MESH+ Gate + +### Added + +- **MESH+ licensing** — Ed25519 activation in main (`mesh-plus-license.js`), IPC `activate-mesh-plus` / `deactivate-mesh-plus`, tier in public config. +- **Settings → MESH+** — benefits carousel, activation UI, FREE / MESH+ status (EN/RU). +- **MESH+ badge** on peer list — platinum plaque + violet gradient pixel label; `meshPlus` in UDP announce. +- **Key generator** — `npm run mesh-plus:keygen` (private key in `scripts/.mesh-plus-private.b64`, gitignored). +- **Installer metadata** — publisher `krwg`, file description, NSIS welcome/finish (`build/installer.nsh`), GPL license page. + +### Changed + +- `get-config` / `config-updated` no longer expose `meshPrivateKey` or raw license signature to renderer. + +## [0.7.8] — MESH+ Gate + +### Added + +- **[`docs/MESH-PLUS-PLAN.md`](docs/MESH-PLUS-PLAN.md)** — full MESH+ roadmap (tiers, Ed25519 keys, feature matrix, phased rollout). Implementation is incremental; FREE baseline unchanged. + +### Changed + +- **Settings UX** — shared list panels, taller dropdowns, aligned section headings (see settings styles / `settings-ui.js`). + ## [0.7.7] — Signal Corps ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fc1c7a5..c03cb67 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -63,6 +63,10 @@ Outputs go to `dist-electron/` (see `electron-builder.yml`). Version and codename live in [`app-metadata.json`](app-metadata.json) (synced into `package.json` on `npm run build`). Update [`CHANGELOG.md`](CHANGELOG.md) when shipping. +**MESH+** features are planned in [`docs/MESH-PLUS-PLAN.md`](docs/MESH-PLUS-PLAN.md) — implement in phases; do not break FREE behavior. + +Issue test keys locally: `npm run mesh-plus:keygen` (uses `scripts/.mesh-plus-private.b64`, gitignored). Embed only the **public** key in `main/mesh-plus-public-key.js` when rotating keys. + ## TCP payloads (renderer ↔ main) Chat and signalling use newline-delimited JSON on TCP port **42070**. Common `type` values: `message`, `typing`, `ping`/`pong`, `call-*`, `group-*`, `group-call-*`, `file-*`, `clipboard-push`. See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). @@ -71,7 +75,7 @@ The renderer Vite build produces three HTML entry points: `index.html`, `call-wi ## Version / metadata -- Release version and display metadata live in **`app-metadata.json`** (current line: **0.6.1 — Portrait**). +- Release version and display metadata live in **`app-metadata.json`** (see current version/codename there). - `npm run build` runs `scripts/sync-app-metadata.mjs` so `package.json`’s `version` stays in sync. - User-facing release notes go in **`CHANGELOG.md`** before tagging. diff --git a/README.md b/README.md index 56fd8b7..6200953 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ Quick VM flow: host runs BLIP (ID **1**), VM runs BLIP (ID **2**), same subnet v | | | |---|---| | **What** | Desktop app: text, voice, and video over LAN / Hamachi / Radmin VPN | -| **Release** | **0.7.7 — Signal Corps** (see [`CHANGELOG.md`](CHANGELOG.md)) | +| **Release** | **0.7.10 — MESH+ Gate** (see [`CHANGELOG.md`](CHANGELOG.md)) | | **Identity** | BLIP ID **1–64** (8×8 grid, Minecraft-style chunk metaphor) | | **Servers** | None — UDP broadcast, TCP, and WebRTC peer-to-peer only | | **Sign-up** | None | @@ -348,7 +348,7 @@ The **Minecraft** font is licensed separately under [MIT](https://github.com/bs- | | | |---|---| | **Что это** | Desktop-приложение: текст, голос и видео по LAN / Hamachi / Radmin VPN | -| **Релиз** | **0.7.7 — Signal Corps** (см. [`CHANGELOG.md`](CHANGELOG.md)) | +| **Релиз** | **0.7.10 — MESH+ Gate** (см. [`CHANGELOG.md`](CHANGELOG.md)) | | **Идентификация** | BLIP ID **1–64** (сетка 8×8) | | **Серверы** | Нет — только UDP broadcast, TCP и WebRTC между пирами | | **Регистрация** | Нет | diff --git a/app-metadata.json b/app-metadata.json index c51df16..5528f18 100644 --- a/app-metadata.json +++ b/app-metadata.json @@ -1,6 +1,6 @@ { "displayName": "BLIP", - "codename": "Signal Corps", - "version": "0.7.7", + "codename": "MESH+ Gate", + "version": "0.7.10", "githubUrl": "https://github.com/krwg/BLIP" } diff --git a/build/icon.ico b/build/icon.ico index 6fa1455713e21c967f2c5b24e27bb63ec30880ef..c6fead42588fa9221281116e6f70835ae7280919 100644 GIT binary patch literal 358742 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 literal 358742 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 Xd4Hz42f0~&OTv6l!fL1ew%q>*e#i{$ diff --git a/build/icon.png b/build/icon.png index cadfce71084cf308c02b0ea965212d143d961d5a..0901a025d6b48d78dbe47aeeac631262dae11c6c 100644 GIT binary patch literal 9659 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 MzUj%56QrpB1NI)+L;wH) literal 5108 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 diff --git a/build/icon.svg b/build/icon.svg index ce06903..dbe885e 100644 --- a/build/icon.svg +++ b/build/icon.svg @@ -1,14 +1,86 @@ - + - - - - + + + + + + + + + + - + + - + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/build/icons/dop-1-tray.png b/build/icons/dop-1-tray.png new file mode 100644 index 0000000000000000000000000000000000000000..d513b71286d555edfb0893dfd497b968fd4fa1a9 GIT binary patch literal 437 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 Rbp-$b002ovPDHLkV1jyTQ$zp& literal 0 HcmV?d00001 diff --git a/build/icons/dop-2.png b/build/icons/dop-2.png new file mode 100644 index 0000000000000000000000000000000000000000..4bbcf784acbe52cc17b2b847e86b0deed5a594a0 GIT binary patch literal 16147 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 I!yecE4-5XdF8}}l literal 0 HcmV?d00001 diff --git a/build/icons/dop-3-tray.png b/build/icons/dop-3-tray.png new file mode 100644 index 0000000000000000000000000000000000000000..5a1368252c0e26f62ffc65706391c4db42853141 GIT binary patch literal 486 zcmV@P)^0m`OxIRCt`NlU+*!K@^4;x`=+4S>70mrm0bzeMm+WC`32XjjUjP zmid)gVf9lY?4NAq%y?sU7UG<3cGt4HY2ksH_q^ww+1VXM`LAcVSdP92OMM7do{Ug+ zFbS-o2fgFXzEZ@9)flLC4)KP~X3NB>sdbLtv5>bw@sSGYo)|N4G)){DdDJ?84VL>> zfDx$Zu+ieg;oT5+x`NohHj#6zefqG+3IJb$Spj=j0=7HCNcDtqaBGt4kw!J?^A(sK z;K>bOwQdLOUJ6(_OEe`1$LByLJ66Yq7 zDud_=!=CQ};R>T>Y`|6I8Q>0R>rGC+i2+pn*A9^v)Kpm7a6S-C@~8>6U3>Z$6sV4clO>2 zc%I9I!JB9FKRC_u%Q!Xkc=PPVbrf@6hbi5aiD4?e4xY&*+?tLa^kUD~3yH$Cq9`+e cfF7&)17Cd~YABq8=Kufz07*qoM6N<$f`7c(N&o-= literal 0 HcmV?d00001 diff --git a/build/icons/dop-3.png b/build/icons/dop-3.png new file mode 100644 index 0000000000000000000000000000000000000000..3eb1c263f88d6d20cffc62c56ce23be9a759eece GIT binary patch literal 9739 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 literal 0 HcmV?d00001 diff --git a/build/icons/main-tray.png b/build/icons/main-tray.png new file mode 100644 index 0000000000000000000000000000000000000000..a0f026669ec45cd650c15b8aa8109a1147bbb485 GIT binary patch literal 482 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 MzUj%56QrpB1NI)+L;wH) literal 0 HcmV?d00001 diff --git a/build/icons/mesh-1-tray.png b/build/icons/mesh-1-tray.png new file mode 100644 index 0000000000000000000000000000000000000000..10c60a7529cfc6aeb9e998b3a3808ed3d169e724 GIT binary patch literal 559 zcmV+~0?_@5P)^0;Ymb6RCt`N)5~s~KmdhdFS2XZu1sCUX(2ISh`E@HVT^BFj2%Lv zP*qlwT_tzXuJamIULY@4`%ze&icvi6w2`_g(!W8RPeP+#i@s*98KwZ12VgMs^Ut^I}D=&!{`d{^-0ZuW6`1G zrfE`P6kQ_DKXHu~*O=xX>A2}wAz*;eHKE%Jm~M(md;>DCpc*k;Qqoj62FWT+@(oN< zK=*P8r#=tJ)&lY{fg6^vM;9>N74E-3;{M?gCRxFbO1QIY$iW)2pQHOcu;hV<15j|l zIR!!&vBwf3>Y{m1`VKafWrZUV2LofL704C4T3d73gpiL!} zvkDej!y*NjM+(eW8#<C_v>)fZX4eO$=L~#S2t(e)Ergw xuPiyJ1=-s@jgS7kkj;x1tyb&RKWe^Vwp4x1<07002ovPDHLkV1oY)_`(1H literal 0 HcmV?d00001 diff --git a/build/icons/mesh-1.png b/build/icons/mesh-1.png new file mode 100644 index 0000000000000000000000000000000000000000..7efd81904009f1e540716a6a0fc87e0d1b115493 GIT binary patch literal 16046 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 z zVthr!nf_$QzbDC&yCjxNHH1uyLh)#^W#qz>D-ahkmwPt6

      >a?`R_{dJ#@kO9VIq zJY~kCIAy(8;xkIAdaBzQ1LxbG5nrtimIE>I^0@NpW6{YIFqggfV)YBp9|4pl4uBaO zC7k5SY^iz|=r7bVEAhIC;$c69Yd>4?1~$w~2)xK#6z+mpyL9K{yjm4tLeZ5R=Eu!x zg0GP2R-*Ix-=8xHc+4G&n+|x1Ghmn@SQ5vGbOM?NC9{H4<=OBz;4Tk+p&HtJ#x@Z0 z*AqCmnY424REmGw-%oVBwVdR^jyAropsop-u)|#d!wDQzqpbzv+?~!*K6c zZLI*z7J?usxkKF#GDNRk!2V^*YTS!`&pjLsWT%_}oCT5$ZFv&s==D$)nx030FJAN( zkdMM%aR4+@bsk{Rj3Co76M#+7G-i?DkD&JhA0+BvMf?z0iX3j$=Hq-bOxzS$H8dV# za!D*u!4-MK3D-bk7Vx~4=L|$y_a+iT-Z_OUs)H-OsZggR^Z{%AY_g&bmn{KI5CXWd zY-oe)+sFxK@!&&uyWBhtn8Qn8OJ)25$G9R1Q>-8B?BZeny!9b_ zuYUdyEW(k=p2QWo$j^wXHaTCnqUThGSbs$35!HO&x))NjA zL~yH35IR{pCk%4f&rWFuNK>G0R}5=!M+F7Uc7D-^lbeHU?jNmG&&>ohW@--DPVut$tTRop{r2-e4Gitbrf(|o?}Gp zelV~xDe(8B-fdq!NI=e>`5M2&Y~`qaHtWFJT&?f57J2}m04(^=4|5BJQ%Uh3az*3A(CT$$>2G_Kkl#tgm14eJB zF@MUI*M{y+27D68w~Lo4)V8|@y5#Kji!!`cIK}w(OTtS8`a*35g7`F3@ofOd8tb>@ zy2W4k=mzTQole<~<*~u`{#mRHacxRei(3sDem*fEhBcOfD^4nyMEG2;lHzO4DW8A@>d>TT!Ta3z#(1Y_>hI^H6!nWQd% zBglP!##yE~dTaF6Pa{LZt4@UcZ^&m!Tsb`f)oKCdnm=lE-AmLvEN&#UE5VM+-Y#Mx+ntI#Kc%0&D020%PeVz(|;R)*B&? zz~2PDHe`bKFX!x;#Lp#zPqM8*BGkzA|5b`w{iPn!upv*tPYTW<9z=edZn)hk-nQ6& zd0s6K5O@l{@)8pN4P+(E6@et(2iWAea(K~pao!t9{WT-6iQ4f^r*cxBF<{0Aj=$wJ zI!|Vm#eMkdzo%cU-s0i}d27RgPU)$iSN#(4vdfl~DAz z711Ch`fJc$d^T&D-K;z>MuiFY`6BQFa&73fI)#CY;K7mfX*bsbu-Ck67(5YB+Jf@3 zhbX>7x*QapBnjyOU5Y%cHIn@eY7YTr+|)?(X(=3e{N^r5{DSsqc0TpP^@|)Nc;~Ai z9l%tBq_|F{jtV4&fdi+`(Ntg;IQvWvCJq^`Nbn=iUTWroOp`t$f0-PO zo2CEFMaG8>-L@RzP>KVdPEwofhyB?Upk4xq}%-F+lbYM-7 zGY?Api>=2W`HD({hPlwA;xAXBJTHJg5D#(*MSAor8pEL!s|b6 z^7;61YOuxPzaQApTS!8U(YQ}bad?2|Jd)@q2r3?@*qNYj|1&JHs20d@$u?hyx19I5 z3L*2dDvR7_@03-AOrO>T3=&Yxh*xsBI=bh$_yFe88VxI2B@5mhPPEnZol(i_1!kCC z91?&&0B?qPC52m{yGrsDz?QY>xL`TjJd+HPD!D?#n35Pnc~gODWtfXJ+1&`u3K1T# z10-%hPWkcRTmyMmN}o)?h_+{q1PM=B2Veg;(10vug7$_aFP_4st3q7%oS$zy8`8yz zHWAdvFMQfQ4K%Yro(N(PT$cYCkI9%1eK%Mr%dqLGG4SiXMafA(-}P`?{@<``B+o3| z<$vBp8(jk)c7XwXj_7*86gX)_M<;@7v-hbmVzvapj4^(S;#W&%yQouXIWF@+Zvi`E5I)Igf`=wt|)*e8)NQ_p)K0i94^tyW# z6K1oa^#P#0^${PRL5h1Pgt`2@l-s0hh!kHFl}EsKTWm1Uc^R31pj~nmu;>7K8FGqB zeEPU>d|a}**W&ALA8yJ355%4B{^=(6N316nqR0Y~RG+z+&+#?_PaLkE59JRwJ0nbo zkv^fzTI>X=9IUJ-`6biE~+kK*DKtNPA_@k|CgM@Z;AK6Xg#( z#!Qls4gK{A`sqQ0D6R&xVTd*rK6*-zkc^OEHCrGyajtT8xNsGYI|OxdoY)6^P6Ow? z!|A(pGp^{dcw)wvnoEzNe3Ym2-Hl>~n7Fx7t*3vJFa9&7TjA6IlDPk7h~wRl6K+R% zDS)*BHyf4H1ub(LHV5j7b8S6|<;;&7j$SvSq7XP%JnLC(0&grmb(q|D2HCCxdo}Ga zU4Dmbe)6U3po{pn+OvCb%w8J<*u#mboX?4HwKM|%8-4Ual!vfk{wyJH)M=uTpTLDP z0K>P39|mlc^N;Z2;P^9AgrBEy)1t!<2Im@fximZIy8^gKci;(iF7TJTz*CK)RNxdj zL=IBgByR{=`+~rH5{mh}_&(svq0&pyrr z3jRekmC~tj9i*pQ_#&g|PJ9IQV-xhcAi4lX@Z;&Sj%(l|{3mT$4D*?b3BfGRd~J0} zUEPEIr3d5`WJMNA7?{w~@-CD;b?m>`A7iHoO3~{9!8eqiNTRTomt$NwCq5AI2Tt%r z+&nMNi1|!Ukufn!$v<(#r)w}vzQQ?D5j*`2$^k`1=Gw#K8S^=k^n5$&!W)A(Za_3M zp8+eQ+5vsORa>VaE7yQfTc#vw_VmPuCef(pbW88BV@<#r%h~qZnJ|unDj4y%^!2FK zPp_na_CIi=W|6lqLYR^w_@z42zNu&yauK$_Lzt;LafR>#1&BY>QkaN2qA1Ka(G8N7 z`Z}F4iiGla2QKgf26udKytn#sljIsc`GfSqdVmAcwg*+SKY=ao8puI!#?8vt9H~c( zjolj#V=;-rw|O2jNz8`hJv<;;JUR@7sJ6E=ISx`!Zurq!QDja|!pxpF)7jTbKDN$u z0SPsEeG_AhiSs#oC>8}_9>vXLH-*% zDcFOD(6?E9MUcdfIzS9J?b%GaheigkRuY)To5iGQ zBMpF;#fl9l#MfB4G9JjK6?sB95UPg7UKj2t`%5Dte{DG@%2-emjEDt?qQ@5rO(Kjp z+(PwVj(bi%={vv6+`W7BVyU~aP-MUG!oC9OsRGr}qc)Q4Rii7Iwl|sxbkT~NfY}T? zfO^)ESsW2++%3+NwqdS-n*_29%ec6VJiuR{5JR8ty!jzGPwwNVvQ-=`sc66HR;pv~ z)y>Pp+~WZjkXi~C0e>r9z+DcaP8nFe3TP5;&m(FdsVsdQAh}v>b_NnP=4dx6l)Q|{ zg<4OUV!P5kQ}-P^ZH zM6fgT#%f1E`H9;jUcz0vtk(&EuLC6Cw0Z8i2E^^v*2BacqjN61`r5mn1Rlg}?0$() zl!@N#IX5w|P1nQApLC`G@V$j33PT`o*BWUWEnGM~j)#WX#V>sN8cFm5I71l7MBfcZ zn$Wp=>Rm@B0yp`bvaC2RD2tCU6%NY2lueQVYttO3i^G>h;e6LVH#QYMl8dHqb@)wX-eAS8+28_8 z!FMVUb=GfHc47oNa(Z`a^KY=uw_&H+&$-epeCFXTE?-6prJ}`zI5GJPGK_c6`D%VO zL?FzTt$@bn-wHhXZ%C!U!=Ps%PX@ZB9NljQEEapy-rIMkaf)3xL%1CFa=Onit1%|# zNUlk;@Li#kES+|=Z5V@q`Y9>qOJ0QKSvY|7{mU|#qGFx(0*g3VTv~nN^a^W^G@+jeI2IEt2Wk5qC3YE3z2Ypc2-8HE<6RrJBE^awDh-KFg zKMP`Xy2It@dvLrk3XIlTSN`@2x>SlF`2k4PjP>B|(1 z2L-%;ca)U4kqlc3Ex3DzxFdGh_6cOtOwXtB+k^Q#(&FVH$8RX{W}JOuCDnv}oPo4| zNPIU(5#l6pTWB$-Sht>?7>_0KUwR$$I!xoPNBabdr><{>(U_si{IfxqX|*I6PThW}2UQAx7w7(lu1qYcaywr=ESx(9)ePDBx>h}n;1)iyF)Z+ak)9UX$m`Ne3 zivCE5ktRa@_kr^*FUv^?>pWT`7j$IVs=VwVmqYWfZ`|yPAL@9*+A3%rxW=LI`h}EMW?Q&UYv6u)18aZ zW|;~V%iL+0_^{dW(CGa}lg7H)%OfUQh%?3GYAaJinrM-uquSr^*t&gM{x6zuFA(aC zKCM5*ef})ey~|>0)dvjd{J3HI7`lI-+oxLE%1~+r(HncIo#&$;_Fnq5Hfv>xqrabg7{wS@RTNrv~U;tyY3A?vGk2Ck!r2b zwRMl5Fj+~b7ty-}Ej)cf=ZFVwrD-+zCu$_INHXH>{DSlNwXGAB3T7OEJveGiq=&EU zuyFl6K-osgLwe+(Chs1`$hO?+?nffDK5f1!^jr=dHT=XT>*nsM?{@GitTw~jiuH|Auqz==g{}Z zm;%~bAq_dZIp|+Z9%rVlmoyjNgnpnc6% z4-ojJcG#~;~^=3_s~j6}}~eQKcng5_fM+m2{SFKL&*0L32S$9he_u`*}egD6Kge)PQY#ilUf zb$9hQHA;7k4#dn)zKqJt8q)^wsJCEdIS7GVpf<4c;wk@;DhWZ=vZk-CijoY>*nuIDB z-*`7B`!gp`{$+nZp@u?(s8JSr`4^lQ$vo`wviKf(scZWsrlB-1RhGeXgpO^BB;|( z8cQfHxee>R$KR#EXd*wi&J-~IW6M&tLrp6F2UV&zUb=I3ls(k{+F7!< zSj;T@KRWWGu3qU?#9HcK8#-H~qd&!=cy=Z!f7a(JS^(-z{ts% z&2#Oh3$#9;+x?rJ?8E+xc9EhcbhaK^_LGSyaM-ETj(quC-g2~6q*tRm+Apav+HwAQ z>m?S{;Owq{z-8>ZE1tfM{qzE@f`0gqkKicMG_q(f18x(?J%I8-ZB6RcJ z*d@W|mZ{&)7u^3@c;^^ftlxd;pVh08kJ5OH8%a|__X;YJ=w?+RP@kJt3J`w_V8upA^l{sG$Q|Wt^1QX`hggTFN^XYe1id|a?0O} zJfr3FIYEpcQ(P*BUmw%|RcH*{JOVJ*=^848N4N;W`Yp_)$>HmA?Gs)8dvS#&)9X|g z(pDZuCj{>Y6PJcG@o?Ni@|Ast)e3(1^Q1V3zCEhBeinarz@LLWSJd9QVaYS{sI6P2 zbHM*#^~}ydshq4n_&`o4=~k$EWRAMSS8U@k%;@pLn1s2Z9cMOM(oDETP|K0;*Fprw zarEex5$-liy>;AM@sbB6`6n8`&|Z!FX}(J4Jx*zqA;~TVr%JB72U;{(M@=UbN)T;L zK%4xpx*?zK%Rl;PBCQ-N{!0;;A@`6e(gYgo}fXlmqz~_Qp6TP|NIlSzL$x;eS6a>IoTSe?e;$m2_!$$vGK|*)&TChbN|u`xzoP0} zq1b-4bo+!iFV(Q5q1kHGxAj4G!BW3-cFSsazDKcp4lkF|?=uZJ&dP^W)DVd|Cm`$f z^FeSE(C-{!mo5y)3z<}l``Q&W^Jh~M!?F*U$|!eE}6 z_H1f7^X&(wDB58IkbbbtF}4j^nyF-aML~ByhvKlMA#9mkt#O5s8diQ`1|B zWU|@j?9b5yij{NKtePriRWiL=lIQ{T^)Hf4VVeH@>7kmpM+b|2rhjQ+$qJ%%CSrL0 z*8Y=NZ`v$h4oy2zgY@G4BDVh`qxpGdoBYn>rON^|h}T_m$L;|Awepr4zG~${wKd*!U)p>3MjJak_UDtRmIstlkCO+RRAqU4kQL{j zLas~K>bR)Dss*l{ul?4?ylYTbqUZ#r9(G6h3*9Xmli5JL;UOr{E%k5 z@ap?4Ca^fm0+y>RRl?UU*3vtnt310tgeZ%Ny{Ah!r%u87cZ8h#XzZD?cQUXJ%NGBX z1LOm=>ntz{YJAcZ1=}ctrIXUwXYUxKgytU zjdFM{_vj}~AKVE1pe-^|sn#k(n&eTZga5x9yFuvXu6{-*mV3+;%_Q_ILvd+nHOE^) zQ0V&IKqA8|&s83(kNEbi-y*J`fmwq;TxsRwtw^5_V`$jZdhjYkqy*_Df#I+;sDS|! z;Ljt!O*?kKJ#BjLKqQ~|pWFj8?~cd6d}KihK_*1BJiJ=qZD0@2v`{zlsEy(B9BMs+ zdKEGrkK>P-1@XZDsd?YHE2#@h_s4RBp35@mf6>|HIPnWk0j8vMAHFADR}$bvzxTY6 z2amzhUbK!?nKIh8s2Q0dSAV+1|8IL?y=UlqpB)#HNJDf s@_qZquH_ilM9zg@z2;(V??@993cT1MrxCV7zkm)H>znIU>$t@H53R!7a{vGU literal 0 HcmV?d00001 diff --git a/build/icons/mesh-2-tray.png b/build/icons/mesh-2-tray.png new file mode 100644 index 0000000000000000000000000000000000000000..e075e3fe63eee2e752d928479475b2010407b761 GIT binary patch literal 481 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 OfeYs^>6hv`NBuu|;^Vad literal 0 HcmV?d00001 diff --git a/build/icons/mesh-3-tray.png b/build/icons/mesh-3-tray.png new file mode 100644 index 0000000000000000000000000000000000000000..8270074772d41c2cde7164208131c3d5e48ee559 GIT binary patch literal 337 zcmV-X0j~auP)^014%?dRCt`NlM8BvKnz9aLA%07DeZc6e|=qmh=1{~5K%``Za^7} zR!ci@V9eZHjgu(#UrTHKI?wZinMXceBQ(%%lBa3955vHH-*eY>D^1e~W(*o=Hzr!^ zPa+%Z3Qz14YXS8$7;aLV_nw_AV||xh~rpTo^iJ5kYgI~au9|g7e&FI=dtU$ z0``dE%rM|XgCt3$P9y>B5&O}gsw%dd<4+v-t`Tnqr`M@8&q%5P<91FhN5^Z^-#R jkO({}rJhfKW=;MAr7zNX_Xf6800000NkvXXu0mjfb*zie literal 0 HcmV?d00001 diff --git a/build/icons/mesh-3.png b/build/icons/mesh-3.png new file mode 100644 index 0000000000000000000000000000000000000000..393f83c8cc9bc7dc9d670799eb64296aaf3c2366 GIT binary patch literal 8404 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 X0iQ4p8A%gpzyWG1+P4dDS%v%$>F2!a literal 0 HcmV?d00001 diff --git a/build/icons/mesh-5-tray.png b/build/icons/mesh-5-tray.png new file mode 100644 index 0000000000000000000000000000000000000000..44db982b7a5e8fd183d89709c325606d09abb172 GIT binary patch literal 268 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`oCO|{#S9EO-XP4l)OOlRpx{1F z7srr_ImrnQj8Qp%{uDL+F!11ACCA3brl%Lk>1TKHY)s9h{X2dv`SUFibvAaR`P1h?40z5hvDIw&(>2iDjk46 OVeoYIb6Mw<&;$UwW@t|U literal 0 HcmV?d00001 diff --git a/build/icons/mesh-5.png b/build/icons/mesh-5.png new file mode 100644 index 0000000000000000000000000000000000000000..3b2ef7960e8f66efee48d7fe28fc2f978d2e32dd GIT binary patch literal 6847 zcmc(EXH=72v-ZA|NRbXIh#-+77EqCr=9)d%4%X39Wje}z6ac`a zuBN0100e)9038kdxbOVl27b^#QNy|bAfkErhO*`2@&Mp|s;;D<@1DGpVjsmb_q?^$ z+%gxB_ptG>*&!vQSVbnJ47+$ln2y`Ay;9~u2(U_B*YUS#eGj4k4jpnRY z&qQ#{u>KAWHZ4B-pmnY+W-1ibF*oFQRLGEy_x$z1CsEJ!oHUI!OMfqS)H&e~4h*!_ zMe@fdes8U>)$MnUzNSMdu>K!k(hshVTH4!Qvb8qmsOft`Gxc3AeJYi*HEbCfJO}80ncxOyn%a>bo9!WO7y~q(7V)ooXw_SK< zp*PRn;qST@mfw^#wE5F=p%**j&CB0{HkD~{4xGEK$7gPRz1dm4V(hoX!(V*Zh=hdU zmB7!b__eNG9nEqR@t_$WvvP-n;zaS69E(E_%c67r-rmyd7d-FaI=*9RsO_UQSZppO z2MeXX@>`fY(f;9uG1Xus*~g&5p?ltKU;CM@V0i|C%-@eERi%lqnmn3##(wRj)(rbV{=VRY+q|#e{5+{kG6ujB zozi_|IP0z6wHT?K69xqvR($Hx%&}2|C-$T$pzE`TQ+p3js zxPlcoDOoGNI6ZgGDpn)adMY z(k&Xfs4L_fgeUsPdKH+-%Yen~#!LItG>*a zGl_ViTQg9nUttrGJYLl#kG9Zg$X=oauW}Etq=FwE8PgJY<C1*Lf0bJQ>riqt+G(YE zP4-m)0Bzziau;s=;~4cCtx<7mFt;<eEyxFObT((RJA$ne@5*!0N4t8u;aP2{ z_+zYqz@;S1GWSt(Ezw4DL&+a4Fv@LHu&;2@2M`($0$7pu)syO#Y84Kux694Kwy=*f z18fF;CiuFa;4efwRVy`d69g#&ij}HCk7Gl(3~EddxJN0((F8!$z;go(`;g_go>PgY zzJfmo>-R$p<^CBB2E=bS0braCmpe%r&q%lEKeqAiCAw>0AyFOzO`KwSMI#m)>cg)Z zjSu#c>QV;RrvP}R@NxGhE-h+6WRuMA6XhT|7Ay8V7XK6hmTGIa>NVG7Ut~Ws9!YlZ z?C6l<_g^wioVz)AIn*pcv<3N^a`1eJ8i znswNlv9TG1=`3#vOhhbVA?R?y_-&gsGNSoLO;XB>Z?)$j0+Zk|6|kE0L3L?a^d8&y z=B|~^CWX`#30LsSula!}abH(SFd^sR2Tp#c*4Nfw4xjRL zmpw$n&p5Gcca=}$-6~3Kogjy#c`XfOR(Igy$(S@=m0k+lu{0qV)tb z47>zNI7EsWW7*T`OlYc0fxyQ!B`%0@lDnW@(W1My8X`PaQ+q!bQ^$LV(iRfRqG>&4 zoke3HeRQ4nfl&828~ih?O3WL?$%tKhN1#(QBE7%oP*Pfgu|i$(dW`(T=*hFir3$LL zX%~_Ym^l9xP0x1-DJN!cwzGhZj`q4xRxpGjmeQ&DNE-&+4@pbW{jwpr<^~G}lrCg& z<_GSMxtzIFBzmP|E_w$A&=~fQ59a~nq)G4Fd;Mjdx4R)6jHSox*+a??3`Nj|5Sm7} z;ik^tZw2`SJWJPsYpz(5PLsjoo|H09e=rs?vT4C1^g8}e050a$mJsU=B-0kwvWP+9@-=MoMOyN}GS!6}b>5(xn`fdc~62F8bs zS$DWqfS~zqO8^_eo%0Hq`i)rK9;XFYs{WTn=`eK+T!7*NZSRqgfNRVO29YTxSl7!< z+(`SFAU4o8=<^4MLQfoF1Y6YvFP}{aFp|V5lHg}vue~A4qJmo0w9hSNyKx;Z4;B%A z73ZxaUvc>LKojbw z*}qL+5&qw8g4g{2;2~l?0Rr-zQQhR5fbfqT4Ilt56pZOt3&o`0Z`9;{Q|;R=y{{mQp!39}}W3e42G8P4NW z<&FNN*p!hLxnKw&0VQ*LnzZ(T%wu*EXs5j!@g)E9`5&`L+oX#H2x9$o=2#3DxB`HD zA|L4{z?6yxNh1&Q_CPTf&h(Qqpc#VzTfz`1En~TYK#f{*L4Yv8jsjEWS?qxIb`u?e zOAQ__2tW@+={gb|V}Sy|@D6Jj*bCK10Dt=%Dg%jfB6I{A`Uv~uoN+B<61EsID$!6*pMJp&GuEVRv;hXSa+y(e%Q zY_%S`P4+0b=YxV{Jg5ZDWI$lX5ENq-=?VSw3u{jC^w}+ssDq_J<7?wB2At41l5g&C zlN8K3!w1lSFpC7Jb6*;IAoO+TL_EU_HXs1VH=-0}5de&F;6$759$E|5rZAU1=R?K!Jk(UT=T$vGVnV*ddQ9$$5x6|q$Mx| zUV7JT3x zlE{ySZK*jAqqRxHe@Y_KxPVAV7JY-JnH!d7rObz8yMlt{T?z-In=?x3=Trdc2s5Dk zS(-22bd#jvf@IKPU>?-y?k$X=9@zeZ2ep?@c>a*6NDUhB3IWC#c)-1J zTCs|$ddRliCeF5=^BL@)pjp@AB{3ph4`hKN(xv~(g}1)wUgle=esMY^?0VRd6cP~t z2QfU@pV4JJgM2$Q7QGsdd9ZrZbtxa4AxD zti-~_CQV$EtFJ?3pB%Z_%jad6xFgLKV0S^4{$W_`vY?QQcx=bOxz`CfRgwxC8)LpE zR#{B7`pNZW1&yjpal4*BFB=BT!zhKyL4LKpQ)82R$9rjoD;leD??K5t_CssOVtyP? z@|nk9vlU&OjC@%{5J5yPKX31S1S0*qg|3z5<3{GJ)z~NZ6iQ70azbcXWt#^K561f`{ZoBzs<_NN2vVtZC#0!+;@)$b!6aF zedNYONKyG*O9ve$M+d290=u#F)GC%eAulw+x3uCY<^XsULckn3@+}yH4rKiviuI${_o3XS)Aq`2@QV-O8PpwmgMNJTnr04l=*4EKOiF;`8JXs{+HhhM=aSy3)ClXc2{Q8)@bmG^u70hy! z(E}Q|z%m0?M!*HyHNQS`5^#jz*43^ct z^f};Q>1(7;2_GB$a?U(4lk9y!U$ZvDA`0QWNcqPh9Mh)}fJtEgfqL`XDaX}5qA;(0 zzQL(c&D?8R+I3UDUa9&EBHP8}eccSvzOZoPv+f6}{&9pt+hxp4ujtb2F$>zAd-5sj3o`1y;M_k&LR&Po5x%kA!| zFn90Y^>{`-;WW((h$?7TV2U6ZBFFf zv^O*8_14;UFC$R?`kT#(XhuL6B>^x`0I}ob)4Y+fB|ic-yE=Y3LsPw6X+tgMYIb$G zayM0^Lakd{_w#%AmNC*@!h7tQXLb?!M&k+t62P?W?rb-f zE4P-yzvg%AxLMcNmJ4Sz z?3#`Zs+`LS*Smj82+RBKt&E1V!>f$*mBY3a1UhwcA3vS8)Tjb51jG^G{~fC9yIoQ> zh#+DtpLCKXOCx;yA_4*EETBBa@z8xf0?HJS--gAhfbhDn@PsEb5bYb{FjDnxIdCL) zUqMj*c=}pKW#qlfUSl-c!6P;N1fM@sQ@z_Xk?3zOOQz{AI^xM_VtZ6L9{U{p4c>7PWx|Amk7%1MCj{ms{*URq5f8S@JPr~Gg zTKg7;ulVlV>gGC-pvg1bKj{d13P+@Ax>mj(&4hUZzmH)oXE(j0K3WznZ4dHwN5KqV z7Za#4_0=(4RTM-3R)Qn}V6I7C(;|iQEwyz=|5rPGMZA0z_Mf0ruE6$)$Z0^3NswK> z(_H@YG`i*VlDgJ~k)fyKj*q8l2#4Lefy=KeWJUPI4koHurGx4N<;(jQhr9JVM=T5j z*iw5tM(#d*{E0C?_=k_<*3NgR$Hx1snWWUmbq8`+TGySw5^ni(REf_fL8b@(g|m}C zQ#$S+J!cJYPW`+S4d&;poOY)FxU|PlC{>&yZIE^XhmVk5=zs6`^L@&@_Nm)565)9q zD{ZE9L72g5p>mfMrW)qE#y(~kn$P@OQqRj%r%_5}%YR9!2e&;fY3*+nHDO}!Ij0aP zg$VDE=*EN$1MT-G@v2Hn_Hn!AREEi5t@Es*6`SsZq3|Qh4BcWDhur3R z4{eByn_heW z4@s$hWxnR<7HO}6okr8;QbeblInFlz&m}{EI|0c8~l^mbTCIgH|;plC~EaLtMSN!3q zS@Drzw_b?wG#W|1cH)H`+#@j{+r==E_&^Gia+lAN~Ne96~M_#ZD%{(sZYxQGQLh=}&`0ZBw)L>n z*}Sw+{)xu%agl<;gT-Mn)6rANek#O_;|(VTd8 zwobobx-2Geh)s<@P3$lRdL07!%GGr*3J=M|lO%H72j7shu$@J4A_!({^Vsr5Oqo(( zJ}GVY49&jS%VR~(x62ph`-*-UEWf`oQ8D{4LCYoAp;}%e>2ByDeHh!K$QI*tYcR4K zp(E_wHZj!XtY7|6`^Cn0GwGIzpo~$H?Xc(fp?0|78?H1#xg8IwQpOSInN4W6P_Sv~M%O#7kXir9d2n!!p!eBZBGOZd7%9|mn0 z^4YZGwOa0Gmf*j!FF3b0aB^zKcX5-KSkKce^q!Bq7-A;(&G}`;Uy*qI0&3VHdOF|! zWMZO`-Akr)dD<~$>y7U8b=~iRO9oT!aPP|Scurp(GYwy1EV4$fHfio?`^h+2k(WAl i|JTbKlycR=0d$e$`#|9=28tY3@( literal 0 HcmV?d00001 diff --git a/build/icons/mesh-6-tray.png b/build/icons/mesh-6-tray.png new file mode 100644 index 0000000000000000000000000000000000000000..d77a70aff9243ff44cae2f39144122a29899c8a6 GIT binary patch literal 276 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 literal 0 HcmV?d00001 diff --git a/build/installer.nsh b/build/installer.nsh new file mode 100644 index 0000000..09f1f3b --- /dev/null +++ b/build/installer.nsh @@ -0,0 +1,11 @@ +; BLIP NSIS customizations — welcome / finish copy (en + ru via electron-builder languages) + +!macro customWelcomePage + !define MUI_WELCOMEPAGE_TITLE "BLIP Setup" + !define MUI_WELCOMEPAGE_TEXT "LAN-only P2P messenger — text, voice, and video.$\r$\n$\r$\nNo cloud. No accounts. Your mesh, your rules.$\r$\n$\r$\nPublisher: krwg" +!macroend + +!macro customFinishPage + !define MUI_FINISHPAGE_TITLE "BLIP installed" + !define MUI_FINISHPAGE_TEXT "BLIP is ready on your network.$\r$\n$\r$\nOpen firewall UDP 42069 and TCP 42070 if peers are not discovered." +!macroend diff --git a/build/tray-16.png b/build/tray-16.png index e4ae0f99070c0ffcc53a745570956b411a6f792f..a0f026669ec45cd650c15b8aa8109a1147bbb485 100644 GIT binary patch delta 435 zcmV;k0Zjh61L6aaIe(N%L_t(|oUM~vO9DY0#TR;rzK#?`y_ls&s2N>M)GqW85q$!o z2T90VUMeq%ko93A`zT8}GrlMtAx_(DYgxUtaA4;AfB&;PX5riM@M(MvR-sDqXzJ!egG?6CtDG7!43--3$Oq4=hwJ4+nXV@-4Hu>-WV$&h8w*9%Ew(lCsv dKSaiEegj1+637B9@Ad!y002ovPDHLkV1mUg#q|IH delta 394 zcmV;50d@Z31Gxi`Ie%+OL_t(|oSl>ZM+0FT$7hV`hcVVM#xzQ@C=pSzM2Scl`;?Y( z*_x}f zKLh9_nDY{~Rk)^OQ!XT}OopKRsPVQUXI6Tfb$As}u30z^c)HJ190It+Xn(~|&X zjsk4okEFgQ09j)Wz_FtMyXsKt`;9^1%Nec&pyow`(wXscYr@6%L1Q5GLjh3qIRFj& zS@VP+-Ss7)Qh)DDy;A=Oe0hB;0qAyA38%O3Qa=`eg62p7Mim7(zkZea$<15fD=q*q zXN(HnM#E4uj*ZMPU)FjOa3%mH&5pH+7qwpC!^MaLxDo)|DqIZgHr5jxh9$wAF)DN$ o4MWX1HZsFpt-i!c{3pHun-RD-G;rST00000NkvXXt^-0~f@rk8Jpcdz diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index eb500fe..9d00ee5 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -86,7 +86,8 @@ Groups (`groups.js`, voice channels) are intentionally separate and remain beta; | Favorite peer IDs | Renderer `localStorage` key `blip_favorites_v1`. | | Avatar seeds (per BLIP ID) | Renderer `localStorage` key `blip_avatar_seed_v1` (`avatar.js`). | <<<<<<< HEAD -| Release metadata | `app-metadata.json` (version **0.7.7**, codename **Signal Corps**, repo URL). | +| Release metadata | `app-metadata.json` (version **0.7.8**, codename **MESH+ Gate**, repo URL). | +| MESH+ roadmap | [`MESH-PLUS-PLAN.md`](MESH-PLUS-PLAN.md) — tiers, license, phased features (not all implemented yet). | | Group avatars | Renderer `localStorage` `blip_group_avatar_v1` | ======= | Release metadata | `app-metadata.json` (version **0.7.4**, codename **Signal**, repo URL). | diff --git a/electron-builder.yml b/electron-builder.yml index 33c24f2..870e737 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -1,6 +1,12 @@ appId: com.blip.messenger productName: BLIP -copyright: Copyright © 2026 BLIP +copyright: Copyright © 2026 krwg +description: P2P LAN messenger — text, voice, and video without cloud or accounts. +extraMetadata: + CompanyName: krwg + FileDescription: BLIP — P2P LAN Messenger + ProductName: BLIP + LegalCopyright: Copyright © 2026 krwg publish: - provider: github owner: krwg @@ -20,8 +26,14 @@ extraResources: to: icons/icon.png - from: build/tray-16.png to: icons/tray-16.png + - from: build/icons + to: icons + filter: + - '**/*' win: icon: icon.ico + publisherName: krwg + legalTrademarks: BLIP target: - nsis - portable @@ -39,8 +51,13 @@ nsis: runAfterFinish: true uninstallDisplayName: BLIP artifactName: ${productName}-Setup-${version}.${ext} + displayLanguageSelector: true + multiLanguageInstaller: true + menuCategory: Chat installerLanguages: - en_US - ru_RU + include: build/installer.nsh + license: LICENSE portable: artifactName: ${productName}-${version}-Portable.${ext} diff --git a/icon-dop-1.svg b/icon-dop-1.svg new file mode 100644 index 0000000..bb494d8 --- /dev/null +++ b/icon-dop-1.svg @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/icon-dop-2.svg b/icon-dop-2.svg new file mode 100644 index 0000000..1d8378d --- /dev/null +++ b/icon-dop-2.svg @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/icon-dop-3.svg b/icon-dop-3.svg new file mode 100644 index 0000000..96543c1 --- /dev/null +++ b/icon-dop-3.svg @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/icon-dop-4.svg b/icon-dop-4.svg new file mode 100644 index 0000000..88ecfb0 --- /dev/null +++ b/icon-dop-4.svg @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/icon-main.svg b/icon-main.svg new file mode 100644 index 0000000..dbe885e --- /dev/null +++ b/icon-main.svg @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/icon-mesh1.svg b/icon-mesh1.svg new file mode 100644 index 0000000..bd5c6f0 --- /dev/null +++ b/icon-mesh1.svg @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/icon-mesh2.svg b/icon-mesh2.svg new file mode 100644 index 0000000..a89bff5 --- /dev/null +++ b/icon-mesh2.svg @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/icon-mesh3.svg b/icon-mesh3.svg new file mode 100644 index 0000000..ee60813 --- /dev/null +++ b/icon-mesh3.svg @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/icon-mesh4.svg b/icon-mesh4.svg new file mode 100644 index 0000000..43a16fd --- /dev/null +++ b/icon-mesh4.svg @@ -0,0 +1,131 @@ + + BLIP icon with Blimp mascot + Иконка BLIP — тёмный фон, концентрические квадраты, светлячок Blimp сидит в правом нижнем углу + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/icon-mesh5.svg b/icon-mesh5.svg new file mode 100644 index 0000000..a65b27b --- /dev/null +++ b/icon-mesh5.svg @@ -0,0 +1,127 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/icon-mesh6.svg b/icon-mesh6.svg new file mode 100644 index 0000000..5917d31 --- /dev/null +++ b/icon-mesh6.svg @@ -0,0 +1,126 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/icon.svg b/icon.svg index ce06903..dbe885e 100644 --- a/icon.svg +++ b/icon.svg @@ -1,14 +1,86 @@ - + - - - - + + + + + + + + + + - + + - + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/main/app-icons.js b/main/app-icons.js new file mode 100644 index 0000000..dd040ab --- /dev/null +++ b/main/app-icons.js @@ -0,0 +1,61 @@ +import { existsSync } from 'fs'; +import { join } from 'path'; +import { resolveBuildAsset } from './paths.js'; +import { isMeshPlusActive } from './mesh-plus-license.js'; + +/** @type {{ id: string, file: string, tier: 'free' | 'mesh_plus' }[]} */ +export const APP_ICON_VARIANTS = [ + { id: 'main', file: 'icon-main.svg', tier: 'free' }, + { id: 'dop-1', file: 'icon-dop-1.svg', tier: 'free' }, + { id: 'dop-2', file: 'icon-dop-2.svg', tier: 'free' }, + { id: 'dop-3', file: 'icon-dop-3.svg', tier: 'free' }, + { id: 'dop-4', file: 'icon-dop-4.svg', tier: 'free' }, + { id: 'mesh-1', file: 'icon-mesh1.svg', tier: 'mesh_plus' }, + { id: 'mesh-2', file: 'icon-mesh2.svg', tier: 'mesh_plus' }, + { id: 'mesh-3', file: 'icon-mesh3.svg', tier: 'mesh_plus' }, + { id: 'mesh-4', file: 'icon-mesh4.svg', tier: 'mesh_plus' }, + { id: 'mesh-5', file: 'icon-mesh5.svg', tier: 'mesh_plus' }, + { id: 'mesh-6', file: 'icon-mesh6.svg', tier: 'mesh_plus' }, +]; + +const FREE_IDS = new Set(APP_ICON_VARIANTS.filter((v) => v.tier === 'free').map((v) => v.id)); +const MESH_IDS = new Set(APP_ICON_VARIANTS.filter((v) => v.tier === 'mesh_plus').map((v) => v.id)); + +export function normalizeAppIconVariant(id) { + const s = String(id || 'main').trim().toLowerCase(); + return APP_ICON_VARIANTS.some((v) => v.id === s) ? s : 'main'; +} + +/** @param {object} config */ +export function resolveAppIconVariant(config) { + let id = normalizeAppIconVariant(config?.appIconVariant); + if (MESH_IDS.has(id) && !isMeshPlusActive(config)) id = 'main'; + return id; +} + +export function canUseAppIconVariant(config, variantId) { + const id = normalizeAppIconVariant(variantId); + if (FREE_IDS.has(id)) return true; + if (MESH_IDS.has(id)) return isMeshPlusActive(config); + return false; +} + +/** @param {string} variantId */ +export function resolveVariantWindowIconPath(variantId) { + const id = normalizeAppIconVariant(variantId); + const named = resolveBuildAsset(join('icons', `${id}.png`)); + if (existsSync(named)) return named; + return resolveBuildAsset('icon.png'); +} + +/** @param {string} variantId */ +export function resolveVariantTrayIconPath(variantId) { + const id = normalizeAppIconVariant(variantId); + const named = resolveBuildAsset(join('icons', `${id}-tray.png`)); + if (existsSync(named)) return named; + return resolveBuildAsset('tray-16.png'); +} + +export function listAppIconVariantIds() { + return APP_ICON_VARIANTS.map((v) => v.id); +} diff --git a/main/apply-app-icons.js b/main/apply-app-icons.js new file mode 100644 index 0000000..21da735 --- /dev/null +++ b/main/apply-app-icons.js @@ -0,0 +1,36 @@ +import { nativeImage } from 'electron'; +import { pathToFileURL } from 'url'; +import { + resolveAppIconVariant, + resolveVariantTrayIconPath, + resolveVariantWindowIconPath, +} from './app-icons.js'; +import { setTrayIconPath } from './tray.js'; + +/** + * @param {object} config + * @param {{ + * mainWindow?: import('electron').BrowserWindow | null; + * callWindow?: import('electron').BrowserWindow | null; + * groupCallWindow?: import('electron').BrowserWindow | null; + * }} wins + */ +export function applyAppIcons(config, wins = {}) { + const variantId = resolveAppIconVariant(config); + const winPath = resolveVariantWindowIconPath(variantId); + const trayPath = resolveVariantTrayIconPath(variantId); + const image = nativeImage.createFromPath(winPath); + + for (const w of [wins.mainWindow, wins.callWindow, wins.groupCallWindow]) { + if (w && !w.isDestroyed()) { + try { + w.setIcon(image); + } catch { + /* ignore */ + } + } + } + + setTrayIconPath(trayPath); + return { variantId, winPath, trayPath, iconUrl: pathToFileURL(winPath).href }; +} diff --git a/main/config-public.js b/main/config-public.js new file mode 100644 index 0000000..9905e8c --- /dev/null +++ b/main/config-public.js @@ -0,0 +1,22 @@ +import { getMeshPlusTier, isMeshPlusActive } from './mesh-plus-license.js'; + +const SENSITIVE_KEYS = new Set(['meshPrivateKey', 'meshPlusLicenseSig']); + +/** + * Config safe for renderer IPC (no private keys / raw license sig). + * @param {object} config + */ +export function toPublicConfig(config) { + if (!config || typeof config !== 'object') return config; + const pub = { ...config }; + for (const key of SENSITIVE_KEYS) delete pub[key]; + pub.tier = getMeshPlusTier(config); + pub.meshPlusActive = isMeshPlusActive(config); + if (pub.meshPlusLicenseId) { + pub.meshPlusLicenseMasked = `••••-${String(pub.meshPlusLicenseId).slice(-4)}`; + } else { + pub.meshPlusLicenseMasked = ''; + } + delete pub.meshPlusLicenseId; + return pub; +} diff --git a/main/config.js b/main/config.js index 191e77f..6032199 100644 --- a/main/config.js +++ b/main/config.js @@ -81,6 +81,14 @@ const DEFAULT_CONFIG = { micInputGain: 100, /** Download and install updates automatically when available. */ autoDownloadUpdates: true, + /** MESH+ license id (16 hex chars) — set after successful activation. */ + meshPlusLicenseId: '', + /** Ed25519 signature (base64) over blip-meshplus-v1|licenseId */ + meshPlusLicenseSig: '', + /** Unix ms when MESH+ was activated. */ + meshPlusActivatedAt: 0, + /** App / tray / about icon: main | dop-1..4 | mesh-1..6 */ + appIconVariant: 'main', }; let configPath = null; diff --git a/main/discovery.js b/main/discovery.js index fa6e140..3faf368 100644 --- a/main/discovery.js +++ b/main/discovery.js @@ -8,6 +8,7 @@ import { signCanonical, verifyAnnouncePayload, } from './mesh-identity.js'; +import { isMeshPlusActive } from './mesh-plus-license.js'; const ANNOUNCE_INTERVAL = 5000; const PEER_TIMEOUT = 30000; @@ -149,7 +150,8 @@ export class Discovery { meshPubkey, }); const meshAnnounceSig = signCanonical(this.config, canonical); - return { ...base, meshAnnounceSig }; + const meshPlus = isMeshPlusActive(this.config); + return { ...base, meshAnnounceSig, meshPlus }; } announce() { @@ -210,6 +212,7 @@ export class Discovery { meshVerified, meshLegacy, meshPubkey, + meshPlus: !!data.meshPlus, }; if ( @@ -220,7 +223,8 @@ export class Discovery { existing.presenceText !== peer.presenceText || existing.tcpPort !== peer.tcpPort || existing.meshVerified !== peer.meshVerified || - existing.meshLegacy !== peer.meshLegacy + existing.meshLegacy !== peer.meshLegacy || + existing.meshPlus !== peer.meshPlus ) { this.peers.set(data.blipId, peer); } else { @@ -233,6 +237,7 @@ export class Discovery { existing.meshVerified = meshVerified; existing.meshLegacy = meshLegacy; existing.meshPubkey = meshPubkey; + existing.meshPlus = peer.meshPlus; } this.occupiedIds.add(data.blipId); diff --git a/main/index.js b/main/index.js index b1d93f7..04ae164 100644 --- a/main/index.js +++ b/main/index.js @@ -8,6 +8,15 @@ import { createTcpServer } from './tcp-server.js'; import { connectToPeer, sendOnSocket, pingPeer } from './tcp-client.js'; import { createTcpLineReader } from './tcp-framing.js'; import { loadConfig, saveConfig, initConfigPath, getLocalIp, getLocalIpv4Set } from './config.js'; +import { toPublicConfig } from './config-public.js'; +import { validateMeshPlusActivationKey, isMeshPlusActive } from './mesh-plus-license.js'; +import { + canUseAppIconVariant, + normalizeAppIconVariant, + APP_ICON_VARIANTS, +} from './app-icons.js'; +import { resolveAppIconVariant, resolveVariantWindowIconPath } from './app-icons.js'; +import { applyAppIcons } from './apply-app-icons.js'; import { ensureMeshIdentity } from './mesh-identity.js'; import { handleMeshHandshakeMessage, @@ -95,11 +104,15 @@ function getRendererUrl() { } function getWindowIcon() { - const iconPath = resolveBuildAsset('icon.png'); + const iconPath = resolveVariantWindowIconPath(resolveAppIconVariant(config)); if (existsSync(iconPath)) return nativeImage.createFromPath(iconPath); return undefined; } +function refreshAppIcons() { + return applyAppIcons(config, { mainWindow, callWindow, groupCallWindow }); +} + function createWindow() { const icon = getWindowIcon(); mainWindow = new BrowserWindow({ @@ -424,14 +437,15 @@ function sendToRenderer(channel, data) { function patchConfig(updates) { config = saveConfig(updates); discovery?.updateConfig(config); + const pub = toPublicConfig(config); if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('config-updated', config); + mainWindow.webContents.send('config-updated', pub); } if (callWindow && !callWindow.isDestroyed()) { - callWindow.webContents.send('config-updated', config); + callWindow.webContents.send('config-updated', pub); } if (groupCallWindow && !groupCallWindow.isDestroyed()) { - groupCallWindow.webContents.send('config-updated', config); + groupCallWindow.webContents.send('config-updated', pub); } if (updates?.launchAtLogin !== undefined) { applyLaunchAtLogin(config.launchAtLogin); @@ -727,10 +741,12 @@ function installTray() { config.language === 'ru' ? { show: 'Показать', quit: 'Выход' } : { show: 'Show', quit: 'Quit' }; + const { trayPath } = refreshAppIcons(); createTray({ getMainWindow: () => mainWindow, tooltip: `${meta.displayName || 'BLIP'} — local network`, labels: trayLabels, + iconPath: trayPath, onQuit: async () => { await stopNetwork(); app.quit(); @@ -739,10 +755,27 @@ function installTray() { } function setupIpc() { - ipcMain.handle('get-config', () => config); + ipcMain.handle('get-config', () => toPublicConfig(config)); ipcMain.handle('save-config', (_, updates) => { const prevLang = config?.language; - config = saveConfig(updates); + const safe = { ...updates }; + delete safe.meshPlusLicenseId; + delete safe.meshPlusLicenseSig; + delete safe.meshPlusActivatedAt; + delete safe.tier; + delete safe.meshPlusActive; + delete safe.meshPlusLicenseMasked; + if (safe.appIconVariant !== undefined) { + const id = normalizeAppIconVariant(safe.appIconVariant); + safe.appIconVariant = canUseAppIconVariant( + { ...config, ...safe }, + id + ) + ? id + : 'main'; + } + config = saveConfig(safe); + if (safe.appIconVariant !== undefined) refreshAppIcons(); if (updates?.receiveBetaUpdates !== undefined || updates?.autoDownloadUpdates !== undefined) { void configureAutoUpdater(config); } @@ -751,14 +784,15 @@ function setupIpc() { if (typeof updates?.language === 'string' && updates.language !== prevLang) { installTray(); } + const pub = toPublicConfig(config); if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('config-updated', config); + mainWindow.webContents.send('config-updated', pub); } if (callWindow && !callWindow.isDestroyed()) { - callWindow.webContents.send('config-updated', config); + callWindow.webContents.send('config-updated', pub); } if (groupCallWindow && !groupCallWindow.isDestroyed()) { - groupCallWindow.webContents.send('config-updated', config); + groupCallWindow.webContents.send('config-updated', pub); } if (updates?.launchAtLogin !== undefined) { applyLaunchAtLogin(config.launchAtLogin); @@ -769,7 +803,55 @@ function setupIpc() { ) { refreshGlobalShortcuts(); } - return config; + return pub; + }); + + ipcMain.handle('activate-mesh-plus', (_, rawKey) => { + const result = validateMeshPlusActivationKey(rawKey); + if (!result.ok) return result; + config = saveConfig({ + meshPlusLicenseId: result.licenseId, + meshPlusLicenseSig: result.sigB64, + meshPlusActivatedAt: Date.now(), + }); + discovery?.updateConfig(config); + discovery?.announce(); + refreshAppIcons(); + const pub = toPublicConfig(config); + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('config-updated', pub); + } + return { ok: true, tier: pub.tier }; + }); + + ipcMain.handle('deactivate-mesh-plus', () => { + const patch = { + meshPlusLicenseId: '', + meshPlusLicenseSig: '', + meshPlusActivatedAt: 0, + }; + if (String(config.appIconVariant || '').startsWith('mesh-')) { + patch.appIconVariant = 'main'; + } + config = saveConfig(patch); + discovery?.updateConfig(config); + discovery?.announce(); + refreshAppIcons(); + const pub = toPublicConfig(config); + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('config-updated', pub); + } + return { ok: true, tier: 'free' }; + }); + + ipcMain.handle('get-mesh-plus-status', () => { + const pub = toPublicConfig(config); + return { + tier: pub.tier, + active: pub.meshPlusActive, + licenseMasked: pub.meshPlusLicenseMasked || '', + activatedAt: config.meshPlusActivatedAt || 0, + }; }); ipcMain.handle('get-github-releases', async (_, limit) => fetchGithubReleases(limit ?? 8)); @@ -972,9 +1054,19 @@ function setupIpc() { })); ipcMain.handle('get-app-icon-url', () => { - const iconPath = resolveBuildAsset('icon.png'); - if (!existsSync(iconPath)) return ''; - return pathToFileURL(iconPath).href; + const { iconUrl } = refreshAppIcons(); + return iconUrl || ''; + }); + + ipcMain.handle('get-app-icon-variants', () => { + return APP_ICON_VARIANTS.map((v) => { + const p = resolveVariantWindowIconPath(v.id); + return { + id: v.id, + tier: v.tier, + previewUrl: existsSync(p) ? pathToFileURL(p).href : '', + }; + }); }); ipcMain.handle('is-voice-call-active', () => { @@ -1222,6 +1314,7 @@ app.whenReady().then(async () => { setupIpc(); createWindow(); + refreshAppIcons(); installTray(); void ensureCallWindow().catch((e) => console.warn('[BLIP] prewarm call window', e)); void ensureGroupCallWindow().catch((e) => console.warn('[BLIP] prewarm group call window', e)); diff --git a/main/mesh-plus-license.js b/main/mesh-plus-license.js new file mode 100644 index 0000000..49d54ee --- /dev/null +++ b/main/mesh-plus-license.js @@ -0,0 +1,138 @@ +import { verifyCanonical } from './mesh-identity.js'; +import { MESH_PLUS_LICENSE_PUBLIC_KEY_B64 } from './mesh-plus-public-key.js'; + +export const MESH_PLUS_LICENSE_CANON = 'blip-meshplus-v1'; +const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTUVWXYZ'; +const LICENSE_BODY_BYTES = 72; // 8-byte id + 64-byte Ed25519 signature + +function base32DecodeChar(c) { + const i = CROCKFORD.indexOf(c); + return i >= 0 ? i : -1; +} + +/** @param {string} encoded */ +function base32Decode(encoded) { + let bits = 0; + let value = 0; + const out = []; + for (const ch of encoded) { + const v = base32DecodeChar(ch); + if (v < 0) return null; + value = (value << 5) | v; + bits += 5; + while (bits >= 8) { + bits -= 8; + out.push((value >> bits) & 0xff); + } + } + return Buffer.from(out); +} + +/** @param {Buffer} buf */ +function base32Encode(buf) { + let bits = 0; + let value = 0; + let out = ''; + for (const byte of buf) { + value = (value << 8) | byte; + bits += 8; + while (bits >= 5) { + bits -= 5; + out += CROCKFORD[(value >> bits) & 31]; + } + } + if (bits > 0) out += CROCKFORD[(value << (5 - bits)) & 31]; + return out; +} + +/** @param {string} body */ +function formatBlipKey(body) { + const groups = body.match(/.{1,4}/g) || []; + return `BLIP-${groups.join('-')}`; +} + +/** + * @param {string} raw + * @returns {string | null} + */ +export function normalizeMeshPlusKeyInput(raw) { + if (!raw || typeof raw !== 'string') return null; + let s = raw.trim().toUpperCase().replace(/\s+/g, ''); + if (s.startsWith('BLIP')) s = s.replace(/^BLIP-?/, ''); + s = s.replace(/-/g, ''); + if (!s || !/^[0-9A-HJKMNP-Z]+$/.test(s)) return null; + return s; +} + +/** + * @param {string} raw + * @returns {{ licenseId: string, sigB64: string } | null} + */ +export function parseMeshPlusLicenseKey(raw) { + const encoded = normalizeMeshPlusKeyInput(raw); + if (!encoded) return null; + const buf = base32Decode(encoded); + if (!buf || buf.length !== LICENSE_BODY_BYTES) return null; + const licenseId = buf.subarray(0, 8).toString('hex').toUpperCase(); + const sig = buf.subarray(8, 72); + return { + licenseId, + sigB64: sig.toString('base64'), + }; +} + +/** + * @param {{ licenseId: string, sigB64: string }} parsed + */ +export function verifyMeshPlusLicenseParsed(parsed) { + if (!parsed?.licenseId || !parsed.sigB64) return false; + const canonical = `${MESH_PLUS_LICENSE_CANON}|${parsed.licenseId}`; + return verifyCanonical( + MESH_PLUS_LICENSE_PUBLIC_KEY_B64, + canonical, + parsed.sigB64 + ); +} + +/** @param {object} config */ +export function isMeshPlusActive(config) { + if (!config?.meshPlusLicenseId || !config?.meshPlusLicenseSig) return false; + return verifyMeshPlusLicenseParsed({ + licenseId: config.meshPlusLicenseId, + sigB64: config.meshPlusLicenseSig, + }); +} + +/** @returns {'free' | 'mesh_plus'} */ +export function getMeshPlusTier(config) { + return isMeshPlusActive(config) ? 'mesh_plus' : 'free'; +} + +/** + * Build display key for a parsed license (for keygen). + * @param {string} licenseId hex 16 chars + * @param {string} sigB64 + */ +export function formatMeshPlusLicenseKey(licenseId, sigB64) { + const idBuf = Buffer.from(licenseId, 'hex'); + if (idBuf.length !== 8) throw new Error('licenseId must be 16 hex chars'); + const sigBuf = Buffer.from(sigB64, 'base64'); + if (sigBuf.length !== 64) throw new Error('signature must be 64 bytes'); + const body = base32Encode(Buffer.concat([idBuf, sigBuf])); + return formatBlipKey(body); +} + +/** + * @param {string} rawKey + * @returns {{ ok: boolean, error?: string, licenseId?: string, sigB64?: string }} + */ +export function validateMeshPlusActivationKey(rawKey) { + const parsed = parseMeshPlusLicenseKey(rawKey); + if (!parsed) { + return { ok: false, error: 'invalid_format' }; + } + if (!verifyMeshPlusLicenseParsed(parsed)) { + return { ok: false, error: 'invalid_signature' }; + } + return { ok: true, licenseId: parsed.licenseId, sigB64: parsed.sigB64 }; +} diff --git a/main/mesh-plus-public-key.js b/main/mesh-plus-public-key.js new file mode 100644 index 0000000..6027278 --- /dev/null +++ b/main/mesh-plus-public-key.js @@ -0,0 +1,6 @@ +/** + * Ed25519 public key for MESH+ license verification (SPKI DER, base64). + * Private key stays local — generate with `node scripts/mesh-plus-keygen.mjs`. + */ +export const MESH_PLUS_LICENSE_PUBLIC_KEY_B64 = + 'MCowBQYDK2VwAyEA1AHZLIEAwoXRtJsGlv5uxNuznWCGqSl2xfJOIsdST6A='; diff --git a/main/paths.js b/main/paths.js index c2ff674..cbdb17d 100644 --- a/main/paths.js +++ b/main/paths.js @@ -6,11 +6,14 @@ import { existsSync } from 'fs'; const rootDir = join(dirname(fileURLToPath(import.meta.url)), '..'); export function resolveBuildAsset(filename) { - const devPath = join(rootDir, 'build', filename); + const normalized = filename.replace(/\\/g, '/'); + const devPath = join(rootDir, 'build', normalized); if (existsSync(devPath)) return devPath; if (app.isPackaged) { - const prodPath = join(process.resourcesPath, 'icons', filename); + const prodPath = join(process.resourcesPath, 'icons', normalized); if (existsSync(prodPath)) return prodPath; + const flatProd = join(process.resourcesPath, 'icons', filename); + if (existsSync(flatProd)) return flatProd; } return devPath; } diff --git a/main/tray.js b/main/tray.js index fdd88b1..b1de7fc 100644 --- a/main/tray.js +++ b/main/tray.js @@ -3,10 +3,10 @@ import { existsSync } from 'fs'; import { resolveBuildAsset } from './paths.js'; let tray = null; +let trayIconPath = null; -function createTrayIcon() { - const trayPath = resolveBuildAsset('tray-16.png'); - if (existsSync(trayPath)) { +function createTrayIconFromPath(trayPath) { + if (trayPath && existsSync(trayPath)) { return nativeImage.createFromPath(trayPath); } @@ -25,6 +25,11 @@ function createTrayIcon() { return nativeImage.createFromBuffer(canvas, { width: size, height: size }); } +/** @deprecated use setTrayIconPath */ +function createTrayIcon() { + return createTrayIconFromPath(trayIconPath) || createTrayIconFallback(); +} + export function destroyTray() { if (!tray) return; try { @@ -44,12 +49,28 @@ export function destroyTray() { * labels?: { show?: string; quit?: string }; * }} opts */ +export function setTrayIconPath(path) { + trayIconPath = path || null; + if (tray && !tray.isDestroyed?.()) { + try { + tray.setImage(createTrayIconFromPath(trayIconPath) || createTrayIconFallback()); + } catch { + /* ignore */ + } + } +} + +function createTrayIconFallback() { + return createTrayIconFromPath(resolveBuildAsset('tray-16.png')); +} + export function createTray(opts) { destroyTray(); - const { getMainWindow, tooltip, onQuit, labels } = opts; + const { getMainWindow, tooltip, onQuit, labels, iconPath } = opts; + trayIconPath = iconPath || trayIconPath || resolveBuildAsset('tray-16.png'); const L = { show: labels?.show || 'Show', quit: labels?.quit || 'Quit' }; try { - tray = new Tray(createTrayIcon()); + tray = new Tray(createTrayIconFromPath(trayIconPath) || createTrayIconFallback()); tray.setToolTip(tooltip || 'BLIP'); const showMain = () => { diff --git a/package-lock.json b/package-lock.json index 49dbea7..a861628 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "blip", - "version": "0.7.7", + "version": "0.7.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "blip", - "version": "0.7.7", + "version": "0.7.9", "hasInstallScript": true, "dependencies": { "electron-updater": "^6.8.3", diff --git a/package.json b/package.json index cc05ef9..3698a61 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,9 @@ { "name": "blip", - "version": "0.7.7", - "description": "P2P messenger for local networks — no cloud, no servers", + "version": "0.7.10", + "description": "P2P LAN messenger — text, voice, and video. No cloud, no servers, no accounts.", "author": "krwg", + "homepage": "https://krwg.github.io/BLIP/", "repository": { "type": "git", "url": "https://github.com/krwg/BLIP.git" @@ -26,7 +27,8 @@ "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", "electron:build:all": "npm run build && npm run build:icons && electron-builder --win", - "electron:build:dir": "npm run build && npm run build:icons && electron-builder --win --dir" + "electron:build:dir": "npm run build && npm run build:icons && electron-builder --win --dir", + "mesh-plus:keygen": "node scripts/mesh-plus-keygen.mjs" }, "devDependencies": { "@fontsource/press-start-2p": "^5.2.7", diff --git a/preload.cjs b/preload.cjs index 33320ac..d33b9cf 100644 --- a/preload.cjs +++ b/preload.cjs @@ -7,8 +7,12 @@ contextBridge.exposeInMainWorld('blip', { platform: process.platform, getConfig: () => ipcRenderer.invoke('get-config'), saveConfig: (config) => ipcRenderer.invoke('save-config', config), + activateMeshPlus: (key) => ipcRenderer.invoke('activate-mesh-plus', key), + deactivateMeshPlus: () => ipcRenderer.invoke('deactivate-mesh-plus'), + getMeshPlusStatus: () => ipcRenderer.invoke('get-mesh-plus-status'), getAppMetadata: () => ipcRenderer.invoke('get-app-metadata'), getAppIconUrl: () => ipcRenderer.invoke('get-app-icon-url'), + getAppIconVariants: () => ipcRenderer.invoke('get-app-icon-variants'), isVoiceCallActive: () => ipcRenderer.invoke('is-voice-call-active'), getAvatarDataUrl: () => ipcRenderer.invoke('get-avatar-data-url'), saveAvatar: (dataUrl) => ipcRenderer.invoke('save-avatar', dataUrl), diff --git a/renderer/app-icon-picker.js b/renderer/app-icon-picker.js new file mode 100644 index 0000000..a885d57 --- /dev/null +++ b/renderer/app-icon-picker.js @@ -0,0 +1,105 @@ +import { t } from './i18n.js'; +import { isMeshPlusActive } from './mesh-plus.js'; +import { showAppToast } from './toasts.js'; +import { buildSectionSubtitleRow } from './settings-ui.js'; + +const FREE_ICON_IDS = ['main', 'dop-1', 'dop-2', 'dop-3', 'dop-4']; +const MESH_ICON_IDS = ['mesh-1', 'mesh-2', 'mesh-3', 'mesh-4', 'mesh-5', 'mesh-6']; + +const ICON_PREVIEW_PX = 80; + +/** + * @param {object} state + * @param {(patch: object) => Promise} saveConfig + */ +export function appendAppIconPickerSections(block, state, saveConfig) { + block.appendChild( + buildSectionSubtitleRow('settings.app_icon_free_title', 'settings.app_icon_free_hint') + ); + + const freeGrid = document.createElement('div'); + freeGrid.className = 'settings-app-icon-grid'; + block.appendChild(freeGrid); + + block.appendChild( + buildSectionSubtitleRow('settings.app_icon_mesh_title', 'settings.app_icon_mesh_hint') + ); + + const meshGrid = document.createElement('div'); + meshGrid.className = 'settings-app-icon-grid settings-app-icon-grid--mesh'; + block.appendChild(meshGrid); + + let variants = []; + + function renderTile(grid, id, tier) { + const meta = variants.find((v) => v.id === id); + const btn = document.createElement('button'); + btn.type = 'button'; + const selected = (state.config?.appIconVariant || 'main') === id; + btn.className = `settings-app-icon-tile${selected ? ' selected' : ''}${ + tier === 'mesh_plus' && !isMeshPlusActive(state.config) + ? ' settings-app-icon-tile--locked' + : '' + }`; + btn.dataset.iconId = id; + btn.setAttribute('aria-label', id); + + const img = document.createElement('img'); + img.className = 'settings-app-icon-tile__img'; + img.alt = ''; + img.width = ICON_PREVIEW_PX; + img.height = ICON_PREVIEW_PX; + if (meta?.previewUrl) img.src = meta.previewUrl; + + btn.appendChild(img); + + if (tier === 'mesh_plus' && !isMeshPlusActive(state.config)) { + const lock = document.createElement('span'); + lock.className = 'settings-app-icon-tile__lock'; + lock.textContent = '◆'; + lock.title = t('settings.app_icon_mesh_locked'); + btn.appendChild(lock); + } + + btn.addEventListener('click', async () => { + if (tier === 'mesh_plus' && !isMeshPlusActive(state.config)) { + showAppToast({ + title: t('settings.app_icon_mesh_locked'), + durationMs: 4200, + }); + return; + } + if ((state.config?.appIconVariant || 'main') === id) return; + state.config = await saveConfig({ appIconVariant: id }); + try { + await window.blip.getAppIconUrl?.(); + } catch { + /* ignore */ + } + freeGrid.querySelectorAll('.settings-app-icon-tile').forEach((el) => { + el.classList.toggle('selected', el.dataset.iconId === state.config.appIconVariant); + }); + meshGrid.querySelectorAll('.settings-app-icon-tile').forEach((el) => { + el.classList.toggle('selected', el.dataset.iconId === state.config.appIconVariant); + }); + showAppToast({ + title: t('settings.app_icon_applied'), + durationMs: 2800, + }); + }); + + grid.appendChild(btn); + } + + void (async () => { + try { + variants = (await window.blip.getAppIconVariants?.()) || []; + } catch { + variants = []; + } + freeGrid.innerHTML = ''; + meshGrid.innerHTML = ''; + for (const id of FREE_ICON_IDS) renderTile(freeGrid, id, 'free'); + for (const id of MESH_ICON_IDS) renderTile(meshGrid, id, 'mesh_plus'); + })(); +} diff --git a/renderer/i18n.js b/renderer/i18n.js index f383496..d394987 100644 --- a/renderer/i18n.js +++ b/renderer/i18n.js @@ -341,6 +341,58 @@ const locales = { 'settings.avatar_error_decode': 'Could not read this image. Try another file.', 'settings.avatar_error_output': 'Image is still too large after processing. Try a smaller picture.', 'settings.section_profile': 'Profile', + 'settings.section_mesh_plus': 'MESH+', + 'mesh_plus.intro_hint': + 'Extended mesh features — LAN superpowers, premium look, and tools. Activation is offline with a personal key.', + 'mesh_plus.status_label': 'Status', + 'mesh_plus.status_free': 'FREE', + 'mesh_plus.status_mesh_plus': 'MESH+', + 'mesh_plus.carousel_title': 'What you get', + 'mesh_plus.carousel_prev': '‹', + 'mesh_plus.carousel_next': '›', + 'mesh_plus.carousel_counter': '{n} / {total}', + 'mesh_plus.carousel_dot': 'Slide {n}', + 'mesh_plus.activation_title': 'Activation', + 'mesh_plus.key_label': 'License key', + 'mesh_plus.key_placeholder': 'BLIP-XXXX-XXXX-…', + 'mesh_plus.activate': 'Activate', + 'mesh_plus.revoke_key': 'Remove key', + 'mesh_plus.no_key_hint': 'No key? Contact the developer.', + 'mesh_plus.inactive_hint': 'Paste your key and tap Activate.', + 'mesh_plus.active_ok': 'MESH+ is active on this device.', + 'mesh_plus.activate_ok': 'MESH+ activated.', + 'mesh_plus.revoke_ok': 'License removed. Status reset to FREE.', + 'mesh_plus.error_empty': 'Enter your license key.', + 'mesh_plus.error_invalid_format': 'Invalid key format. Paste the full BLIP-… key.', + 'mesh_plus.error_invalid_signature': 'Key is not valid or was revoked.', + 'mesh_plus.error_invalid': 'Could not activate. Check the key and try again.', + 'mesh_plus.badge_label': 'MESH+', + 'mesh_plus.badge_title': 'MESH+ subscriber', + 'mesh_plus.slide_premium_bg': 'Premium scenes', + 'mesh_plus.slide_premium_bg_desc': 'CRT, glitch, and particle backgrounds — MESH+ only.', + 'mesh_plus.slide_sound': 'Exclusive sound packs', + 'mesh_plus.slide_sound_desc': 'Extra chiptune FX and call melodies for your mesh.', + 'mesh_plus.slide_relay': 'LAN relay & bridge', + 'mesh_plus.slide_relay_desc': 'Host a relay or bridge two subnets without Hamachi.', + 'mesh_plus.slide_projects': 'Signal Corps Pro', + 'mesh_plus.slide_projects_desc': 'Full Board & Canvas, Pad history with rollback.', + 'mesh_plus.slide_clipboard': 'Clipboard Board', + 'mesh_plus.slide_clipboard_desc': 'Unlimited LAN clipboard history with search.', + 'mesh_plus.slide_identity': 'Identity & stats', + 'mesh_plus.slide_identity_desc': 'MESH+ badge, ID color, reservations, achievements.', + 'settings.app_icon_free_title': 'App icon', + 'settings.app_icon_free_hint': 'Tray, window, and About. Start-menu shortcut uses the installer icon until reinstall.', + 'settings.app_icon_mesh_title': 'MESH+ icons', + 'settings.app_icon_mesh_hint': 'Six premium icons — MESH+ subscription required.', + 'settings.app_icon_main': 'Default', + 'settings.app_icon_dop': 'Alt {n}', + 'settings.app_icon_mesh': 'Mesh {n}', + 'settings.app_icon_mesh_locked': 'This icon requires MESH+.', + 'settings.app_icon_applied': 'App icon updated.', + 'settings.dev_clear_mesh_plus': 'Remove subscription', + 'settings.dev_clear_mesh_plus_ok': 'MESH+ license cleared.', + 'settings.dev_clear_mesh_plus_none': 'No active MESH+ subscription.', + 'settings.dev_clear_mesh_plus_fail': 'Could not clear subscription.', 'settings.section_language': 'Language', 'settings.section_notifications': 'Notifications', 'settings.section_privacy': 'Privacy', @@ -714,7 +766,7 @@ const locales = { 'peers.ping_ok': 'Абонент доступен', 'peers.ping_ok_body': 'Задержка {ms} мс', 'peers.ping_fail': 'Пинг не прошёл', - 'peers.copy_id': 'Копировать ID', + 'peers.copy_id': 'Копировать АЙДИ', 'peers.copy_id_done': 'ID скопирован', 'peers.block': 'Скрыть локально', 'peers.unblock': 'Показать снова', @@ -810,7 +862,7 @@ const locales = { 'settings.presence_busy': 'Занят', 'settings.status_text': 'Статус', 'settings.status_custom': 'Кастомный статус', - 'settings.status_placeholder': 'В игре, AFK…', + 'settings.status_placeholder': 'В игре, АФК…', 'settings.status_presets': 'Быстрый статус', 'settings.status_clear': 'Сброс', 'settings.status_empty': 'Пусто', @@ -819,15 +871,15 @@ const locales = { 'settings.status_code': 'Кодю', 'settings.status_away_short': 'Отошёл', 'settings.status_game': 'В игре', - 'settings.status_afk': 'AFK', + 'settings.status_afk': 'АФК', 'settings.status_work': 'Работаю', 'settings.name': 'Имя', 'settings.name_placeholder': 'Введи имя...', 'settings.id': 'Твой БЛИП АЙДИ', - 'settings.change_id': 'Сменить ID', + 'settings.change_id': 'Сменить АЙДИ', 'settings.language': 'Язык', 'settings.about_title': 'О приложении', - 'settings.github': 'GitHub', + 'settings.github': 'ГИТХАБ', 'settings.appearance': 'Внешний вид и фон', 'settings.appearance_theme': 'Тема', 'settings.appearance_accent': 'Цветовой акцент', @@ -924,6 +976,58 @@ const locales = { 'settings.avatar_error_decode': 'Не удалось прочитать изображение. Попробуй другой файл.', 'settings.avatar_error_output': 'После обработки изображение всё ещё слишком большое.', 'settings.section_profile': 'Профиль', + 'settings.section_mesh_plus': 'МЭШ ПЛЮС', + 'mesh_plus.intro_hint': + 'Расширенные возможности меша — сеть, премиум-оформление и инструменты. Активация офлайн по личному ключу.', + 'mesh_plus.status_label': 'Статус', + 'mesh_plus.status_free': 'БЕСПЛАТНО', + 'mesh_plus.status_mesh_plus': 'МЭШ ПЛЮС', + 'mesh_plus.carousel_title': 'Что входит', + 'mesh_plus.carousel_prev': '‹', + 'mesh_plus.carousel_next': '›', + 'mesh_plus.carousel_counter': '{n} / {total}', + 'mesh_plus.carousel_dot': 'Слайд {n}', + 'mesh_plus.activation_title': 'Активация', + 'mesh_plus.key_label': 'Ключ активации', + 'mesh_plus.key_placeholder': 'BLIP-XXXX-XXXX-…', + 'mesh_plus.activate': 'Активировать', + 'mesh_plus.revoke_key': 'Удалить ключ', + 'mesh_plus.no_key_hint': 'Нет ключа? Свяжись с разработчиком.', + 'mesh_plus.inactive_hint': 'Вставь ключ и нажми «Активировать».', + 'mesh_plus.active_ok': 'МЭШ ПЛЮС активен на этом устройстве.', + 'mesh_plus.activate_ok': 'МЭШ ПЛЮС активирован.', + 'mesh_plus.revoke_ok': 'Ключ удалён. Статус сброшен на «БЕСПЛАТНО».', + 'mesh_plus.error_empty': 'Введи ключ.', + 'mesh_plus.error_invalid_format': 'Неверный формат. Вставь полный ключ BLIP-…', + 'mesh_plus.error_invalid_signature': 'Ключ недействителен или отозван.', + 'mesh_plus.error_invalid': 'Не удалось активировать. Проверь ключ.', + 'mesh_plus.badge_label': 'МЭШ+', + 'mesh_plus.badge_title': 'Подписчик МЭШ ПЛЮС', + 'mesh_plus.slide_premium_bg': 'Премиум-сцены', + 'mesh_plus.slide_premium_bg_desc': 'СРТ, глитч и живые частицы — только в МЭШ ПЛЮС.', + 'mesh_plus.slide_sound': 'Эксклюзивные звуки', + 'mesh_plus.slide_sound_desc': 'Отдельные ФХ и мелодии звонков для твоего меша.', + 'mesh_plus.slide_relay': 'РЕЛЭЙ и мост', + 'mesh_plus.slide_relay_desc': 'Локальный релэй или мост двух подсетей без ХАМАЧИ.', + 'mesh_plus.slide_projects': 'Сигнал Корпс ПРО', + 'mesh_plus.slide_projects_desc': 'БОАРД (доска) и КАНВАС полностью, история ПАД (блокнот) с откатом.', + 'mesh_plus.slide_clipboard': 'КЛИПБОАРД (доска)', + 'mesh_plus.slide_clipboard_desc': 'Безлимитная история буфера по ЛАН и поиск.', + 'mesh_plus.slide_identity': 'Идентичность и статистика', + 'mesh_plus.slide_identity_desc': 'Бейдж МЭШ+, цвет АЙДИ, резерв номера, достижения.', + 'settings.app_icon_free_title': 'Иконка приложения', + 'settings.app_icon_free_hint': 'Трей, окно и «О приложении». Ярлык в меню «Пуск» обновится после переустановки.', + 'settings.app_icon_mesh_title': 'Иконки МЭШ ПЛЮС', + 'settings.app_icon_mesh_hint': 'Шесть премиум-иконок — нужна подписка МЭШ ПЛЮС.', + 'settings.app_icon_main': 'Основная', + 'settings.app_icon_dop': 'Доп. {n}', + 'settings.app_icon_mesh': 'Мэш {n}', + 'settings.app_icon_mesh_locked': 'Эта иконка доступна с МЭШ ПЛЮС.', + 'settings.app_icon_applied': 'Иконка приложения обновлена.', + 'settings.dev_clear_mesh_plus': 'Удалить подписку', + 'settings.dev_clear_mesh_plus_ok': 'Подписка МЭШ ПЛЮС сброшена.', + 'settings.dev_clear_mesh_plus_none': 'Сейчас подписки МЭШ ПЛЮС нет.', + 'settings.dev_clear_mesh_plus_fail': 'Не удалось сбросить подписку.', 'settings.section_language': 'Язык', 'settings.section_notifications': 'Уведомления', 'settings.section_privacy': 'Конфиденциальность', @@ -933,8 +1037,8 @@ const locales = { 'settings.notifications_calls': 'Входящий звонок', 'settings.notifications_dnd': 'Не беспокоить', 'settings.notifications_dnd_hint': 'Отключает звуки интерфейса и уведомления о сообщениях и звонках.', - 'settings.copy_id': 'Копировать ID', - 'settings.changelog': 'Changelog', + 'settings.copy_id': 'Копировать АЙДИ', + 'settings.changelog': 'ЧТО НОВОГО', 'settings.shortcuts_main_scope': 'Главное окно', 'settings.shortcuts_nav_dial': 'Набор', 'settings.shortcuts_nav_peers': 'Абоненты', diff --git a/renderer/mesh-plus-settings.js b/renderer/mesh-plus-settings.js new file mode 100644 index 0000000..b58759b --- /dev/null +++ b/renderer/mesh-plus-settings.js @@ -0,0 +1,272 @@ +import { t } from './i18n.js'; +import { isMeshPlusActive } from './mesh-plus.js'; +import { showAppToast } from './toasts.js'; +import { buildPanelTitleRow } from './settings-ui.js'; + +const CAROUSEL_SLIDES = [ + { icon: '◈', titleKey: 'mesh_plus.slide_premium_bg', descKey: 'mesh_plus.slide_premium_bg_desc' }, + { icon: '♪', titleKey: 'mesh_plus.slide_sound', descKey: 'mesh_plus.slide_sound_desc' }, + { icon: '◎', titleKey: 'mesh_plus.slide_relay', descKey: 'mesh_plus.slide_relay_desc' }, + { icon: '▦', titleKey: 'mesh_plus.slide_projects', descKey: 'mesh_plus.slide_projects_desc' }, + { icon: '⧉', titleKey: 'mesh_plus.slide_clipboard', descKey: 'mesh_plus.slide_clipboard_desc' }, + { icon: '★', titleKey: 'mesh_plus.slide_identity', descKey: 'mesh_plus.slide_identity_desc' }, +]; + +/** + * @param {object} state + * @param {() => void} onConfigChange + */ +export function buildSettingsMeshPlusPanel(state, onConfigChange) { + const frag = document.createElement('div'); + frag.className = 'settings-panel settings-panel--mesh-plus'; + + frag.appendChild(buildPanelTitleRow('settings.section_mesh_plus', 'mesh_plus.intro_hint')); + + const statusCard = document.createElement('div'); + statusCard.className = 'mesh-plus-status-card settings-list-panel'; + const statusRow = document.createElement('div'); + statusRow.className = 'mesh-plus-status-row'; + const statusLabel = document.createElement('span'); + statusLabel.className = 'settings-field-label'; + statusLabel.dataset.i18n = 'mesh_plus.status_label'; + statusLabel.textContent = t('mesh_plus.status_label'); + const statusPill = document.createElement('span'); + statusPill.className = 'mesh-plus-tier-pill'; + statusRow.appendChild(statusLabel); + statusRow.appendChild(statusPill); + statusCard.appendChild(statusRow); + frag.appendChild(statusCard); + + const carouselTitle = document.createElement('h3'); + carouselTitle.className = 'section-subtitle'; + carouselTitle.dataset.i18n = 'mesh_plus.carousel_title'; + carouselTitle.textContent = t('mesh_plus.carousel_title'); + frag.appendChild(carouselTitle); + + const carousel = document.createElement('div'); + carousel.className = 'mesh-plus-carousel settings-list-panel'; + const carouselTop = document.createElement('div'); + carouselTop.className = 'mesh-plus-carousel__top'; + const slideCounter = document.createElement('span'); + slideCounter.className = 'mesh-plus-carousel__counter'; + carouselTop.appendChild(slideCounter); + carousel.appendChild(carouselTop); + + const carouselBody = document.createElement('div'); + carouselBody.className = 'mesh-plus-carousel__body'; + const prevBtn = document.createElement('button'); + prevBtn.type = 'button'; + prevBtn.className = 'btn btn-lang mesh-plus-carousel__nav'; + prevBtn.dataset.i18n = 'mesh_plus.carousel_prev'; + prevBtn.textContent = t('mesh_plus.carousel_prev'); + prevBtn.setAttribute('aria-label', t('mesh_plus.carousel_prev')); + const carouselInner = document.createElement('div'); + carouselInner.className = 'mesh-plus-carousel__track'; + const nextBtn = document.createElement('button'); + nextBtn.type = 'button'; + nextBtn.className = 'btn btn-lang mesh-plus-carousel__nav'; + nextBtn.dataset.i18n = 'mesh_plus.carousel_next'; + nextBtn.textContent = t('mesh_plus.carousel_next'); + nextBtn.setAttribute('aria-label', t('mesh_plus.carousel_next')); + carouselBody.appendChild(prevBtn); + carouselBody.appendChild(carouselInner); + carouselBody.appendChild(nextBtn); + carousel.appendChild(carouselBody); + + const dots = document.createElement('div'); + dots.className = 'mesh-plus-carousel__dots'; + carousel.appendChild(dots); + frag.appendChild(carousel); + + let slideIndex = 0; + let carouselTimer = null; + + function renderSlide(i) { + slideIndex = ((i % CAROUSEL_SLIDES.length) + CAROUSEL_SLIDES.length) % CAROUSEL_SLIDES.length; + const slide = CAROUSEL_SLIDES[slideIndex]; + carouselInner.innerHTML = ''; + const icon = document.createElement('div'); + icon.className = 'mesh-plus-carousel__icon'; + icon.textContent = slide.icon; + const title = document.createElement('div'); + title.className = 'mesh-plus-carousel__title'; + title.dataset.i18n = slide.titleKey; + title.textContent = t(slide.titleKey); + const desc = document.createElement('p'); + desc.className = 'mesh-plus-carousel__desc'; + desc.dataset.i18n = slide.descKey; + desc.textContent = t(slide.descKey); + carouselInner.appendChild(icon); + carouselInner.appendChild(title); + carouselInner.appendChild(desc); + slideCounter.textContent = t('mesh_plus.carousel_counter') + .replace('{n}', String(slideIndex + 1)) + .replace('{total}', String(CAROUSEL_SLIDES.length)); + dots.querySelectorAll('button').forEach((btn, idx) => { + btn.classList.toggle('selected', idx === slideIndex); + btn.setAttribute('aria-label', t('mesh_plus.carousel_dot').replace('{n}', String(idx + 1))); + }); + } + + CAROUSEL_SLIDES.forEach((_, idx) => { + const dot = document.createElement('button'); + dot.type = 'button'; + dot.className = 'mesh-plus-carousel__dot'; + dot.addEventListener('click', () => { + renderSlide(idx); + resetCarouselTimer(); + }); + dots.appendChild(dot); + }); + + prevBtn.addEventListener('click', () => { + renderSlide(slideIndex - 1); + resetCarouselTimer(); + }); + nextBtn.addEventListener('click', () => { + renderSlide(slideIndex + 1); + resetCarouselTimer(); + }); + + function resetCarouselTimer() { + if (carouselTimer) clearInterval(carouselTimer); + carouselTimer = setInterval(() => renderSlide(slideIndex + 1), 7000); + } + + const activationCard = document.createElement('div'); + activationCard.className = 'mesh-plus-activation settings-list-panel'; + + const activationTitle = document.createElement('h3'); + activationTitle.className = 'section-subtitle'; + activationTitle.dataset.i18n = 'mesh_plus.activation_title'; + activationTitle.textContent = t('mesh_plus.activation_title'); + activationCard.appendChild(activationTitle); + + const keyLabel = document.createElement('label'); + keyLabel.className = 'settings-field-label'; + keyLabel.dataset.i18n = 'mesh_plus.key_label'; + keyLabel.textContent = t('mesh_plus.key_label'); + keyLabel.htmlFor = 'mesh-plus-key-input'; + + const keyInput = document.createElement('input'); + keyInput.type = 'text'; + keyInput.id = 'mesh-plus-key-input'; + keyInput.className = 'input mesh-plus-key-input'; + keyInput.autocomplete = 'off'; + keyInput.spellcheck = false; + keyInput.placeholder = t('mesh_plus.key_placeholder'); + + const btnRow = document.createElement('div'); + btnRow.className = 'mesh-plus-activation__actions'; + + const activateBtn = document.createElement('button'); + activateBtn.type = 'button'; + activateBtn.className = 'btn btn-accent'; + activateBtn.dataset.i18n = 'mesh_plus.activate'; + activateBtn.textContent = t('mesh_plus.activate'); + + const revokeBtn = document.createElement('button'); + revokeBtn.type = 'button'; + revokeBtn.className = 'btn btn-danger hidden'; + revokeBtn.dataset.i18n = 'mesh_plus.revoke_key'; + revokeBtn.textContent = t('mesh_plus.revoke_key'); + + btnRow.appendChild(activateBtn); + btnRow.appendChild(revokeBtn); + + const feedback = document.createElement('p'); + feedback.className = 'mesh-plus-feedback hint'; + + const hint = document.createElement('p'); + hint.className = 'hint mesh-plus-hint'; + hint.dataset.i18n = 'mesh_plus.no_key_hint'; + hint.textContent = t('mesh_plus.no_key_hint'); + + activationCard.appendChild(keyLabel); + activationCard.appendChild(keyInput); + activationCard.appendChild(btnRow); + activationCard.appendChild(feedback); + activationCard.appendChild(hint); + frag.appendChild(activationCard); + + function syncActivationUi() { + const active = isMeshPlusActive(state.config); + statusPill.textContent = active ? t('mesh_plus.status_mesh_plus') : t('mesh_plus.status_free'); + statusPill.classList.toggle('mesh-plus-tier-pill--active', active); + statusCard.classList.toggle('mesh-plus-status-card--active', active); + keyInput.classList.toggle('hidden', active); + keyLabel.classList.toggle('hidden', active); + activateBtn.classList.toggle('hidden', active); + revokeBtn.classList.toggle('hidden', !active); + if (active) { + feedback.textContent = state.config.meshPlusLicenseMasked + ? `${t('mesh_plus.active_ok')} · ${state.config.meshPlusLicenseMasked}` + : t('mesh_plus.active_ok'); + feedback.classList.remove('mesh-plus-feedback--error'); + } else { + feedback.textContent = t('mesh_plus.inactive_hint'); + feedback.classList.remove('mesh-plus-feedback--error'); + } + } + + async function revokeLicense() { + revokeBtn.disabled = true; + try { + await window.blip.deactivateMeshPlus(); + state.config = await window.blip.getConfig(); + keyInput.value = ''; + syncActivationUi(); + showAppToast({ title: t('mesh_plus.revoke_ok'), durationMs: 4000 }); + onConfigChange?.(); + } catch (e) { + showAppToast({ + title: e?.message || t('mesh_plus.error_invalid'), + durationMs: 4500, + variant: 'danger', + }); + } finally { + revokeBtn.disabled = false; + } + } + + activateBtn.addEventListener('click', async () => { + const raw = keyInput.value.trim(); + if (!raw) { + feedback.textContent = t('mesh_plus.error_empty'); + feedback.classList.add('mesh-plus-feedback--error'); + return; + } + activateBtn.disabled = true; + try { + const res = await window.blip.activateMeshPlus(raw); + if (!res?.ok) { + const errKey = `mesh_plus.error_${res?.error || 'invalid'}`; + feedback.textContent = t(errKey) !== errKey ? t(errKey) : t('mesh_plus.error_invalid'); + feedback.classList.add('mesh-plus-feedback--error'); + return; + } + state.config = await window.blip.getConfig(); + keyInput.value = ''; + syncActivationUi(); + showAppToast({ title: t('mesh_plus.activate_ok'), durationMs: 4000 }); + onConfigChange?.(); + } catch (e) { + feedback.textContent = e?.message || t('mesh_plus.error_invalid'); + feedback.classList.add('mesh-plus-feedback--error'); + } finally { + activateBtn.disabled = false; + } + }); + + revokeBtn.addEventListener('click', () => void revokeLicense()); + + renderSlide(0); + resetCarouselTimer(); + syncActivationUi(); + + frag._meshPlusCleanup = () => { + if (carouselTimer) clearInterval(carouselTimer); + }; + + return frag; +} diff --git a/renderer/mesh-plus.js b/renderer/mesh-plus.js new file mode 100644 index 0000000..b8ef33d --- /dev/null +++ b/renderer/mesh-plus.js @@ -0,0 +1,36 @@ +import { t } from './i18n.js'; + +/** @param {object} [cfg] */ +export function isMeshPlusActive(cfg) { + return cfg?.meshPlusActive === true || cfg?.tier === 'mesh_plus'; +} + +/** @param {object} [peer] */ +export function peerHasMeshPlus(peer) { + return !!peer?.meshPlus; +} + +/** + * Platinum plaque + pixel gradient "MESH+" label. + * @param {{ title?: string, compact?: boolean }} [opts] + */ +export function createMeshPlusBadge(opts = {}) { + const el = document.createElement('span'); + el.className = `mesh-plus-badge${opts.compact ? ' mesh-plus-badge--compact' : ''}`; + el.title = opts.title || t('mesh_plus.badge_title'); + const text = document.createElement('span'); + text.className = 'mesh-plus-badge__text'; + text.textContent = t('mesh_plus.badge_label'); + el.appendChild(text); + return el; +} + +/** + * @param {HTMLElement} nameRow + * @param {object} [peer] + */ +export function appendMeshPlusBadgeToNameRow(nameRow, peer) { + if (!peerHasMeshPlus(peer)) return; + if (nameRow.querySelector('.mesh-plus-badge')) return; + nameRow.appendChild(createMeshPlusBadge({ compact: true })); +} diff --git a/renderer/settings-ui.js b/renderer/settings-ui.js index 3ba1bf0..ada926b 100644 --- a/renderer/settings-ui.js +++ b/renderer/settings-ui.js @@ -64,6 +64,32 @@ export function createPixelHintIcon(hintKey) { return btn; } +/** Section subtitle (h3) + optional pixel ? hint. */ +export function buildSectionSubtitleRow(labelKey, hintKey) { + const row = document.createElement('div'); + row.className = 'section-title-row'; + const sub = document.createElement('h3'); + sub.className = 'section-subtitle'; + sub.dataset.i18n = labelKey; + sub.textContent = t(labelKey); + row.appendChild(sub); + if (hintKey) row.appendChild(createPixelHintIcon(hintKey)); + return row; +} + +/** Panel title (h2) + optional hint. */ +export function buildPanelTitleRow(labelKey, hintKey) { + const row = document.createElement('div'); + row.className = 'settings-panel-title-row'; + const h = document.createElement('h2'); + h.className = 'settings-panel-title'; + h.dataset.i18n = labelKey; + h.textContent = t(labelKey); + row.appendChild(h); + if (hintKey) row.appendChild(createPixelHintIcon(hintKey)); + return row; +} + /** Label row: text + optional hint icon. */ export function buildSettingsLabelRow(labelKey, hintKey) { const row = document.createElement('div'); diff --git a/renderer/styles.css b/renderer/styles.css index b4060fe..71db0a4 100644 --- a/renderer/styles.css +++ b/renderer/styles.css @@ -444,29 +444,56 @@ select.blip-select:focus { display: flex; flex-direction: column; align-items: center; - max-width: 520px; + gap: 14px; + width: 100%; + max-width: 560px; margin: 0 auto; + padding: 20px 22px 18px; + background: color-mix(in srgb, var(--blip-glass) 92%, var(--blip-bg)); + border: 2px solid var(--blip-glass-border); + box-shadow: + 4px 4px 0 rgba(0, 0, 0, 0.5), + inset 0 0 0 1px color-mix(in srgb, var(--blip-accent) 10%, transparent); +} + +.id-grid-wrap .section-title { + margin: 0; + text-align: center; + color: var(--blip-accent); +} + +.id-grid-wrap .hint { + margin: 0; + text-align: center; + max-width: 420px; + line-height: 1.45; +} + +.id-grid-wrap .btn-accent { + min-width: 200px; + margin-top: 4px; } .id-grid { display: grid; - grid-template-columns: repeat(8, minmax(52px, 1fr)); - gap: 6px; + grid-template-columns: repeat(8, minmax(56px, 1fr)); + gap: 8px; width: 100%; - max-width: 496px; - margin-bottom: 16px; + max-width: 520px; + margin: 0; + padding: 4px; } .id-cell { position: relative; aspect-ratio: 1; - min-height: 48px; + min-height: 56px; min-width: 0; - border: 2px solid rgba(0, 255, 200, 0.35); - background: #141414; + border: 2px solid color-mix(in srgb, var(--blip-accent) 38%, var(--blip-muted)); + background: color-mix(in srgb, var(--blip-bg) 78%, var(--blip-glass)); color: var(--blip-text); font-family: inherit; - font-size: 13px; + font-size: 14px; line-height: 1; cursor: pointer; padding: 0; @@ -474,6 +501,11 @@ select.blip-select:focus { align-items: center; justify-content: center; overflow: visible; + transition: + border-color 0.12s ease, + background 0.12s ease, + box-shadow 0.12s ease, + color 0.12s ease; } .id-cell-num { @@ -491,25 +523,31 @@ select.blip-select:focus { .id-cell:hover:not(:disabled) { border-color: var(--blip-accent); + background: color-mix(in srgb, var(--blip-accent) 14%, var(--blip-bg)); + box-shadow: 0 0 10px color-mix(in srgb, var(--blip-accent) 35%, transparent); } .id-cell.pending { border-color: var(--blip-accent); - box-shadow: inset 0 0 0 2px var(--blip-accent); + background: color-mix(in srgb, var(--blip-accent) 22%, var(--blip-bg)); + box-shadow: + inset 0 0 0 2px color-mix(in srgb, var(--blip-accent) 55%, transparent), + 0 0 12px color-mix(in srgb, var(--blip-accent) 40%, transparent); } .id-cell.selected { background: var(--blip-accent); color: var(--blip-on-accent); border-color: var(--blip-accent); - box-shadow: 0 0 12px var(--blip-accent); + box-shadow: 0 0 16px color-mix(in srgb, var(--blip-accent) 65%, transparent); } .id-cell.occupied { - background: var(--blip-muted); - border-color: var(--blip-muted); - color: var(--blip-muted); + background: color-mix(in srgb, var(--blip-muted) 55%, var(--blip-bg)); + border-color: color-mix(in srgb, var(--blip-muted) 80%, transparent); + color: color-mix(in srgb, var(--blip-text) 45%, transparent); cursor: not-allowed; + box-shadow: none; } .id-cell-cross { @@ -2383,6 +2421,12 @@ select.blip-select:focus { min-width: 0; } +.settings-panel-title-row .section-subtitle { + margin: 0; + flex: 1; + min-width: 0; +} + .settings-panel--empty .hint { margin: 0; max-width: 420px; @@ -3854,7 +3898,7 @@ body.media-viewer-open { .settings-panel--about > .btn, .settings-panel--about > .settings-about-links { width: 100%; - max-width: 360px; + max-width: 420px; margin-left: auto; margin-right: auto; } @@ -3863,33 +3907,323 @@ body.media-viewer-open { display: flex; flex-direction: column; align-items: center; - gap: 10px; - margin: 0; + gap: 14px; + margin: 0 0 8px; + padding: 8px 0 4px; } .settings-about-icon { - width: 72px; - height: 72px; + width: 112px; + height: 112px; image-rendering: pixelated; border: 2px solid var(--blip-glass-border); - box-shadow: 3px 3px 0 rgba(0, 0, 0, 0.5); + box-shadow: 4px 4px 0 rgba(0, 0, 0, 0.55); } .settings-about-line { - font-size: 16px; + font-size: 28px; + line-height: 1.15; + letter-spacing: 0.14em; text-transform: uppercase; margin: 0; + color: var(--blip-text); } .settings-about-version { + font-size: 14px; color: var(--blip-accent); margin: 0; + letter-spacing: 0.06em; } .settings-about-links { justify-content: center; } +/* MESH+ badge — platinum plaque + gradient pixel label */ +.mesh-plus-badge { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + margin-left: 6px; + padding: 2px 6px 3px; + vertical-align: middle; + background: linear-gradient( + 180deg, + #ececf2 0%, + #b8b8c4 38%, + #9a9aa8 52%, + #d4d4dc 100% + ); + border: 1px solid #6e6e7a; + box-shadow: + 1px 1px 0 rgba(0, 0, 0, 0.55), + inset 0 1px 0 rgba(255, 255, 255, 0.35); + image-rendering: pixelated; +} + +.mesh-plus-badge--compact { + padding: 1px 5px 2px; + margin-left: 4px; +} + +.mesh-plus-badge__text { + font-family: var(--blip-font, 'Minecraft', monospace); + font-size: 8px; + font-weight: bold; + line-height: 1.1; + letter-spacing: 0.1em; + text-transform: uppercase; + background: linear-gradient( + 180deg, + #f5f0ff 0%, + #c084fc 28%, + #7c3aed 52%, + #a78bfa 78%, + #e9d5ff 100% + ); + -webkit-background-clip: text; + background-clip: text; + color: transparent; + -webkit-text-fill-color: transparent; + paint-order: stroke fill; + filter: drop-shadow(0 1px 0 rgba(0, 0, 0, 0.45)); +} + +.peer-name .mesh-plus-badge { + align-self: center; +} + +/* Settings → MESH+ */ +.settings-panel--mesh-plus { + max-width: 580px; +} + +.settings-tray-wrap--flat { + gap: 10px; +} + +.settings-tray-wrap--flat .settings-toggle-with-hint { + margin: 0; +} + +.mesh-plus-status-card { + padding: 12px 14px; + margin-bottom: 12px; + max-height: none; + overflow: visible; +} + +.mesh-plus-status-card--active { + box-shadow: + inset 0 0 0 1px color-mix(in srgb, #a78bfa 35%, transparent), + 0 0 16px rgba(124, 58, 237, 0.2); +} + +.mesh-plus-status-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + margin: 0; +} + +.mesh-plus-tier-pill { + display: inline-block; + padding: 6px 14px; + font-size: 12px; + font-weight: bold; + letter-spacing: 0.12em; + text-transform: uppercase; + border: 2px solid var(--blip-muted); + color: var(--blip-muted); + background: color-mix(in srgb, var(--blip-bg) 85%, transparent); +} + +.mesh-plus-tier-pill--active { + border-color: #9a9aa8; + color: #e9d5ff; + background: linear-gradient(180deg, #2a2438 0%, #1a1528 100%); + box-shadow: 0 0 12px rgba(124, 58, 237, 0.35); +} + +.mesh-plus-carousel { + padding: 12px 12px 10px; + margin: 0 0 16px; + max-height: none; + overflow: visible; +} + +.mesh-plus-carousel__top { + display: flex; + justify-content: flex-end; + margin-bottom: 6px; +} + +.mesh-plus-carousel__counter { + font-size: 10px; + letter-spacing: 0.1em; + text-transform: uppercase; + color: color-mix(in srgb, var(--blip-accent) 75%, transparent); +} + +.mesh-plus-carousel__body { + display: grid; + grid-template-columns: auto 1fr auto; + gap: 8px; + align-items: center; +} + +.mesh-plus-carousel__track { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + gap: 8px; + min-height: 128px; + padding: 4px 8px; +} + +.mesh-plus-carousel__icon { + font-size: 28px; + line-height: 1; + color: var(--blip-accent); + text-shadow: 0 0 10px color-mix(in srgb, var(--blip-accent) 40%, transparent); +} + +.mesh-plus-carousel__title { + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--blip-accent); + line-height: 1.35; +} + +.mesh-plus-carousel__desc { + margin: 0; + font-size: 11px; + line-height: 1.45; + color: color-mix(in srgb, var(--blip-text) 78%, transparent); + max-width: 360px; +} + +.mesh-plus-carousel__nav { + min-width: 36px; + padding: 6px 8px; + font-size: 14px; +} + +.mesh-plus-carousel__dots { + display: flex; + justify-content: center; + gap: 6px; + flex-wrap: wrap; + margin-top: 10px; +} + +.mesh-plus-carousel__dot { + width: 10px; + height: 10px; + padding: 0; + border: 2px solid var(--blip-muted); + background: transparent; + cursor: pointer; +} + +.mesh-plus-carousel__dot.selected { + border-color: #a78bfa; + background: #7c3aed; +} + +.mesh-plus-activation { + display: flex; + flex-direction: column; + gap: 10px; + max-width: 100%; + padding: 14px; + max-height: none; + overflow: visible; +} + +.mesh-plus-activation__actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.mesh-plus-key-input { + max-width: 100%; + font-size: 13px; + letter-spacing: 0.04em; +} + +/* Appearance → app icons */ +.settings-app-icon-grid { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-bottom: 20px; +} + +.settings-app-icon-grid--mesh { + margin-bottom: 4px; +} + +.settings-app-icon-tile { + display: flex; + align-items: center; + justify-content: center; + width: 96px; + height: 96px; + padding: 6px; + border: 2px solid var(--blip-muted); + background: color-mix(in srgb, var(--blip-bg) 88%, transparent); + cursor: pointer; + position: relative; + box-shadow: 2px 2px 0 rgba(0, 0, 0, 0.45); +} + +.settings-app-icon-tile:hover { + border-color: var(--blip-accent); +} + +.settings-app-icon-tile.selected { + border-color: var(--blip-accent); + background: color-mix(in srgb, var(--blip-accent) 12%, var(--blip-bg)); + box-shadow: 0 0 0 1px var(--blip-accent); +} + +.settings-app-icon-tile--locked { + opacity: 0.72; +} + +.settings-app-icon-tile__img { + width: 80px; + height: 80px; + image-rendering: pixelated; + object-fit: contain; + pointer-events: none; +} + +.settings-app-icon-tile__lock { + position: absolute; + top: 4px; + right: 4px; + font-size: 10px; + color: #a78bfa; +} + +.mesh-plus-feedback--error { + color: var(--blip-danger) !important; +} + +.mesh-plus-hint { + margin: 0; +} + .avatar-crop-modal { max-width: min(96vw, 360px); } diff --git a/renderer/ui.js b/renderer/ui.js index f3005ee..e2be048 100644 --- a/renderer/ui.js +++ b/renderer/ui.js @@ -97,6 +97,10 @@ import { wrapInSettingsListPanel, } from './settings-ui.js'; import { buildMicTestPanel } from './mic-test-panel.js'; +import { appendMeshPlusBadgeToNameRow } from './mesh-plus.js'; +import { buildSettingsMeshPlusPanel } from './mesh-plus-settings.js'; +import { appendAppIconPickerSections } from './app-icon-picker.js'; +import { isMeshPlusActive } from './mesh-plus.js'; import { startClipboardSync, stopClipboardSync, @@ -748,6 +752,7 @@ function renderPeersView() { name.appendChild(star); } name.appendChild(document.createTextNode(formatPeerDisplayName(peer))); + appendMeshPlusBadgeToNameRow(name, peer); if (peer.meshLegacy) { const leg = document.createElement('span'); leg.className = 'peer-handshake-badge peer-handshake-badge--legacy'; @@ -1167,13 +1172,7 @@ function buildLaunchAtLoginSection() { } const block = document.createElement('div'); - block.className = 'settings-tray-wrap'; - - const h = document.createElement('h3'); - h.className = 'section-subtitle'; - h.dataset.i18n = 'settings.launch_at_login'; - h.textContent = t('settings.launch_at_login'); - block.appendChild(h); + block.className = 'settings-tray-wrap settings-tray-wrap--flat'; const row = document.createElement('div'); row.className = 'settings-toggle-with-hint'; @@ -1197,13 +1196,7 @@ function buildCloseToTraySection() { } const block = document.createElement('div'); - block.className = 'settings-tray-wrap'; - - const h = document.createElement('h3'); - h.className = 'section-subtitle'; - h.dataset.i18n = 'settings.tray_title'; - h.textContent = t('settings.tray_title'); - block.appendChild(h); + block.className = 'settings-tray-wrap settings-tray-wrap--flat'; const row = document.createElement('div'); row.className = 'settings-toggle-with-hint'; @@ -1310,6 +1303,8 @@ function buildAppearanceSection() { }); block.appendChild(buildSettingsField('settings.default_reaction', reactionInput)); + appendAppIconPickerSections(block, state, (patch) => api.saveConfig(patch)); + return block; } @@ -1527,6 +1522,7 @@ function setupGlobalShortcuts() { function getSettingsSectionIds() { const ids = [ 'profile', + 'mesh_plus', 'language', 'notifications', 'privacy', @@ -2647,6 +2643,38 @@ function buildSettingsDeveloperPanel() { }); frag.appendChild(exportBtn); + const clearMeshBtn = document.createElement('button'); + clearMeshBtn.type = 'button'; + clearMeshBtn.className = 'btn btn-danger'; + clearMeshBtn.dataset.i18n = 'settings.dev_clear_mesh_plus'; + clearMeshBtn.textContent = t('settings.dev_clear_mesh_plus'); + clearMeshBtn.addEventListener('click', async () => { + if (!isMeshPlusActive(state.config)) { + showAppToast({ + title: t('settings.dev_clear_mesh_plus_none'), + durationMs: 4000, + }); + return; + } + try { + await window.blip.deactivateMeshPlus(); + state.config = await window.blip.getConfig(); + showAppToast({ + title: t('settings.dev_clear_mesh_plus_ok'), + durationMs: 4500, + }); + if (state.view === 'settings') renderView('settings'); + if (state.view === 'peers') renderView('peers'); + } catch (e) { + showAppToast({ + title: e?.message || t('settings.dev_clear_mesh_plus_fail'), + durationMs: 4500, + variant: 'danger', + }); + } + }); + frag.appendChild(clearMeshBtn); + return frag; } @@ -3014,6 +3042,11 @@ function renderSettingsMainPanel() { switch (state.settingsSection) { case 'profile': return buildSettingsProfilePanel(); + case 'mesh_plus': + return buildSettingsMeshPlusPanel(state, () => { + if (state.view === 'peers') renderView('peers'); + if (state.view === 'settings') renderView('settings'); + }); case 'language': return buildSettingsLanguagePanel(); case 'notifications': diff --git a/scripts/build-icons.mjs b/scripts/build-icons.mjs index c6e1dab..14c0adc 100644 --- a/scripts/build-icons.mjs +++ b/scripts/build-icons.mjs @@ -5,24 +5,83 @@ import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; const root = join(dirname(fileURLToPath(import.meta.url)), '..'); -const svgPath = join(root, 'icon.svg'); const buildDir = join(root, 'build'); +const iconsDir = join(buildDir, 'icons'); -if (!existsSync(svgPath)) { - console.error('[build-icons] icon.svg not found at project root'); +/** @type {{ id: string, file: string }[]} */ +const VARIANTS = [ + { id: 'main', file: 'icon-main.svg' }, + { id: 'dop-1', file: 'icon-dop-1.svg' }, + { id: 'dop-2', file: 'icon-dop-2.svg' }, + { id: 'dop-3', file: 'icon-dop-3.svg' }, + { id: 'dop-4', file: 'icon-dop-4.svg' }, + { id: 'mesh-1', file: 'icon-mesh1.svg' }, + { id: 'mesh-2', file: 'icon-mesh2.svg' }, + { id: 'mesh-3', file: 'icon-mesh3.svg' }, + { id: 'mesh-4', file: 'icon-mesh4.svg' }, + { id: 'mesh-5', file: 'icon-mesh5.svg' }, + { id: 'mesh-6', file: 'icon-mesh6.svg' }, +]; + +const DEFAULT_ID = 'main'; +const defaultVariant = VARIANTS.find((v) => v.id === DEFAULT_ID); +if (!defaultVariant) { + console.error('[build-icons] main variant missing'); + process.exit(1); +} + +const defaultSvgPath = join(root, defaultVariant.file); +if (!existsSync(defaultSvgPath)) { + console.error('[build-icons] icon-main.svg not found at project root'); process.exit(1); } mkdirSync(buildDir, { recursive: true }); -copyFileSync(svgPath, join(buildDir, 'icon.svg')); +mkdirSync(iconsDir, { recursive: true }); -const svg = readFileSync(svgPath); -const sizes = [256, 128, 64, 48, 32, 16]; +async function rasterizeVariant(variant) { + const svgPath = join(root, variant.file); + if (!existsSync(svgPath)) { + console.warn(`[build-icons] skip ${variant.id}: ${variant.file} missing`); + return null; + } + const svg = readFileSync(svgPath); + const png256 = join(iconsDir, `${variant.id}.png`); + const tray16 = join(iconsDir, `${variant.id}-tray.png`); + await sharp(svg, { density: 288 }) + .resize(256, 256, { kernel: sharp.kernel.nearest }) + .png() + .toFile(png256); + await sharp(svg, { density: 288 }) + .resize(16, 16, { kernel: sharp.kernel.nearest }) + .png() + .toFile(tray16); + return { svg, png256, tray16 }; +} + +console.log('[build-icons] Rasterizing variants…'); +const rasterized = new Map(); +for (const variant of VARIANTS) { + const out = await rasterizeVariant(variant); + if (out) { + rasterized.set(variant.id, out); + console.log('[build-icons]', variant.id, '→', out.png256, out.tray16); + } +} + +const main = rasterized.get(DEFAULT_ID); +if (!main) { + console.error('[build-icons] failed to build main icon'); + process.exit(1); +} -console.log('[build-icons] Generating PNG sizes…'); +copyFileSync(defaultSvgPath, join(buildDir, 'icon.svg')); +copyFileSync(defaultSvgPath, join(root, 'icon.svg')); + +const sizes = [256, 128, 64, 48, 32, 16]; const pngBuffers = await Promise.all( sizes.map((size) => - sharp(svg, { density: 288 }) + sharp(main.svg, { density: 288 }) .resize(size, size, { kernel: sharp.kernel.nearest }) .png() .toBuffer() @@ -33,16 +92,8 @@ const icoPath = join(buildDir, 'icon.ico'); writeFileSync(icoPath, await toIco(pngBuffers)); console.log('[build-icons] Wrote', icoPath); -const png256 = join(buildDir, 'icon.png'); -await sharp(svg, { density: 288 }) - .resize(256, 256, { kernel: sharp.kernel.nearest }) - .png() - .toFile(png256); -console.log('[build-icons] Wrote', png256); - -const tray16 = join(buildDir, 'tray-16.png'); -await sharp(svg, { density: 288 }) - .resize(16, 16, { kernel: sharp.kernel.nearest }) - .png() - .toFile(tray16); -console.log('[build-icons] Wrote', tray16); +copyFileSync(main.png256, join(buildDir, 'icon.png')); +console.log('[build-icons] Wrote', join(buildDir, 'icon.png')); + +copyFileSync(main.tray16, join(buildDir, 'tray-16.png')); +console.log('[build-icons] Wrote', join(buildDir, 'tray-16.png')); diff --git a/scripts/mesh-plus-keygen.mjs b/scripts/mesh-plus-keygen.mjs new file mode 100644 index 0000000..dcfa689 --- /dev/null +++ b/scripts/mesh-plus-keygen.mjs @@ -0,0 +1,54 @@ +#!/usr/bin/env node +/** + * Generate a MESH+ license key (run locally only — never commit private keys). + * + * node scripts/mesh-plus-keygen.mjs + * node scripts/mesh-plus-keygen.mjs --private-key path/to/mesh-plus-private.b64 + * + * Writes optional private key to scripts/.mesh-plus-private.b64 (gitignored). + */ +import { createPrivateKey, generateKeyPairSync, randomBytes, sign } from 'crypto'; +import { readFileSync, writeFileSync, existsSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import { + formatMeshPlusLicenseKey, + MESH_PLUS_LICENSE_CANON, +} from '../main/mesh-plus-license.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const DEFAULT_PRIV_PATH = join(__dirname, '.mesh-plus-private.b64'); + +function loadOrCreatePrivateKey(path) { + if (existsSync(path)) { + const b64 = readFileSync(path, 'utf8').trim(); + return createPrivateKey({ + key: Buffer.from(b64, 'base64'), + format: 'der', + type: 'pkcs8', + }); + } + const { privateKey, publicKey } = generateKeyPairSync('ed25519'); + const privB64 = privateKey.export({ type: 'pkcs8', format: 'der' }).toString('base64'); + const pubB64 = publicKey.export({ type: 'spki', format: 'der' }).toString('base64'); + writeFileSync(path, `${privB64}\n`, 'utf8'); + console.log(`[mesh-plus-keygen] Wrote new private key → ${path}`); + console.log(`[mesh-plus-keygen] Public key (embed in mesh-plus-public-key.js if rotated):\n${pubB64}\n`); + return privateKey; +} + +const privArg = process.argv.indexOf('--private-key'); +const privPath = privArg >= 0 ? process.argv[privArg + 1] : DEFAULT_PRIV_PATH; +const pk = loadOrCreatePrivateKey(privPath); + +const licenseId8 = randomBytes(8); +const licenseId = licenseId8.toString('hex').toUpperCase(); +const canonical = `${MESH_PLUS_LICENSE_CANON}|${licenseId}`; +const sig = sign(null, Buffer.from(canonical, 'utf8'), pk); +const sigB64 = sig.toString('base64'); +const displayKey = formatMeshPlusLicenseKey(licenseId, sigB64); + +console.log('--- MESH+ license (give to one user) ---'); +console.log(displayKey); +console.log('---'); +console.log(`licenseId: ${licenseId}`); From b2c291a1f917304217af6f46ed7d7671cbdb5cb6 Mon Sep 17 00:00:00 2001 From: krwg Date: Thu, 21 May 2026 17:57:22 +0300 Subject: [PATCH 06/25] feat: MESH+ Signal Corps, achievements, and 0.10.1 polish Ship phases 5-6 (Projects Board/Canvas, Pad history, Clipboard, theme editor), optional Achievements settings (off by default, Steam-style cards), session stats in Network with activity chart, GIF and themed export gates for MESH+, and profile banner styling. Remove mesh identity UI and MESH-PLUS-PLAN.md (replaced by MESH-PLUS.md). Co-authored-by: Cursor --- .gitignore | 3 +- CHANGELOG.md | 75 +- CONTRIBUTING.md | 2 +- app-metadata.json | 4 +- docs/ARCHITECTURE.md | 13 +- docs/MESH-PLUS.md | 33 + main/config.js | 10 + main/discovery.js | 8 +- main/giphy-client.js | 102 ++ main/giphy-key.js | 43 + main/index.js | 150 +++ main/profile-gif-store.js | 158 +++ package.json | 2 +- preload.cjs | 11 + renderer/achievements-settings-panel.js | 82 ++ renderer/achievements-store.js | 44 + renderer/achievements-tracker.js | 31 + renderer/achievements.js | 126 +++ renderer/app-icon-picker.js | 50 +- renderer/appearance.js | 29 + renderer/chat-export.js | 130 ++- renderer/chat.js | 53 +- renderer/gif-crop-dialog.js | 166 +++ renderer/gif-frame-size.js | 36 + renderer/grid.js | 14 +- renderer/group-projects-store.js | 18 +- renderer/i18n.js | 276 ++++- renderer/mesh-plus-settings.js | 2 +- renderer/mesh-plus.js | 74 +- renderer/pad-history-store.js | 68 ++ renderer/peer-gif-cache.js | 15 + renderer/peer-private-notes.js | 43 + renderer/peer-profile.js | 19 + renderer/profile-card.js | 288 ++++++ renderer/profile-gif-picker.js | 313 ++++++ renderer/project-tools-ui.js | 216 +++- renderer/projects-mesh-wire.js | 148 ++- renderer/projects-view.js | 154 ++- renderer/session-stats-panel.js | 70 ++ renderer/session-stats.js | 104 ++ renderer/settings-profile-panel.js | 313 ++++++ renderer/styles.css | 1258 ++++++++++++++++++++--- renderer/theme-editor.js | 105 ++ renderer/ui.js | 455 +++++--- shared/mesh-plus-gates.js | 148 +++ 45 files changed, 5043 insertions(+), 419 deletions(-) create mode 100644 docs/MESH-PLUS.md create mode 100644 main/giphy-client.js create mode 100644 main/giphy-key.js create mode 100644 main/profile-gif-store.js create mode 100644 renderer/achievements-settings-panel.js create mode 100644 renderer/achievements-store.js create mode 100644 renderer/achievements-tracker.js create mode 100644 renderer/achievements.js create mode 100644 renderer/gif-crop-dialog.js create mode 100644 renderer/gif-frame-size.js create mode 100644 renderer/pad-history-store.js create mode 100644 renderer/peer-gif-cache.js create mode 100644 renderer/peer-private-notes.js create mode 100644 renderer/peer-profile.js create mode 100644 renderer/profile-card.js create mode 100644 renderer/profile-gif-picker.js create mode 100644 renderer/session-stats-panel.js create mode 100644 renderer/session-stats.js create mode 100644 renderer/settings-profile-panel.js create mode 100644 renderer/theme-editor.js create mode 100644 shared/mesh-plus-gates.js diff --git a/.gitignore b/.gitignore index d615f87..96deb4c 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,5 @@ github-profile/ MESH-PLUS-PLAN.md scripts/.mesh-plus-private.b64 mesh-plus-private.b64 -keysandlicense.md \ No newline at end of file +keysandlicense.md +giphy-api-key.local \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b11b1e..02be468 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,79 @@ Release **version numbers** track [`app-metadata.json`](app-metadata.json) (sync _Nothing yet._ +## [0.10.1] — Achievements, Network stats, polish + +### Added + +- **Settings → Achievements** — off by default; Steam-style cards; unlock toasts; empty `iconAsset` slots for custom icons. +- **Settings → Network** — session stats summary + activity bar chart. +- **MESH+** — status GIF gated; themed PDF/HTML chat export; profile banner styling. + +### Removed + +- **Mesh identity** settings (ID color, status icons, ID bookmarks) — dropped as unnecessary. +- **`docs/MESH-PLUS-PLAN.md`** — replaced by short [`docs/MESH-PLUS.md`](docs/MESH-PLUS.md). + +### Changed + +- MESH+ panel is activation + carousel only (relay/bridge remain marketing-only). + +## [0.10.0] — MESH+ Identity & Stats + +### Added + +- **MESH+ Phase 8 — Identity:** custom BLIP ID color (LAN announce), status icons (game/code/stream/listen/work/afk), up to 8 reserved ID bookmarks on the change-ID grid. +- **MESH+ Phase 9 — Gamification:** session stats (messages, files, calls, peak peers, online time) and achievements in Settings → MESH+. +- **Profile:** quick status-icon picker for MESH+; preview shows ID tint and icon. +- **Peers list:** peer ID color and status glyph when announced on LAN. + +### Changed + +- Phase 7 (LAN relay, MESH+ Bridge, traffic priority) stays in the MESH+ carousel only — not implemented until after a stable release. + +## [0.9.1] — Signal Corps · Clipboard & Theme + +### Added + +- **MESH+ Phase 6 — Clipboard Board:** mesh sync (`mesh-proj-clipboard`, pull); **FREE** up to 20 entries; **MESH+** up to 500 + search filter. +- **MESH+ Theme editor:** custom `#RRGGBB` accent in Settings → Appearance (color picker + hex); cleared on license revoke. + +### Changed + +- Projects → **БУФЕР / CLIPBOARD** is live for all users (dev Projects flag still required). + +## [0.9.0] — Signal Corps + +### Added + +- **MESH+ Phase 5 — Projects (Signal Corps):** **BOARD** (kanban, mesh sync `mesh-proj-board`) and **CANVAS** (32×16 grid, `mesh-proj-canvas`) in standalone Projects — MESH+ only; FREE sees lock stub + toast. +- **Pad history (MESH+):** local snapshots in Pad tool — save, list, restore/rollback; sync still via `mesh-proj-pad` with LWW on `updatedAt`. +- **Mesh wire:** `broadcastMeshBoard` / `broadcastMeshCanvas`; pad receive respects last-write-wins. + +### Changed + +- Projects hub uses live config getter so activation unlocks Board/Canvas without restart. +- Clipboard in Projects nav remains “coming soon” (Phase 6). + +## [0.8.1] — MESH+ Gate + +### Added + +- **Peer profile page** — full in-app profile (avatar left, details right): BLIP ID, status, Mesh Pulse, MESH+ badge, mesh label, actions. Open from avatar click, context menu **Profile**, or chat header. Back / Esc returns to the previous screen. + +## [0.8.0] — MESH+ Gate + +### Added + +- **`requireMeshPlus(feature)`** — shared gates in `shared/mesh-plus-gates.js`; enforced in main on load, save, and deactivate. +- **Premium animated backgrounds** — Ember & Rift locked for FREE (◆ in picker, toast on attempt). +- **Premium sound packs** — WIRE / STATIC FX and BEACON / CHIME melodies locked for FREE. + +### Changed + +- Settings hints for animated background and sound sections (what requires MESH+). +- Revoking MESH+ resets premium wallpaper and sound prefs to FREE defaults. + ## [0.7.10] — MESH+ Gate ### Added @@ -43,7 +116,7 @@ _Nothing yet._ ### Added -- **[`docs/MESH-PLUS-PLAN.md`](docs/MESH-PLUS-PLAN.md)** — full MESH+ roadmap (tiers, Ed25519 keys, feature matrix, phased rollout). Implementation is incremental; FREE baseline unchanged. +- **[`docs/MESH-PLUS.md`](docs/MESH-PLUS.md)** — MESH+ tier summary (license, feature list). Phased roadmap file removed in 0.10.1. ### Changed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c03cb67..767b27a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -63,7 +63,7 @@ Outputs go to `dist-electron/` (see `electron-builder.yml`). Version and codename live in [`app-metadata.json`](app-metadata.json) (synced into `package.json` on `npm run build`). Update [`CHANGELOG.md`](CHANGELOG.md) when shipping. -**MESH+** features are planned in [`docs/MESH-PLUS-PLAN.md`](docs/MESH-PLUS-PLAN.md) — implement in phases; do not break FREE behavior. +**MESH+** tier notes: [`docs/MESH-PLUS.md`](docs/MESH-PLUS.md). Do not break FREE behavior when gating premium features. Issue test keys locally: `npm run mesh-plus:keygen` (uses `scripts/.mesh-plus-private.b64`, gitignored). Embed only the **public** key in `main/mesh-plus-public-key.js` when rotating keys. diff --git a/app-metadata.json b/app-metadata.json index 5528f18..26ec083 100644 --- a/app-metadata.json +++ b/app-metadata.json @@ -1,6 +1,6 @@ { "displayName": "BLIP", - "codename": "MESH+ Gate", - "version": "0.7.10", + "codename": "Signal Corps", + "version": "0.10.1", "githubUrl": "https://github.com/krwg/BLIP" } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9d00ee5..7c7430c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -72,7 +72,8 @@ Environment overrides: `BLIP_UDP_PORT`, `BLIP_TCP_PORT`. Separate user data dirs |--------|------| | **Nav entry** | `PROJECTS` between Chat and Settings when the developer toggle is on. | | **Pad (✦)** | Full-height shared textarea; `mesh-proj-pad` broadcasts LWW updates to all **online** peers (not group-scoped). | -| **Board / Canvas / Clipboard** | UI stubs; TCP types reserved for future drops. | +| **Board / Canvas** | MESH+ gated kanban + grid (`mesh-proj-board`, `mesh-proj-canvas`). | +| **Clipboard** | Mesh sync; FREE 20 entries, MESH+ 500 + search. | | **UX** | Tool list in a 140px sidebar; page title uses the standard **?** hint (`projects.hub_hint`) like Peers and Settings. | Groups (`groups.js`, voice channels) are intentionally separate and remain beta; do not route Signal Corps traffic through group relays. @@ -85,13 +86,11 @@ Groups (`groups.js`, voice channels) are intentionally separate and remain beta; | Chat history | Renderer `localStorage` key `blip_chat_v1`. | | Favorite peer IDs | Renderer `localStorage` key `blip_favorites_v1`. | | Avatar seeds (per BLIP ID) | Renderer `localStorage` key `blip_avatar_seed_v1` (`avatar.js`). | -<<<<<<< HEAD -| Release metadata | `app-metadata.json` (version **0.7.8**, codename **MESH+ Gate**, repo URL). | -| MESH+ roadmap | [`MESH-PLUS-PLAN.md`](MESH-PLUS-PLAN.md) — tiers, license, phased features (not all implemented yet). | +| Release metadata | `app-metadata.json` (version **0.10.x**, codename **Signal Corps**, repo URL). | +| MESH+ overview | [`MESH-PLUS.md`](MESH-PLUS.md) — tier summary (no phased roadmap file). | | Group avatars | Renderer `localStorage` `blip_group_avatar_v1` | -======= -| Release metadata | `app-metadata.json` (version **0.7.4**, codename **Signal**, repo URL). | ->>>>>>> 3ae1d92 (update /docs) +| Session stats | Renderer `session-stats.js` + **Settings → Network** | +| Achievements | Renderer `achievements*.js` + **Settings → Achievements** (off by default) | ## Security posture (today) diff --git a/docs/MESH-PLUS.md b/docs/MESH-PLUS.md new file mode 100644 index 0000000..3ba1bc9 --- /dev/null +++ b/docs/MESH-PLUS.md @@ -0,0 +1,33 @@ +# BLIP · MESH+ + +MESH+ is a **license tier** (`BLIP-XXXX-…` full key, Ed25519 verify in main). FREE users keep the full baseline chat, calls, and LAN mesh. + +## Shipped (high level) + +| Area | MESH+ | +|------|--------| +| Premium animated backgrounds & sound packs | ✦ | +| Theme editor (custom `#RRGGBB` accent) | ✦ | +| App icons `mesh-1` … `mesh-6` | ✦ | +| Profile status GIF (LAN cloud) | ✦ | +| Signal Corps: Board, Canvas, Pad history, Clipboard 500 + search | ✦ | +| Chat export PDF/HTML with BLIP theme | ✦ | +| Badge on peers (`meshPlus` in UDP announce) | ✦ | + +## Not in app yet (marketing only) + +- LAN relay, MESH+ Bridge, bandwidth priority — carousel slide only; implementation deferred. + +## Settings layout + +- **MESH+** — activation + benefits carousel. +- **Network** — session stats + activity bars (all users). +- **Achievements** — optional, **off by default**; Steam-style cards; set `iconAsset` per achievement in `renderer/achievements.js`. + +## Code + +- Gates: `shared/mesh-plus-gates.js` +- License: `main/mesh-plus-license.js` +- UI: `renderer/mesh-plus-settings.js`, `renderer/mesh-plus.js` + +*No ROOT build switcher in public repo.* diff --git a/main/config.js b/main/config.js index 6032199..e81e156 100644 --- a/main/config.js +++ b/main/config.js @@ -14,9 +14,15 @@ const DEFAULT_CONFIG = { themeMode: 'dark', /** Color accent id — see themes.css [data-accent] */ accentId: 'mint', + /** MESH+: custom #RRGGBB accent (overrides preset when set). */ + accentCustomHex: '', animatedBgId: 'none', /** User uploaded profile image on disk */ customAvatar: false, + /** Active profile GIF id (local file in profile-gifs/). */ + profileGifActiveId: '', + /** Whether a profile GIF is set (for LAN announce). */ + hasProfileGif: false, /** User override: pause animated wallpapers (lighter GPU). */ reduceMotion: false, /** Pulse animated wallpaper from mic level (voice / 1:1 call). */ @@ -89,6 +95,10 @@ const DEFAULT_CONFIG = { meshPlusActivatedAt: 0, /** App / tray / about icon: main | dop-1..4 | mesh-1..6 */ appIconVariant: 'main', + /** Track session achievements (off by default). */ + achievementsEnabled: false, + /** Toast when an achievement unlocks. */ + achievementsNotify: true, }; let configPath = null; diff --git a/main/discovery.js b/main/discovery.js index 3faf368..cfb42e0 100644 --- a/main/discovery.js +++ b/main/discovery.js @@ -151,7 +151,8 @@ export class Discovery { }); const meshAnnounceSig = signCanonical(this.config, canonical); const meshPlus = isMeshPlusActive(this.config); - return { ...base, meshAnnounceSig, meshPlus }; + const hasProfileGif = meshPlus && !!this.config?.hasProfileGif; + return { ...base, meshAnnounceSig, meshPlus, hasProfileGif }; } announce() { @@ -213,6 +214,7 @@ export class Discovery { meshLegacy, meshPubkey, meshPlus: !!data.meshPlus, + hasProfileGif: !!data.meshPlus && !!data.hasProfileGif, }; if ( @@ -224,7 +226,8 @@ export class Discovery { existing.tcpPort !== peer.tcpPort || existing.meshVerified !== peer.meshVerified || existing.meshLegacy !== peer.meshLegacy || - existing.meshPlus !== peer.meshPlus + existing.meshPlus !== peer.meshPlus || + existing.hasProfileGif !== peer.hasProfileGif ) { this.peers.set(data.blipId, peer); } else { @@ -238,6 +241,7 @@ export class Discovery { existing.meshLegacy = meshLegacy; existing.meshPubkey = meshPubkey; existing.meshPlus = peer.meshPlus; + existing.hasProfileGif = peer.hasProfileGif; } this.occupiedIds.add(data.blipId); diff --git a/main/giphy-client.js b/main/giphy-client.js new file mode 100644 index 0000000..1f22528 --- /dev/null +++ b/main/giphy-client.js @@ -0,0 +1,102 @@ +/** + * Giphy search/trending (optional API key via BLIP_GIPHY_API_KEY). + * @see https://developers.giphy.com/docs/api/ + */ + +import { getGiphyApiKey } from './giphy-key.js'; +import { MAX_PROFILE_GIF_BYTES } from './profile-gif-store.js'; + +const GIPHY_BASE = 'https://api.giphy.com/v1/gifs'; + +function apiKey() { + return getGiphyApiKey(); +} + +export function isGiphyConfigured() { + return apiKey().length > 0; +} + +/** + * @param {object} gif + */ +function pickImage(images, ...keys) { + for (const key of keys) { + const o = images[key]; + if (o?.url) return o; + } + return {}; +} + +function mapGif(gif) { + const images = gif?.images || {}; + const preview = pickImage( + images, + 'preview_gif', + 'fixed_height', + 'fixed_width', + 'downsized', + 'downsized_small' + ); + const importSrc = pickImage( + images, + 'downsized', + 'fixed_width', + 'fixed_height', + 'preview_gif', + 'original' + ); + return { + id: String(gif?.id || ''), + title: String(gif?.title || ''), + previewUrl: preview.url || '', + gifUrl: importSrc.url || preview.url || '', + width: Number(preview.width || importSrc.width || 0), + height: Number(preview.height || importSrc.height || 0), + }; +} + +/** + * @param {string} query + * @param {{ offset?: number, limit?: number }} [opts] + */ +export async function searchGiphy(query, opts = {}) { + const key = apiKey(); + if (!key) return { ok: false, error: 'no_api_key', items: [] }; + const limit = opts.limit ?? 24; + const offset = opts.offset ?? 0; + const q = encodeURIComponent(String(query || '').trim()); + const url = `${GIPHY_BASE}/search?api_key=${key}&q=${q}&limit=${limit}&offset=${offset}&rating=g&lang=en`; + const res = await fetch(url); + if (!res.ok) return { ok: false, error: 'giphy_http', items: [] }; + const data = await res.json(); + const items = (data?.data || []).map(mapGif).filter((g) => g.gifUrl); + return { ok: true, items, pagination: data?.pagination }; +} + +/** + * @param {{ offset?: number, limit?: number }} [opts] + */ +export async function trendingGiphy(opts = {}) { + const key = apiKey(); + if (!key) return { ok: false, error: 'no_api_key', items: [] }; + const limit = opts.limit ?? 24; + const offset = opts.offset ?? 0; + const url = `${GIPHY_BASE}/trending?api_key=${key}&limit=${limit}&offset=${offset}&rating=g`; + const res = await fetch(url); + if (!res.ok) return { ok: false, error: 'giphy_http', items: [] }; + const data = await res.json(); + const items = (data?.data || []).map(mapGif).filter((g) => g.gifUrl); + return { ok: true, items, pagination: data?.pagination }; +} + +/** + * @param {string} gifUrl + * @returns {Promise} + */ +export async function downloadGifUrl(gifUrl) { + const res = await fetch(gifUrl); + if (!res.ok) throw new Error('download_failed'); + const buf = Buffer.from(await res.arrayBuffer()); + if (buf.length > MAX_PROFILE_GIF_BYTES) throw new Error('gif_too_large'); + return buf; +} diff --git a/main/giphy-key.js b/main/giphy-key.js new file mode 100644 index 0000000..722c97e --- /dev/null +++ b/main/giphy-key.js @@ -0,0 +1,43 @@ +import { existsSync, readFileSync } from 'fs'; +import { join } from 'path'; +import { app } from 'electron'; + +let cached = ''; + +/** + * Giphy API key: env, then userData/giphy-api-key.txt, then ./giphy-api-key.local (gitignored). + */ +export function getGiphyApiKey() { + if (cached) return cached; + const fromEnv = ( + process.env.BLIP_GIPHY_API_KEY || + process.env.GIPHY_API_KEY || + '' + ).trim(); + if (fromEnv) { + cached = fromEnv; + return cached; + } + const paths = [ + join(app.getPath('userData'), 'giphy-api-key.txt'), + join(process.cwd(), 'giphy-api-key.local'), + ]; + for (const p of paths) { + try { + if (existsSync(p)) { + const k = readFileSync(p, 'utf8').trim(); + if (k) { + cached = k; + return cached; + } + } + } catch { + /* ignore */ + } + } + return ''; +} + +export function resetGiphyApiKeyCache() { + cached = ''; +} diff --git a/main/index.js b/main/index.js index 04ae164..dccad18 100644 --- a/main/index.js +++ b/main/index.js @@ -10,6 +10,10 @@ import { createTcpLineReader } from './tcp-framing.js'; import { loadConfig, saveConfig, initConfigPath, getLocalIp, getLocalIpv4Set } from './config.js'; import { toPublicConfig } from './config-public.js'; import { validateMeshPlusActivationKey, isMeshPlusActive } from './mesh-plus-license.js'; +import { + meshPlusClampPatch, + sanitizeMeshPlusConfigUpdates, +} from '../shared/mesh-plus-gates.js'; import { canUseAppIconVariant, normalizeAppIconVariant, @@ -44,6 +48,23 @@ import { clearCustomAvatar, hasCustomAvatar, } from './avatar-store.js'; +import { + clearActiveProfileGif, + getActiveProfileGifId, + getProfileGifDataUrl, + getProfileGifPublicState, + hasActiveProfileGif, + listProfileGifHistory, + saveProfileGifFromBuffer, + saveProfileGifFromDataUrl, + setActiveProfileGif, +} from './profile-gif-store.js'; +import { + downloadGifUrl, + isGiphyConfigured, + searchGiphy, + trendingGiphy, +} from './giphy-client.js'; import { registerGlobalShortcuts, unregisterGlobalShortcuts } from './global-shortcuts.js'; import { listDisplaySources, @@ -602,6 +623,8 @@ function handleTcpPayload(msg, fromBlipId) { case 'file-abort': case 'clipboard-push': case 'avatar-share': + case 'profile-gif-share': + case 'profile-gif-request': sendToRenderer('tcp-message', msg); break; case 'call-offer': { @@ -774,6 +797,11 @@ function setupIpc() { ? id : 'main'; } + const meshActive = isMeshPlusActive({ ...config, ...safe }); + Object.assign( + safe, + sanitizeMeshPlusConfigUpdates(config, safe, meshActive) + ); config = saveConfig(safe); if (safe.appIconVariant !== undefined) refreshAppIcons(); if (updates?.receiveBetaUpdates !== undefined || updates?.autoDownloadUpdates !== undefined) { @@ -833,6 +861,9 @@ function setupIpc() { if (String(config.appIconVariant || '').startsWith('mesh-')) { patch.appIconVariant = 'main'; } + const prefsPatch = meshPlusClampPatch(config); + if (prefsPatch) Object.assign(patch, prefsPatch); + if (prefsPatch?.hasProfileGif === false) clearActiveProfileGif(); config = saveConfig(patch); discovery?.updateConfig(config); discovery?.announce(); @@ -1093,6 +1124,111 @@ function setupIpc() { return { ok: true }; }); + ipcMain.handle('get-profile-gif-active-url', () => getProfileGifDataUrl()); + ipcMain.handle('get-profile-gif-history', () => listProfileGifHistory()); + ipcMain.handle('is-giphy-configured', () => isGiphyConfigured()); + ipcMain.handle('search-giphy', (_, query, offset) => searchGiphy(query, { offset })); + ipcMain.handle('trending-giphy', (_, offset) => trendingGiphy({ offset })); + + ipcMain.handle('save-profile-gif', async (_, dataUrl) => { + if (!isMeshPlusActive(config)) return { ok: false, error: 'mesh_plus_required' }; + try { + const id = saveProfileGifFromDataUrl(dataUrl); + const pub = getProfileGifPublicState(); + config = saveConfig({ + profileGifActiveId: pub.profileGifActiveId, + hasProfileGif: pub.hasProfileGif, + }); + discovery?.announce(); + return { ok: true, id, dataUrl: getProfileGifDataUrl(id) }; + } catch (e) { + return { ok: false, error: e?.message || 'save_failed' }; + } + }); + + ipcMain.handle('save-profile-gif-bytes', async (_, base64) => { + if (!isMeshPlusActive(config)) return { ok: false, error: 'mesh_plus_required' }; + try { + const buf = Buffer.from(String(base64 || ''), 'base64'); + if (!buf.length) return { ok: false, error: 'invalid_gif' }; + const id = saveProfileGifFromBuffer(buf); + const pub = getProfileGifPublicState(); + config = saveConfig({ + profileGifActiveId: pub.profileGifActiveId, + hasProfileGif: pub.hasProfileGif, + }); + discovery?.announce(); + return { ok: true, id, dataUrl: getProfileGifDataUrl(id) }; + } catch (e) { + return { ok: false, error: e?.message || 'save_failed' }; + } + }); + + ipcMain.handle('save-profile-gif-path', async (_, filePath) => { + if (!isMeshPlusActive(config)) return { ok: false, error: 'mesh_plus_required' }; + try { + const p = String(filePath || '').trim(); + if (!p) return { ok: false, error: 'invalid_gif' }; + const buf = readFileSync(p); + const id = saveProfileGifFromBuffer(buf); + const pub = getProfileGifPublicState(); + config = saveConfig({ + profileGifActiveId: pub.profileGifActiveId, + hasProfileGif: pub.hasProfileGif, + }); + discovery?.announce(); + return { ok: true, id, dataUrl: getProfileGifDataUrl(id) }; + } catch (e) { + return { ok: false, error: e?.message || 'save_failed' }; + } + }); + + ipcMain.handle('import-giphy-gif', async (_, gifUrl) => { + try { + const buf = await downloadGifUrl(gifUrl); + const id = saveProfileGifFromBuffer(buf); + const pub = getProfileGifPublicState(); + config = saveConfig({ + profileGifActiveId: pub.profileGifActiveId, + hasProfileGif: pub.hasProfileGif, + }); + discovery?.announce(); + return { ok: true, id, dataUrl: getProfileGifDataUrl(id) }; + } catch (e) { + return { ok: false, error: e?.message || 'import_failed' }; + } + }); + + ipcMain.handle('set-profile-gif-active', (_, id) => { + try { + if (!id) { + clearActiveProfileGif(); + } else { + setActiveProfileGif(id); + } + const pub = getProfileGifPublicState(); + config = saveConfig({ + profileGifActiveId: pub.profileGifActiveId, + hasProfileGif: pub.hasProfileGif, + }); + discovery?.announce(); + return { ok: true, dataUrl: getProfileGifDataUrl() }; + } catch (e) { + return { ok: false, error: e?.message || 'set_failed' }; + } + }); + + ipcMain.handle('clear-profile-gif', () => { + clearActiveProfileGif(); + const pub = getProfileGifPublicState(); + config = saveConfig({ + profileGifActiveId: '', + hasProfileGif: false, + }); + discovery?.announce(); + return { ok: true }; + }); + ipcMain.handle('check-for-updates', () => checkForUpdatesNow(() => config)); ipcMain.handle('quit-and-install', () => { quitAndInstallUpdater(); @@ -1292,6 +1428,20 @@ app.whenReady().then(async () => { initConfigPath(); config = loadConfig(); + const gifPub = getProfileGifPublicState(); + if ( + config.hasProfileGif !== gifPub.hasProfileGif || + config.profileGifActiveId !== gifPub.profileGifActiveId + ) { + config = saveConfig({ + profileGifActiveId: gifPub.profileGifActiveId, + hasProfileGif: gifPub.hasProfileGif, + }); + } + const meshClamp = meshPlusClampPatch(config); + if (meshClamp && !isMeshPlusActive(config)) { + config = saveConfig(meshClamp); + } const hadMeshKeys = !!(config.meshPrivateKey && config.meshPublicKey); config = ensureMeshIdentity(config); if (!hadMeshKeys && config.meshPrivateKey) { diff --git a/main/profile-gif-store.js b/main/profile-gif-store.js new file mode 100644 index 0000000..99c1eee --- /dev/null +++ b/main/profile-gif-store.js @@ -0,0 +1,158 @@ +import { app } from 'electron'; +import { + existsSync, + readFileSync, + writeFileSync, + unlinkSync, + mkdirSync, + readdirSync, +} from 'fs'; +import { join } from 'path'; +import { randomBytes } from 'crypto'; + +const MAX_HISTORY = 5; +/** @type {number} max stored GIF size (bytes) */ +export const MAX_PROFILE_GIF_BYTES = 8 * 1024 * 1024; +const MAX_BYTES = MAX_PROFILE_GIF_BYTES; + +function gifsDir() { + return join(app.getPath('userData'), 'profile-gifs'); +} + +function metaPath() { + return join(app.getPath('userData'), 'profile-gif-meta.json'); +} + +function ensureDirs() { + const dir = gifsDir(); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); +} + +function loadMeta() { + ensureDirs(); + try { + const raw = readFileSync(metaPath(), 'utf8'); + const o = JSON.parse(raw); + const history = Array.isArray(o?.history) + ? o.history.filter((id) => typeof id === 'string').slice(0, MAX_HISTORY) + : []; + const activeId = typeof o?.activeId === 'string' && history.includes(o.activeId) ? o.activeId : ''; + return { activeId, history }; + } catch { + return { activeId: '', history: [] }; + } +} + +function saveMeta(meta) { + ensureDirs(); + const history = (meta.history || []).slice(0, MAX_HISTORY); + let activeId = meta.activeId || ''; + if (activeId && !history.includes(activeId)) activeId = history[0] || ''; + writeFileSync(metaPath(), JSON.stringify({ activeId, history }, null, 2), 'utf8'); + return { activeId, history }; +} + +function filePathForId(id) { + return join(gifsDir(), `${id}.gif`); +} + +function newId() { + return randomBytes(8).toString('hex'); +} + +/** + * @param {string} dataUrl + * @returns {string} id + */ +/** + * @param {Buffer} buf + * @returns {string} + */ +export function saveProfileGifFromBuffer(buf) { + if (!buf || buf.length > MAX_BYTES) throw new Error('gif_too_large'); + ensureDirs(); + const id = newId(); + writeFileSync(filePathForId(id), buf); + const meta = loadMeta(); + const history = [id, ...meta.history.filter((x) => x !== id)].slice(0, MAX_HISTORY); + for (const old of meta.history) { + if (!history.includes(old) && existsSync(filePathForId(old))) { + try { + unlinkSync(filePathForId(old)); + } catch { + /* ignore */ + } + } + } + saveMeta({ activeId: id, history }); + return id; +} + +export function saveProfileGifFromDataUrl(dataUrl) { + const m = String(dataUrl || '').match(/^data:(?:image\/gif|application\/octet-stream);base64,(.+)$/i); + if (!m) throw new Error('invalid_gif'); + const buf = Buffer.from(m[1], 'base64'); + return saveProfileGifFromBuffer(buf); +} + +/** @param {string} id */ +export function setActiveProfileGif(id) { + const meta = loadMeta(); + if (!id) { + return saveMeta({ ...meta, activeId: '' }); + } + if (!meta.history.includes(id) || !existsSync(filePathForId(id))) { + throw new Error('gif_not_found'); + } + return saveMeta({ ...meta, activeId: id }); +} + +export function clearActiveProfileGif() { + const meta = loadMeta(); + return saveMeta({ ...meta, activeId: '' }); +} + +/** @param {string} [id] */ +export function getProfileGifDataUrl(id) { + const meta = loadMeta(); + const useId = id || meta.activeId; + if (!useId) return null; + const p = filePathForId(useId); + if (!existsSync(p)) return null; + try { + const buf = readFileSync(p); + return `data:image/gif;base64,${buf.toString('base64')}`; + } catch { + return null; + } +} + +export function getActiveProfileGifId() { + return loadMeta().activeId; +} + +export function hasActiveProfileGif() { + const { activeId } = loadMeta(); + return !!(activeId && existsSync(filePathForId(activeId))); +} + +/** @returns {{ id: string, dataUrl: string }[]} */ +export function listProfileGifHistory() { + const { history } = loadMeta(); + const out = []; + for (const id of history) { + const dataUrl = getProfileGifDataUrl(id); + if (dataUrl) out.push({ id, dataUrl }); + } + return out; +} + +/** Sync config fields for announce / public config. */ +export function getProfileGifPublicState() { + const meta = loadMeta(); + return { + profileGifActiveId: meta.activeId || '', + profileGifHistory: [...meta.history], + hasProfileGif: hasActiveProfileGif(), + }; +} diff --git a/package.json b/package.json index 3698a61..708a5d1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "blip", - "version": "0.7.10", + "version": "0.10.1", "description": "P2P LAN messenger — text, voice, and video. No cloud, no servers, no accounts.", "author": "krwg", "homepage": "https://krwg.github.io/BLIP/", diff --git a/preload.cjs b/preload.cjs index d33b9cf..e61fcc6 100644 --- a/preload.cjs +++ b/preload.cjs @@ -17,6 +17,17 @@ contextBridge.exposeInMainWorld('blip', { getAvatarDataUrl: () => ipcRenderer.invoke('get-avatar-data-url'), saveAvatar: (dataUrl) => ipcRenderer.invoke('save-avatar', dataUrl), clearAvatar: () => ipcRenderer.invoke('clear-avatar'), + getProfileGifActiveUrl: () => ipcRenderer.invoke('get-profile-gif-active-url'), + getProfileGifHistory: () => ipcRenderer.invoke('get-profile-gif-history'), + saveProfileGif: (dataUrl) => ipcRenderer.invoke('save-profile-gif', dataUrl), + saveProfileGifBytes: (base64) => ipcRenderer.invoke('save-profile-gif-bytes', base64), + saveProfileGifPath: (filePath) => ipcRenderer.invoke('save-profile-gif-path', filePath), + setProfileGifActive: (id) => ipcRenderer.invoke('set-profile-gif-active', id), + clearProfileGif: () => ipcRenderer.invoke('clear-profile-gif'), + isGiphyConfigured: () => ipcRenderer.invoke('is-giphy-configured'), + searchGiphy: (q, offset) => ipcRenderer.invoke('search-giphy', q, offset), + trendingGiphy: (offset) => ipcRenderer.invoke('trending-giphy', offset), + importGiphyGif: (url) => ipcRenderer.invoke('import-giphy-gif', url), openExternal: (url) => ipcRenderer.invoke('open-external', url), listDisplaySources: () => ipcRenderer.invoke('list-display-sources'), prepareDisplayCapture: (sourceId) => ipcRenderer.invoke('prepare-display-capture', sourceId), diff --git a/renderer/achievements-settings-panel.js b/renderer/achievements-settings-panel.js new file mode 100644 index 0000000..ea7242e --- /dev/null +++ b/renderer/achievements-settings-panel.js @@ -0,0 +1,82 @@ +import { t } from './i18n.js'; +import { buildPanelTitleRow, buildSettingsFieldWithHint } from './settings-ui.js'; +import { renderAchievementsGrid } from './achievements.js'; +import { clearAllAchievementUnlocks } from './achievements-store.js'; +import { showAppToast } from './toasts.js'; + +/** + * @param {object} state + * @param {object} api + */ +export function buildSettingsAchievementsPanel(state, api) { + const frag = document.createElement('div'); + frag.className = 'settings-panel settings-panel--achievements'; + + frag.appendChild(buildPanelTitleRow('settings.section_achievements', 'achievements.hint')); + + const enabledWrap = document.createElement('label'); + enabledWrap.className = 'settings-toggle-row'; + const enabledInput = document.createElement('input'); + enabledInput.type = 'checkbox'; + enabledInput.checked = !!state.config.achievementsEnabled; + const enabledLabel = document.createElement('span'); + enabledLabel.dataset.i18n = 'achievements.enabled'; + enabledLabel.textContent = t('achievements.enabled'); + enabledWrap.appendChild(enabledInput); + enabledWrap.appendChild(enabledLabel); + frag.appendChild( + buildSettingsFieldWithHint('achievements.enabled_label', 'achievements.enabled_hint', enabledWrap) + ); + + const notifyWrap = document.createElement('label'); + notifyWrap.className = 'settings-toggle-row'; + const notifyInput = document.createElement('input'); + notifyInput.type = 'checkbox'; + notifyInput.checked = state.config.achievementsNotify !== false; + const notifyLabel = document.createElement('span'); + notifyLabel.dataset.i18n = 'achievements.notify'; + notifyLabel.textContent = t('achievements.notify'); + notifyWrap.appendChild(notifyInput); + notifyWrap.appendChild(notifyLabel); + frag.appendChild( + buildSettingsFieldWithHint('achievements.notify_label', 'achievements.notify_hint', notifyWrap) + ); + + const gridHost = document.createElement('div'); + gridHost.className = 'ach-grid settings-list-panel'; + + const resetBtn = document.createElement('button'); + resetBtn.type = 'button'; + resetBtn.className = 'btn btn-lang'; + resetBtn.dataset.i18n = 'achievements.reset'; + resetBtn.textContent = t('achievements.reset'); + + function refreshGrid() { + const on = !!state.config.achievementsEnabled; + gridHost.classList.toggle('ach-grid--disabled', !on); + notifyWrap.classList.toggle('hidden', !on); + renderAchievementsGrid(gridHost); + } + + enabledInput.addEventListener('change', async () => { + state.config = await api.saveConfig({ achievementsEnabled: enabledInput.checked }); + refreshGrid(); + }); + + notifyInput.addEventListener('change', async () => { + state.config = await api.saveConfig({ achievementsNotify: notifyInput.checked }); + }); + + resetBtn.addEventListener('click', () => { + if (!confirm(t('achievements.reset_confirm'))) return; + clearAllAchievementUnlocks(); + refreshGrid(); + showAppToast({ title: t('achievements.reset_ok'), durationMs: 3000 }); + }); + + frag.appendChild(gridHost); + frag.appendChild(resetBtn); + refreshGrid(); + + return frag; +} diff --git a/renderer/achievements-store.js b/renderer/achievements-store.js new file mode 100644 index 0000000..94d7556 --- /dev/null +++ b/renderer/achievements-store.js @@ -0,0 +1,44 @@ +const STORAGE_KEY = 'blip_ach_unlocked_v1'; + +function loadSet() { + try { + const raw = localStorage.getItem(STORAGE_KEY); + const arr = raw ? JSON.parse(raw) : []; + return new Set(Array.isArray(arr) ? arr.filter((id) => typeof id === 'string') : []); + } catch { + return new Set(); + } +} + +function saveSet(set) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify([...set])); + } catch { + /* quota */ + } +} + +/** @param {string} id */ +export function isAchievementUnlocked(id) { + return loadSet().has(id); +} + +/** + * @param {string} id + * @returns {boolean} true if newly unlocked + */ +export function unlockAchievement(id) { + const set = loadSet(); + if (set.has(id)) return false; + set.add(id); + saveSet(set); + return true; +} + +export function clearAllAchievementUnlocks() { + try { + localStorage.removeItem(STORAGE_KEY); + } catch { + /* ignore */ + } +} diff --git a/renderer/achievements-tracker.js b/renderer/achievements-tracker.js new file mode 100644 index 0000000..637ed7f --- /dev/null +++ b/renderer/achievements-tracker.js @@ -0,0 +1,31 @@ +import { ACHIEVEMENT_DEFS } from './achievements.js'; +import { getSessionStats } from './session-stats.js'; +import { isAchievementUnlocked, unlockAchievement } from './achievements-store.js'; +import { t } from './i18n.js'; +import { showAppToast } from './toasts.js'; + +/** + * @param {object} [config] + * @returns {import('./achievements.js').AchievementDef[]} + */ +export function syncAchievements(config) { + if (!config?.achievementsEnabled) return []; + const stats = getSessionStats(); + const newly = []; + for (const def of ACHIEVEMENT_DEFS) { + if (!def.check(stats)) continue; + if (isAchievementUnlocked(def.id)) continue; + if (unlockAchievement(def.id)) newly.push(def); + } + if (newly.length && config.achievementsNotify !== false) { + for (const def of newly) { + showAppToast({ + title: t('achievements.unlocked_title'), + body: t(def.titleKey), + variant: 'accent', + durationMs: 5500, + }); + } + } + return newly; +} diff --git a/renderer/achievements.js b/renderer/achievements.js new file mode 100644 index 0000000..72bbd1c --- /dev/null +++ b/renderer/achievements.js @@ -0,0 +1,126 @@ +import { t } from './i18n.js'; +import { getSessionStats, sessionOnlineHours } from './session-stats.js'; +import { isAchievementUnlocked } from './achievements-store.js'; + +/** + * @typedef {{ id: string, titleKey: string, descKey: string, iconAsset: string, check: (s: object) => boolean }} AchievementDef + */ + +/** Icon paths — fill with your assets (e.g. `achievements/first_peer.png`). */ +/** @type {AchievementDef[]} */ +export const ACHIEVEMENT_DEFS = [ + { + id: 'first_peer', + titleKey: 'achievements.first_peer', + descKey: 'achievements.first_peer_desc', + iconAsset: '', + check: (s) => (s.peersMaxOnline || 0) >= 1, + }, + { + id: 'mesh_5', + titleKey: 'achievements.mesh_5', + descKey: 'achievements.mesh_5_desc', + iconAsset: '', + check: (s) => (s.peersMaxOnline || 0) >= 5, + }, + { + id: 'messages_50', + titleKey: 'achievements.messages_50', + descKey: 'achievements.messages_50_desc', + iconAsset: '', + check: (s) => (s.messagesSent || 0) >= 50, + }, + { + id: 'messages_100', + titleKey: 'achievements.messages_100', + descKey: 'achievements.messages_100_desc', + iconAsset: '', + check: (s) => (s.messagesSent || 0) >= 100, + }, + { + id: 'first_call', + titleKey: 'achievements.first_call', + descKey: 'achievements.first_call_desc', + iconAsset: '', + check: (s) => (s.callsStarted || 0) >= 1, + }, + { + id: 'first_file', + titleKey: 'achievements.first_file', + descKey: 'achievements.first_file_desc', + iconAsset: '', + check: (s) => (s.filesSent || 0) >= 1, + }, + { + id: 'online_1h', + titleKey: 'achievements.online_1h', + descKey: 'achievements.online_1h_desc', + iconAsset: '', + check: () => sessionOnlineHours() >= 1, + }, +]; + +/** + * @returns {{ def: AchievementDef, unlocked: boolean, progressMet: boolean }[]} + */ +export function getAchievementStates() { + const stats = getSessionStats(); + return ACHIEVEMENT_DEFS.map((def) => { + const progressMet = !!def.check(stats); + return { + def, + progressMet, + unlocked: isAchievementUnlocked(def.id) || progressMet, + }; + }); +} + +/** + * @param {HTMLElement} root + */ +export function renderAchievementsGrid(root) { + root.innerHTML = ''; + for (const { def, unlocked, progressMet } of getAchievementStates()) { + const card = document.createElement('div'); + card.className = `ach-card${unlocked ? ' ach-card--unlocked' : ''}${ + progressMet && !unlocked ? ' ach-card--ready' : '' + }`; + card.dataset.achId = def.id; + + const iconWrap = document.createElement('div'); + iconWrap.className = 'ach-card__icon'; + if (def.iconAsset) { + const img = document.createElement('img'); + img.className = 'ach-card__icon-img'; + img.src = def.iconAsset; + img.alt = ''; + iconWrap.appendChild(img); + } else { + const placeholder = document.createElement('span'); + placeholder.className = 'ach-card__icon-placeholder'; + placeholder.textContent = '◆'; + placeholder.title = t('achievements.icon_placeholder'); + iconWrap.appendChild(placeholder); + } + + const body = document.createElement('div'); + body.className = 'ach-card__body'; + const title = document.createElement('div'); + title.className = 'ach-card__title'; + title.textContent = t(def.titleKey); + const desc = document.createElement('div'); + desc.className = 'ach-card__desc'; + desc.textContent = t(def.descKey); + body.appendChild(title); + body.appendChild(desc); + + const mark = document.createElement('div'); + mark.className = 'ach-card__mark'; + mark.textContent = unlocked ? '✓' : progressMet ? '…' : ''; + + card.appendChild(iconWrap); + card.appendChild(body); + card.appendChild(mark); + root.appendChild(card); + } +} diff --git a/renderer/app-icon-picker.js b/renderer/app-icon-picker.js index a885d57..c47e7ee 100644 --- a/renderer/app-icon-picker.js +++ b/renderer/app-icon-picker.js @@ -6,7 +6,48 @@ import { buildSectionSubtitleRow } from './settings-ui.js'; const FREE_ICON_IDS = ['main', 'dop-1', 'dop-2', 'dop-3', 'dop-4']; const MESH_ICON_IDS = ['mesh-1', 'mesh-2', 'mesh-3', 'mesh-4', 'mesh-5', 'mesh-6']; -const ICON_PREVIEW_PX = 80; +const ICON_PREVIEW_PX = 96; +const ICON_LIGHTBOX_PX = 256; + +/** + * @param {string} src + * @param {string} [label] + */ +function openAppIconPreview(src, label = '') { + if (!src) return; + const backdrop = document.createElement('div'); + backdrop.className = 'blip-modal-backdrop app-icon-preview-backdrop'; + + const box = document.createElement('div'); + box.className = 'app-icon-preview-box glass'; + box.setAttribute('role', 'dialog'); + box.setAttribute('aria-modal', 'true'); + if (label) box.setAttribute('aria-label', label); + + const img = document.createElement('img'); + img.className = 'app-icon-preview-img'; + img.src = src; + img.alt = label; + img.width = ICON_LIGHTBOX_PX; + img.height = ICON_LIGHTBOX_PX; + + function close() { + backdrop.remove(); + document.removeEventListener('keydown', onKey); + } + function onKey(e) { + if (e.key === 'Escape') close(); + } + + backdrop.addEventListener('click', (e) => { + if (e.target === backdrop) close(); + }); + document.addEventListener('keydown', onKey); + + box.appendChild(img); + backdrop.appendChild(box); + document.body.appendChild(backdrop); +} /** * @param {object} state @@ -14,7 +55,7 @@ const ICON_PREVIEW_PX = 80; */ export function appendAppIconPickerSections(block, state, saveConfig) { block.appendChild( - buildSectionSubtitleRow('settings.app_icon_free_title', 'settings.app_icon_free_hint') + buildSectionSubtitleRow('settings.app_icon_free_title', 'settings.app_icon_preview_hint') ); const freeGrid = document.createElement('div'); @@ -61,6 +102,11 @@ export function appendAppIconPickerSections(block, state, saveConfig) { btn.appendChild(lock); } + btn.addEventListener('contextmenu', (e) => { + e.preventDefault(); + if (meta?.previewUrl) openAppIconPreview(meta.previewUrl, id); + }); + btn.addEventListener('click', async () => { if (tier === 'mesh_plus' && !isMeshPlusActive(state.config)) { showAppToast({ diff --git a/renderer/appearance.js b/renderer/appearance.js index a6f4995..34247d7 100644 --- a/renderer/appearance.js +++ b/renderer/appearance.js @@ -3,6 +3,7 @@ */ import { t } from './i18n.js'; +import { isMeshPlusTierActive } from '../shared/mesh-plus-gates.js'; export const THEME_MODES = ['light', 'dark', 'auto']; @@ -126,6 +127,32 @@ export function labelBg(id) { return label === key ? id : label; } +/** + * @param {string} raw + * @returns {string} #rrggbb or '' + */ +export function normalizeCustomAccentHex(raw) { + const s = String(raw || '').trim(); + if (/^#[0-9A-Fa-f]{6}$/.test(s)) return s.toLowerCase(); + if (/^[0-9A-Fa-f]{6}$/.test(s)) return `#${s.toLowerCase()}`; + return ''; +} + +function applyCustomAccentVars(html, config) { + const hex = normalizeCustomAccentHex(config?.accentCustomHex); + if (isMeshPlusTierActive(config) && hex) { + html.style.setProperty('--blip-accent', hex); + html.style.setProperty('--blip-glass-border', hex); + html.style.setProperty('--blip-caret', hex); + html.dataset.accentCustom = '1'; + } else { + html.style.removeProperty('--blip-accent'); + html.style.removeProperty('--blip-glass-border'); + html.style.removeProperty('--blip-caret'); + delete html.dataset.accentCustom; + } +} + export function applyAppearance(config) { const html = document.documentElement; const mode = normalizeThemeMode(config?.themeMode, config?.themeId); @@ -140,6 +167,7 @@ export function applyAppearance(config) { delete html.dataset.callWindow; html.dataset.reactiveBg = config?.reactiveBackground === true && bg !== 'none' ? '1' : '0'; + applyCustomAccentVars(html, config); syncReducedMotion(config); } @@ -153,6 +181,7 @@ export function applyCallWindowAppearance(config) { html.dataset.animatedBg = 'none'; html.dataset.callWindow = '1'; html.dataset.reactiveBg = '0'; + applyCustomAccentVars(html, config); syncReducedMotion(config); } diff --git a/renderer/chat-export.js b/renderer/chat-export.js index 8f9ff0c..96c8c7f 100644 --- a/renderer/chat-export.js +++ b/renderer/chat-export.js @@ -1,4 +1,6 @@ import { getMessages } from './chat.js'; +import { normalizeCustomAccentHex } from './appearance.js'; +import { isMeshPlusTierActive } from '../shared/mesh-plus-gates.js'; function downloadBlob(blob, filename) { const url = URL.createObjectURL(blob); @@ -38,6 +40,12 @@ function sanitizeMessageForExport(m) { return row; } +function resolveThemedAccent(config) { + const custom = normalizeCustomAccentHex(config?.accentCustomHex); + if (isMeshPlusTierActive(config) && custom) return custom; + return '#00ffc8'; +} + export function buildChatExportPayload(peerId, displayName) { const msgs = getMessages(peerId); return { @@ -55,7 +63,7 @@ export function exportPeerChatJson(peerId, displayName) { downloadBlob(new Blob([json], { type: 'application/json;charset=utf-8' }), `blip-${peerId}-chat.json`); } -function formatLineForPdf(m, label) { +function formatLineForExport(m, label) { const who = m.outgoing ? 'You' : label; const time = new Date(m.timestamp || Date.now()).toLocaleString(); let body = m.text || ''; @@ -65,10 +73,27 @@ function formatLineForPdf(m, label) { return `[${time}] ${who}: ${body}`; } -export async function exportPeerChatPdf(peerId, displayName) { +function hexToRgb(hex) { + const h = String(hex || '').replace('#', ''); + if (h.length !== 6) return { r: 0, g: 255, b: 200 }; + return { + r: parseInt(h.slice(0, 2), 16), + g: parseInt(h.slice(2, 4), 16), + b: parseInt(h.slice(4, 6), 16), + }; +} + +/** + * @param {number} peerId + * @param {string} displayName + * @param {{ themed?: boolean, config?: object }} [opts] + */ +export async function exportPeerChatPdf(peerId, displayName, opts = {}) { const { jsPDF } = await import('jspdf'); const msgs = getMessages(peerId); const label = displayName || `BLIP-${peerId}`; + const themed = !!opts.themed; + const accent = themed ? resolveThemedAccent(opts.config) : null; const doc = new jsPDF({ unit: 'pt', format: 'a4' }); const margin = 48; const pageW = doc.internal.pageSize.getWidth(); @@ -76,23 +101,55 @@ export async function exportPeerChatPdf(peerId, displayName) { let y = margin; const lineH = 14; + if (themed && accent) { + const { r, g, b } = hexToRgb(accent); + doc.setFillColor(12, 14, 18); + doc.rect(0, 0, pageW, doc.internal.pageSize.getHeight(), 'F'); + doc.setFillColor(r, g, b); + doc.rect(0, 0, pageW, 56, 'F'); + doc.setTextColor(255, 255, 255); + doc.setFont('courier', 'bold'); + doc.setFontSize(14); + doc.text('BLIP', margin, 34); + doc.setFont('courier', 'normal'); + doc.setFontSize(10); + doc.text(`SIGNAL LOG · #${peerId}`, margin + 52, 34); + y = 72; + doc.setTextColor(200, 220, 210); + } else { + doc.setFont('courier', 'normal'); + doc.setFontSize(10); + doc.setTextColor(0, 0, 0); + doc.text(`BLIP chat export — ${label} (#${peerId})`, margin, y); + y += lineH * 2; + doc.setFontSize(9); + doc.text(`Exported: ${new Date().toLocaleString()}`, margin, y); + y += lineH * 2; + } + doc.setFont('courier', 'normal'); - doc.setFontSize(10); - doc.text(`BLIP chat export — ${label} (#${peerId})`, margin, y); - y += lineH * 2; doc.setFontSize(9); - doc.text(`Exported: ${new Date().toLocaleString()}`, margin, y); - y += lineH * 2; + if (themed) { + doc.text(`Peer: ${label} · Exported: ${new Date().toLocaleString()}`, margin, y); + y += lineH * 2; + if (accent) { + const { r, g, b } = hexToRgb(accent); + doc.setTextColor(r, g, b); + } + } - const lines = msgs.length - ? msgs.map((m) => formatLineForPdf(m, label)) - : ['(no messages)']; + const lines = msgs.length ? msgs.map((m) => formatLineForExport(m, label)) : ['(no messages)']; for (const line of lines) { const wrapped = doc.splitTextToSize(line, maxW); for (const wl of wrapped) { if (y > doc.internal.pageSize.getHeight() - margin) { doc.addPage(); + if (themed) { + doc.setFillColor(12, 14, 18); + doc.rect(0, 0, pageW, doc.internal.pageSize.getHeight(), 'F'); + doc.setTextColor(200, 220, 210); + } y = margin; } doc.text(wl, margin, y); @@ -100,5 +157,56 @@ export async function exportPeerChatPdf(peerId, displayName) { } } - doc.save(`blip-${peerId}-chat.pdf`); + const suffix = themed ? '-themed' : ''; + doc.save(`blip-${peerId}-chat${suffix}.pdf`); +} + +/** + * @param {number} peerId + * @param {string} displayName + * @param {{ config?: object }} [opts] + */ +export function exportPeerChatHtml(peerId, displayName, opts = {}) { + const payload = buildChatExportPayload(peerId, displayName); + const accent = resolveThemedAccent(opts.config); + const label = displayName || `BLIP-${peerId}`; + const rows = payload.messages + .map((m) => { + const who = m.outgoing ? 'You' : label; + const time = new Date(m.timestamp || Date.now()).toLocaleString(); + let body = m.text || ''; + if (m.attachment?.kind === 'image') body = `[IMG] ${body}`; + else if (m.attachment?.kind === 'file') body = `[FILE] ${m.attachment.name || ''} ${body}`; + const esc = (s) => + String(s) + .replace(/&/g, '&') + .replace(//g, '>'); + return `

      ${esc(time)} · ${esc(who)}

      ${esc(body)}

      `; + }) + .join('\n'); + + const html = ` + + + +BLIP #${peerId} — ${label} + + + +

      BLIP

      Signal log · #${peerId} · ${label}

      +
      ${rows || '

      (no messages)

      '}
      + +`; + downloadBlob(new Blob([html], { type: 'text/html;charset=utf-8' }), `blip-${peerId}-chat-themed.html`); } diff --git a/renderer/chat.js b/renderer/chat.js index b5db671..54cfcba 100644 --- a/renderer/chat.js +++ b/renderer/chat.js @@ -21,7 +21,9 @@ function formatFileLimitLabelForChat(config) { } import { createMessageId } from './message-id.js'; import { getPinnedMessageId, setPinnedMessageId } from './chat-pins.js'; -import { exportPeerChatJson, exportPeerChatPdf } from './chat-export.js'; +import { exportPeerChatJson, exportPeerChatPdf, exportPeerChatHtml } from './chat-export.js'; +import { isMeshPlusActive, showMeshPlusLockedToast } from './mesh-plus.js'; +import { recordMessageSent } from './session-stats.js'; const STORAGE_KEY = 'blip_chat_v1'; const MAX_PER_PEER = 500; @@ -149,7 +151,8 @@ export function createChatView( onSendFile, onPeerMenu, onPin, - onEdit + onEdit, + onPeerProfile ) { registerMediaPlaceholder(t('chat.image_sent')); registerMediaPlaceholder(t('chat.file_sent')); @@ -198,6 +201,19 @@ export function createChatView( meta.classList.add('chat-peer-meta--menu'); } + if (onPeerProfile) { + const openProfile = (e) => { + e.preventDefault(); + e.stopPropagation(); + onPeerProfile(peerId); + }; + avatarMount.style.cursor = 'pointer'; + avatarMount.title = t('peers.profile_open'); + avatarMount.addEventListener('click', openProfile); + name.style.cursor = 'pointer'; + name.addEventListener('click', openProfile); + } + const headActions = document.createElement("div"); headActions.className = 'chat-header-actions'; @@ -253,7 +269,35 @@ export function createChatView( exportPdfItem.textContent = t('chat.export_pdf'); exportPdfItem.addEventListener('click', () => { menu.classList.add('hidden'); - void exportPeerChatPdf(peerId, name.textContent); + void exportPeerChatPdf(peerId, name.textContent, { themed: false }); + }); + + const exportPdfThemedItem = document.createElement('button'); + exportPdfThemedItem.type = 'button'; + exportPdfThemedItem.className = 'chat-menu-item'; + exportPdfThemedItem.dataset.i18n = 'chat.export_pdf_themed'; + exportPdfThemedItem.textContent = `${t('chat.export_pdf_themed')}${isMeshPlusActive(getConfig()) ? '' : ' ◆'}`; + exportPdfThemedItem.addEventListener('click', () => { + menu.classList.add('hidden'); + if (!isMeshPlusActive(getConfig())) { + showMeshPlusLockedToast(); + return; + } + void exportPeerChatPdf(peerId, name.textContent, { themed: true, config: getConfig() }); + }); + + const exportHtmlItem = document.createElement('button'); + exportHtmlItem.type = 'button'; + exportHtmlItem.className = 'chat-menu-item'; + exportHtmlItem.dataset.i18n = 'chat.export_html_themed'; + exportHtmlItem.textContent = `${t('chat.export_html_themed')}${isMeshPlusActive(getConfig()) ? '' : ' ◆'}`; + exportHtmlItem.addEventListener('click', () => { + menu.classList.add('hidden'); + if (!isMeshPlusActive(getConfig())) { + showMeshPlusLockedToast(); + return; + } + exportPeerChatHtml(peerId, name.textContent, { config: getConfig() }); }); const clearItem = document.createElement('button'); @@ -271,6 +315,8 @@ export function createChatView( menu.appendChild(exportItem); menu.appendChild(exportJsonItem); menu.appendChild(exportPdfItem); + menu.appendChild(exportPdfThemedItem); + menu.appendChild(exportHtmlItem); menu.appendChild(clearItem); menuWrap.appendChild(menuBtn); menuWrap.appendChild(menu); @@ -492,6 +538,7 @@ export function createChatView( }; clearReplyTarget(); addMessage(peerId, msg); + recordMessageSent(); renderMessages(); sounds.messageSent(); const result = await onSend?.(peerId, msg); diff --git a/renderer/gif-crop-dialog.js b/renderer/gif-crop-dialog.js new file mode 100644 index 0000000..843627a --- /dev/null +++ b/renderer/gif-crop-dialog.js @@ -0,0 +1,166 @@ +import { t } from './i18n.js'; +import { computeGifFramePx } from './gif-frame-size.js'; + +const CROP_MAX_W = 400; +const CROP_MAX_H = 225; + +/** + * Preview positioning for profile GIF cloud. Returns true if user confirmed. + * @param {File} file + * @returns {Promise} + */ +export function openGifCropDialog(file) { + const isGif = + file.type === 'image/gif' || /\.gif$/i.test(file.name || ''); + if (!isGif) return Promise.resolve(false); + + return new Promise((resolve) => { + const objectUrl = URL.createObjectURL(file); + const img = new Image(); + img.onerror = () => { + URL.revokeObjectURL(objectUrl); + resolve(false); + }; + img.onload = () => { + const { w: VIEW_W, h: VIEW_H } = computeGifFramePx( + img.width, + img.height, + CROP_MAX_W, + CROP_MAX_H + ); + + let scale = Math.max(VIEW_W / img.width, VIEW_H / img.height); + let offsetX = (VIEW_W - img.width * scale) / 2; + let offsetY = (VIEW_H - img.height * scale) / 2; + let dragging = false; + let dragStart = { x: 0, y: 0, ox: 0, oy: 0 }; + + const backdrop = document.createElement('div'); + backdrop.className = 'blip-modal-backdrop avatar-crop-backdrop'; + + const modal = document.createElement('div'); + modal.className = 'blip-modal glass avatar-crop-modal gif-crop-modal'; + + const title = document.createElement('h3'); + title.className = 'blip-modal-title'; + title.dataset.i18n = 'settings.profile_gif_crop_title'; + title.textContent = t('settings.profile_gif_crop_title'); + + const frame = document.createElement('div'); + frame.className = 'avatar-crop-frame gif-crop-frame'; + frame.style.width = `${VIEW_W}px`; + frame.style.height = `${VIEW_H}px`; + + const canvas = document.createElement('canvas'); + canvas.className = 'avatar-crop-canvas gif-crop-canvas'; + canvas.width = VIEW_W; + canvas.height = VIEW_H; + canvas.style.width = `${VIEW_W}px`; + canvas.style.height = `${VIEW_H}px`; + + const overlay = document.createElement('div'); + overlay.className = 'avatar-crop-overlay gif-crop-overlay'; + overlay.setAttribute('aria-hidden', 'true'); + + frame.appendChild(canvas); + frame.appendChild(overlay); + + const zoomLabel = document.createElement('label'); + zoomLabel.className = 'settings-field-label'; + zoomLabel.dataset.i18n = 'settings.avatar_crop_zoom'; + zoomLabel.textContent = t('settings.avatar_crop_zoom'); + const zoomRow = document.createElement('div'); + zoomRow.className = 'avatar-crop-zoom-row'; + const zoom = document.createElement('input'); + zoom.type = 'range'; + zoom.min = '100'; + zoom.max = '300'; + zoom.value = '100'; + zoomRow.appendChild(zoom); + + const ctx = canvas.getContext('2d'); + ctx.imageSmoothingEnabled = false; + const baseScale = scale; + + function clampOffsets() { + const w = img.width * scale; + const h = img.height * scale; + offsetX = Math.min(0, Math.max(VIEW_W - w, offsetX)); + offsetY = Math.min(0, Math.max(VIEW_H - h, offsetY)); + } + + function draw() { + ctx.clearRect(0, 0, VIEW_W, VIEW_H); + ctx.drawImage(img, offsetX, offsetY, img.width * scale, img.height * scale); + } + + draw(); + + canvas.addEventListener('mousedown', (e) => { + dragging = true; + dragStart = { x: e.clientX, y: e.clientY, ox: offsetX, oy: offsetY }; + }); + window.addEventListener('mousemove', (e) => { + if (!dragging) return; + offsetX = dragStart.ox + (e.clientX - dragStart.x); + offsetY = dragStart.oy + (e.clientY - dragStart.y); + clampOffsets(); + draw(); + }); + window.addEventListener('mouseup', () => { + dragging = false; + }); + + zoom.addEventListener('input', () => { + const centerX = offsetX + (img.width * scale) / 2; + const centerY = offsetY + (img.height * scale) / 2; + scale = baseScale * (Number(zoom.value) / 100); + offsetX = centerX - (img.width * scale) / 2; + offsetY = centerY - (img.height * scale) / 2; + clampOffsets(); + draw(); + }); + + const actions = document.createElement('div'); + actions.className = 'blip-modal-actions'; + + const cancelBtn = document.createElement('button'); + cancelBtn.type = 'button'; + cancelBtn.className = 'btn btn-lang'; + cancelBtn.textContent = t('dialog.cancel'); + + const okBtn = document.createElement('button'); + okBtn.type = 'button'; + okBtn.className = 'btn btn-accent'; + okBtn.textContent = t('dialog.confirm'); + + let done = false; + function finish(confirmed) { + if (done) return; + done = true; + URL.revokeObjectURL(objectUrl); + backdrop.remove(); + resolve(!!confirmed); + } + + cancelBtn.addEventListener('click', () => finish(false)); + backdrop.addEventListener('click', (e) => { + if (e.target === backdrop) finish(false); + }); + + okBtn.addEventListener('click', () => finish(true)); + + actions.appendChild(cancelBtn); + actions.appendChild(okBtn); + modal.appendChild(title); + modal.appendChild(frame); + modal.appendChild(zoomLabel); + modal.appendChild(zoomRow); + modal.appendChild(actions); + backdrop.appendChild(modal); + document.body.appendChild(backdrop); + okBtn.focus(); + }; + img.src = objectUrl; + }); +} diff --git a/renderer/gif-frame-size.js b/renderer/gif-frame-size.js new file mode 100644 index 0000000..12540e3 --- /dev/null +++ b/renderer/gif-frame-size.js @@ -0,0 +1,36 @@ +/** Horizontal frame: default 16:9, minimum width:height 4:3. */ +export const GIF_FRAME_MAX_AR = 16 / 9; +export const GIF_FRAME_MIN_AR = 4 / 3; + +/** + * @param {number} imgWidth + * @param {number} imgHeight + * @returns {number} width / height + */ +export function clampGifAspectRatio(imgWidth, imgHeight) { + const w = Math.max(1, imgWidth); + const h = Math.max(1, imgHeight); + const ar = w / h; + return Math.min(GIF_FRAME_MAX_AR, Math.max(GIF_FRAME_MIN_AR, ar)); +} + +/** + * Fit a horizontal GIF frame inside max bounds. + * @param {number} imgWidth + * @param {number} imgHeight + * @param {number} maxW + * @param {number} maxH + * @returns {{ w: number, h: number, ar: number }} + */ +export function computeGifFramePx(imgWidth, imgHeight, maxW, maxH) { + const ar = clampGifAspectRatio(imgWidth, imgHeight); + let w = maxW; + let h = Math.round(w / ar); + if (h > maxH) { + h = maxH; + w = Math.round(h * ar); + } + w = Math.max(1, w); + h = Math.max(1, h); + return { w, h, ar }; +} diff --git a/renderer/grid.js b/renderer/grid.js index 3bd5416..753ce13 100644 --- a/renderer/grid.js +++ b/renderer/grid.js @@ -1,6 +1,6 @@ import { t } from './i18n.js'; -export function createIdGrid({ occupiedIds = [], selectedId = null, onSelect }) { +export function createIdGrid({ occupiedIds = [], reservedIds = [], selectedId = null, onSelect }) { const container = document.createElement('div'); container.className = 'id-grid-wrap'; @@ -11,14 +11,20 @@ export function createIdGrid({ occupiedIds = [], selectedId = null, onSelect }) const hint = document.createElement('p'); hint.className = 'hint'; - hint.dataset.i18n = 'grid.hint'; - hint.textContent = t('grid.hint'); + if (reserved.size > 0) { + hint.dataset.i18n = 'grid.hint_reserved'; + hint.textContent = t('grid.hint_reserved'); + } else { + hint.dataset.i18n = 'grid.hint'; + hint.textContent = t('grid.hint'); + } const grid = document.createElement('div'); grid.className = 'id-grid'; grid.setAttribute('role', 'grid'); const occupied = new Set(occupiedIds); + const reserved = new Set(reservedIds); let pending = selectedId; for (let n = 1; n <= 64; n++) { @@ -43,6 +49,8 @@ export function createIdGrid({ occupiedIds = [], selectedId = null, onSelect }) cell.appendChild(cross); } else if (n === selectedId) { cell.classList.add('selected'); + } else if (reserved.has(n)) { + cell.classList.add('reserved'); } cell.addEventListener('click', () => { diff --git a/renderer/group-projects-store.js b/renderer/group-projects-store.js index 166cdff..8ee6422 100644 --- a/renderer/group-projects-store.js +++ b/renderer/group-projects-store.js @@ -13,7 +13,13 @@ const clips = new Map(); const CANVAS_W = 32; const CANVAS_H = 16; -const CLIP_MAX = 20; +export const CLIP_MAX_FREE = 20; +/** Practical cap for MESH+ clipboard board (effectively unlimited vs FREE). */ +export const CLIP_MAX_MESH_PLUS = 500; + +export function clipLimitForTier(meshPlusActive) { + return meshPlusActive ? CLIP_MAX_MESH_PLUS : CLIP_MAX_FREE; +} function padKey(groupId) { return String(groupId); @@ -63,14 +69,16 @@ export function getClipState(groupId) { return clips.get(k); } -export function pushClipEntry(groupId, entry) { +export function pushClipEntry(groupId, entry, maxEntries = CLIP_MAX_FREE) { const st = getClipState(groupId); - st.entries = [entry, ...st.entries].slice(0, CLIP_MAX); + const cap = Math.max(1, maxEntries); + st.entries = [entry, ...st.entries].slice(0, cap); emit(groupId, 'clipboard'); } -export function mergeClipEntries(groupId, entries) { +export function mergeClipEntries(groupId, entries, maxEntries = CLIP_MAX_FREE) { const st = getClipState(groupId); + const cap = Math.max(1, maxEntries); const seen = new Set(st.entries.map((e) => e.id)); for (const e of entries || []) { if (!e?.id || seen.has(e.id)) continue; @@ -78,7 +86,7 @@ export function mergeClipEntries(groupId, entries) { st.entries.push(e); } st.entries.sort((a, b) => b.ts - a.ts); - st.entries = st.entries.slice(0, CLIP_MAX); + st.entries = st.entries.slice(0, cap); emit(groupId, 'clipboard'); } diff --git a/renderer/i18n.js b/renderer/i18n.js index d394987..eab0ff9 100644 --- a/renderer/i18n.js +++ b/renderer/i18n.js @@ -4,6 +4,7 @@ const locales = { 'app.slogan': "You're on the grid. You're the signal.", 'grid.title': 'SELECT YOUR BLIP ID', 'grid.hint': 'Choose a free number. 8×8. 1–64.', + 'grid.hint_reserved': 'Choose a free number. Bookmarked slots are highlighted (MESH+).', 'grid.occupied': 'Occupied', 'grid.free': 'Free', 'grid.yours': 'Yours', @@ -116,7 +117,7 @@ const locales = { 'Click an online peer to open chat. Right-click for Mesh label (local nickname), ping, copy BLIP ID, favorite, or block. Offline peers are dimmed.', 'peers.typing': 'typing…', 'peers.pulse_ms': 'Pulse · {ms} ms', - 'peers.pulse_pending': 'Pulse · measuring…', + 'peers.pulse_pending': 'Pulse · measuring', 'peers.pulse_offline': 'Pulse · offline', 'peers.mesh_hint': 'Right-click a peer → Mesh label: local nickname (only on your device).', 'peers.mesh_label': 'Mesh label…', @@ -130,6 +131,22 @@ const locales = { 'peers.ping_ok': 'Peer reachable', 'peers.ping_ok_body': 'Round-trip {ms} ms', 'peers.ping_fail': 'Ping failed', + 'peers.profile': 'Profile', + 'peers.profile_title': 'Profile', + 'peers.profile_back': 'Back', + 'peers.profile_status': 'Status', + 'peers.profile_pulse': 'Mesh Pulse', + 'peers.profile_banner_future': 'Profile banner — coming later', + 'peers.profile_note_title': 'Note (only you can see this)', + 'peers.profile_note_placeholder': 'Click to add a note', + 'peers.profile_open': 'Open profile', + 'peers.profile_message': 'Message', + 'peers.profile_call': 'Call', + 'peers.profile_block': 'Block', + 'peers.profile_unblock': 'Unblock', + 'peers.profile_mesh_label': 'Mesh label', + 'peers.profile_display_name': 'LAN name', + 'peers.profile_latency': 'Mesh Pulse · {ms} ms', 'peers.copy_id': 'Copy BLIP ID', 'peers.copy_id_done': 'ID copied', 'peers.block': 'Block locally', @@ -243,6 +260,7 @@ const locales = { 'settings.appearance_theme': 'Theme', 'settings.appearance_accent': 'Color accent', 'settings.bg_animated': 'Animated background', + 'settings.bg_animated_mesh_hint': 'Ember and Rift scenes require MESH+.', 'settings.bg_art': 'Art background', 'appearance.mode.light': 'Light', 'appearance.mode.dark': 'Dark', @@ -341,6 +359,27 @@ const locales = { 'settings.avatar_error_decode': 'Could not read this image. Try another file.', 'settings.avatar_error_output': 'Image is still too large after processing. Try a smaller picture.', 'settings.section_profile': 'Profile', + 'settings.section_achievements': 'Achievements', + 'settings.profile_editor_hint': 'Edit your profile and preview how peers see you on the LAN.', + 'settings.profile_editor_title': 'Edit', + 'settings.profile_preview_title': 'As others see you', + 'settings.profile_gif_pick': 'Status GIF', + 'settings.profile_gif_picker_title': 'Profile status GIF', + 'settings.profile_gif_tab_mine': 'Mine', + 'settings.profile_gif_tab_online': 'Giphy', + 'settings.profile_gif_upload': 'Upload GIF', + 'settings.profile_gif_clear': 'Remove GIF', + 'settings.profile_gif_empty': 'No GIFs yet — upload or pick from Giphy.', + 'settings.profile_gif_search': 'Search GIFs…', + 'settings.profile_gif_search_btn': 'Search', + 'settings.profile_gif_online_empty': 'Nothing found. Try another query.', + 'settings.profile_gif_giphy_unavailable': + 'Online GIF catalog is temporarily unavailable. Try again later or upload your own.', + 'settings.profile_gif_saved': 'Status GIF saved', + 'settings.profile_gif_invalid': 'Choose a GIF file.', + 'settings.profile_gif_too_large': 'GIF is too large (max 8 MB).', + 'settings.profile_gif_save_failed': 'Could not save GIF. Try another file.', + 'settings.profile_gif_crop_title': 'Status GIF', 'settings.section_mesh_plus': 'MESH+', 'mesh_plus.intro_hint': 'Extended mesh features — LAN superpowers, premium look, and tools. Activation is offline with a personal key.', @@ -362,6 +401,7 @@ const locales = { 'mesh_plus.active_ok': 'MESH+ is active on this device.', 'mesh_plus.activate_ok': 'MESH+ activated.', 'mesh_plus.revoke_ok': 'License removed. Status reset to FREE.', + 'mesh_plus.feature_locked': 'Requires MESH+.', 'mesh_plus.error_empty': 'Enter your license key.', 'mesh_plus.error_invalid_format': 'Invalid key format. Paste the full BLIP-… key.', 'mesh_plus.error_invalid_signature': 'Key is not valid or was revoked.', @@ -378,8 +418,45 @@ const locales = { 'mesh_plus.slide_projects_desc': 'Full Board & Canvas, Pad history with rollback.', 'mesh_plus.slide_clipboard': 'Clipboard Board', 'mesh_plus.slide_clipboard_desc': 'Unlimited LAN clipboard history with search.', - 'mesh_plus.slide_identity': 'Identity & stats', - 'mesh_plus.slide_identity_desc': 'MESH+ badge, ID color, reservations, achievements.', + 'mesh_plus.slide_gif': 'Status GIF', + 'mesh_plus.slide_gif_desc': 'Animated profile cloud in peer cards — MESH+ only.', + 'mesh_plus.identity_title': 'Mesh identity', + 'mesh_plus.identity_hint': 'Customize how you appear on the LAN mesh.', + 'mesh_plus.id_color_label': 'BLIP ID color', + 'mesh_plus.id_color_hint': 'Tint shown to peers in the list (hex).', + 'mesh_plus.id_color_apply': 'Apply', + 'mesh_plus.id_color_clear': 'Clear', + 'mesh_plus.status_icon_label': 'Status icon', + 'mesh_plus.status_icon_hint': 'Small glyph next to your pulse line for peers.', + 'mesh_plus.status_icon_none': 'None', + 'mesh_plus.status_icon_game': 'Gaming', + 'mesh_plus.status_icon_code': 'Coding', + 'mesh_plus.status_icon_stream': 'Streaming', + 'mesh_plus.status_icon_listen': 'Listening', + 'mesh_plus.status_icon_work': 'Working', + 'mesh_plus.status_icon_afk': 'AFK', + 'mesh_plus.id_reserve_label': 'Reserved IDs', + 'mesh_plus.id_reserve_hint': 'Bookmark up to 8 numbers on the change-ID grid.', + 'mesh_plus.id_reserve_add': 'Bookmark current ID', + 'mesh_plus.id_reserve_empty': 'No bookmarks yet.', + 'mesh_plus.stats_title': 'Session stats', + 'mesh_plus.stats_locked': 'Activate MESH+ to track session stats and achievements.', + 'mesh_plus.stats_summary': + 'Online {hours} · {messages} messages · {files} files · {calls} calls · peak {peers} peers', + 'mesh_plus.ach_first_peer': 'First contact', + 'mesh_plus.ach_first_peer_desc': 'At least one peer online in this session.', + 'mesh_plus.ach_mesh_5': 'Full mesh', + 'mesh_plus.ach_mesh_5_desc': 'Five peers online at once.', + 'mesh_plus.ach_messages_50': 'Chatter', + 'mesh_plus.ach_messages_50_desc': 'Send 50 messages this session.', + 'mesh_plus.ach_messages_100': 'Signal operator', + 'mesh_plus.ach_messages_100_desc': 'Send 100 messages this session.', + 'mesh_plus.ach_first_call': 'On the line', + 'mesh_plus.ach_first_call_desc': 'Start a voice call.', + 'mesh_plus.ach_first_file': 'Payload', + 'mesh_plus.ach_first_file_desc': 'Send a file over the mesh.', + 'mesh_plus.ach_online_1h': 'Long haul', + 'mesh_plus.ach_online_1h_desc': 'Stay in BLIP for one hour this session.', 'settings.app_icon_free_title': 'App icon', 'settings.app_icon_free_hint': 'Tray, window, and About. Start-menu shortcut uses the installer icon until reinstall.', 'settings.app_icon_mesh_title': 'MESH+ icons', @@ -389,6 +466,7 @@ const locales = { 'settings.app_icon_mesh': 'Mesh {n}', 'settings.app_icon_mesh_locked': 'This icon requires MESH+.', 'settings.app_icon_applied': 'App icon updated.', + 'settings.app_icon_preview_hint': 'Right-click an icon to enlarge', 'settings.dev_clear_mesh_plus': 'Remove subscription', 'settings.dev_clear_mesh_plus_ok': 'MESH+ license cleared.', 'settings.dev_clear_mesh_plus_none': 'No active MESH+ subscription.', @@ -415,6 +493,7 @@ const locales = { 'settings.sound_enable': 'UI sounds', 'settings.sound_volume': 'Volume', 'settings.sound_pack': 'Sound pack', + 'settings.sound_mesh_plus_hint': 'WIRE and STATIC FX packs, BEACON and CHIME melodies — MESH+ only.', 'settings.sound_pack_signal': 'SIGNAL', 'settings.sound_pack_pulse': 'PULSE', 'settings.sound_pack_wire': 'WIRE', @@ -442,6 +521,44 @@ const locales = { 'settings.section_call': 'Call', 'settings.section_transfer': 'Files', 'settings.section_network': 'Network', + 'settings.network_stats_title': 'Session stats', + 'settings.network_stats_hint': 'Counters for this BLIP session (local).', + 'settings.network_stats_summary': + 'Online {hours} · {messages} messages · {files} files · {calls} calls · peak {peers} peers', + 'settings.network_stats_chart': 'Activity bars', + 'settings.network_stats_bar_messages': 'Messages', + 'settings.network_stats_bar_files': 'Files', + 'settings.network_stats_bar_calls': 'Calls', + 'settings.network_stats_bar_peers': 'Peak peers', + 'settings.network_stats_bar_minutes': 'Minutes online', + 'achievements.hint': 'Optional session goals — Steam-style cards. Off by default.', + 'achievements.enabled_label': 'Track achievements', + 'achievements.enabled_hint': 'When off, progress is not saved and unlock toasts are hidden.', + 'achievements.enabled': 'Enable achievements', + 'achievements.notify_label': 'Unlock notifications', + 'achievements.notify_hint': 'Show a toast when you earn an achievement.', + 'achievements.notify': 'Notify on unlock', + 'achievements.icon_placeholder': 'Icon slot — add your asset path in achievements.js', + 'achievements.reset': 'Reset unlocks', + 'achievements.reset_confirm': 'Clear all unlocked achievements on this device?', + 'achievements.reset_ok': 'Achievement progress reset.', + 'achievements.unlocked_title': 'Achievement unlocked', + 'achievements.first_peer': 'First contact', + 'achievements.first_peer_desc': 'At least one peer online in this session.', + 'achievements.mesh_5': 'Full mesh', + 'achievements.mesh_5_desc': 'Five peers online at once.', + 'achievements.messages_50': 'Chatter', + 'achievements.messages_50_desc': 'Send 50 messages this session.', + 'achievements.messages_100': 'Signal operator', + 'achievements.messages_100_desc': 'Send 100 messages this session.', + 'achievements.first_call': 'On the line', + 'achievements.first_call_desc': 'Start a voice call.', + 'achievements.first_file': 'Payload', + 'achievements.first_file_desc': 'Send a file over the mesh.', + 'achievements.online_1h': 'Long haul', + 'achievements.online_1h_desc': 'Stay in BLIP for one hour this session.', + 'chat.export_pdf_themed': 'Export PDF (BLIP theme)', + 'chat.export_html_themed': 'Export HTML (BLIP theme)', 'settings.group_account': 'Account', 'settings.group_connect': 'Connect', 'settings.group_media': 'Media', @@ -593,6 +710,12 @@ const locales = { 'projects.hub_hint': 'Signal Corps — BLIP’s must-have builder workspace on the mesh (not tied to groups). Shared Pad syncs to online peers on your LAN while you pair-program or debug. Coming: kanban board, pixel canvas, pull clipboard (20 clips). Enable in Settings → Developer → Projects (off by default).', 'projects.tool_soon': 'In development — coming in a future Signal Corps drop.', + 'projects.mesh_plus_required': 'BOARD and CANVAS are MESH+ tools. Activate in Settings → MESH+.', + 'projects.pad_history_title': 'Pad history', + 'projects.pad_history_save': 'Save snapshot', + 'projects.pad_history_restore': 'Restored', + 'projects.pad_history_manual': 'Manual snapshot', + 'projects.pad_history_empty': 'No snapshots yet — type in the pad or tap Save snapshot.', 'projects.sidebar_title': 'tools', 'projects.tool_pad': 'PAD', 'projects.tool_board': 'BOARD', @@ -611,6 +734,15 @@ const locales = { 'projects.clip_count': '{n} entries · pull model', 'projects.clip_pull': 'PULL', 'projects.clip_push': 'PUSH', + 'projects.clip_search': 'Search clipboard…', + 'projects.clip_count_limit': '{n} / {max} entries', + 'projects.clip_count_search': '{n} shown · {total} total', + 'projects.clip_free_limit_hint': 'FREE: up to {max} entries. MESH+ — more + search.', + 'appearance.custom_accent': 'Custom accent', + 'appearance.custom_accent_mesh_hint': 'Pick any #RRGGBB color — MESH+ only. Presets above still work when cleared.', + 'appearance.custom_accent_placeholder': '#00ffc8', + 'appearance.custom_accent_apply': 'Apply color', + 'appearance.custom_accent_clear': 'Use preset', 'settings.dev_projects': 'Projects section', 'settings.dev_projects_on': 'Projects enabled', 'settings.dev_projects_off': 'Projects hidden', @@ -639,6 +771,7 @@ const locales = { 'app.slogan': 'Ты в сети. Ты сигнал.', 'grid.title': 'ВЫБЕРИ СВОЙ БЛИП АЙДИ', 'grid.hint': 'Выбери свободный номер. 8×8. 1–64.', + 'grid.hint_reserved': 'Выбери свободный номер. Закладки подсвечены (МЭШ ПЛЮС).', 'grid.occupied': 'Занято', 'grid.free': 'Свободно', 'grid.yours': 'Твой', @@ -752,7 +885,7 @@ const locales = { 'Нажми на абонента в сети — откроется чат. ПКМ: метка Меш (локальное имя), пинг, копировать АЙДИ, избранное, блок. Офлайн приглушены.', 'peers.typing': 'печатает…', 'peers.pulse_ms': 'Пульс · {ms} мс', - 'peers.pulse_pending': 'Пульс · замер…', + 'peers.pulse_pending': 'Пульс · замер', 'peers.pulse_offline': 'Пульс · не в сети', 'peers.mesh_hint': 'ПКМ по абоненту → метка МЕШа: локальное имя (только у тебя).', 'peers.mesh_label': 'Метка МЕШа…', @@ -766,6 +899,22 @@ const locales = { 'peers.ping_ok': 'Абонент доступен', 'peers.ping_ok_body': 'Задержка {ms} мс', 'peers.ping_fail': 'Пинг не прошёл', + 'peers.profile': 'Профиль', + 'peers.profile_title': 'Профиль', + 'peers.profile_back': 'Назад', + 'peers.profile_status': 'Статус', + 'peers.profile_pulse': 'МЕШ ПУЛЬС', + 'peers.profile_banner_future': 'Фон профиля — скоро', + 'peers.profile_note_title': 'Заметка (видна только вам)', + 'peers.profile_note_placeholder': 'Нажмите, чтобы добавить заметку', + 'peers.profile_open': 'Открыть профиль', + 'peers.profile_message': 'Сообщение', + 'peers.profile_call': 'Звонок', + 'peers.profile_block': 'Заблокировать', + 'peers.profile_unblock': 'Разблокировать', + 'peers.profile_mesh_label': 'Метка МЕШа', + 'peers.profile_display_name': 'Имя в сети', + 'peers.profile_latency': 'МЕШ ПУЛЬС · {ms} мс', 'peers.copy_id': 'Копировать АЙДИ', 'peers.copy_id_done': 'ID скопирован', 'peers.block': 'Скрыть локально', @@ -884,6 +1033,7 @@ const locales = { 'settings.appearance_theme': 'Тема', 'settings.appearance_accent': 'Цветовой акцент', 'settings.bg_animated': 'Анимированный фон', + 'settings.bg_animated_mesh_hint': 'Сцены «Угли» и «Разлом» — только с МЭШ ПЛЮС.', 'settings.bg_art': 'Арт-фон', 'appearance.mode.light': 'Светлая', 'appearance.mode.dark': 'Тёмная', @@ -976,6 +1126,27 @@ const locales = { 'settings.avatar_error_decode': 'Не удалось прочитать изображение. Попробуй другой файл.', 'settings.avatar_error_output': 'После обработки изображение всё ещё слишком большое.', 'settings.section_profile': 'Профиль', + 'settings.section_achievements': 'Достижения', + 'settings.profile_editor_hint': 'Настрой профиль и смотри, как тебя видят в сети.', + 'settings.profile_editor_title': 'Редактирование', + 'settings.profile_preview_title': 'Как видят другие', + 'settings.profile_gif_pick': 'Гифка статуса', + 'settings.profile_gif_picker_title': 'Гифка в профиле', + 'settings.profile_gif_tab_mine': 'Мои', + 'settings.profile_gif_tab_online': 'ГИФИ', + 'settings.profile_gif_upload': 'Загрузить ГИФ', + 'settings.profile_gif_clear': 'Убрать гифку', + 'settings.profile_gif_empty': 'Пока пусто — загрузи или выбери в ГИФИ.', + 'settings.profile_gif_search': 'Поиск гифок…', + 'settings.profile_gif_search_btn': 'Найти', + 'settings.profile_gif_online_empty': 'Ничего не найдено.', + 'settings.profile_gif_giphy_unavailable': + 'Каталог гифок временно недоступен. Попробуй позже или загрузи свою.', + 'settings.profile_gif_saved': 'Гифка статуса сохранена', + 'settings.profile_gif_invalid': 'Выбери файл ГИФ.', + 'settings.profile_gif_too_large': 'ГИФ слишком большой (макс. 8 МБ).', + 'settings.profile_gif_save_failed': 'Не удалось сохранить ГИФ. Попробуй другой файл.', + 'settings.profile_gif_crop_title': 'Гифка статуса', 'settings.section_mesh_plus': 'МЭШ ПЛЮС', 'mesh_plus.intro_hint': 'Расширенные возможности меша — сеть, премиум-оформление и инструменты. Активация офлайн по личному ключу.', @@ -997,6 +1168,7 @@ const locales = { 'mesh_plus.active_ok': 'МЭШ ПЛЮС активен на этом устройстве.', 'mesh_plus.activate_ok': 'МЭШ ПЛЮС активирован.', 'mesh_plus.revoke_ok': 'Ключ удалён. Статус сброшен на «БЕСПЛАТНО».', + 'mesh_plus.feature_locked': 'Нужен МЭШ ПЛЮС.', 'mesh_plus.error_empty': 'Введи ключ.', 'mesh_plus.error_invalid_format': 'Неверный формат. Вставь полный ключ BLIP-…', 'mesh_plus.error_invalid_signature': 'Ключ недействителен или отозван.', @@ -1013,8 +1185,45 @@ const locales = { 'mesh_plus.slide_projects_desc': 'БОАРД (доска) и КАНВАС полностью, история ПАД (блокнот) с откатом.', 'mesh_plus.slide_clipboard': 'КЛИПБОАРД (доска)', 'mesh_plus.slide_clipboard_desc': 'Безлимитная история буфера по ЛАН и поиск.', - 'mesh_plus.slide_identity': 'Идентичность и статистика', - 'mesh_plus.slide_identity_desc': 'Бейдж МЭШ+, цвет АЙДИ, резерв номера, достижения.', + 'mesh_plus.slide_gif': 'Гифка статуса', + 'mesh_plus.slide_gif_desc': 'Анимированное облако в карточке профиля — только МЭШ ПЛЮС.', + 'mesh_plus.identity_title': 'Идентичность в меше', + 'mesh_plus.identity_hint': 'Как тебя видят в локальной сети.', + 'mesh_plus.id_color_label': 'Цвет BLIP ID', + 'mesh_plus.id_color_hint': 'Оттенок в списке абонентов (hex).', + 'mesh_plus.id_color_apply': 'Применить', + 'mesh_plus.id_color_clear': 'Сбросить', + 'mesh_plus.status_icon_label': 'Иконка статуса', + 'mesh_plus.status_icon_hint': 'Символ рядом с пульсом для других.', + 'mesh_plus.status_icon_none': 'Нет', + 'mesh_plus.status_icon_game': 'Игра', + 'mesh_plus.status_icon_code': 'Код', + 'mesh_plus.status_icon_stream': 'Стрим', + 'mesh_plus.status_icon_listen': 'Музыка', + 'mesh_plus.status_icon_work': 'Работа', + 'mesh_plus.status_icon_afk': 'Отошёл', + 'mesh_plus.id_reserve_label': 'Закладки ID', + 'mesh_plus.id_reserve_hint': 'До 8 номеров на сетке смены ID.', + 'mesh_plus.id_reserve_add': 'Закладка текущего ID', + 'mesh_plus.id_reserve_empty': 'Закладок пока нет.', + 'mesh_plus.stats_title': 'Статистика сессии', + 'mesh_plus.stats_locked': 'Активируй МЭШ ПЛЮС для статистики и достижений.', + 'mesh_plus.stats_summary': + 'Онлайн {hours} · {messages} сообщ. · {files} файлов · {calls} звонков · пик {peers} в сети', + 'mesh_plus.ach_first_peer': 'Первый контакт', + 'mesh_plus.ach_first_peer_desc': 'Хотя бы один абонент онлайн за сессию.', + 'mesh_plus.ach_mesh_5': 'Полный меш', + 'mesh_plus.ach_mesh_5_desc': 'Пять абонентов онлайн одновременно.', + 'mesh_plus.ach_messages_50': 'Болтун', + 'mesh_plus.ach_messages_50_desc': '50 сообщений за сессию.', + 'mesh_plus.ach_messages_100': 'Оператор сигнала', + 'mesh_plus.ach_messages_100_desc': '100 сообщений за сессию.', + 'mesh_plus.ach_first_call': 'На связи', + 'mesh_plus.ach_first_call_desc': 'Начать голосовой звонок.', + 'mesh_plus.ach_first_file': 'Груз', + 'mesh_plus.ach_first_file_desc': 'Отправить файл по сети.', + 'mesh_plus.ach_online_1h': 'Длинная смена', + 'mesh_plus.ach_online_1h_desc': 'Час в BLIP за эту сессию.', 'settings.app_icon_free_title': 'Иконка приложения', 'settings.app_icon_free_hint': 'Трей, окно и «О приложении». Ярлык в меню «Пуск» обновится после переустановки.', 'settings.app_icon_mesh_title': 'Иконки МЭШ ПЛЮС', @@ -1024,6 +1233,7 @@ const locales = { 'settings.app_icon_mesh': 'Мэш {n}', 'settings.app_icon_mesh_locked': 'Эта иконка доступна с МЭШ ПЛЮС.', 'settings.app_icon_applied': 'Иконка приложения обновлена.', + 'settings.app_icon_preview_hint': 'ПКМ по иконке — увеличить', 'settings.dev_clear_mesh_plus': 'Удалить подписку', 'settings.dev_clear_mesh_plus_ok': 'Подписка МЭШ ПЛЮС сброшена.', 'settings.dev_clear_mesh_plus_none': 'Сейчас подписки МЭШ ПЛЮС нет.', @@ -1050,6 +1260,7 @@ const locales = { 'settings.sound_enable': 'Звуки интерфейса', 'settings.sound_volume': 'Громкость', 'settings.sound_pack': 'Набор звуков', + 'settings.sound_mesh_plus_hint': 'ПРОВОД и СТАТИК, мелодии МАЯК и ПЕРЕЗВОН — только с МЭШ ПЛЮС.', 'settings.sound_pack_signal': 'СИГНАЛ', 'settings.sound_pack_pulse': 'ПУЛЬС', 'settings.sound_pack_wire': 'ПРОВОД', @@ -1077,6 +1288,44 @@ const locales = { 'settings.section_call': 'Звонок', 'settings.section_transfer': 'Файлы', 'settings.section_network': 'Сеть', + 'settings.network_stats_title': 'Статистика сессии', + 'settings.network_stats_hint': 'Счётчики текущей сессии BLIP (локально).', + 'settings.network_stats_summary': + 'Онлайн {hours} · {messages} сообщ. · {files} файлов · {calls} звонков · пик {peers} в сети', + 'settings.network_stats_chart': 'Столбцы активности', + 'settings.network_stats_bar_messages': 'Сообщения', + 'settings.network_stats_bar_files': 'Файлы', + 'settings.network_stats_bar_calls': 'Звонки', + 'settings.network_stats_bar_peers': 'Пик в сети', + 'settings.network_stats_bar_minutes': 'Минут онлайн', + 'achievements.hint': 'Опциональные цели сессии — карточки в стиле Steam. По умолчанию выкл.', + 'achievements.enabled_label': 'Учёт достижений', + 'achievements.enabled_hint': 'Если выкл. — прогресс не сохраняется, тосты скрыты.', + 'achievements.enabled': 'Включить достижения', + 'achievements.notify_label': 'Уведомления', + 'achievements.notify_hint': 'Тост при получении достижения.', + 'achievements.notify': 'Уведомлять о разблокировке', + 'achievements.icon_placeholder': 'Слот иконки — укажи путь в achievements.js', + 'achievements.reset': 'Сбросить разблокировки', + 'achievements.reset_confirm': 'Сбросить все достижения на этом устройстве?', + 'achievements.reset_ok': 'Прогресс достижений сброшен.', + 'achievements.unlocked_title': 'Достижение получено', + 'achievements.first_peer': 'Первый контакт', + 'achievements.first_peer_desc': 'Хотя бы один абонент онлайн за сессию.', + 'achievements.mesh_5': 'Полный меш', + 'achievements.mesh_5_desc': 'Пять абонентов онлайн одновременно.', + 'achievements.messages_50': 'Болтун', + 'achievements.messages_50_desc': '50 сообщений за сессию.', + 'achievements.messages_100': 'Оператор сигнала', + 'achievements.messages_100_desc': '100 сообщений за сессию.', + 'achievements.first_call': 'На связи', + 'achievements.first_call_desc': 'Начать голосовой звонок.', + 'achievements.first_file': 'Груз', + 'achievements.first_file_desc': 'Отправить файл по сети.', + 'achievements.online_1h': 'Длинная смена', + 'achievements.online_1h_desc': 'Час в BLIP за эту сессию.', + 'chat.export_pdf_themed': 'Экспорт PDF (тема BLIP)', + 'chat.export_html_themed': 'Экспорт HTML (тема BLIP)', 'settings.group_account': 'Аккаунт', 'settings.group_connect': 'Связь', 'settings.group_media': 'Медиа', @@ -1227,6 +1476,12 @@ const locales = { 'projects.hub_hint': 'Сигнал Корпс — главная фича BLIP для разработчиков в МЕШе (без групп). Общий блокнот синхронизируется с онлайн-абонентами по ЛАН. Скоро: канбан, пиксель-холст, буфер с историей (20 записей). Включите в Настройки → Разработчик → Проекты (по умолчанию выкл).', 'projects.tool_soon': 'В разработке — появится в следующих версиях Сигнал Корпс.', + 'projects.mesh_plus_required': 'ДОСКА и ХОЛСТ — инструменты МЭШ ПЛЮС. Активация в Настройки → МЭШ ПЛЮС.', + 'projects.pad_history_title': 'История блокнота', + 'projects.pad_history_save': 'Сохранить снимок', + 'projects.pad_history_restore': 'Восстановлено', + 'projects.pad_history_manual': 'Ручной снимок', + 'projects.pad_history_empty': 'Пока пусто — пиши в блокноте или нажми «Сохранить снимок».', 'projects.sidebar_title': 'инструменты', 'projects.tool_pad': 'БЛОКНОТ', 'projects.tool_board': 'ДОСКА', @@ -1245,6 +1500,15 @@ const locales = { 'projects.clip_count': '{n} записей · pull-модель', 'projects.clip_pull': 'PULL', 'projects.clip_push': 'В БУФЕР', + 'projects.clip_search': 'Поиск в буфере…', + 'projects.clip_count_limit': '{n} / {max} записей', + 'projects.clip_count_search': '{n} показано · всего {total}', + 'projects.clip_free_limit_hint': 'БЕСПЛАТНО: до {max} записей. МЭШ ПЛЮС — больше и поиск.', + 'appearance.custom_accent': 'Свой акцент', + 'appearance.custom_accent_mesh_hint': 'Любой цвет #RRGGBB — только МЭШ ПЛЮС. После сброса — пресеты выше.', + 'appearance.custom_accent_placeholder': '#00ffc8', + 'appearance.custom_accent_apply': 'Применить', + 'appearance.custom_accent_clear': 'Сбросить', 'settings.dev_projects': 'Раздел «Проекты»', 'settings.dev_projects_on': 'Проекты включены', 'settings.dev_projects_off': 'Проекты скрыты', diff --git a/renderer/mesh-plus-settings.js b/renderer/mesh-plus-settings.js index b58759b..c986a1e 100644 --- a/renderer/mesh-plus-settings.js +++ b/renderer/mesh-plus-settings.js @@ -9,7 +9,7 @@ const CAROUSEL_SLIDES = [ { icon: '◎', titleKey: 'mesh_plus.slide_relay', descKey: 'mesh_plus.slide_relay_desc' }, { icon: '▦', titleKey: 'mesh_plus.slide_projects', descKey: 'mesh_plus.slide_projects_desc' }, { icon: '⧉', titleKey: 'mesh_plus.slide_clipboard', descKey: 'mesh_plus.slide_clipboard_desc' }, - { icon: '★', titleKey: 'mesh_plus.slide_identity', descKey: 'mesh_plus.slide_identity_desc' }, + { icon: '▶', titleKey: 'mesh_plus.slide_gif', descKey: 'mesh_plus.slide_gif_desc' }, ]; /** diff --git a/renderer/mesh-plus.js b/renderer/mesh-plus.js index b8ef33d..3838c4d 100644 --- a/renderer/mesh-plus.js +++ b/renderer/mesh-plus.js @@ -1,8 +1,26 @@ import { t } from './i18n.js'; +import { showAppToast } from './toasts.js'; +import { + MESH_PLUS_FEATURES, + PREMIUM_ANIMATED_BG_IDS, + PREMIUM_MELODY_PACK_IDS, + PREMIUM_SOUND_PACK_IDS, + isMeshPlusTierActive, + requireMeshPlus, +} from '../shared/mesh-plus-gates.js'; + +export { + MESH_PLUS_FEATURES, + PREMIUM_ANIMATED_BG_IDS, + PREMIUM_SOUND_PACK_IDS, + PREMIUM_MELODY_PACK_IDS, + isMeshPlusTierActive, + requireMeshPlus, +}; /** @param {object} [cfg] */ export function isMeshPlusActive(cfg) { - return cfg?.meshPlusActive === true || cfg?.tier === 'mesh_plus'; + return isMeshPlusTierActive(cfg); } /** @param {object} [peer] */ @@ -10,6 +28,60 @@ export function peerHasMeshPlus(peer) { return !!peer?.meshPlus; } +export function showMeshPlusLockedToast() { + showAppToast({ + title: t('mesh_plus.feature_locked'), + durationMs: 4200, + }); +} + +/** + * @param {{ value: string, label: string }[]} options + * @param {string} feature + * @param {object} [cfg] + */ +export function markMeshPlusGatedOptions(options, feature, cfg) { + if (isMeshPlusTierActive(cfg)) return options; + return options.map((opt) => ({ + ...opt, + meshPlus: !requireMeshPlus(cfg, feature, opt.value), + })); +} + +/** + * @param {HTMLSelectElement} select + * @param {{ value: string, label: string, meshPlus?: boolean }[]} options + * @param {string} current + * @param {string} feature + * @param {object} cfg + * @param {(value: string) => void | Promise} onChange + */ +export function fillMeshGatedDropdown(select, options, current, feature, cfg, onChange) { + select.innerHTML = ''; + for (const opt of options) { + const o = document.createElement('option'); + o.value = opt.value; + o.textContent = opt.meshPlus ? `${opt.label} ◆` : opt.label; + if (opt.meshPlus) o.disabled = true; + select.appendChild(o); + } + const allowed = options.filter((o) => !o.meshPlus); + const ok = options.some((o) => o.value === current && !o.meshPlus); + select.value = ok ? current : allowed[0]?.value || options[0]?.value || ''; + + select.addEventListener('change', () => { + const val = select.value; + const picked = options.find((o) => o.value === val); + if (picked?.meshPlus || !requireMeshPlus(cfg, feature, val)) { + showMeshPlusLockedToast(); + const revert = ok ? current : allowed[0]?.value ?? options[0]?.value ?? ''; + select.value = revert; + return; + } + void onChange(val); + }); +} + /** * Platinum plaque + pixel gradient "MESH+" label. * @param {{ title?: string, compact?: boolean }} [opts] diff --git a/renderer/pad-history-store.js b/renderer/pad-history-store.js new file mode 100644 index 0000000..d8369b0 --- /dev/null +++ b/renderer/pad-history-store.js @@ -0,0 +1,68 @@ +const STORAGE_KEY = 'blip_pad_history_v1'; +const MAX_SNAPSHOTS = 32; + +function readAll() { + try { + const raw = localStorage.getItem(STORAGE_KEY); + const o = raw ? JSON.parse(raw) : {}; + return o && typeof o === 'object' ? o : {}; + } catch { + return {}; + } +} + +function writeAll(data) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); + } catch { + /* quota */ + } +} + +/** + * @param {string} scopeId + * @returns {Array<{ id: string, text: string, updatedAt: number, from: number, label?: string }>} + */ +export function getPadHistory(scopeId) { + const key = String(scopeId); + const all = readAll(); + const list = Array.isArray(all[key]) ? all[key] : []; + return list.slice(0, MAX_SNAPSHOTS); +} + +/** + * @param {string} scopeId + * @param {{ text: string, updatedAt?: number, from?: number, label?: string }} snap + */ +export function pushPadSnapshot(scopeId, snap) { + const key = String(scopeId); + const text = String(snap.text ?? ''); + if (!text.trim()) return; + const entry = { + id: `${snap.updatedAt || Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + text, + updatedAt: Number(snap.updatedAt) || Date.now(), + from: Number(snap.from) || 0, + label: snap.label ? String(snap.label) : '', + }; + const all = readAll(); + const prev = Array.isArray(all[key]) ? all[key] : []; + const next = [entry, ...prev.filter((e) => e.text !== text || e.updatedAt !== entry.updatedAt)].slice( + 0, + MAX_SNAPSHOTS + ); + all[key] = next; + writeAll(all); + return entry; +} + +/** + * @param {string} scopeId + * @param {string} snapshotId + * @returns {{ text: string, updatedAt: number, from: number } | null} + */ +export function getPadSnapshotById(scopeId, snapshotId) { + const hit = getPadHistory(scopeId).find((e) => e.id === snapshotId); + if (!hit) return null; + return { text: hit.text, updatedAt: hit.updatedAt, from: hit.from }; +} diff --git a/renderer/peer-gif-cache.js b/renderer/peer-gif-cache.js new file mode 100644 index 0000000..b9f0362 --- /dev/null +++ b/renderer/peer-gif-cache.js @@ -0,0 +1,15 @@ +/** @type {Map} */ +const peerGifById = new Map(); + +/** @param {number} blipId @param {string | null} dataUrl */ +export function setPeerProfileGifDataUrl(blipId, dataUrl) { + const id = Number(blipId); + if (!Number.isFinite(id)) return; + if (dataUrl) peerGifById.set(id, dataUrl); + else peerGifById.delete(id); +} + +/** @param {number} blipId */ +export function getPeerProfileGifDataUrl(blipId) { + return peerGifById.get(Number(blipId)) || null; +} diff --git a/renderer/peer-private-notes.js b/renderer/peer-private-notes.js new file mode 100644 index 0000000..e6102b7 --- /dev/null +++ b/renderer/peer-private-notes.js @@ -0,0 +1,43 @@ +/** + * Private peer notes — local only, never sent over the network (like Steam / Discord). + */ +const STORAGE_KEY = 'blip_peer_private_notes_v1'; +const MAX_NOTE_LEN = 500; + +function loadMap() { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return {}; + const o = JSON.parse(raw); + return o && typeof o === 'object' ? o : {}; + } catch { + return {}; + } +} + +function saveMap(map) { + localStorage.setItem(STORAGE_KEY, JSON.stringify(map)); +} + +/** @param {number | string} peerId */ +export function getPeerPrivateNote(peerId) { + const id = String(peerId); + const v = loadMap()[id]; + return typeof v === 'string' ? v : ''; +} + +/** @param {number | string} peerId @param {string} text */ +export function setPeerPrivateNote(peerId, text) { + const id = String(peerId); + const map = loadMap(); + const trimmed = String(text ?? '').trim(); + if (!trimmed) { + delete map[id]; + } else { + map[id] = trimmed.slice(0, MAX_NOTE_LEN); + } + saveMap(map); + window.dispatchEvent( + new CustomEvent('blip-peer-private-notes-changed', { detail: { peerId: Number(peerId) } }) + ); +} diff --git a/renderer/peer-profile.js b/renderer/peer-profile.js new file mode 100644 index 0000000..08572a4 --- /dev/null +++ b/renderer/peer-profile.js @@ -0,0 +1,19 @@ +import { buildProfileCard } from './profile-card.js'; +import { getPeerProfileGifDataUrl } from './peer-gif-cache.js'; + +/** + * @param {object} peer + * @param {object} hooks + */ +export function buildPeerProfilePage(peer, hooks = {}) { + return buildProfileCard(peer, { + ...hooks, + showPrivateNote: true, + showActions: true, + showBanner: true, + getProfileGifUrl: async (p) => { + if (hooks.getProfileGifUrl) return hooks.getProfileGifUrl(p); + return getPeerProfileGifDataUrl(p.blipId); + }, + }); +} diff --git a/renderer/profile-card.js b/renderer/profile-card.js new file mode 100644 index 0000000..f43432f --- /dev/null +++ b/renderer/profile-card.js @@ -0,0 +1,288 @@ +import { t } from './i18n.js'; +import { createAvatarElement } from './avatar.js'; +import { formatPeerDisplayName } from './peer-labels.js'; +import { getPeerPrivateNote, setPeerPrivateNote } from './peer-private-notes.js'; +import { getPeerProfileGifDataUrl } from './peer-gif-cache.js'; +import { appendMeshPlusBadgeToNameRow } from './mesh-plus.js'; +import { computeGifFramePx } from './gif-frame-size.js'; + +const CLOUD_MAX_W = 168; +const CLOUD_MAX_H = 95; + +function applyGifCloudSize(cloudEl, imgEl) { + const nw = imgEl.naturalWidth || 160; + const nh = imgEl.naturalHeight || 90; + const { w, h } = computeGifFramePx(nw, nh, CLOUD_MAX_W, CLOUD_MAX_H); + cloudEl.style.width = `${w}px`; + cloudEl.style.height = `${h}px`; +} + +/** + * @param {object} peer + * @param {object} hooks + * @returns {{ el: HTMLElement, refresh: () => void, destroy: () => void }} + */ +export function buildProfileCard(peerInput, hooks = {}) { + let peer = peerInput; + const { + selfBlipId = null, + isBlocked = () => false, + presenceClass = defaultPresenceClass, + statusTooltip = defaultStatusTooltip, + getProfileGifUrl, + showPrivateNote = false, + showActions = false, + showBanner = true, + onMessage, + onCall, + onBlock, + onPing, + isSelfPreview = false, + } = hooks; + + if (isSelfPreview && hooks.meshPlusOnSelf) { + peer = { ...peer, meshPlus: true }; + } + + const card = document.createElement('div'); + card.className = 'peer-profile-card glass'; + + if (showBanner) { + const banner = document.createElement('div'); + banner.className = 'peer-profile-banner'; + if (peer.meshPlus) banner.classList.add('peer-profile-banner--mesh-plus'); + if (peer.online) banner.classList.add('peer-profile-banner--online'); + card.appendChild(banner); + } + + const body = document.createElement('div'); + body.className = 'peer-profile-body'; + + const layout = document.createElement('div'); + layout.className = 'peer-profile-layout'; + + const aside = document.createElement('aside'); + aside.className = 'peer-profile-aside'; + + const cloudWrap = document.createElement('div'); + cloudWrap.className = 'profile-gif-cloud-wrap hidden'; + const cloud = document.createElement('div'); + cloud.className = 'profile-gif-cloud'; + const cloudImg = document.createElement('img'); + cloudImg.className = 'profile-gif-cloud__img hidden'; + cloudImg.alt = ''; + cloud.appendChild(cloudImg); + cloudWrap.appendChild(cloud); + + const avatarWrap = document.createElement('div'); + avatarWrap.className = 'peer-profile-avatar-large'; + avatarWrap.appendChild( + createAvatarElement(peer.blipId, 8, { selfBlipId }) + ); + + const asideStack = document.createElement('div'); + asideStack.className = 'peer-profile-aside-stack'; + asideStack.appendChild(avatarWrap); + asideStack.appendChild(cloudWrap); + aside.appendChild(asideStack); + + const main = document.createElement('div'); + main.className = 'peer-profile-main'; + + const head = document.createElement('header'); + head.className = 'peer-profile-head'; + + const nameRow = document.createElement('div'); + nameRow.className = 'peer-profile-name-row'; + const nameLine = document.createElement('div'); + nameLine.className = 'peer-profile-name-line'; + const nameEl = document.createElement('h1'); + nameEl.className = 'peer-profile-name'; + nameEl.textContent = formatPeerDisplayName(peer); + const nameStatusDot = document.createElement('span'); + nameStatusDot.className = 'status-dot peer-profile-name-dot'; + nameStatusDot.title = statusTooltip(peer); + nameLine.appendChild(nameEl); + nameLine.appendChild(nameStatusDot); + nameRow.appendChild(nameLine); + appendMeshPlusBadgeToNameRow(nameRow, peer); + if (peer.meshLegacy) { + const leg = document.createElement('span'); + leg.className = 'peer-handshake-badge peer-handshake-badge--legacy'; + leg.title = t('peers.handshake_legacy'); + leg.textContent = '!'; + nameRow.appendChild(leg); + } + + const idEl = document.createElement('div'); + idEl.className = 'peer-profile-id'; + idEl.textContent = `BLIP #${peer.blipId}`; + + const customStatusEl = document.createElement('div'); + customStatusEl.className = 'peer-profile-custom-status hidden'; + + head.appendChild(nameRow); + head.appendChild(idEl); + head.appendChild(customStatusEl); + main.appendChild(head); + + let noteInput = null; + let noteSaveTimer = null; + if (showPrivateNote && !isSelfPreview) { + const noteSection = document.createElement('section'); + noteSection.className = 'peer-profile-note'; + const noteTitle = document.createElement('div'); + noteTitle.className = 'peer-profile-note-title'; + noteTitle.dataset.i18n = 'peers.profile_note_title'; + noteTitle.textContent = t('peers.profile_note_title'); + noteInput = document.createElement('textarea'); + noteInput.className = 'peer-profile-note-input input'; + noteInput.rows = 3; + noteInput.maxLength = 500; + noteInput.placeholder = t('peers.profile_note_placeholder'); + noteInput.value = getPeerPrivateNote(peer.blipId); + noteInput.addEventListener('input', () => { + if (noteSaveTimer) clearTimeout(noteSaveTimer); + noteSaveTimer = setTimeout(() => { + setPeerPrivateNote(peer.blipId, noteInput.value); + }, 280); + }); + noteInput.addEventListener('blur', () => { + if (noteSaveTimer) clearTimeout(noteSaveTimer); + setPeerPrivateNote(peer.blipId, noteInput.value); + }); + noteSection.appendChild(noteTitle); + noteSection.appendChild(noteInput); + main.appendChild(noteSection); + } + + let msgBtn; + let callBtn; + let blockBtn; + if (showActions) { + const actions = document.createElement('div'); + actions.className = 'peer-profile-actions'; + msgBtn = document.createElement('button'); + msgBtn.type = 'button'; + msgBtn.className = 'btn btn-accent peer-profile-action-btn'; + msgBtn.textContent = t('peers.profile_message'); + msgBtn.addEventListener('click', () => onMessage?.()); + callBtn = document.createElement('button'); + callBtn.type = 'button'; + callBtn.className = 'btn btn-lang peer-profile-action-btn'; + callBtn.textContent = t('peers.profile_call'); + callBtn.addEventListener('click', () => onCall?.()); + blockBtn = document.createElement('button'); + blockBtn.type = 'button'; + blockBtn.className = 'btn btn-danger peer-profile-action-btn'; + blockBtn.addEventListener('click', () => { + onBlock?.(); + syncBlockBtn(); + }); + actions.appendChild(msgBtn); + actions.appendChild(callBtn); + actions.appendChild(blockBtn); + main.appendChild(actions); + } + + layout.appendChild(aside); + layout.appendChild(main); + body.appendChild(layout); + card.appendChild(body); + + function syncBlockBtn() { + if (!blockBtn) return; + const blocked = isBlocked(peer.blipId); + blockBtn.textContent = t(blocked ? 'peers.profile_unblock' : 'peers.profile_block'); + } + + async function resolveGifUrl() { + if (getProfileGifUrl) { + const u = await getProfileGifUrl(peer); + return u || null; + } + if (selfBlipId != null && Number(peer.blipId) === Number(selfBlipId)) { + return (await window.blip?.getProfileGifActiveUrl?.()) || null; + } + return getPeerProfileGifDataUrl(peer.blipId); + } + + function refresh() { + const pClass = presenceClass(peer); + const tip = statusTooltip(peer); + nameStatusDot.className = `status-dot peer-profile-name-dot ${pClass}`; + nameStatusDot.title = tip; + nameEl.textContent = formatPeerDisplayName(peer); + const custom = (peer.presenceText || '').trim(); + if (custom && peer.online) { + customStatusEl.textContent = custom; + customStatusEl.classList.remove('hidden'); + } else { + customStatusEl.textContent = ''; + customStatusEl.classList.add('hidden'); + } + if (callBtn) callBtn.disabled = !peer.online; + syncBlockBtn(); + + void resolveGifUrl().then((url) => { + if (url) { + cloudWrap.classList.remove('hidden'); + cloudImg.onload = () => applyGifCloudSize(cloud, cloudImg); + cloudImg.src = url; + cloudImg.classList.remove('hidden'); + cloud.classList.add('profile-gif-cloud--active'); + if (cloudImg.complete && cloudImg.naturalWidth) { + applyGifCloudSize(cloud, cloudImg); + } + } else { + cloudWrap.classList.add('hidden'); + cloudImg.removeAttribute('src'); + cloudImg.onload = null; + cloudImg.classList.add('hidden'); + cloud.classList.remove('profile-gif-cloud--active'); + cloud.style.width = ''; + cloud.style.height = ''; + } + }); + } + + refresh(); + let pulseTimer = null; + if (peer.online && onPing) { + void onPing(); + pulseTimer = window.setInterval(refresh, 2000); + } + + return { + el: card, + refresh, + destroy() { + if (noteSaveTimer) clearTimeout(noteSaveTimer); + if (noteInput) setPeerPrivateNote(peer.blipId, noteInput.value); + if (pulseTimer) { + clearInterval(pulseTimer); + pulseTimer = null; + } + }, + }; +} + +function defaultPresenceClass(peer) { + if (!peer?.online) return 'offline'; + if (peer.presence === 'away') return 'away'; + if (peer.presence === 'busy') return 'busy'; + return 'online'; +} + +function defaultStatusTooltip(peer) { + const base = + defaultPresenceClass(peer) === 'away' + ? t('peers.away') + : defaultPresenceClass(peer) === 'busy' + ? t('peers.busy') + : peer?.online + ? t('peers.online') + : t('peers.offline'); + const custom = (peer?.presenceText || '').trim(); + return custom && peer?.online ? `${base} · ${custom}` : base; +} diff --git a/renderer/profile-gif-picker.js b/renderer/profile-gif-picker.js new file mode 100644 index 0000000..980eced --- /dev/null +++ b/renderer/profile-gif-picker.js @@ -0,0 +1,313 @@ +import { t } from './i18n.js'; +import { openGifCropDialog } from './gif-crop-dialog.js'; +import { showAppToast } from './toasts.js'; + +function bytesToBase64(bytes) { + const chunk = 0x8000; + let binary = ''; + for (let i = 0; i < bytes.length; i += chunk) { + binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk)); + } + return btoa(binary); +} + +function toastProfileGifError(error) { + const key = + error === 'mesh_plus_required' + ? 'mesh_plus.feature_locked' + : error === 'gif_too_large' + ? 'settings.profile_gif_too_large' + : error === 'invalid_gif' + ? 'settings.profile_gif_invalid' + : 'settings.profile_gif_save_failed'; + showAppToast({ title: t(key), variant: 'danger', durationMs: 4500 }); +} + +function appendGifPickerTile(parent, sources, alt, onClick) { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'profile-gif-picker-tile'; + const img = document.createElement('img'); + img.alt = alt || ''; + img.loading = 'lazy'; + img.decoding = 'async'; + const urls = [...new Set(sources.filter(Boolean))]; + let urlIdx = 0; + img.src = urls[0] || ''; + img.onerror = () => { + urlIdx += 1; + if (urls[urlIdx]) img.src = urls[urlIdx]; + else btn.classList.add('profile-gif-picker-tile--broken'); + }; + btn.appendChild(img); + if (alt) btn.title = alt; + btn.addEventListener('click', onClick); + parent.appendChild(btn); + return btn; +} + +async function saveLocalGifFile(file) { + const isGif = + file.type === 'image/gif' || /\.gif$/i.test(file.name || ''); + if (!isGif) { + showAppToast({ title: t('settings.profile_gif_invalid'), variant: 'danger', durationMs: 4000 }); + return false; + } + const confirmed = await openGifCropDialog(file); + if (!confirmed) return false; + let r; + if (file.path && window.blip.saveProfileGifPath) { + r = await window.blip.saveProfileGifPath(file.path); + } else { + const bytes = new Uint8Array(await file.arrayBuffer()); + r = await window.blip.saveProfileGifBytes?.(bytesToBase64(bytes)); + } + if (!r?.ok) { + toastProfileGifError(r?.error); + return false; + } + showAppToast({ title: t('settings.profile_gif_saved'), durationMs: 3000 }); + return true; +} + +/** + * @param {{ onSelected?: () => void }} [opts] + * @returns {Promise} true if selection changed + */ +export function openProfileGifPicker(opts = {}) { + return new Promise((resolve) => { + let changed = false; + let activeTab = 'mine'; + + const backdrop = document.createElement('div'); + backdrop.className = 'blip-modal-backdrop profile-gif-picker-backdrop'; + + const modal = document.createElement('div'); + modal.className = 'blip-modal glass profile-gif-picker-modal'; + modal.setAttribute('role', 'dialog'); + modal.setAttribute('aria-modal', 'true'); + + const title = document.createElement('h3'); + title.className = 'blip-modal-title'; + title.dataset.i18n = 'settings.profile_gif_picker_title'; + title.textContent = t('settings.profile_gif_picker_title'); + + const tabs = document.createElement('div'); + tabs.className = 'profile-gif-picker-tabs'; + const tabMine = document.createElement('button'); + tabMine.type = 'button'; + tabMine.className = 'btn btn-lang profile-gif-picker-tab selected'; + tabMine.dataset.tab = 'mine'; + tabMine.dataset.i18n = 'settings.profile_gif_tab_mine'; + tabMine.textContent = t('settings.profile_gif_tab_mine'); + const tabOnline = document.createElement('button'); + tabOnline.type = 'button'; + tabOnline.className = 'btn btn-lang profile-gif-picker-tab'; + tabOnline.dataset.tab = 'online'; + tabOnline.dataset.i18n = 'settings.profile_gif_tab_online'; + tabOnline.textContent = t('settings.profile_gif_tab_online'); + tabs.appendChild(tabMine); + tabs.appendChild(tabOnline); + + const body = document.createElement('div'); + body.className = 'profile-gif-picker-body'; + + const minePanel = document.createElement('div'); + minePanel.className = 'profile-gif-picker-panel'; + const mineGrid = document.createElement('div'); + mineGrid.className = 'profile-gif-picker-grid'; + minePanel.appendChild(mineGrid); + + const uploadRow = document.createElement('div'); + uploadRow.className = 'profile-gif-picker-upload-row'; + const fileInput = document.createElement('input'); + fileInput.type = 'file'; + fileInput.accept = 'image/gif'; + fileInput.className = 'settings-avatar-file-input'; + const uploadBtn = document.createElement('label'); + uploadBtn.className = 'btn btn-accent'; + uploadBtn.dataset.i18n = 'settings.profile_gif_upload'; + uploadBtn.textContent = t('settings.profile_gif_upload'); + uploadBtn.htmlFor = ''; + const inputId = `profile-gif-file-${Date.now()}`; + fileInput.id = inputId; + uploadBtn.htmlFor = inputId; + const clearBtn = document.createElement('button'); + clearBtn.type = 'button'; + clearBtn.className = 'btn btn-lang'; + clearBtn.dataset.i18n = 'settings.profile_gif_clear'; + clearBtn.textContent = t('settings.profile_gif_clear'); + uploadRow.appendChild(fileInput); + uploadRow.appendChild(uploadBtn); + uploadRow.appendChild(clearBtn); + minePanel.appendChild(uploadRow); + + const onlinePanel = document.createElement('div'); + onlinePanel.className = 'profile-gif-picker-panel hidden'; + const searchRow = document.createElement('div'); + searchRow.className = 'profile-gif-picker-search'; + const searchInput = document.createElement('input'); + searchInput.type = 'search'; + searchInput.className = 'input'; + searchInput.placeholder = t('settings.profile_gif_search'); + searchInput.dataset.i18nPlaceholder = 'settings.profile_gif_search'; + const searchBtn = document.createElement('button'); + searchBtn.type = 'button'; + searchBtn.className = 'btn btn-accent'; + searchBtn.dataset.i18n = 'settings.profile_gif_search_btn'; + searchBtn.textContent = t('settings.profile_gif_search_btn'); + searchRow.appendChild(searchInput); + searchRow.appendChild(searchBtn); + const onlineHint = document.createElement('p'); + onlineHint.className = 'hint profile-gif-picker-online-hint'; + const onlineGrid = document.createElement('div'); + onlineGrid.className = 'profile-gif-picker-grid'; + onlinePanel.appendChild(searchRow); + onlinePanel.appendChild(onlineHint); + onlinePanel.appendChild(onlineGrid); + + body.appendChild(minePanel); + body.appendChild(onlinePanel); + + const actions = document.createElement('div'); + actions.className = 'blip-modal-actions'; + const closeBtn = document.createElement('button'); + closeBtn.type = 'button'; + closeBtn.className = 'btn btn-lang'; + closeBtn.textContent = t('dialog.cancel'); + + let done = false; + function finish(ok) { + if (done) return; + done = true; + backdrop.remove(); + resolve(ok); + } + + async function selectLocalId(id) { + const r = await window.blip.setProfileGifActive?.(id); + if (r?.ok) { + changed = true; + opts.onSelected?.(); + await renderMine(); + } + } + + async function renderMine() { + mineGrid.innerHTML = ''; + const history = (await window.blip.getProfileGifHistory?.()) || []; + const activeUrl = await window.blip.getProfileGifActiveUrl?.(); + if (history.length === 0) { + const empty = document.createElement('p'); + empty.className = 'hint'; + empty.dataset.i18n = 'settings.profile_gif_empty'; + empty.textContent = t('settings.profile_gif_empty'); + mineGrid.appendChild(empty); + return; + } + for (const item of history) { + const btn = appendGifPickerTile( + mineGrid, + [item.dataUrl], + '', + () => void selectLocalId(item.id) + ); + if (activeUrl === item.dataUrl) btn.classList.add('selected'); + } + } + + async function loadOnline(query, offset = 0) { + onlineGrid.innerHTML = ''; + const giphyOk = await window.blip.isGiphyConfigured?.(); + if (!giphyOk) { + onlineHint.dataset.i18n = 'settings.profile_gif_giphy_unavailable'; + onlineHint.textContent = t('settings.profile_gif_giphy_unavailable'); + return; + } + onlineHint.textContent = ''; + const res = query + ? await window.blip.searchGiphy?.(query, offset) + : await window.blip.trendingGiphy?.(offset); + if (!res?.ok || !res.items?.length) { + onlineHint.dataset.i18n = 'settings.profile_gif_online_empty'; + onlineHint.textContent = t('settings.profile_gif_online_empty'); + return; + } + for (const g of res.items) { + const btn = appendGifPickerTile( + onlineGrid, + [g.previewUrl, g.gifUrl], + g.title, + async () => { + btn.disabled = true; + const imp = await window.blip.importGiphyGif?.(g.gifUrl); + btn.disabled = false; + if (imp?.ok) { + changed = true; + opts.onSelected?.(); + showAppToast({ title: t('settings.profile_gif_saved'), durationMs: 3000 }); + activeTab = 'mine'; + tabMine.classList.add('selected'); + tabOnline.classList.remove('selected'); + minePanel.classList.remove('hidden'); + onlinePanel.classList.add('hidden'); + await renderMine(); + } else { + toastProfileGifError(imp?.error); + } + } + ); + } + } + + function setTab(tab) { + activeTab = tab; + tabMine.classList.toggle('selected', tab === 'mine'); + tabOnline.classList.toggle('selected', tab === 'online'); + minePanel.classList.toggle('hidden', tab !== 'mine'); + onlinePanel.classList.toggle('hidden', tab !== 'online'); + if (tab === 'online') void loadOnline(searchInput.value.trim()); + } + + tabMine.addEventListener('click', () => setTab('mine')); + tabOnline.addEventListener('click', () => setTab('online')); + + fileInput.addEventListener('change', async () => { + const file = fileInput.files?.[0]; + fileInput.value = ''; + if (!file) return; + if (await saveLocalGifFile(file)) { + changed = true; + opts.onSelected?.(); + await renderMine(); + } + }); + + clearBtn.addEventListener('click', async () => { + await window.blip.clearProfileGif?.(); + changed = true; + opts.onSelected?.(); + await renderMine(); + }); + + searchBtn.addEventListener('click', () => void loadOnline(searchInput.value.trim())); + searchInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') void loadOnline(searchInput.value.trim()); + }); + + closeBtn.addEventListener('click', () => finish(changed)); + backdrop.addEventListener('click', (e) => { + if (e.target === backdrop) finish(changed); + }); + + actions.appendChild(closeBtn); + modal.appendChild(title); + modal.appendChild(tabs); + modal.appendChild(body); + modal.appendChild(actions); + backdrop.appendChild(modal); + document.body.appendChild(backdrop); + + void renderMine(); + }); +} diff --git a/renderer/project-tools-ui.js b/renderer/project-tools-ui.js index 803c134..fc3f53b 100644 --- a/renderer/project-tools-ui.js +++ b/renderer/project-tools-ui.js @@ -1,7 +1,15 @@ import { t } from './i18n.js'; import { getPadState, setPadState, getBoardState, setBoardState, getCanvasState, getClipState, pushClipEntry, subscribeGroupProject } from './group-projects-store.js'; import { broadcastProject, requestClipboardPull } from './group-projects-wire.js'; -import { broadcastMeshPad } from './projects-mesh-wire.js'; +import { + broadcastMeshPad, + broadcastMeshBoard, + broadcastMeshCanvas, + broadcastMeshClipboard, + requestMeshClipboardPull, +} from './projects-mesh-wire.js'; +import { clipLimitForTier } from './group-projects-store.js'; +import { getPadHistory, pushPadSnapshot, getPadSnapshotById } from './pad-history-store.js'; import { createMessageId } from './message-id.js'; const CANVAS_COLORS = ['', '#00ffc8', '#888888', '#ff3366', '#e0e0e0', '#4488ff']; @@ -54,23 +62,35 @@ export function createPadToolView(group, config, api, meshOpts = null) { syncStatus(true, st.from, 0); } - function pushLocal() { + function pushLocal(opts = {}) { if (applying) return; const text = ta.value; - const payload = { text, updatedAt: Date.now() }; + const updatedAt = Date.now(); + const payload = { text, updatedAt }; setPadState(scopeId, { ...payload, from: config.blipId }); if (meshOpts?.getBroadcastTargets) { void broadcastMeshPad(api, config, meshOpts.getBroadcastTargets(), payload); } else { void broadcastProject(api, config, group, 'pad', payload); } + if (meshOpts?.meshPlusActive && !opts.skipHistory) { + pushPadSnapshot(scopeId, { + text, + updatedAt, + from: config.blipId, + label: opts.historyLabel || '', + }); + renderHistory?.(); + } syncStatus(true, config.blipId, 0); } + let renderHistory = null; + ta.addEventListener('input', () => { clearTimeout(debounce); syncStatus(false, config.blipId, 0); - debounce = setTimeout(pushLocal, 300); + debounce = setTimeout(() => pushLocal(), 300); }); const unsub = subscribeGroupProject(scopeId, (tool) => { @@ -78,8 +98,83 @@ export function createPadToolView(group, config, api, meshOpts = null) { }); applyRemote(); - wrap.appendChild(ta); - wrap.appendChild(status.bar); + + if (meshOpts?.meshPlusActive) { + const histWrap = document.createElement('div'); + histWrap.className = 'proj-pad-history'; + const histTitle = document.createElement('div'); + histTitle.className = 'proj-pad-history-title'; + histTitle.dataset.i18n = 'projects.pad_history_title'; + histTitle.textContent = t('projects.pad_history_title'); + const histList = document.createElement('div'); + histList.className = 'proj-pad-history-list'; + const histActions = document.createElement('div'); + histActions.className = 'proj-pad-history-actions'; + const saveBtn = document.createElement('button'); + saveBtn.type = 'button'; + saveBtn.className = 'btn btn-lang'; + saveBtn.dataset.i18n = 'projects.pad_history_save'; + saveBtn.textContent = t('projects.pad_history_save'); + saveBtn.addEventListener('click', () => { + pushPadSnapshot(scopeId, { + text: ta.value, + updatedAt: Date.now(), + from: config.blipId, + label: t('projects.pad_history_manual'), + }); + renderHistory(); + }); + + renderHistory = () => { + histList.innerHTML = ''; + const items = getPadHistory(scopeId); + if (!items.length) { + const empty = document.createElement('p'); + empty.className = 'hint'; + empty.dataset.i18n = 'projects.pad_history_empty'; + empty.textContent = t('projects.pad_history_empty'); + histList.appendChild(empty); + return; + } + for (const item of items) { + const row = document.createElement('button'); + row.type = 'button'; + row.className = 'proj-pad-history-item'; + const meta = document.createElement('span'); + meta.className = 'proj-pad-history-meta'; + const when = new Date(item.updatedAt).toLocaleString(); + meta.textContent = item.label + ? `${item.label} · #${item.from}` + : `#${item.from} · ${when}`; + const preview = document.createElement('span'); + preview.className = 'proj-pad-history-preview'; + preview.textContent = item.text.slice(0, 120) + (item.text.length > 120 ? '…' : ''); + row.appendChild(meta); + row.appendChild(preview); + row.addEventListener('click', () => { + const snap = getPadSnapshotById(scopeId, item.id); + if (!snap) return; + applying = true; + ta.value = snap.text; + applying = false; + pushLocal({ historyLabel: t('projects.pad_history_restore') }); + }); + histList.appendChild(row); + } + }; + + histActions.appendChild(saveBtn); + histWrap.appendChild(histTitle); + histWrap.appendChild(histList); + histWrap.appendChild(histActions); + wrap.appendChild(ta); + wrap.appendChild(status.bar); + wrap.appendChild(histWrap); + renderHistory(); + } else { + wrap.appendChild(ta); + wrap.appendChild(status.bar); + } return { el: wrap, @@ -90,7 +185,8 @@ export function createPadToolView(group, config, api, meshOpts = null) { }; } -export function createBoardToolView(group, config, api) { +export function createBoardToolView(group, config, api, meshOpts = null) { + const scopeId = meshOpts?.scopeId ?? group.id; const wrap = document.createElement('div'); wrap.className = 'proj-tool proj-tool--board'; @@ -98,8 +194,16 @@ export function createBoardToolView(group, config, api) { const colEls = new Map(); const status = mkStatusBar(); + function broadcastBoard(cards) { + if (meshOpts?.getBroadcastTargets) { + void broadcastMeshBoard(api, config, meshOpts.getBroadcastTargets(), { cards }); + } else { + void broadcastProject(api, config, group, 'board', { cards }); + } + } + function render() { - const st = getBoardState(group.id); + const st = getBoardState(scopeId); for (const col of cols) { const list = colEls.get(col); if (!list) continue; @@ -136,8 +240,8 @@ export function createBoardToolView(group, config, api) { }, ], }; - setBoardState(group.id, next); - void broadcastProject(api, config, group, 'board', { cards: next.cards }); + setBoardState(scopeId, next); + broadcastBoard(next.cards); render(); }); list.appendChild(add); @@ -161,7 +265,7 @@ export function createBoardToolView(group, config, api) { colEls.set(col, list); } - const unsub = subscribeGroupProject(group.id, (tool) => { + const unsub = subscribeGroupProject(scopeId, (tool) => { if (tool === 'board') render(); }); @@ -178,13 +282,22 @@ export function createBoardToolView(group, config, api) { }; } -export function createCanvasToolView(group, config, api) { +export function createCanvasToolView(group, config, api, meshOpts = null) { + const scopeId = meshOpts?.scopeId ?? group.id; const wrap = document.createElement('div'); wrap.className = 'proj-tool proj-tool--canvas'; let color = CANVAS_COLORS[1]; let activePainter = null; + function broadcastCanvasPixel(x, y, c) { + if (meshOpts?.getBroadcastTargets) { + void broadcastMeshCanvas(api, config, meshOpts.getBroadcastTargets(), { x, y, color: c }); + } else { + void broadcastProject(api, config, group, 'canvas', { x, y, color: c }); + } + } + const head = document.createElement('div'); head.className = 'proj-canvas-head'; const hint = document.createElement('span'); @@ -195,7 +308,7 @@ export function createCanvasToolView(group, config, api) { grid.className = 'proj-canvas-grid'; const cells = []; - const st0 = getCanvasState(group.id); + const st0 = getCanvasState(scopeId); for (let y = 0; y < st0.h; y++) { for (let x = 0; x < st0.w; x++) { const cell = document.createElement('button'); @@ -204,8 +317,8 @@ export function createCanvasToolView(group, config, api) { const idx = y * st0.w + x; cell.addEventListener('click', () => { const c = color || '#00ffc8'; - setCanvasPixel(group.id, x, y, c); - void broadcastProject(api, config, group, 'canvas', { x, y, color: c }); + setCanvasPixel(scopeId, x, y, c); + broadcastCanvasPixel(x, y, c); activePainter = config.blipId; paint(); }); @@ -233,7 +346,7 @@ export function createCanvasToolView(group, config, api) { const status = mkStatusBar(); function paint() { - const st = getCanvasState(group.id); + const st = getCanvasState(scopeId); st.cells.forEach((c, i) => { cells[i].style.background = c || '#1a1a1a'; }); @@ -244,7 +357,7 @@ export function createCanvasToolView(group, config, api) { status.text.textContent = t('projects.canvas_size'); } - const unsub = subscribeGroupProject(group.id, (tool) => { + const unsub = subscribeGroupProject(scopeId, (tool) => { if (tool === 'canvas') paint(); }); @@ -262,18 +375,34 @@ export function createCanvasToolView(group, config, api) { }; } -export function createClipboardToolView(group, config, api) { +export function createClipboardToolView(group, config, api, meshOpts = null) { + const scopeId = meshOpts?.scopeId ?? group.id; + const clipMax = meshOpts?.clipMax ?? clipLimitForTier(!!meshOpts?.meshPlusActive); + const meshPlus = !!meshOpts?.meshPlusActive; const wrap = document.createElement('div'); wrap.className = 'proj-tool proj-tool--clipboard'; const list = document.createElement('div'); list.className = 'proj-clip-list'; const status = mkStatusBar(); + let searchQuery = ''; + + function broadcastClip(payload) { + if (meshOpts?.getBroadcastTargets) { + void broadcastMeshClipboard(api, config, meshOpts.getBroadcastTargets(), payload); + } else { + void broadcastProject(api, config, group, 'clipboard', payload); + } + } function render() { - const st = getClipState(group.id); + const st = getClipState(scopeId); list.innerHTML = ''; - st.entries.forEach((e) => { + const q = searchQuery.trim().toLowerCase(); + const entries = q + ? st.entries.filter((e) => String(e.text || '').toLowerCase().includes(q)) + : st.entries; + entries.forEach((e) => { const row = document.createElement('div'); row.className = 'proj-clip-row'; if (Number(e.from) === Number(config.blipId)) row.classList.add('proj-clip-row--mine'); @@ -300,11 +429,23 @@ export function createClipboardToolView(group, config, api) { row.appendChild(copyBtn); list.appendChild(row); }); - status.text.textContent = t('projects.clip_count').replace('{n}', String(st.entries.length)); + if (meshPlus) { + status.text.textContent = t('projects.clip_count_search') + .replace('{n}', String(entries.length)) + .replace('{total}', String(st.entries.length)); + } else { + status.text.textContent = t('projects.clip_count_limit') + .replace('{n}', String(st.entries.length)) + .replace('{max}', String(clipMax)); + } } async function pull() { - await requestClipboardPull(api, config, group); + if (meshOpts?.getBroadcastTargets) { + await requestMeshClipboardPull(api, config, meshOpts.getBroadcastTargets()); + } else { + await requestClipboardPull(api, config, group); + } } const pullBtn = document.createElement('button'); @@ -329,8 +470,8 @@ export function createClipboardToolView(group, config, api) { from: config.blipId, ts: Date.now(), }; - pushClipEntry(group.id, entry); - void broadcastProject(api, config, group, 'clipboard', { entry }); + pushClipEntry(scopeId, entry, clipMax); + broadcastClip({ entry }); render(); } catch { /* permission */ @@ -342,13 +483,36 @@ export function createClipboardToolView(group, config, api) { actions.appendChild(pullBtn); actions.appendChild(pushBtn); - const unsub = subscribeGroupProject(group.id, (tool) => { + const topRow = document.createElement('div'); + topRow.className = 'proj-clip-top'; + topRow.appendChild(actions); + + if (meshPlus) { + const search = document.createElement('input'); + search.type = 'search'; + search.className = 'input proj-clip-search'; + search.placeholder = t('projects.clip_search'); + search.dataset.i18nPlaceholder = 'projects.clip_search'; + search.addEventListener('input', () => { + searchQuery = search.value; + render(); + }); + topRow.appendChild(search); + } else { + const limitHint = document.createElement('p'); + limitHint.className = 'hint proj-clip-limit-hint'; + limitHint.dataset.i18n = 'projects.clip_free_limit_hint'; + limitHint.textContent = t('projects.clip_free_limit_hint').replace('{max}', String(clipMax)); + topRow.appendChild(limitHint); + } + + const unsub = subscribeGroupProject(scopeId, (tool) => { if (tool === 'clipboard') render(); }); void pull(); render(); - wrap.appendChild(actions); + wrap.appendChild(topRow); wrap.appendChild(list); wrap.appendChild(status.bar); diff --git a/renderer/projects-mesh-wire.js b/renderer/projects-mesh-wire.js index 200cf86..cebb792 100644 --- a/renderer/projects-mesh-wire.js +++ b/renderer/projects-mesh-wire.js @@ -1,4 +1,14 @@ -import { setPadState } from './group-projects-store.js'; +import { + getPadState, + setPadState, + setBoardState, + setCanvasPixel, + getClipState, + pushClipEntry, + mergeClipEntries, + clipLimitForTier, +} from './group-projects-store.js'; +import { isMeshPlusTierActive } from '../shared/mesh-plus-gates.js'; export const MESH_PROJECT_SCOPE = '__mesh__'; @@ -10,6 +20,19 @@ async function safeSend(api, payload) { } } +async function broadcastMesh(api, config, peerIds, type, payload) { + const myId = Number(config.blipId); + for (const to of peerIds || []) { + if (Number(to) === myId) continue; + await safeSend(api, { + type, + to, + from: myId, + payload, + }); + } +} + /** * @param {object} api * @param {object} config @@ -17,29 +40,130 @@ async function safeSend(api, payload) { * @param {object} payload */ export async function broadcastMeshPad(api, config, peerIds, payload) { + return broadcastMesh(api, config, peerIds, 'mesh-proj-pad', payload); +} + +/** + * @param {object} api + * @param {object} config + * @param {number[]} peerIds + * @param {object} payload + */ +export async function broadcastMeshBoard(api, config, peerIds, payload) { + return broadcastMesh(api, config, peerIds, 'mesh-proj-board', payload); +} + +/** + * @param {object} api + * @param {object} config + * @param {number[]} peerIds + * @param {object} payload + */ +export async function broadcastMeshCanvas(api, config, peerIds, payload) { + return broadcastMesh(api, config, peerIds, 'mesh-proj-canvas', payload); +} + +/** + * @param {object} api + * @param {object} config + * @param {number[]} peerIds + * @param {object} payload + */ +export async function broadcastMeshClipboard(api, config, peerIds, payload) { + return broadcastMesh(api, config, peerIds, 'mesh-proj-clipboard', payload); +} + +/** + * @param {object} api + * @param {object} config + * @param {number[]} peerIds + */ +export async function requestMeshClipboardPull(api, config, peerIds) { const myId = Number(config.blipId); for (const to of peerIds || []) { if (Number(to) === myId) continue; await safeSend(api, { - type: 'mesh-proj-pad', + type: 'mesh-proj-clipboard-pull', to, from: myId, - payload, + payload: {}, }); } } -export function handleMeshProjectTcp(msg, config) { +/** + * @param {object} api + * @param {object} config + * @param {number} requesterId + */ +export async function respondMeshClipboardPull(api, config, requesterId) { + const cap = clipLimitForTier(isMeshPlusTierActive(config)); + const st = getClipState(MESH_PROJECT_SCOPE); + await broadcastMeshClipboard(api, config, [requesterId], { + entries: st.entries.slice(0, cap), + }); +} + +export function handleMeshProjectTcp(msg, config, api) { const type = msg.type; - if (type !== 'mesh-proj-pad') return false; const from = Number(msg.from); const myId = Number(config.blipId); - if (!Number.isFinite(from) || from === myId) return true; + const clipCap = clipLimitForTier(isMeshPlusTierActive(config)); + + if (type === 'mesh-proj-clipboard-pull') { + if (!Number.isFinite(from) || from === myId) return true; + if (api) void respondMeshClipboardPull(api, config, from); + return true; + } + + if (!Number.isFinite(from) || from === myId) { + return ( + type === 'mesh-proj-pad' || + type === 'mesh-proj-board' || + type === 'mesh-proj-canvas' || + type === 'mesh-proj-clipboard' + ); + } + const payload = msg.payload || {}; - setPadState(MESH_PROJECT_SCOPE, { - text: String(payload.text || ''), - updatedAt: Number(payload.updatedAt) || Date.now(), - from, - }); - return true; + + if (type === 'mesh-proj-pad') { + const incomingAt = Number(payload.updatedAt) || 0; + const current = getPadState(MESH_PROJECT_SCOPE); + if (incomingAt >= (current.updatedAt || 0)) { + setPadState(MESH_PROJECT_SCOPE, { + text: String(payload.text || ''), + updatedAt: incomingAt, + from, + }); + } + return true; + } + + if (type === 'mesh-proj-board') { + const cards = Array.isArray(payload.cards) ? payload.cards : []; + setBoardState(MESH_PROJECT_SCOPE, { cards }); + return true; + } + + if (type === 'mesh-proj-canvas') { + const x = Number(payload.x); + const y = Number(payload.y); + const color = String(payload.color || ''); + if (Number.isFinite(x) && Number.isFinite(y)) { + setCanvasPixel(MESH_PROJECT_SCOPE, x, y, color); + } + return true; + } + + if (type === 'mesh-proj-clipboard') { + if (Array.isArray(payload.entries)) { + mergeClipEntries(MESH_PROJECT_SCOPE, payload.entries, clipCap); + } else if (payload.entry) { + pushClipEntry(MESH_PROJECT_SCOPE, payload.entry, clipCap); + } + return true; + } + + return false; } diff --git a/renderer/projects-view.js b/renderer/projects-view.js index 594c0de..7347f38 100644 --- a/renderer/projects-view.js +++ b/renderer/projects-view.js @@ -1,22 +1,31 @@ import { t } from './i18n.js'; import { createPixelHintIcon } from './settings-ui.js'; -import { createPadToolView } from './project-tools-ui.js'; +import { + createPadToolView, + createBoardToolView, + createCanvasToolView, + createClipboardToolView, +} from './project-tools-ui.js'; +import { clipLimitForTier } from './group-projects-store.js'; import { MESH_PROJECT_SCOPE } from './projects-mesh-wire.js'; +import { isMeshPlusActive, showMeshPlusLockedToast, createMeshPlusBadge } from './mesh-plus.js'; const TOOLS = [ - { id: 'pad', icon: '✦', labelKey: 'projects.tool_pad', ready: true }, - { id: 'board', icon: '▦', labelKey: 'projects.tool_board', ready: false }, - { id: 'canvas', icon: '◻', labelKey: 'projects.tool_canvas', ready: false }, - { id: 'clipboard', icon: '⧉', labelKey: 'projects.tool_clip', ready: false }, + { id: 'pad', icon: '✦', labelKey: 'projects.tool_pad', tier: 'free' }, + { id: 'board', icon: '▦', labelKey: 'projects.tool_board', tier: 'mesh_plus' }, + { id: 'canvas', icon: '◻', labelKey: 'projects.tool_canvas', tier: 'mesh_plus' }, + { id: 'clipboard', icon: '⧉', labelKey: 'projects.tool_clip', tier: 'free' }, ]; /** * Standalone Projects workspace (not tied to groups). - * @param {object} config + * @param {object | (() => object)} configOrGetter — live config (use getter so MESH+ activation applies) * @param {object} api * @param {() => number[]} getOnlinePeerIds */ -export function createProjectsView(config, api, getOnlinePeerIds) { +export function createProjectsView(configOrGetter, api, getOnlinePeerIds) { + const getConfig = + typeof configOrGetter === 'function' ? configOrGetter : () => configOrGetter; const root = document.createElement('div'); root.className = 'projects-workspace'; @@ -48,17 +57,56 @@ export function createProjectsView(config, api, getOnlinePeerIds) { let activeTool = 'pad'; let padView = null; + let boardView = null; + let canvasView = null; + let clipboardView = null; + + function meshGroup() { + return { id: MESH_PROJECT_SCOPE, members: getOnlinePeerIds() }; + } + + function meshOpts() { + const mp = isMeshPlusActive(getConfig()); + return { + scopeId: MESH_PROJECT_SCOPE, + getBroadcastTargets: getOnlinePeerIds, + meshPlusActive: mp, + clipMax: clipLimitForTier(mp), + }; + } function destroyPad() { padView?.destroy?.(); padView = null; } - function showStub(toolId) { + function destroyBoard() { + boardView?.destroy?.(); + boardView = null; + } + + function destroyCanvas() { + canvasView?.destroy?.(); + canvasView = null; + } + + function destroyClipboard() { + clipboardView?.destroy?.(); + clipboardView = null; + } + + function clearMain() { destroyPad(); + destroyBoard(); + destroyCanvas(); + destroyClipboard(); main.innerHTML = ''; + } + + function showStub(toolId, opts = {}) { + clearMain(); const stubEl = document.createElement('div'); - stubEl.className = 'projects-stub glass'; + stubEl.className = `projects-stub glass${opts.locked ? ' projects-stub--locked' : ''}`; const icon = document.createElement('span'); icon.className = 'projects-stub-icon'; const def = TOOLS.find((x) => x.id === toolId); @@ -68,8 +116,14 @@ export function createProjectsView(config, api, getOnlinePeerIds) { stubTitle.textContent = def ? t(def.labelKey) : toolId; const hint = document.createElement('p'); hint.className = 'hint projects-stub-hint'; - hint.dataset.i18n = 'projects.tool_soon'; - hint.textContent = t('projects.tool_soon'); + if (opts.locked) { + hint.dataset.i18n = 'projects.mesh_plus_required'; + hint.textContent = t('projects.mesh_plus_required'); + stubEl.appendChild(createMeshPlusBadge()); + } else { + hint.dataset.i18n = 'projects.tool_soon'; + hint.textContent = t('projects.tool_soon'); + } stubEl.appendChild(icon); stubEl.appendChild(stubTitle); stubEl.appendChild(hint); @@ -77,28 +131,61 @@ export function createProjectsView(config, api, getOnlinePeerIds) { } function showPad() { - main.innerHTML = ''; - destroyPad(); - padView = createPadToolView( - { id: MESH_PROJECT_SCOPE, members: getOnlinePeerIds() }, - config, - api, - { - scopeId: MESH_PROJECT_SCOPE, - getBroadcastTargets: getOnlinePeerIds, - } - ); + clearMain(); + padView = createPadToolView(meshGroup(), getConfig(), api, meshOpts()); padView.el.classList.add('glass'); main.appendChild(padView.el); } + function showBoard() { + clearMain(); + boardView = createBoardToolView(meshGroup(), getConfig(), api, meshOpts()); + boardView.el.classList.add('glass'); + main.appendChild(boardView.el); + } + + function showCanvas() { + clearMain(); + canvasView = createCanvasToolView(meshGroup(), getConfig(), api, meshOpts()); + canvasView.el.classList.add('glass'); + main.appendChild(canvasView.el); + } + + function showClipboard() { + clearMain(); + clipboardView = createClipboardToolView(meshGroup(), getConfig(), api, meshOpts()); + clipboardView.el.classList.add('glass'); + main.appendChild(clipboardView.el); + } + + function canUseTool(tool) { + if (tool.tier === 'free') return true; + if (tool.tier === 'mesh_plus') return isMeshPlusActive(getConfig()); + return false; + } + function selectTool(id) { activeTool = id; toolList.querySelectorAll('.projects-tool-btn').forEach((btn) => { btn.classList.toggle('projects-tool-btn--active', btn.dataset.tool === id); }); const def = TOOLS.find((x) => x.id === id); - if (def?.ready) showPad(); + if (!def) return; + + if (!canUseTool(def)) { + if (def.tier === 'mesh_plus') { + showMeshPlusLockedToast(); + showStub(id, { locked: true }); + } else { + showStub(id); + } + return; + } + + if (id === 'pad') showPad(); + else if (id === 'board') showBoard(); + else if (id === 'canvas') showCanvas(); + else if (id === 'clipboard') showClipboard(); else showStub(id); } @@ -115,7 +202,9 @@ export function createProjectsView(config, api, getOnlinePeerIds) { iconSpan.textContent = tool.icon; btn.appendChild(iconSpan); btn.appendChild(label); - if (!tool.ready) btn.classList.add('projects-tool-btn--soon'); + if (tool.tier === 'mesh_plus' && !isMeshPlusActive(getConfig())) { + btn.classList.add('projects-tool-btn--mesh-locked'); + } btn.addEventListener('click', () => selectTool(tool.id)); toolList.appendChild(btn); }); @@ -133,10 +222,23 @@ export function createProjectsView(config, api, getOnlinePeerIds) { return { el: root, destroy() { - destroyPad(); + clearMain(); }, refreshPeers() { - /* pad reads getOnlinePeerIds on each broadcast */ + boardView?.refresh?.(); + }, + refreshMeshPlus() { + toolList.querySelectorAll('.projects-tool-btn').forEach((btn) => { + const def = TOOLS.find((x) => x.id === btn.dataset.tool); + if (!def) return; + btn.classList.toggle( + 'projects-tool-btn--mesh-locked', + def.tier === 'mesh_plus' && !isMeshPlusActive(getConfig()) + ); + }); + if (activeTool !== 'pad' && !canUseTool(TOOLS.find((x) => x.id === activeTool) || {})) { + selectTool(activeTool); + } }, }; } diff --git a/renderer/session-stats-panel.js b/renderer/session-stats-panel.js new file mode 100644 index 0000000..5e535a0 --- /dev/null +++ b/renderer/session-stats-panel.js @@ -0,0 +1,70 @@ +import { t } from './i18n.js'; +import { buildSectionSubtitleRow } from './settings-ui.js'; +import { + getSessionStats, + getSessionStatsChartBars, + sessionOnlineHours, +} from './session-stats.js'; + +/** + * @param {HTMLElement} parent + */ +export function appendSessionStatsSection(parent) { + parent.appendChild(buildSectionSubtitleRow('settings.network_stats_title', 'settings.network_stats_hint')); + + const card = document.createElement('div'); + card.className = 'session-stats-panel settings-list-panel'; + + const summary = document.createElement('div'); + summary.className = 'session-stats-summary'; + + const chartWrap = document.createElement('div'); + chartWrap.className = 'session-stats-chart'; + chartWrap.setAttribute('role', 'img'); + chartWrap.setAttribute('aria-label', t('settings.network_stats_chart')); + + function refresh() { + const s = getSessionStats(); + const hours = sessionOnlineHours(); + const hStr = + hours >= 1 ? `${hours.toFixed(1)} h` : `${Math.max(1, Math.round(hours * 60))} min`; + summary.textContent = t('settings.network_stats_summary') + .replace('{hours}', hStr) + .replace('{messages}', String(s.messagesSent || 0)) + .replace('{files}', String(s.filesSent || 0)) + .replace('{calls}', String(s.callsStarted || 0)) + .replace('{peers}', String(s.peersMaxOnline || 0)); + + chartWrap.innerHTML = ''; + const bars = getSessionStatsChartBars(); + const maxVal = Math.max(1, ...bars.map((b) => b.value)); + for (const bar of bars) { + const row = document.createElement('div'); + row.className = 'session-stats-chart__row'; + const label = document.createElement('span'); + label.className = 'session-stats-chart__label'; + label.textContent = t(bar.labelKey); + const track = document.createElement('div'); + track.className = 'session-stats-chart__track'; + const fill = document.createElement('div'); + fill.className = 'session-stats-chart__fill'; + fill.style.width = `${Math.round((bar.value / maxVal) * 100)}%`; + fill.title = String(bar.value); + track.appendChild(fill); + const val = document.createElement('span'); + val.className = 'session-stats-chart__val'; + val.textContent = String(bar.value); + row.appendChild(label); + row.appendChild(track); + row.appendChild(val); + chartWrap.appendChild(row); + } + } + + card.appendChild(summary); + card.appendChild(chartWrap); + parent.appendChild(card); + refresh(); + + return { refresh }; +} diff --git a/renderer/session-stats.js b/renderer/session-stats.js new file mode 100644 index 0000000..8615d58 --- /dev/null +++ b/renderer/session-stats.js @@ -0,0 +1,104 @@ +import { syncAchievements } from './achievements-tracker.js'; + +const STORAGE_KEY = 'blip_session_stats_v1'; + +/** @type {(() => object | null) | null} */ +let achievementConfigProvider = null; + +/** @param {() => object | null} fn */ +export function setAchievementConfigProvider(fn) { + achievementConfigProvider = fn; +} + +function tickAchievements() { + const cfg = achievementConfigProvider?.(); + if (cfg) syncAchievements(cfg); +} + +function load() { + try { + const raw = localStorage.getItem(STORAGE_KEY); + const o = raw ? JSON.parse(raw) : {}; + return o && typeof o === 'object' ? o : {}; + } catch { + return {}; + } +} + +function save(data) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); + } catch { + /* quota */ + } +} + +function ensure() { + const data = load(); + if (!data.sessionStartedAt) { + data.sessionStartedAt = Date.now(); + data.messagesSent = 0; + data.filesSent = 0; + data.callsStarted = 0; + data.peersMaxOnline = 0; + save(data); + } + return data; +} + +export function getSessionStats() { + return ensure(); +} + +export function recordMessageSent() { + const d = ensure(); + d.messagesSent = (d.messagesSent || 0) + 1; + save(d); + tickAchievements(); +} + +export function recordFileSent() { + const d = ensure(); + d.filesSent = (d.filesSent || 0) + 1; + save(d); + tickAchievements(); +} + +export function recordCallStarted() { + const d = ensure(); + d.callsStarted = (d.callsStarted || 0) + 1; + save(d); + tickAchievements(); +} + +/** + * @param {number} onlineCount + */ +export function recordPeersOnline(onlineCount) { + const d = ensure(); + const n = Number(onlineCount) || 0; + if (n > (d.peersMaxOnline || 0)) { + d.peersMaxOnline = n; + save(d); + tickAchievements(); + } +} + +export function sessionOnlineHours() { + const d = ensure(); + const ms = Date.now() - (d.sessionStartedAt || Date.now()); + return Math.max(0, ms / (1000 * 60 * 60)); +} + +/** @returns {{ labelKey: string, value: number }[]} */ +export function getSessionStatsChartBars() { + const s = getSessionStats(); + const minutes = Math.max(1, Math.round(sessionOnlineHours() * 60)); + return [ + { labelKey: 'settings.network_stats_bar_messages', value: s.messagesSent || 0 }, + { labelKey: 'settings.network_stats_bar_files', value: s.filesSent || 0 }, + { labelKey: 'settings.network_stats_bar_calls', value: s.callsStarted || 0 }, + { labelKey: 'settings.network_stats_bar_peers', value: s.peersMaxOnline || 0 }, + { labelKey: 'settings.network_stats_bar_minutes', value: minutes }, + ]; +} diff --git a/renderer/settings-profile-panel.js b/renderer/settings-profile-panel.js new file mode 100644 index 0000000..933250f --- /dev/null +++ b/renderer/settings-profile-panel.js @@ -0,0 +1,313 @@ +import { t } from './i18n.js'; +import { buildProfileCard } from './profile-card.js'; +import { createAvatarElement, setSelfAvatarCache, regenerateAvatar } from './avatar.js'; +import { openAvatarCropDialog } from './avatar-crop-dialog.js'; +import { openProfileGifPicker } from './profile-gif-picker.js'; +import { isMeshPlusActive, showMeshPlusLockedToast } from './mesh-plus.js'; +import { showAppToast } from './toasts.js'; +import { + buildSettingsField, + buildThemedSelect, + fillSettingsDropdown, + buildPanelTitleRow, +} from './settings-ui.js'; + +/** + * @param {object} state + * @param {object} api + * @param {{ broadcastCustomAvatar?: () => void, broadcastProfileGif?: () => void }} deps + */ +export function buildSettingsProfilePanel(state, api, deps = {}) { + const frag = document.createElement('div'); + frag.className = 'settings-panel settings-panel--profile'; + + frag.appendChild(buildPanelTitleRow('settings.section_profile')); + + const layout = document.createElement('div'); + layout.className = 'settings-profile-layout'; + + const editor = document.createElement('div'); + editor.className = 'settings-profile-editor glass'; + + const editorTitle = document.createElement('h3'); + editorTitle.className = 'section-subtitle'; + editorTitle.dataset.i18n = 'settings.profile_editor_title'; + editorTitle.textContent = t('settings.profile_editor_title'); + editor.appendChild(editorTitle); + + const editorCard = document.createElement('div'); + editorCard.className = 'settings-profile-editor-card'; + + const avatarRow = document.createElement('div'); + avatarRow.className = 'settings-profile-avatar-row'; + const avatarPreview = document.createElement('div'); + avatarPreview.className = 'settings-profile-avatar-preview'; + const avatarActions = document.createElement('div'); + avatarActions.className = 'settings-profile-avatar-actions'; + + const fileInput = document.createElement('input'); + fileInput.type = 'file'; + fileInput.accept = 'image/png,image/jpeg,image/webp'; + fileInput.className = 'settings-avatar-file-input'; + const uploadLabel = document.createElement('label'); + uploadLabel.className = 'btn btn-accent'; + uploadLabel.dataset.i18n = 'settings.avatar_upload'; + uploadLabel.textContent = t('settings.avatar_upload'); + const fid = `settings-prof-av-${Date.now()}`; + fileInput.id = fid; + uploadLabel.htmlFor = fid; + + const removeBtn = document.createElement('button'); + removeBtn.type = 'button'; + removeBtn.className = 'btn btn-lang'; + removeBtn.textContent = t('settings.avatar_remove'); + const regenBtn = document.createElement('button'); + regenBtn.type = 'button'; + regenBtn.className = 'btn btn-lang'; + regenBtn.textContent = t('settings.avatar_regenerate'); + + const gifBtn = document.createElement('button'); + gifBtn.type = 'button'; + gifBtn.className = 'btn btn-lang'; + gifBtn.dataset.i18n = 'settings.profile_gif_pick'; + gifBtn.textContent = isMeshPlusActive(state.config) + ? t('settings.profile_gif_pick') + : `${t('settings.profile_gif_pick')} ◆`; + + avatarActions.appendChild(fileInput); + avatarActions.appendChild(uploadLabel); + avatarActions.appendChild(removeBtn); + avatarActions.appendChild(regenBtn); + avatarActions.appendChild(gifBtn); + avatarRow.appendChild(avatarPreview); + avatarRow.appendChild(avatarActions); + editorCard.appendChild(avatarRow); + + const nameInput = document.createElement('input'); + nameInput.type = 'text'; + nameInput.className = 'input'; + nameInput.value = state.config.displayName || ''; + nameInput.placeholder = t('settings.name_placeholder'); + editorCard.appendChild(buildSettingsField('settings.name', nameInput)); + + const presenceSelect = buildThemedSelect(); + fillSettingsDropdown( + presenceSelect, + [ + { value: 'online', label: t('settings.presence_online') }, + { value: 'away', label: t('settings.presence_away') }, + { value: 'busy', label: t('settings.presence_busy') }, + ], + state.config.presenceStatus || 'online', + async (id) => { + state.config.presenceStatus = id; + await api.saveConfig({ presenceStatus: id }); + refreshPreview(); + } + ); + editorCard.appendChild(buildSettingsField('settings.presence', presenceSelect)); + + const statusInput = document.createElement('input'); + statusInput.type = 'text'; + statusInput.className = 'input settings-status-input'; + statusInput.maxLength = 48; + statusInput.placeholder = t('settings.status_placeholder'); + statusInput.value = state.config.presenceText || ''; + editorCard.appendChild(buildSettingsField('settings.status_custom', statusInput)); + + const presetsWrap = document.createElement('div'); + presetsWrap.className = 'status-preset-buttons'; + const presetDefs = [ + 'settings.status_empty', + 'settings.status_game', + 'settings.status_afk', + 'settings.status_work', + 'settings.status_stream', + 'settings.status_listen', + 'settings.status_code', + 'settings.status_away_short', + ]; + for (const key of presetDefs) { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'btn btn-lang status-preset-btn'; + btn.textContent = t(key); + btn.addEventListener('click', async () => { + const text = key === 'settings.status_empty' ? '' : t(key); + statusInput.value = text; + state.config = await api.saveConfig({ presenceText: text }); + refreshPreview(); + }); + presetsWrap.appendChild(btn); + } + editorCard.appendChild(presetsWrap); + + const idRow = document.createElement('div'); + idRow.className = 'settings-id-row'; + const idLabel = document.createElement('span'); + idLabel.textContent = `${t('settings.id')}: ${state.config.blipId ?? '—'}`; + const copyIdBtn = document.createElement('button'); + copyIdBtn.type = 'button'; + copyIdBtn.className = 'btn btn-lang'; + copyIdBtn.textContent = t('settings.copy_id'); + copyIdBtn.addEventListener('click', () => { + void navigator.clipboard.writeText(String(state.config.blipId ?? '')); + }); + const changeIdBtn = document.createElement('button'); + changeIdBtn.type = 'button'; + changeIdBtn.className = 'btn btn-accent'; + changeIdBtn.textContent = t('settings.change_id'); + idRow.appendChild(idLabel); + idRow.appendChild(copyIdBtn); + idRow.appendChild(changeIdBtn); + editorCard.appendChild(idRow); + + editor.appendChild(editorCard); + + const previewCol = document.createElement('div'); + previewCol.className = 'settings-profile-preview-col'; + const previewTitle = document.createElement('h3'); + previewTitle.className = 'section-subtitle'; + previewTitle.dataset.i18n = 'settings.profile_preview_title'; + previewTitle.textContent = t('settings.profile_preview_title'); + const previewMount = document.createElement('div'); + previewMount.className = 'settings-profile-preview-mount'; + previewCol.appendChild(previewTitle); + previewCol.appendChild(previewMount); + + layout.appendChild(previewCol); + layout.appendChild(editor); + frag.appendChild(layout); + + let previewBuilt = null; + + function selfPeer() { + return { + blipId: state.config.blipId, + displayName: state.config.displayName || 'Anonymous', + online: true, + presence: state.config.presenceStatus || 'online', + presenceText: state.config.presenceText || '', + meshPlus: isMeshPlusActive(state.config), + }; + } + + function refreshAvatarPreview() { + avatarPreview.innerHTML = ''; + avatarPreview.appendChild( + createAvatarElement(state.config.blipId, 4, { selfBlipId: state.config.blipId }) + ); + } + + function refreshPreview() { + previewBuilt?.destroy?.(); + previewMount.innerHTML = ''; + previewBuilt = buildProfileCard(selfPeer(), { + selfBlipId: state.config.blipId, + isSelfPreview: true, + meshPlusOnSelf: isMeshPlusActive(state.config), + showBanner: true, + showPrivateNote: false, + showActions: false, + presenceClass: (p) => { + if (p.presence === 'away') return 'away'; + if (p.presence === 'busy') return 'busy'; + return 'online'; + }, + statusTooltip: (p) => { + const base = + p.presence === 'away' + ? t('peers.away') + : p.presence === 'busy' + ? t('peers.busy') + : t('peers.online'); + const custom = (p.presenceText || '').trim(); + return custom ? `${base} · ${custom}` : base; + }, + }); + previewMount.appendChild(previewBuilt.el); + } + + nameInput.addEventListener('change', async () => { + const name = nameInput.value.trim() || 'Anonymous'; + state.config.displayName = name; + await api.saveConfig({ displayName: name }); + refreshPreview(); + }); + + statusInput.addEventListener('change', async () => { + const presenceText = statusInput.value.trim().slice(0, 48); + statusInput.value = presenceText; + state.config = await api.saveConfig({ presenceText }); + refreshPreview(); + }); + + fileInput.addEventListener('change', async () => { + const file = fileInput.files?.[0]; + fileInput.value = ''; + if (!file) return; + try { + const dataUrl = await openAvatarCropDialog(file); + if (!dataUrl) return; + const r = await window.blip.saveAvatar?.(dataUrl); + if (!r?.ok) throw new Error(r?.error || 'save_failed'); + setSelfAvatarCache(dataUrl); + state.config.customAvatar = true; + refreshAvatarPreview(); + refreshPreview(); + window.dispatchEvent(new CustomEvent('blip-avatar-changed')); + deps.broadcastCustomAvatar?.(); + showAppToast({ title: t('settings.avatar_saved'), durationMs: 3000 }); + } catch (e) { + showAppToast({ + title: t('settings.avatar_failed'), + body: e?.message || '', + variant: 'danger', + durationMs: 4500, + }); + } + }); + + removeBtn.addEventListener('click', async () => { + await window.blip.clearAvatar?.(); + setSelfAvatarCache(null); + state.config.customAvatar = false; + refreshAvatarPreview(); + refreshPreview(); + window.dispatchEvent(new CustomEvent('blip-avatar-changed')); + deps.broadcastCustomAvatar?.(); + }); + + regenBtn.addEventListener('click', async () => { + if (state.config.customAvatar) await window.blip.clearAvatar?.(); + setSelfAvatarCache(null); + regenerateAvatar(state.config.blipId); + refreshAvatarPreview(); + refreshPreview(); + window.dispatchEvent(new CustomEvent('blip-avatar-changed')); + }); + + gifBtn.addEventListener('click', () => { + if (!isMeshPlusActive(state.config)) { + showMeshPlusLockedToast(); + return; + } + void openProfileGifPicker({ + onSelected: () => { + refreshPreview(); + deps.broadcastProfileGif?.(); + }, + }); + }); + + changeIdBtn.addEventListener('click', () => { + if (typeof deps.onChangeId === 'function') deps.onChangeId(); + }); + + refreshAvatarPreview(); + refreshPreview(); + + frag._profileCleanup = () => previewBuilt?.destroy?.(); + + return frag; +} diff --git a/renderer/styles.css b/renderer/styles.css index 71db0a4..5dfb8ac 100644 --- a/renderer/styles.css +++ b/renderer/styles.css @@ -550,6 +550,11 @@ select.blip-select:focus { box-shadow: none; } +.id-cell.reserved:not(.occupied):not(.selected) { + border-color: color-mix(in srgb, #a78bfa 55%, var(--blip-accent)); + box-shadow: inset 0 0 0 1px color-mix(in srgb, #a78bfa 35%, transparent); +} + .id-cell-cross { position: absolute; inset: 0; @@ -751,6 +756,18 @@ select.blip-select:focus { font-size: 11px; } +.peer-id--mesh-color { + font-weight: bold; +} + +.peer-status-icon { + display: inline-block; + margin-right: 4px; + font-size: 12px; + line-height: 1; + opacity: 0.92; +} + .status-dot { width: 10px; height: 10px; @@ -1761,6 +1778,11 @@ select.blip-select:focus { height: 24px; } +.chat-hub-avatar, +.peer-row-avatar { + cursor: pointer; +} + .settings-avatar-preview .avatar-wrap, .settings-avatar-preview .avatar-img, .settings-avatar-preview .avatar-canvas { @@ -2211,7 +2233,7 @@ select.blip-select:focus { flex: 1; min-height: 0; gap: 12px; - max-width: min(100%, 920px); + max-width: none; width: 100%; align-self: stretch; } @@ -2917,224 +2939,845 @@ select.blip-select:focus { } /* Mesh label modal */ -.blip-modal-backdrop { - position: fixed; - inset: 0; - z-index: 500; +/* Peer profile page */ +.peer-profile-view { display: flex; - align-items: center; - justify-content: center; - background: rgba(0, 0, 0, 0.55); - padding: 16px; + flex-direction: column; + height: 100%; + min-height: 0; + gap: 14px; + padding-bottom: 8px; + width: 100%; + max-width: none; } -.blip-modal { +.peer-profile-view .peer-profile-card { width: 100%; - max-width: 400px; - padding: 16px 18px; - border: 2px solid var(--blip-accent); + max-width: none; } -.blip-modal-title { - margin: 0 0 8px; - font-size: 14px; - text-transform: uppercase; - color: var(--blip-accent); +.peer-profile-toolbar { + display: flex; + align-items: center; + gap: 14px; + flex-shrink: 0; } -.blip-modal-hint { - margin: 0 0 12px; +.peer-profile-back { + flex-shrink: 0; } -.blip-modal-input { - max-width: none; - margin-bottom: 14px; +.peer-profile-toolbar-title { + margin: 0; + flex: 1; + min-width: 0; } -.blip-modal-actions { +.peer-profile-card { + flex: 1; + min-height: 0; + padding: 0; + border: 2px solid color-mix(in srgb, var(--blip-accent) 35%, transparent); + overflow: auto; display: flex; - justify-content: flex-end; - gap: 8px; -} - -.app-toast strong { - display: block; - color: var(--blip-accent); - margin-bottom: 6px; - text-transform: uppercase; + flex-direction: column; } -.toast-preview { - color: var(--blip-text); - margin: 0 0 10px; - word-break: break-word; - max-height: 72px; +.peer-profile-banner { + flex-shrink: 0; + height: 120px; + min-height: 96px; + background: linear-gradient( + 120deg, + color-mix(in srgb, var(--blip-accent) 28%, var(--blip-bg, #0a0a0a)) 0%, + color-mix(in srgb, var(--blip-bg, #0a0a0a) 92%, var(--blip-accent)) 100% + ); + border-bottom: 2px solid color-mix(in srgb, var(--blip-accent) 22%, transparent); + position: relative; overflow: hidden; } -.app-toast .toast-open { - width: 100%; - margin-top: 4px; -} - -.app-toast.toast-out { - opacity: 0; - transition: opacity 0.3s steps(1); +.peer-profile-banner--online::before { + content: ''; + position: absolute; + inset: 0; + background: radial-gradient( + ellipse 80% 60% at 20% 40%, + color-mix(in srgb, var(--blip-accent) 35%, transparent), + transparent 70% + ); + opacity: 0.55; + pointer-events: none; } -/* Error toast */ -.error-toast { - position: fixed; - bottom: 24px; - left: 50%; - transform: translateX(-50%); - padding: 16px 24px; - z-index: 300; - border-color: var(--blip-danger); - text-align: center; +.peer-profile-banner--mesh-plus { + box-shadow: inset 0 -1px 0 color-mix(in srgb, #a78bfa 35%, transparent); } -.error-toast strong { - display: block; - color: var(--blip-danger); - text-transform: uppercase; - margin-bottom: 4px; +.peer-profile-body { + flex: 1; + padding: 0 clamp(20px, 3vw, 40px) 36px; + margin-top: -44px; + position: relative; + z-index: 1; + min-width: 0; } -/* Chat hub — group invite cards */ -.chat-hub-invites-section { +.peer-profile-layout { display: flex; - flex-direction: column; - gap: 8px; - margin-bottom: 12px; - padding-bottom: 12px; - border-bottom: 2px solid var(--blip-glass-border); -} - -.chat-hub-invites-title { - font-size: 10px; - letter-spacing: 0.14em; - color: var(--blip-accent); - margin: 0 0 4px 4px; + flex-direction: row; + align-items: flex-start; + gap: clamp(24px, 4vw, 48px); + max-width: none; + width: 100%; + margin: 0; } -.chat-hub-invite { +.peer-profile-aside { + flex-shrink: 0; display: flex; flex-direction: column; - gap: 10px; - padding: 12px; - border: 2px solid var(--blip-glass-border); + align-items: center; + width: 176px; + padding-top: 0; } -.chat-hub-invite-body { +.peer-profile-aside-stack { + position: relative; + width: 100%; display: flex; flex-direction: column; - gap: 4px; + align-items: center; } -.chat-hub-invite-name { - font-size: 12px; - font-weight: 700; - letter-spacing: 0.04em; +.profile-gif-cloud-wrap { + position: absolute; + left: 50%; + top: -58px; + transform: translateX(-50%); + z-index: 5; + width: auto; + max-width: 100%; + display: flex; + justify-content: center; + pointer-events: none; + margin: 0; } -.chat-hub-invite-meta { - font-size: 10px; - color: var(--blip-muted); +.profile-gif-cloud-wrap.hidden { + display: none !important; } -.chat-hub-invite-actions { +.profile-gif-cloud { + position: relative; + width: 168px; + height: 95px; + max-width: 100%; + padding: 5px; + box-sizing: border-box; display: flex; - gap: 8px; - flex-wrap: wrap; + align-items: center; + justify-content: center; + border: 2px dashed color-mix(in srgb, var(--blip-accent) 40%, transparent); + border-radius: 4px; + background: color-mix(in srgb, var(--blip-bg, #0a0a0a) 88%, transparent); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.45); } -.chat-hub-invite-actions .btn { - flex: 1; - min-width: 88px; - font-size: 10px; - padding: 8px 10px; +.profile-gif-cloud::after { + content: ''; + position: absolute; + left: 28px; + bottom: -7px; + width: 12px; + height: 12px; + background: inherit; + border-right: 2px dashed color-mix(in srgb, var(--blip-accent) 40%, transparent); + border-bottom: 2px dashed color-mix(in srgb, var(--blip-accent) 40%, transparent); + transform: rotate(45deg); + border-radius: 0 0 2px 0; } -/* Group community — channel sidebar + voice stage */ -.group-community { - display: flex; - gap: 8px; - height: calc(100vh - 120px); - min-height: 320px; +.profile-gif-cloud--active { + border-style: solid; + border-color: color-mix(in srgb, var(--blip-accent) 55%, transparent); } -.group-sidebar { - flex: 0 0 140px; - display: flex; - flex-direction: column; - gap: 6px; - padding: 10px 8px; - overflow-y: auto; +.profile-gif-cloud__img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; + image-rendering: pixelated; } -.group-sidebar-head { - display: flex; - flex-wrap: wrap; - gap: 6px; - align-items: flex-start; - padding-bottom: 8px; - border-bottom: 2px solid var(--blip-glass-border); - margin-bottom: 4px; +.profile-gif-cloud__img.hidden { + display: none; } -.group-sidebar-head-text { - flex: 1; - min-width: 0; +.peer-profile-name-line { display: flex; - flex-direction: column; - gap: 2px; + align-items: center; + gap: 10px; + min-width: 0; + flex: 1; } -.group-sidebar-avatar-btn { - border: none; - background: transparent; - padding: 0; - cursor: pointer; +.peer-profile-name-dot { + width: 11px; + height: 11px; + flex-shrink: 0; + margin: 0; + align-self: center; + box-shadow: 0 0 0 2px color-mix(in srgb, var(--blip-bg, #0a0a0a) 80%, transparent); } -.group-sidebar-avatar-btn .group-avatar-wrap, -.group-sidebar-avatar-btn .avatar-wrap { - width: 28px; - height: 28px; +.peer-profile-avatar-large { + position: relative; + z-index: 2; + padding: 10px; + border-radius: 4px; + background: color-mix(in srgb, var(--blip-bg, #0a0a0a) 88%, transparent); + border: 2px solid color-mix(in srgb, var(--blip-accent) 50%, transparent); + box-shadow: 0 6px 0 color-mix(in srgb, var(--blip-accent) 12%, transparent); } -.group-sidebar-avatar-btn canvas, -.group-sidebar-avatar-btn img { - width: 28px !important; - height: 28px !important; +.peer-profile-avatar-large .avatar-wrap, +.peer-profile-avatar-large .avatar-canvas, +.peer-profile-avatar-large .avatar-img { + width: 128px !important; + height: 128px !important; + image-rendering: pixelated; } -.group-unstable-banner { - display: flex; - gap: 4px; +/* Dashed rounded status chip (pixel ~4px radius) */ +.peer-profile-status-chip { + display: inline-flex; align-items: center; - padding: 4px 6px; - margin: 2px 0; - font-size: 9px; - color: #ff3366; - border: 1px solid color-mix(in srgb, #ff3366 40%, transparent); + justify-content: center; + gap: 8px; + width: 100%; + max-width: 176px; + padding: 10px 12px; + border: 2px dashed color-mix(in srgb, var(--blip-accent) 45%, transparent); + border-radius: 4px; + background: color-mix(in srgb, var(--blip-glass, rgba(12, 12, 12, 0.55)) 90%, transparent); + box-sizing: border-box; } -.group-unstable-help { - min-width: 20px; - padding: 0 4px; +.peer-profile-status-chip--online { + border-color: color-mix(in srgb, var(--blip-accent) 55%, transparent); } -.group-unstable-text { - line-height: 1.2; +.peer-profile-status-chip--away { + border-color: color-mix(in srgb, #e6b800 50%, transparent); } -.group-sidebar-section--projects { - color: var(--blip-accent); +.peer-profile-status-chip--busy { + border-color: color-mix(in srgb, var(--blip-danger, #e85d5d) 50%, transparent); } -.group-sidebar-back { +.peer-profile-status-chip--offline { + border-color: color-mix(in srgb, var(--blip-text) 25%, transparent); + opacity: 0.88; +} + +.peer-profile-status-chip-dot { + width: 10px; + height: 10px; + flex-shrink: 0; +} + +.peer-profile-status-chip-text { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.06em; + line-height: 1.35; + text-align: center; + color: var(--blip-text); + word-break: break-word; +} + +.peer-profile-main { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 28px; + padding-top: 52px; +} + +.peer-profile-head { + display: flex; + flex-direction: column; + gap: 8px; + padding-bottom: 12px; + border-bottom: 1px solid color-mix(in srgb, var(--blip-accent) 22%, transparent); +} + +.peer-profile-name-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + min-width: 0; +} + +.peer-profile-name { + margin: 0; + font-size: clamp(18px, 2.2vw, 24px); + line-height: 1.2; + font-weight: normal; + text-transform: uppercase; + color: var(--blip-text); + letter-spacing: 0.03em; + min-width: 0; + word-break: break-word; + overflow-wrap: anywhere; +} + +.peer-profile-custom-status { + font-size: 12px; + color: color-mix(in srgb, var(--blip-text) 78%, transparent); + margin-top: 2px; + word-break: break-word; +} + +.peer-profile-custom-status.hidden { + display: none; +} + +.peer-profile-id { + font-size: 13px; + color: var(--blip-accent); + letter-spacing: 0.1em; + opacity: 0.92; +} + +.peer-profile-fields { + display: grid; + grid-template-columns: minmax(108px, 148px) 1fr; + gap: 16px 24px; + margin: 0; +} + +.peer-profile-field-label { + margin: 0; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.1em; + color: color-mix(in srgb, var(--blip-text) 55%, transparent); + align-self: start; + padding-top: 2px; +} + +.peer-profile-field-value { + margin: 0; + font-size: 13px; + color: var(--blip-text); + word-break: break-word; +} + +.peer-profile-pulse-value { + color: color-mix(in srgb, var(--blip-text) 70%, transparent); +} + +.peer-profile-pulse-value--live { + color: var(--blip-accent); +} + +/* Private note (Steam / Discord style) */ +.peer-profile-note { + display: flex; + flex-direction: column; + gap: 8px; + padding: 14px 16px; + border: 2px dashed color-mix(in srgb, var(--blip-text) 22%, transparent); + border-radius: 4px; + background: color-mix(in srgb, var(--blip-bg, #0a0a0a) 65%, transparent); +} + +.peer-profile-note-title { + font-size: 11px; + font-weight: bold; + text-transform: none; + letter-spacing: 0.02em; + color: color-mix(in srgb, var(--blip-text) 88%, transparent); +} + +.peer-profile-note-input { + width: 100%; + max-width: none; + min-height: 72px; + margin: 0; + padding: 10px 12px; + resize: vertical; + border: none; + border-radius: 4px; + background: color-mix(in srgb, var(--blip-bg, #0a0a0a) 50%, transparent); + color: var(--blip-text); + font: inherit; + font-size: 12px; + line-height: 1.45; +} + +.peer-profile-note-input::placeholder { + color: color-mix(in srgb, var(--blip-text) 42%, transparent); +} + +.peer-profile-note-input:focus { + outline: 2px solid color-mix(in srgb, var(--blip-accent) 55%, transparent); + outline-offset: 0; +} + +.peer-profile-actions { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 14px; + margin-top: auto; + padding-top: 12px; + width: 100%; +} + +.peer-profile-action-btn { + width: 100%; + min-height: 44px; + padding: 12px 16px; + font-size: 11px; +} + +@media (max-width: 720px) { + .peer-profile-body { + padding: 0 18px 24px; + margin-top: -36px; + } + + .peer-profile-banner { + height: 96px; + } + + .peer-profile-layout { + flex-direction: column; + align-items: center; + gap: 28px; + } + + .peer-profile-aside { + width: 100%; + } + + .peer-profile-main { + width: 100%; + padding-top: 8px; + } + + .peer-profile-fields { + grid-template-columns: 1fr; + gap: 6px 0; + } + + .peer-profile-field-label { + padding-top: 10px; + } + + .peer-profile-actions { + grid-template-columns: 1fr; + } +} + +/* Settings profile editor + preview */ +.settings-panel--profile { + width: 100%; + max-width: none; +} + +.settings-profile-layout { + display: flex; + flex-direction: column; + gap: clamp(16px, 2.5vw, 28px); + align-items: stretch; + width: 100%; + max-width: none; +} + +.settings-profile-editor { + padding: 20px 22px; + border: 2px solid color-mix(in srgb, var(--blip-accent) 28%, transparent); +} + +.settings-profile-editor-card { + display: flex; + flex-direction: column; + gap: 14px; +} + +.settings-profile-avatar-row { + display: flex; + gap: 16px; + align-items: flex-start; + flex-wrap: wrap; +} + +.settings-profile-avatar-preview { + flex-shrink: 0; +} + +.settings-profile-avatar-actions { + display: flex; + flex-direction: column; + gap: 8px; + flex: 1; + min-width: 160px; +} + +.settings-profile-avatar-actions .btn, +.settings-profile-avatar-actions label.btn { + width: 100%; + display: flex; + align-items: center; + justify-content: center; + text-align: center; +} + +.settings-profile-preview-col { + display: flex; + flex-direction: column; + gap: 12px; + min-width: 0; + width: 100%; +} + +.settings-profile-preview-mount { + width: 100%; + min-width: 0; +} + +.settings-profile-preview-mount .peer-profile-card { + width: 100%; + max-width: none; +} + +.settings-profile-preview-mount .peer-profile-body { + padding: 0 clamp(16px, 2vw, 32px) 24px; + margin-top: -40px; +} + +.settings-profile-preview-mount .peer-profile-layout { + max-width: none; +} + +.profile-gif-picker-modal { + max-width: min(92vw, 760px); + width: 100%; + min-height: min(72vh, 560px); + max-height: 90vh; + display: flex; + flex-direction: column; +} + +.profile-gif-picker-body { + flex: 1; + min-height: 320px; + max-height: min(62vh, 480px); + overflow: auto; +} + +.profile-gif-picker-tabs { + display: flex; + gap: 8px; + margin-bottom: 12px; +} + +.profile-gif-picker-tab.selected { + border-color: var(--blip-accent); + color: var(--blip-accent); +} + +.profile-gif-picker-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(136px, 1fr)); + gap: 10px; + margin-bottom: 12px; +} + +.profile-gif-picker-tile { + padding: 4px; + border: 2px solid transparent; + border-radius: 4px; + background: color-mix(in srgb, var(--blip-bg, #0a0a0a) 50%, transparent); + cursor: pointer; + aspect-ratio: 16 / 9; + overflow: hidden; +} + +.profile-gif-picker-tile.selected { + border-color: var(--blip-accent); +} + +.profile-gif-picker-tile--broken { + min-height: 76px; + background: color-mix(in srgb, var(--blip-muted) 30%, transparent); +} + +.profile-gif-picker-tile img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; + image-rendering: auto; +} + +/* Narrow app window: tiles down to 4:3 horizontal */ +@media (max-width: 960px) { + .profile-gif-picker-grid { + grid-template-columns: repeat(auto-fill, minmax(112px, 1fr)); + } + + .profile-gif-picker-tile { + aspect-ratio: 4 / 3; + } + + .profile-gif-picker-tile--broken { + min-height: 84px; + } +} + +.profile-gif-picker-upload-row { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + +.profile-gif-picker-search { + display: flex; + gap: 8px; + margin-bottom: 10px; +} + +.profile-gif-picker-search .input { + flex: 1; + max-width: none; +} + +.blip-modal-backdrop { + position: fixed; + inset: 0; + z-index: 500; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.55); + padding: 16px; +} + +.blip-modal { + width: 100%; + max-width: 400px; + padding: 16px 18px; + border: 2px solid var(--blip-accent); +} + +.blip-modal-title { + margin: 0 0 8px; + font-size: 14px; + text-transform: uppercase; + color: var(--blip-accent); +} + +.blip-modal-hint { + margin: 0 0 12px; +} + +.blip-modal-input { + max-width: none; + margin-bottom: 14px; +} + +.blip-modal-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.app-toast strong { + display: block; + color: var(--blip-accent); + margin-bottom: 6px; + text-transform: uppercase; +} + +.toast-preview { + color: var(--blip-text); + 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; + bottom: 24px; + left: 50%; + transform: translateX(-50%); + padding: 16px 24px; + z-index: 300; + border-color: var(--blip-danger); + text-align: center; +} + +.error-toast strong { + display: block; + color: var(--blip-danger); + text-transform: uppercase; + margin-bottom: 4px; +} + +/* Chat hub — group invite cards */ +.chat-hub-invites-section { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 12px; + padding-bottom: 12px; + border-bottom: 2px solid var(--blip-glass-border); +} + +.chat-hub-invites-title { + font-size: 10px; + letter-spacing: 0.14em; + color: var(--blip-accent); + margin: 0 0 4px 4px; +} + +.chat-hub-invite { + display: flex; + flex-direction: column; + gap: 10px; + padding: 12px; + border: 2px solid var(--blip-glass-border); +} + +.chat-hub-invite-body { + display: flex; + flex-direction: column; + gap: 4px; +} + +.chat-hub-invite-name { + font-size: 12px; + font-weight: 700; + letter-spacing: 0.04em; +} + +.chat-hub-invite-meta { + font-size: 10px; + color: var(--blip-muted); +} + +.chat-hub-invite-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.chat-hub-invite-actions .btn { + flex: 1; + min-width: 88px; + font-size: 10px; + padding: 8px 10px; +} + +/* Group community — channel sidebar + voice stage */ +.group-community { + display: flex; + gap: 8px; + height: calc(100vh - 120px); + min-height: 320px; +} + +.group-sidebar { + flex: 0 0 140px; + display: flex; + flex-direction: column; + gap: 6px; + padding: 10px 8px; + overflow-y: auto; +} + +.group-sidebar-head { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: flex-start; + padding-bottom: 8px; + border-bottom: 2px solid var(--blip-glass-border); + margin-bottom: 4px; +} + +.group-sidebar-head-text { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.group-sidebar-avatar-btn { + border: none; + background: transparent; + padding: 0; + cursor: pointer; +} + +.group-sidebar-avatar-btn .group-avatar-wrap, +.group-sidebar-avatar-btn .avatar-wrap { + width: 28px; + height: 28px; +} + +.group-sidebar-avatar-btn canvas, +.group-sidebar-avatar-btn img { + width: 28px !important; + height: 28px !important; +} + +.group-unstable-banner { + display: flex; + gap: 4px; + align-items: center; + padding: 4px 6px; + margin: 2px 0; + font-size: 9px; + color: #ff3366; + border: 1px solid color-mix(in srgb, #ff3366 40%, transparent); +} + +.group-unstable-help { + min-width: 20px; + padding: 0 4px; +} + +.group-unstable-text { + line-height: 1.2; +} + +.group-sidebar-section--projects { + color: var(--blip-accent); +} + +.group-sidebar-back { align-self: flex-start; min-width: 36px; padding: 4px 8px; @@ -3238,6 +3881,23 @@ select.blip-select:focus { opacity: 0.75; } +.projects-tool-btn--mesh-locked { + opacity: 0.82; +} + +.projects-tool-btn--mesh-locked .projects-tool-icon { + color: #a78bfa; +} + +.projects-stub--locked { + align-items: center; + text-align: center; +} + +.projects-stub--locked .mesh-plus-badge { + margin-bottom: 10px; +} + .projects-tool-icon { color: var(--blip-accent); } @@ -4176,9 +4836,9 @@ body.media-viewer-open { display: flex; align-items: center; justify-content: center; - width: 96px; - height: 96px; - padding: 6px; + width: 112px; + height: 112px; + padding: 8px; border: 2px solid var(--blip-muted); background: color-mix(in srgb, var(--blip-bg) 88%, transparent); cursor: pointer; @@ -4201,13 +4861,33 @@ body.media-viewer-open { } .settings-app-icon-tile__img { - width: 80px; - height: 80px; + width: 96px; + height: 96px; image-rendering: pixelated; object-fit: contain; pointer-events: none; } +.app-icon-preview-backdrop { + z-index: 600; +} + +.app-icon-preview-box { + padding: 20px 24px; + border: 2px solid var(--blip-accent); + max-width: min(92vw, 360px); +} + +.app-icon-preview-img { + display: block; + width: 256px; + height: 256px; + max-width: min(72vw, 256px); + max-height: min(72vw, 256px); + image-rendering: pixelated; + object-fit: contain; +} + .settings-app-icon-tile__lock { position: absolute; top: 4px; @@ -4224,6 +4904,150 @@ body.media-viewer-open { margin: 0; } +.session-stats-panel { + margin: 12px 0; +} + +.session-stats-summary { + font-size: 12px; + line-height: 1.45; + margin-bottom: 12px; +} + +.session-stats-chart { + display: flex; + flex-direction: column; + gap: 8px; +} + +.session-stats-chart__row { + display: grid; + grid-template-columns: 88px 1fr 36px; + gap: 8px; + align-items: center; +} + +.session-stats-chart__label { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--blip-muted); +} + +.session-stats-chart__track { + height: 10px; + background: color-mix(in srgb, var(--blip-muted) 25%, var(--blip-bg)); + border: 1px solid var(--blip-glass-border); +} + +.session-stats-chart__fill { + height: 100%; + background: linear-gradient( + 90deg, + color-mix(in srgb, var(--blip-accent) 70%, #1a1a22), + var(--blip-accent) + ); + min-width: 2px; + transition: width 0.2s ease; +} + +.session-stats-chart__val { + font-size: 11px; + text-align: right; + color: var(--blip-accent); +} + +.settings-panel--achievements .ach-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 10px; + margin: 12px 0; + padding: 12px; +} + +.ach-grid--disabled { + opacity: 0.45; + pointer-events: none; +} + +.ach-card { + display: grid; + grid-template-columns: 64px 1fr auto; + gap: 10px; + align-items: center; + padding: 10px 12px; + background: linear-gradient( + 145deg, + color-mix(in srgb, #1e2030 85%, var(--blip-bg)) 0%, + color-mix(in srgb, #12141c 90%, var(--blip-bg)) 100% + ); + border: 1px solid color-mix(in srgb, var(--blip-muted) 40%, transparent); + box-shadow: + inset 0 1px 0 color-mix(in srgb, #fff 8%, transparent), + 0 2px 0 rgba(0, 0, 0, 0.35); +} + +.ach-card--unlocked { + border-color: color-mix(in srgb, var(--blip-accent) 55%, #4a5568); + background: linear-gradient( + 145deg, + color-mix(in srgb, var(--blip-accent) 18%, #1a1c28) 0%, + color-mix(in srgb, #12141c 92%, var(--blip-bg)) 100% + ); +} + +.ach-card--ready:not(.ach-card--unlocked) { + border-color: color-mix(in srgb, #c9a227 45%, transparent); +} + +.ach-card__icon { + width: 64px; + height: 64px; + display: flex; + align-items: center; + justify-content: center; + background: color-mix(in srgb, #000 55%, transparent); + border: 1px solid color-mix(in srgb, var(--blip-muted) 35%, transparent); +} + +.ach-card__icon-img { + width: 56px; + height: 56px; + object-fit: contain; + image-rendering: pixelated; +} + +.ach-card__icon-placeholder { + font-size: 22px; + color: color-mix(in srgb, var(--blip-muted) 70%, transparent); + opacity: 0.5; +} + +.ach-card__title { + font-size: 12px; + font-weight: bold; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.ach-card__desc { + font-size: 11px; + color: var(--blip-muted); + line-height: 1.35; + margin-top: 2px; +} + +.ach-card__mark { + font-size: 18px; + color: var(--blip-muted); + font-weight: bold; +} + +.ach-card--unlocked .ach-card__mark { + color: color-mix(in srgb, #c9a227 85%, var(--blip-accent)); + text-shadow: 0 0 8px color-mix(in srgb, var(--blip-accent) 40%, transparent); +} + .avatar-crop-modal { max-width: min(96vw, 360px); } @@ -4238,6 +5062,12 @@ body.media-viewer-open { box-shadow: inset 0 0 0 2px rgba(0, 0, 0, 0.35); } +.gif-crop-frame { + width: auto; + height: auto; + max-width: min(96vw, 400px); +} + .avatar-crop-canvas { display: block; width: 280px; @@ -4245,6 +5075,11 @@ body.media-viewer-open { cursor: grab; } +.gif-crop-frame .gif-crop-canvas { + display: block; + cursor: grab; +} + .avatar-crop-canvas:active { cursor: grabbing; } @@ -4475,6 +5310,67 @@ body.media-viewer-open { width: 100%; max-width: none; } + +.proj-pad-history { + flex-shrink: 0; + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid color-mix(in srgb, var(--blip-accent) 22%, transparent); + display: flex; + flex-direction: column; + gap: 8px; + max-height: 200px; +} + +.proj-pad-history-title { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--blip-muted); +} + +.proj-pad-history-list { + overflow: auto; + display: flex; + flex-direction: column; + gap: 6px; + max-height: 140px; +} + +.proj-pad-history-item { + display: flex; + flex-direction: column; + gap: 4px; + padding: 8px 10px; + border: 2px solid color-mix(in srgb, var(--blip-accent) 18%, transparent); + background: color-mix(in srgb, var(--blip-bg) 70%, transparent); + cursor: pointer; + text-align: left; + font-family: inherit; + color: var(--blip-text); +} + +.proj-pad-history-item:hover { + border-color: var(--blip-accent); +} + +.proj-pad-history-meta { + font-size: 9px; + color: var(--blip-muted); + text-transform: uppercase; +} + +.proj-pad-history-preview { + font-size: 10px; + line-height: 1.35; + word-break: break-word; +} + +.proj-pad-history-actions { + display: flex; + gap: 8px; +} + .proj-status-bar { display: flex; align-items: center; @@ -4570,15 +5466,34 @@ body.media-viewer-open { color: var(--blip-text-muted); margin-bottom: 6px; } +.proj-clip-top { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 10px; +} + .proj-clip-actions { display: flex; + flex-wrap: wrap; gap: 6px; - margin-bottom: 8px; } + +.proj-clip-search { + width: 100%; + max-width: none; +} + +.proj-clip-limit-hint { + margin: 0; + font-size: 10px; +} + .proj-clip-list { flex: 1; overflow-y: auto; - max-width: 420px; + max-width: none; + width: 100%; } .proj-clip-row { display: grid; @@ -4602,3 +5517,30 @@ body.media-viewer-open { font-size: 9px; color: var(--blip-text-muted); } + +.settings-theme-editor-controls { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + +.settings-theme-editor-color { + width: 44px; + height: 36px; + padding: 2px; + border: 2px solid var(--blip-glass-border); + background: var(--blip-bg); + cursor: pointer; +} + +.settings-theme-editor-hex { + width: 7.5em; + font-family: 'BlipPixel', 'Minecraft', monospace; + letter-spacing: 0.04em; +} + +.settings-theme-editor--locked { + opacity: 0.72; + pointer-events: none; +} diff --git a/renderer/theme-editor.js b/renderer/theme-editor.js new file mode 100644 index 0000000..e78ac3f --- /dev/null +++ b/renderer/theme-editor.js @@ -0,0 +1,105 @@ +import { t } from './i18n.js'; +import { isMeshPlusActive, showMeshPlusLockedToast } from './mesh-plus.js'; +import { applyAppearance, normalizeCustomAccentHex } from './appearance.js'; +import { buildSettingsFieldWithHint } from './settings-ui.js'; + +/** + * MESH+ custom accent (#RRGGBB). + * @param {() => object} getConfig + * @param {(patch: object) => Promise} saveConfig + */ +export function appendThemeEditorSection(block, getConfig, saveConfig) { + const row = document.createElement('div'); + row.className = 'settings-theme-editor'; + + const hexInput = document.createElement('input'); + hexInput.type = 'text'; + hexInput.className = 'input settings-theme-editor-hex'; + hexInput.maxLength = 7; + hexInput.placeholder = '#00ffc8'; + hexInput.dataset.i18nPlaceholder = 'appearance.custom_accent_placeholder'; + + const colorPick = document.createElement('input'); + colorPick.type = 'color'; + colorPick.className = 'settings-theme-editor-color'; + colorPick.value = '#00ffc8'; + + const applyBtn = document.createElement('button'); + applyBtn.type = 'button'; + applyBtn.className = 'btn btn-accent'; + applyBtn.dataset.i18n = 'appearance.custom_accent_apply'; + applyBtn.textContent = t('appearance.custom_accent_apply'); + + const clearBtn = document.createElement('button'); + clearBtn.type = 'button'; + clearBtn.className = 'btn btn-lang'; + clearBtn.dataset.i18n = 'appearance.custom_accent_clear'; + clearBtn.textContent = t('appearance.custom_accent_clear'); + + const controls = document.createElement('div'); + controls.className = 'settings-theme-editor-controls'; + controls.appendChild(colorPick); + controls.appendChild(hexInput); + controls.appendChild(applyBtn); + controls.appendChild(clearBtn); + + function syncInputsFromConfig() { + const cfg = getConfig(); + const hex = normalizeCustomAccentHex(cfg?.accentCustomHex) || '#00ffc8'; + hexInput.value = cfg?.accentCustomHex ? hex : ''; + colorPick.value = hex; + } + + function setDisabled(locked) { + hexInput.disabled = locked; + colorPick.disabled = locked; + applyBtn.disabled = locked; + clearBtn.disabled = locked; + row.classList.toggle('settings-theme-editor--locked', locked); + } + + colorPick.addEventListener('input', () => { + hexInput.value = colorPick.value; + }); + + applyBtn.addEventListener('click', async () => { + if (!isMeshPlusActive(getConfig())) { + showMeshPlusLockedToast(); + return; + } + const hex = normalizeCustomAccentHex(hexInput.value || colorPick.value); + if (!hex) return; + const cfg = await saveConfig({ accentCustomHex: hex }); + applyAppearance(cfg); + syncInputsFromConfig(); + }); + + clearBtn.addEventListener('click', async () => { + if (!isMeshPlusActive(getConfig())) { + showMeshPlusLockedToast(); + return; + } + const cfg = await saveConfig({ accentCustomHex: '' }); + applyAppearance(cfg); + syncInputsFromConfig(); + }); + + row.appendChild(controls); + block.appendChild( + buildSettingsFieldWithHint( + 'appearance.custom_accent', + 'appearance.custom_accent_mesh_hint', + row + ) + ); + + syncInputsFromConfig(); + setDisabled(!isMeshPlusActive(getConfig())); + + return { + refresh() { + syncInputsFromConfig(); + setDisabled(!isMeshPlusActive(getConfig())); + }, + }; +} diff --git a/renderer/ui.js b/renderer/ui.js index e2be048..9bdf2e6 100644 --- a/renderer/ui.js +++ b/renderer/ui.js @@ -97,10 +97,20 @@ import { wrapInSettingsListPanel, } from './settings-ui.js'; import { buildMicTestPanel } from './mic-test-panel.js'; -import { appendMeshPlusBadgeToNameRow } from './mesh-plus.js'; +import { + appendMeshPlusBadgeToNameRow, + fillMeshGatedDropdown, + isMeshPlusActive, + markMeshPlusGatedOptions, + MESH_PLUS_FEATURES, +} from './mesh-plus.js'; import { buildSettingsMeshPlusPanel } from './mesh-plus-settings.js'; import { appendAppIconPickerSections } from './app-icon-picker.js'; -import { isMeshPlusActive } from './mesh-plus.js'; +import { appendThemeEditorSection } from './theme-editor.js'; +import { recordCallStarted, recordFileSent, recordPeersOnline, setAchievementConfigProvider } from './session-stats.js'; +import { buildSettingsAchievementsPanel } from './achievements-settings-panel.js'; +import { appendSessionStatsSection } from './session-stats-panel.js'; +import { syncAchievements } from './achievements-tracker.js'; import { startClipboardSync, stopClipboardSync, @@ -113,6 +123,9 @@ import { formatPeerDisplayName } from './peer-labels.js'; import { openMeshLabelDialog } from './mesh-label-dialog.js'; import { showAppToast } from './toasts.js'; import { openConfirmDialog } from './confirm-dialog.js'; +import { buildPeerProfilePage } from './peer-profile.js'; +import { buildSettingsProfilePanel as buildSettingsProfilePanelView } from './settings-profile-panel.js'; +import { setPeerProfileGifDataUrl } from './peer-gif-cache.js'; import { initPeerTrust, applyTrustFromConfig, @@ -155,6 +168,37 @@ async function broadcastCustomAvatar() { } } +async function broadcastProfileGif() { + const dataUrl = await window.blip?.getProfileGifActiveUrl?.(); + if (!dataUrl || !state.config?.blipId) return; + const targets = state.peers.filter((p) => p.online && !isBlocked(p.blipId)); + for (const p of targets) { + try { + await api.sendTcpMessage({ + type: 'profile-gif-share', + to: p.blipId, + from: state.config.blipId, + dataUrl, + }); + } catch { + /* offline */ + } + } +} + +async function requestPeerProfileGif(blipId) { + if (!state.config?.blipId || !blipId) return; + try { + await api.sendTcpMessage({ + type: 'profile-gif-request', + to: blipId, + from: state.config.blipId, + }); + } catch { + /* ignore */ + } +} + function restartClipboardSync() { stopClipboardSync(); startClipboardSync({ @@ -176,6 +220,10 @@ let state = { groupChatViews: new Map(), /** `null` = no subsection selected (placeholder in content column). */ settingsSection: null, + /** Peer profile page (full main-content view). */ + profilePeerId: null, + /** Where to return from profile (view + chat targets). */ + profileReturn: null, }; /** @type {Map} */ @@ -195,6 +243,8 @@ const peerLatencyMs = new Map(); const MESH_PULSE_INTERVAL_MS = 60_000; let meshPulseTimer = null; +/** @type {(() => void) | null} */ +let profilePageCleanup = null; /** @type {Map} */ const unreadByPeer = new Map(); @@ -210,6 +260,7 @@ function formatAppVersion(meta) { async function openCallOutgoing(peerId, video = false) { if (!window.blip?.openCallOutgoing) return; + recordCallStarted(); try { await window.blip.openCallOutgoing({ peerId, video }); } catch (e) { @@ -368,7 +419,7 @@ function ensureProjectsView() { if (!projectsViewInstance || !projectsViewInstance.el?.parentElement) { projectsViewInstance?.destroy?.(); projectsViewInstance = createProjectsView( - state.config, + () => state.config, api, () => state.peers @@ -467,6 +518,7 @@ function ensureChatView(peerId) { { transferId: xfer.transferId } ); if (result.transferId) xfer.transferId = result.transferId; + if (result?.ok !== false) recordFileSent(); if (result.chunked) { const dataUrl = await fileToDataUrl(file); result.attachment = { ...result.attachment, dataUrl }; @@ -480,6 +532,7 @@ function ensureChatView(peerId) { } }, (e, peerId) => showPeerContextMenu(e, peerId, { hideMessage: true }), + (peerId) => openPeerProfileFromUi(peerId), (to, payload) => api.sendTcpMessage({ type: 'message-pin', @@ -740,6 +793,13 @@ function renderPeersView() { }`; const avatar = createAvatarElement(peer.blipId, 2, { selfBlipId: state.config.blipId }); + avatar.classList.add('peer-row-avatar'); + avatar.style.cursor = 'pointer'; + avatar.title = t('peers.profile_open'); + avatar.addEventListener('click', (e) => { + e.stopPropagation(); + openPeerProfileFromUi(peer); + }); const info = document.createElement('div'); info.className = 'peer-info'; const name = document.createElement('span'); @@ -833,6 +893,7 @@ async function promptMeshLabel(peer) { const chat = state.chatViews.get(peer.blipId); if (chat) chat.setPeerName(formatPeerDisplayName(peer)); if (state.view === 'peers') renderView('peers'); + if (state.view === 'profile') renderView('profile'); if (state.view === 'chat' && !state.activePeer) renderView('chat'); showAppToast({ @@ -842,6 +903,102 @@ async function promptMeshLabel(peer) { }); } +function getPeerProfileHooks(peer) { + return { + selfBlipId: state.config?.blipId ?? null, + isBlocked: (id) => isBlocked(id), + presenceClass: peerPresenceClass, + statusTooltip: peerStatusTooltip, + onMessage: () => openChatFromProfile(peer.blipId), + onCall: () => { + if (peer.online) openCallOutgoing(peer.blipId, false); + }, + onBlock: () => { + if (isBlocked(peer.blipId)) { + unblockPeer(peer.blipId); + showAppToast({ title: t('peers.unblock_done'), durationMs: 3000 }); + } else { + blockPeer(peer.blipId); + showAppToast({ title: t('peers.block_done'), durationMs: 3000 }); + } + if (state.view === 'profile' && state.profilePeerId === peer.blipId) { + renderView('profile'); + } + if (state.view === 'peers') renderView('peers'); + if (state.view === 'chat' && state.activePeer === peer.blipId) { + state.activePeer = null; + renderView('chat'); + } + }, + onPing: () => runPeerPing(peer), + }; +} + +function openPeerProfileFromUi(peerOrId) { + const peer = peerForContextMenu(peerOrId); + if (state.view !== 'profile') { + state.profileReturn = { + view: state.view, + activePeer: state.activePeer, + activeGroup: state.activeGroup, + }; + } + state.profilePeerId = peer.blipId; + renderView('profile'); +} + +function leavePeerProfilePage() { + const ret = state.profileReturn ?? { view: 'peers', activePeer: null, activeGroup: null }; + state.profilePeerId = null; + state.profileReturn = null; + state.activePeer = ret.activePeer ?? null; + state.activeGroup = ret.activeGroup ?? null; + renderView(ret.view); +} + +function openChatFromProfile(peerId) { + state.profilePeerId = null; + state.profileReturn = null; + void openChat(peerId); +} + +function renderPeerProfileView() { + profilePageCleanup?.(); + profilePageCleanup = null; + + const peer = peerForContextMenu(state.profilePeerId); + if (peer.hasProfileGif && !getPeerProfileGifDataUrl(peer.blipId)) { + void requestPeerProfileGif(peer.blipId); + } + const built = buildPeerProfilePage(peer, getPeerProfileHooks(peer)); + profilePageCleanup = built.destroy; + + const wrap = document.createElement('div'); + wrap.className = 'view peer-profile-view'; + + const toolbar = document.createElement('div'); + toolbar.className = 'peer-profile-toolbar'; + + const backBtn = document.createElement('button'); + backBtn.type = 'button'; + backBtn.className = 'btn btn-lang peer-profile-back'; + backBtn.dataset.i18n = 'peers.profile_back'; + backBtn.textContent = `← ${t('peers.profile_back')}`; + backBtn.addEventListener('click', () => leavePeerProfilePage()); + + const title = document.createElement('h2'); + title.className = 'section-title peer-profile-toolbar-title'; + title.dataset.i18n = 'peers.profile_title'; + title.textContent = t('peers.profile_title'); + + toolbar.appendChild(backBtn); + toolbar.appendChild(title); + wrap.appendChild(toolbar); + wrap.appendChild(built.el); + + return wrap; +} + function peerForContextMenu(peerOrId) { if (peerOrId && typeof peerOrId === 'object' && peerOrId.blipId != null) return peerOrId; const id = Number(peerOrId); @@ -968,6 +1125,12 @@ function showPeerContextMenu(e, peerOrId, options = {}) { }); } + const profileItem = document.createElement('button'); + profileItem.type = 'button'; + profileItem.dataset.i18n = 'peers.profile'; + profileItem.textContent = t('peers.profile'); + bindItem(profileItem, () => openPeerProfileFromUi(peer)); + const msgItem = document.createElement('button'); msgItem.type = 'button'; msgItem.textContent = t('dial.message'); @@ -1048,6 +1211,7 @@ function showPeerContextMenu(e, peerOrId, options = {}) { menu.addEventListener('mousedown', (ev) => ev.stopPropagation()); menu.addEventListener('click', (ev) => ev.stopPropagation()); + menu.appendChild(profileItem); if (!options.hideMessage) menu.appendChild(msgItem); menu.appendChild(callItem); menu.appendChild(labelItem); @@ -1237,14 +1401,32 @@ function buildAppearanceSection() { }); block.appendChild(buildSettingsField('settings.appearance_accent', accentSelect)); - const animOpts = ANIMATED_BACKGROUNDS.map((id) => ({ value: id, label: labelBg(id) })); - const animSelect = buildThemedSelect(); - fillSettingsDropdown(animSelect, animOpts, ANIMATED_BACKGROUNDS.includes(curBg) ? curBg : 'none', async (id) => { - state.config = await api.saveConfig({ animatedBgId: normalizeBgId(id) }); - applyAppearance(state.config); - applyReactiveWallpaperConfig(state.config); + appendThemeEditorSection(block, () => state.config, async (patch) => { + state.config = await api.saveConfig(patch); + return state.config; }); - block.appendChild(buildSettingsField('settings.bg_animated', animSelect)); + + const animOpts = markMeshPlusGatedOptions( + ANIMATED_BACKGROUNDS.map((id) => ({ value: id, label: labelBg(id) })), + MESH_PLUS_FEATURES.animated_bg, + state.config + ); + const animSelect = buildThemedSelect(); + fillMeshGatedDropdown( + animSelect, + animOpts, + ANIMATED_BACKGROUNDS.includes(curBg) ? curBg : 'none', + MESH_PLUS_FEATURES.animated_bg, + state.config, + async (id) => { + state.config = await api.saveConfig({ animatedBgId: normalizeBgId(id) }); + applyAppearance(state.config); + applyReactiveWallpaperConfig(state.config); + } + ); + block.appendChild( + buildSettingsFieldWithHint('settings.bg_animated', 'settings.bg_animated_mesh_hint', animSelect) + ); const artOpts = STATIC_ART_BACKGROUNDS.map((id) => ({ value: id, label: labelBg(id) })); const artSelect = buildThemedSelect(); @@ -1509,6 +1691,11 @@ function setupGlobalShortcuts() { } if (e.key === 'Escape' && !e.ctrlKey && !e.altKey && !e.metaKey && !typing) { + if (state.view === 'profile' && state.profilePeerId != null) { + e.preventDefault(); + leavePeerProfilePage(); + return; + } if (state.view === 'chat' && (state.activePeer || state.activeGroup)) { e.preventDefault(); state.activePeer = null; @@ -1522,6 +1709,7 @@ function setupGlobalShortcuts() { function getSettingsSectionIds() { const ids = [ 'profile', + 'achievements', 'mesh_plus', 'language', 'notifications', @@ -1544,129 +1732,11 @@ function getSettingsSectionIds() { } function buildSettingsProfilePanel() { - const frag = document.createElement('div'); - frag.className = 'settings-panel'; - - const h = document.createElement('h2'); - h.className = 'settings-panel-title'; - h.dataset.i18n = 'settings.section_profile'; - h.textContent = t('settings.section_profile'); - frag.appendChild(h); - - const nameLabel = document.createElement('label'); - nameLabel.dataset.i18n = 'settings.name'; - nameLabel.textContent = t('settings.name'); - const nameInput = document.createElement('input'); - nameInput.type = 'text'; - nameInput.className = 'input'; - nameInput.value = state.config.displayName || ''; - nameInput.placeholder = t('settings.name_placeholder'); - nameInput.dataset.i18nPlaceholder = 'settings.name_placeholder'; - - const idRow = document.createElement('div'); - idRow.className = 'settings-id-row'; - const idLabel = document.createElement('span'); - idLabel.dataset.i18n = 'settings.id'; - idLabel.textContent = `${t('settings.id')}: ${state.config.blipId ?? '—'}`; - const changeIdBtn = document.createElement('button'); - changeIdBtn.type = 'button'; - changeIdBtn.className = 'btn btn-accent'; - changeIdBtn.dataset.i18n = 'settings.change_id'; - changeIdBtn.textContent = t('settings.change_id'); - changeIdBtn.addEventListener('click', () => showGridView(true)); - - const copyIdBtn = document.createElement('button'); - copyIdBtn.type = 'button'; - copyIdBtn.className = 'btn btn-lang'; - copyIdBtn.dataset.i18n = 'settings.copy_id'; - copyIdBtn.textContent = t('settings.copy_id'); - copyIdBtn.addEventListener('click', async () => { - const text = String(state.config.blipId ?? ''); - try { - await navigator.clipboard.writeText(text); - } catch { - /* ignore */ - } - }); - - idRow.appendChild(idLabel); - idRow.appendChild(copyIdBtn); - idRow.appendChild(changeIdBtn); - - nameInput.addEventListener('change', async () => { - const name = nameInput.value.trim() || 'Anonymous'; - state.config.displayName = name; - await api.saveConfig({ displayName: name }); + return buildSettingsProfilePanelView(state, api, { + broadcastCustomAvatar, + broadcastProfileGif, + onChangeId: () => showGridView(true), }); - - const presenceSelect = buildThemedSelect(); - fillSettingsDropdown( - presenceSelect, - [ - { id: 'online', key: 'settings.presence_online' }, - { id: 'away', key: 'settings.presence_away' }, - { id: 'busy', key: 'settings.presence_busy' }, - ].map(({ id, key }) => ({ value: id, label: t(key) })), - state.config.presenceStatus || 'online', - async (id) => { - state.config.presenceStatus = id; - await api.saveConfig({ presenceStatus: id }); - } - ); - - const statusLabel = document.createElement('label'); - statusLabel.className = 'settings-field-label'; - statusLabel.dataset.i18n = 'settings.status_custom'; - statusLabel.textContent = t('settings.status_custom'); - const statusInput = document.createElement('input'); - statusInput.type = 'text'; - statusInput.className = 'input settings-status-input'; - statusInput.maxLength = 48; - statusInput.placeholder = t('settings.status_placeholder'); - statusInput.dataset.i18nPlaceholder = 'settings.status_placeholder'; - statusInput.value = state.config.presenceText || ''; - - statusInput.addEventListener('change', async () => { - const presenceText = statusInput.value.trim().slice(0, 48); - statusInput.value = presenceText; - state.config = await api.saveConfig({ presenceText }); - }); - - const presetsWrap = document.createElement('div'); - presetsWrap.className = 'status-preset-buttons'; - const presetDefs = [ - 'settings.status_empty', - 'settings.status_game', - 'settings.status_afk', - 'settings.status_work', - 'settings.status_stream', - 'settings.status_listen', - 'settings.status_code', - 'settings.status_away_short', - ]; - for (const key of presetDefs) { - const btn = document.createElement('button'); - btn.type = 'button'; - btn.className = 'btn btn-lang status-preset-btn'; - btn.dataset.i18n = key; - btn.textContent = t(key); - btn.addEventListener('click', async () => { - const text = key === 'settings.status_empty' ? '' : t(key); - statusInput.value = text; - state.config = await api.saveConfig({ presenceText: text }); - }); - presetsWrap.appendChild(btn); - } - - frag.appendChild(buildAvatarSettingsSection()); - frag.appendChild(buildSettingsField('settings.presence', presenceSelect)); - frag.appendChild(statusLabel); - frag.appendChild(statusInput); - frag.appendChild(presetsWrap); - frag.appendChild(nameLabel); - frag.appendChild(nameInput); - frag.appendChild(idRow); - return frag; } function buildSettingsLanguagePanel() { @@ -1897,15 +1967,21 @@ function buildSettingsSoundPanel() { volRow.appendChild(volVal); const soundPackSelect = buildThemedSelect(); - fillSettingsDropdown( + fillMeshGatedDropdown( soundPackSelect, - [ - { value: 'signal', label: t('settings.sound_pack_signal') }, - { value: 'pulse', label: t('settings.sound_pack_pulse') }, - { value: 'wire', label: t('settings.sound_pack_wire') }, - { value: 'static', label: t('settings.sound_pack_static') }, - ], + markMeshPlusGatedOptions( + [ + { value: 'signal', label: t('settings.sound_pack_signal') }, + { value: 'pulse', label: t('settings.sound_pack_pulse') }, + { value: 'wire', label: t('settings.sound_pack_wire') }, + { value: 'static', label: t('settings.sound_pack_static') }, + ], + MESH_PLUS_FEATURES.sound_pack, + state.config + ), SOUND_PACK_IDS.includes(state.config.uiSoundPack) ? state.config.uiSoundPack : 'signal', + MESH_PLUS_FEATURES.sound_pack, + state.config, async (id) => { state.config = await api.saveConfig({ uiSoundPack: id }); applySoundPrefsFromConfig(state.config); @@ -1913,15 +1989,21 @@ function buildSettingsSoundPanel() { ); const melodyPackSelect = buildThemedSelect(); - fillSettingsDropdown( + fillMeshGatedDropdown( melodyPackSelect, - [ - { value: 'mesh', label: t('settings.melody_pack_mesh') }, - { value: 'grid', label: t('settings.melody_pack_grid') }, - { value: 'beacon', label: t('settings.melody_pack_beacon') }, - { value: 'chime', label: t('settings.melody_pack_chime') }, - ], + markMeshPlusGatedOptions( + [ + { value: 'mesh', label: t('settings.melody_pack_mesh') }, + { value: 'grid', label: t('settings.melody_pack_grid') }, + { value: 'beacon', label: t('settings.melody_pack_beacon') }, + { value: 'chime', label: t('settings.melody_pack_chime') }, + ], + MESH_PLUS_FEATURES.melody_pack, + state.config + ), MELODY_PACK_IDS.includes(state.config.uiMelodyPack) ? state.config.uiMelodyPack : 'mesh', + MESH_PLUS_FEATURES.melody_pack, + state.config, async (id) => { state.config = await api.saveConfig({ uiMelodyPack: id }); applySoundPrefsFromConfig(state.config); @@ -1980,7 +2062,9 @@ function buildSettingsSoundPanel() { frag.appendChild(enableToggle.el); frag.appendChild(volLabel); frag.appendChild(volRow); - frag.appendChild(buildSettingsField('settings.sound_pack', soundPackSelect)); + frag.appendChild( + buildSettingsFieldWithHint('settings.sound_pack', 'settings.sound_mesh_plus_hint', soundPackSelect) + ); frag.appendChild(buildSettingsField('settings.melody_pack', melodyPackSelect)); const previewTitle = document.createElement('h3'); previewTitle.className = 'section-subtitle'; @@ -2292,6 +2376,8 @@ function buildSettingsNetworkPanel() { buildSettingsFieldWithHint('clipboard.mode', 'clipboard.hint', clipSelect) ); + const statsUi = appendSessionStatsSection(frag); + const actions = document.createElement('div'); actions.className = 'settings-network-actions'; @@ -2460,6 +2546,14 @@ function buildSettingsNetworkPanel() { }); void loadDiagnostics(); + + const statsRefreshTimer = setInterval(() => { + statsUi?.refresh?.(); + if (state.config?.achievementsEnabled) syncAchievements(state.config); + }, 60_000); + + frag._networkCleanup = () => clearInterval(statsRefreshTimer); + return frag; } @@ -3042,8 +3136,12 @@ function renderSettingsMainPanel() { switch (state.settingsSection) { case 'profile': return buildSettingsProfilePanel(); + case 'achievements': + return buildSettingsAchievementsPanel(state, api); case 'mesh_plus': return buildSettingsMeshPlusPanel(state, () => { + applyAppearance(state.config); + applySoundPrefsFromConfig(state.config); if (state.view === 'peers') renderView('peers'); if (state.view === 'settings') renderView('settings'); }); @@ -3447,6 +3545,12 @@ function renderChatHubView() { item.className = `chat-hub-row glass ${row.online ? 'online' : 'offline'}`; const avatar = createAvatarElement(row.blipId, 2, { selfBlipId: state.config.blipId }); + avatar.classList.add('chat-hub-avatar'); + avatar.addEventListener('click', (e) => { + e.stopPropagation(); + e.preventDefault(); + openPeerProfileFromUi(peer || { blipId: row.blipId, displayName: row.displayName, online: row.online }); + }); const info = document.createElement('div'); info.className = 'chat-hub-info'; const name = document.createElement('span'); @@ -3503,6 +3607,9 @@ function renderChatHubView() { function renderView(viewName) { if (!mainContent) return; + if (viewName === 'profile' && state.profilePeerId == null) { + viewName = 'peers'; + } state.view = viewName; let view; @@ -3519,6 +3626,9 @@ function renderView(viewName) { case 'projects': view = renderProjectsView(); break; + case 'profile': + view = renderPeerProfileView(); + break; case 'chat': { if (state.activeGroup) { const gchat = ensureGroupChatView(state.activeGroup); @@ -3549,7 +3659,7 @@ function renderView(viewName) { } applyI18n(mainContent); updateNavActive(); - if (viewName === 'peers') { + if (viewName === 'peers' || viewName === 'profile') { void runMeshPulseRound(); } return; @@ -3561,11 +3671,18 @@ function renderView(viewName) { updateNavActive(); - if (viewName === 'peers') { + if (viewName === 'peers' || viewName === 'profile') { void runMeshPulseRound(); } } +function clearProfileNavigationState() { + profilePageCleanup?.(); + profilePageCleanup = null; + state.profilePeerId = null; + state.profileReturn = null; +} + function render() { if (!state.config.blipId) { stopMeshPulse(); @@ -3579,6 +3696,7 @@ function render() { layout.className = 'app-layout'; const nav = createNav((view) => { + clearProfileNavigationState(); if (view === 'chat' && state.view === 'chat') { state.activePeer = null; state.activeGroup = null; @@ -3622,7 +3740,9 @@ export function initUI(config, blipApi) { applySoundPrefsFromConfig(config); applyAppearance(state.config); applyReactiveWallpaperConfig(state.config); + setAchievementConfigProvider(() => state.config); initReactiveWallpaper(() => state.config); + if (state.config?.achievementsEnabled) syncAchievements(state.config); void loadSelfAvatarFromMain(); appearanceListenerDispose?.(); appearanceListenerDispose = listenReducedMotion( @@ -3757,12 +3877,15 @@ export function updatePeers({ peers, occupiedIds }) { const nextOnline = new Set(peers.filter((p) => p.online).map((p) => p.blipId)); state.peers = peers; state.occupiedIds = occupiedIds; + recordPeersOnline(peers.filter((p) => p.online).length); peers.forEach((p) => { if (p.online && !prevOnline.has(p.blipId)) { logPeerEvent(p.blipId, 'online'); if (!state.config?.doNotDisturb) sounds.peerOnline(); if (getSelfAvatarCache()) void broadcastCustomAvatar(); + if (p.hasProfileGif) void requestPeerProfileGif(p.blipId); + void broadcastProfileGif(); } else if (!p.online && prevOnline.has(p.blipId)) { logPeerEvent(p.blipId, 'offline'); if (!state.config?.doNotDisturb) sounds.peerOffline(); @@ -3784,6 +3907,9 @@ export function updatePeers({ peers, occupiedIds }) { if (state.view === 'peers' && mainContent) { renderView('peers'); } + if (state.view === 'profile' && mainContent && state.profilePeerId != null) { + renderView('profile'); + } if (state.view === 'chat' && !state.activePeer && mainContent) { renderView('chat'); } @@ -3812,6 +3938,32 @@ export function handleTcpMessage(msg) { const peerId = Number(msg.from === state.config.blipId ? msg.to : msg.from); if (Number.isFinite(peerId)) logPeerEvent(peerId, `tcp:${msg.type}`); } + if (msg.type === 'profile-gif-request') { + const from = Number(msg.from); + if (!Number.isFinite(from) || isBlocked(from)) return; + void (async () => { + const dataUrl = await window.blip?.getProfileGifActiveUrl?.(); + if (!dataUrl) return; + await api.sendTcpMessage({ + type: 'profile-gif-share', + to: from, + from: state.config.blipId, + dataUrl, + }); + })(); + return; + } + + if (msg.type === 'profile-gif-share') { + const from = Number(msg.from); + if (!Number.isFinite(from) || isBlocked(from)) return; + setPeerProfileGifDataUrl(from, msg.dataUrl ? String(msg.dataUrl) : null); + if (state.view === 'profile' && state.profilePeerId === from) { + renderView('profile'); + } + return; + } + if (msg.type === 'avatar-share') { const from = Number(msg.from); if (!Number.isFinite(from) || isBlocked(from)) return; @@ -3826,9 +3978,12 @@ export function handleTcpMessage(msg) { dataUrl: mine, }); } + void broadcastProfileGif(); window.dispatchEvent(new CustomEvent('blip-avatar-changed')); if (state.view === 'peers') { renderView('peers'); + } else if (state.view === 'profile') { + renderView('profile'); } else if (state.view === 'chat') { if (state.activePeer != null) { state.chatViews.get(state.activePeer)?.refreshHeaderAvatar?.(); @@ -3841,7 +3996,7 @@ export function handleTcpMessage(msg) { return; } - if (handleMeshProjectTcp(msg, state.config)) return; + if (handleMeshProjectTcp(msg, state.config, api)) return; if (msg.type === 'clipboard-push') { if (isBlocked(Number(msg.from))) return; diff --git a/shared/mesh-plus-gates.js b/shared/mesh-plus-gates.js new file mode 100644 index 0000000..7635992 --- /dev/null +++ b/shared/mesh-plus-gates.js @@ -0,0 +1,148 @@ +/** + * MESH+ feature gates — shared by main (enforce) and renderer (UI). + * FREE baseline: signal/pulse FX, mesh/grid melodies, core animated + art backgrounds. + */ + +export const MESH_PLUS_FEATURES = { + animated_bg: 'animated_bg', + sound_pack: 'sound_pack', + melody_pack: 'melody_pack', + projects_board: 'projects_board', + projects_canvas: 'projects_canvas', + projects_pad_history: 'projects_pad_history', + projects_clipboard_unlimited: 'projects_clipboard_unlimited', + theme_editor: 'theme_editor', + profile_gif: 'profile_gif', + chat_export_themed: 'chat_export_themed', +}; + +/** Animated CSS backgrounds (themes.css). */ +export const PREMIUM_ANIMATED_BG_IDS = ['ember', 'rift']; + +export const FREE_SOUND_PACK_IDS = ['signal', 'pulse']; +export const PREMIUM_SOUND_PACK_IDS = ['wire', 'static']; + +export const FREE_MELODY_PACK_IDS = ['mesh', 'grid']; +export const PREMIUM_MELODY_PACK_IDS = ['beacon', 'chime']; + +const DEFAULTS = { + animatedBgId: 'none', + uiSoundPack: 'signal', + uiMelodyPack: 'mesh', +}; + +/** @param {object} [cfg] */ +export function isMeshPlusTierActive(cfg) { + return cfg?.meshPlusActive === true || cfg?.tier === 'mesh_plus'; +} + +/** + * @param {object} [cfg] + * @param {string} feature — MESH_PLUS_FEATURES.* + * @param {string} [value] + */ +export function requireMeshPlus(cfg, feature, value) { + if (isMeshPlusTierActive(cfg)) return true; + if (!value) return true; + switch (feature) { + case MESH_PLUS_FEATURES.animated_bg: + return !PREMIUM_ANIMATED_BG_IDS.includes(value); + case MESH_PLUS_FEATURES.sound_pack: + return !PREMIUM_SOUND_PACK_IDS.includes(value); + case MESH_PLUS_FEATURES.melody_pack: + return !PREMIUM_MELODY_PACK_IDS.includes(value); + default: + return true; + } +} + +/** + * Values that must be reset when tier is FREE. + * @param {object} config + * @returns {Record | null} + */ +export function meshPlusClampPatch(config) { + if (!config || typeof config !== 'object') return null; + const patch = {}; + + const bg = String(config.animatedBgId || 'none'); + if (PREMIUM_ANIMATED_BG_IDS.includes(bg)) { + patch.animatedBgId = DEFAULTS.animatedBgId; + } + + const fx = String(config.uiSoundPack || DEFAULTS.uiSoundPack); + if (PREMIUM_SOUND_PACK_IDS.includes(fx)) { + patch.uiSoundPack = DEFAULTS.uiSoundPack; + } + + const mel = String(config.uiMelodyPack || DEFAULTS.uiMelodyPack); + if (PREMIUM_MELODY_PACK_IDS.includes(mel)) { + patch.uiMelodyPack = DEFAULTS.uiMelodyPack; + } + + if (String(config.accentCustomHex || '').trim()) { + patch.accentCustomHex = ''; + } + + if (config.hasProfileGif || String(config.profileGifActiveId || '').trim()) { + patch.profileGifActiveId = ''; + patch.hasProfileGif = false; + } + + return Object.keys(patch).length ? patch : null; +} + +/** + * Strip or downgrade premium prefs in a save-config patch. + * @param {object} config — current merged config + * @param {object} updates + * @param {boolean} meshPlusActive + */ +export function sanitizeMeshPlusConfigUpdates(config, updates, meshPlusActive) { + if (!updates || typeof updates !== 'object') return updates; + if (meshPlusActive) return updates; + + const out = { ...updates }; + + if (out.animatedBgId !== undefined) { + const id = String(out.animatedBgId); + if (!requireMeshPlus(config, MESH_PLUS_FEATURES.animated_bg, id)) { + out.animatedBgId = DEFAULTS.animatedBgId; + } + } + + if (out.uiSoundPack !== undefined) { + const id = String(out.uiSoundPack); + if (!requireMeshPlus(config, MESH_PLUS_FEATURES.sound_pack, id)) { + out.uiSoundPack = DEFAULTS.uiSoundPack; + } + } + + if (out.uiMelodyPack !== undefined) { + const id = String(out.uiMelodyPack); + if (!requireMeshPlus(config, MESH_PLUS_FEATURES.melody_pack, id)) { + out.uiMelodyPack = DEFAULTS.uiMelodyPack; + } + } + + if (out.accentCustomHex !== undefined && !meshPlusActive) { + out.accentCustomHex = ''; + } + if (!meshPlusActive && (out.profileGifActiveId !== undefined || out.hasProfileGif !== undefined)) { + out.profileGifActiveId = ''; + out.hasProfileGif = false; + } + + return out; +} + +/** + * @param {object} config + * @param {boolean} meshPlusActive + * @returns {object} + */ +export function applyMeshPlusClampToConfig(config, meshPlusActive) { + if (meshPlusActive) return config; + const patch = meshPlusClampPatch(config); + return patch ? { ...config, ...patch } : config; +} From e5ccf2cd1e08abb85dcdf52677736a43b8d38481 Mon Sep 17 00:00:00 2001 From: krwg Date: Thu, 21 May 2026 20:55:16 +0300 Subject: [PATCH 07/25] =?UTF-8?q?release:=20BLIP=201.0.0=20Mesh=20Plus=20?= =?UTF-8?q?=E2=80=94=20Signal=20Corps,=20MESH+,=20achievements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First stable 1.x release. Codename Mesh Plus. Summarizes main-line work since the last public GitHub release 0.7.7. Signal Corps / Projects - Pad, Board (RMB: delete, move column), Canvas (brush/fill/palette dock) - Clipboard mesh history; toggle in Settings → Network - Pad snapshots (MESH+); dev Projects flag; groups beta behind devGroupsEnabled - In-app text/confirm dialogs (no broken Electron prompt) MESH+ - Licensing, carousel, theme editor, premium assets, status GIF, peer badge - Removed mesh identity settings; relay slide remains planned-only Achievements & UX - Settings → Achievements (off by default); Unlocked / Not yet unlocked - ach-icons/*.svg via achievements-icons.js; unlock toasts - Settings sidebar groups; profile preview/GIF fix; BLIP color picker - Default reaction ❤️ only; CAPS section labels; dial layout polish Docs: CHANGELOG, README, ARCHITECTURE, MESH-PLUS, docs/index.html → 1.0.0 --- CHANGELOG.md | 42 +- README.md | 19 +- ach-icons/first_call.svg | 14 + ach-icons/first_file.svg | 10 + ach-icons/first_peer.svg | 17 + ach-icons/mesh_5.svg | 14 + ach-icons/mesh_plus_active.svg | 20 + ach-icons/messages_100.svg | 14 + ach-icons/messages_50.svg | 13 + ach-icons/online_1h.svg | 12 + app-metadata.json | 4 +- docs/ARCHITECTURE.md | 11 +- docs/MESH-PLUS.md | 11 +- docs/index.html | 8 +- main/config.js | 6 +- package.json | 2 +- renderer/achievement-toast.js | 66 ++ renderer/achievements-icons.js | 21 + renderer/achievements-settings-panel.js | 78 +- renderer/achievements-tracker.js | 13 +- renderer/achievements.js | 254 +++-- renderer/app-icon-picker.js | 8 - renderer/appearance.js | 2 +- renderer/blip-color-input.js | 60 ++ renderer/chat.js | 32 +- renderer/confirm-dialog.js | 57 ++ renderer/group-chat.js | 23 +- renderer/group-create-dialog.js | 5 +- renderer/group-projects-store.js | 19 + renderer/groups-wire.js | 4 + renderer/i18n.js | 242 +++-- renderer/mesh-plus-settings.js | 34 +- renderer/mesh-plus.js | 2 +- renderer/pad-history-store.js | 16 + renderer/profile-card.js | 8 +- renderer/project-tools-ui.js | 350 ++++++- renderer/projects-view.js | 67 +- renderer/prompt-dialog.js | 92 ++ renderer/session-stats-panel.js | 57 +- renderer/settings-nav.js | 102 ++ renderer/settings-profile-panel.js | 20 +- renderer/settings-ui.js | 11 +- renderer/styles.css | 1147 ++++++++++++++++++++--- renderer/theme-editor.js | 27 +- renderer/ui.js | 388 ++++---- 45 files changed, 2784 insertions(+), 638 deletions(-) create mode 100644 ach-icons/first_call.svg create mode 100644 ach-icons/first_file.svg create mode 100644 ach-icons/first_peer.svg create mode 100644 ach-icons/mesh_5.svg create mode 100644 ach-icons/mesh_plus_active.svg create mode 100644 ach-icons/messages_100.svg create mode 100644 ach-icons/messages_50.svg create mode 100644 ach-icons/online_1h.svg create mode 100644 renderer/achievement-toast.js create mode 100644 renderer/achievements-icons.js create mode 100644 renderer/blip-color-input.js create mode 100644 renderer/prompt-dialog.js create mode 100644 renderer/settings-nav.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 02be468..f5c2350 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,47 @@ Release **version numbers** track [`app-metadata.json`](app-metadata.json) (sync ## [Unreleased] -_Nothing yet._ +## [1.0.0] — Mesh Plus — 2026-05-21 + +First **1.x** stable line. Codename **Mesh Plus**. Builds on everything since the last public GitHub release **[v0.7.7](https://github.com/krwg/BLIP/releases/tag/v0.7.7)** (pin/edit messages, export). Intermediate versions **0.7.8–0.10.1** were developed on `main` and are summarized here. + +### Added + +- **MESH+ licensing** (0.7.9+) — Ed25519 keys, activation UI, benefits carousel, premium wallpapers/sounds/icons, theme editor (`#RRGGBB`), status GIF cloud, peer badge. +- **Signal Corps / Projects** (0.7.6+) — standalone **PROJECTS** nav (`devProjectsEnabled`): shared **Pad**, **Board** (kanban), **Canvas** (32×16, brush/fill/palette), **Clipboard** mesh history; pad snapshots (MESH+); board card context menu (delete, move column). +- **Settings → Achievements** — optional, off by default; **Unlocked** / **Not yet unlocked** sections; custom SVG icons in [`ach-icons/`](ach-icons/); locked icons hidden until unlock (click **?** slot for hint). +- **Settings → Network** — session stats + activity bars; **Signal Corps clipboard** toggle (off by default, confirm on enable). +- **Settings → Developer** — **Groups (conferences)** beta toggle (off by default); Projects hint on toggle (no separate Signal Corps prose block). +- **Peer profile page** (0.8+) — in-app full profile; settings preview polish. +- **In-app dialogs** — `openConfirmDialog` / `openAlertDialog` / `openTextPromptDialog` replace native `confirm`/`alert`/`prompt` in key flows. +- **Achievement unlock toasts** with icon; **BLIP-styled color picker** (`blip-color-input.js`) for appearance + canvas. +- **8 achievement SVGs** — `ach-icons/*.svg`, bundled via `renderer/achievements-icons.js`. + +### Changed + +- **Version & branding:** `1.0.0`, codename **Mesh Plus** (`app-metadata.json` / `package.json`). +- **Settings shell** — grouped sidebar (Account / App / Connection / System); opens **Profile** first; more transparent panels; appearance backgrounds visible again. +- **UI copy** — section labels use **ALL CAPS** (EN/RU) via stylesheet; inputs/chat unchanged. +- **Dial** — title, ID field, **Message** / **Call** aligned in one column. +- **Default reaction** — **❤️** only; custom reaction field removed. +- **Hotkeys panel** — no vertical squeeze; shortcuts list scrolls horizontally. +- **Updates panel** — release feed fills remaining height in fullscreen. +- **MESH+ carousel** — theme editor, app icons, export slides; relay slide marked planned-only. +- **Projects** — removed mesh online strip and tier legend; MESH+ lock uses toast + CTA; clipboard gated in Network settings. +- **Profile preview** — GIF cloud position fix; no black bar over avatar when GIF is set. + +### Removed + +- **Mesh identity** settings (ID color, status icons, ID bookmarks). +- **Custom default reaction** emoji field. +- **Native `confirm`/`alert`** in achievements reset, chat clear, group flows (replaced by modals). + +### Fixed + +- **Canvas** — `setCanvasPixel` import; painting, flood fill, eraser; full-area grid; tools/palette dock bottom-right. +- **Board** — card create via in-app prompt (Electron `prompt` was broken). +- **Achievements** — icon glyphs no longer show `{}`; reset only clears unlock storage. +- **Groups** — create menu hidden unless `devGroupsEnabled`. ## [0.10.1] — Achievements, Network stats, polish diff --git a/README.md b/README.md index 6200953..92520ba 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ Quick VM flow: host runs BLIP (ID **1**), VM runs BLIP (ID **2**), same subnet v | | | |---|---| | **What** | Desktop app: text, voice, and video over LAN / Hamachi / Radmin VPN | -| **Release** | **0.7.10 — MESH+ Gate** (see [`CHANGELOG.md`](CHANGELOG.md)) | +| **Release** | **1.0.0 — Mesh Plus** (see [`CHANGELOG.md`](CHANGELOG.md); last GitHub tag: [v0.7.7](https://github.com/krwg/BLIP/releases/tag/v0.7.7)) | | **Identity** | BLIP ID **1–64** (8×8 grid, Minecraft-style chunk metaphor) | | **Servers** | None — UDP broadcast, TCP, and WebRTC peer-to-peer only | | **Sign-up** | None | @@ -101,10 +101,10 @@ Quick VM flow: host runs BLIP (ID **1**), VM runs BLIP (ID **2**), same subnet v | Why it matters | What you get | |----------------|--------------| -| **Pair programming without the cloud** | Shared **Pad** — one notepad synced over TCP to every **online peer** on your mesh (LWW, 300 ms debounce). | -| **Built for dev crews** | Left rail of tools (pixel symbols, no emoji clutter): **✦ Pad** live today; **▦ Board**, **◻ Canvas**, **⧉ Clipboard** on the roadmap. | +| **Pair programming without the cloud** | Shared **Pad** — one notepad synced over TCP to every **online peer** on your mesh (LWW, 300 ms debounce); **MESH+** pad snapshots with restore. | +| **Built for dev crews** | **✦ Pad** · **▦ Board** (kanban, RMB on cards) · **◻ Canvas** (32×16, brush/fill, palette) · **⧉ Clipboard** (enable in **Settings → Network**). Board/Canvas/500-clip search need **MESH+**. | | **LAN-native** | Same philosophy as BLIP chat and calls — no servers, no accounts, no upload to someone else’s SaaS. | -| **Off until you opt in** | Hidden by default so casual users stay on dial / chat; flip one toggle and **PROJECTS** appears in the nav — teams that try it tend to keep it open. | +| **Off until you opt in** | Hidden by default; flip **Projects** in Developer and **PROJECTS** appears in the nav. | If you ship one BLIP feature to your squad this quarter, make it **Signal Corps**. @@ -289,7 +289,8 @@ blip/ │ └── assets/fonts/ # Minecraft woff2/ttf ├── docs/ # ARCHITECTURE.md + GitHub Pages landing ├── build/ # icon.ico, icon.png (generated) -├── app-metadata.json # version 0.6.1, codename Portrait +├── app-metadata.json # version 1.0.0, codename Mesh Plus +├── ach-icons/ # achievement SVGs (bundled in renderer) ├── preload.cjs # IPC bridge ├── scripts/ # electron-dev, copy-fonts, build-icons, sync metadata ├── icon.svg # source app icon @@ -348,7 +349,7 @@ The **Minecraft** font is licensed separately under [MIT](https://github.com/bs- | | | |---|---| | **Что это** | Desktop-приложение: текст, голос и видео по LAN / Hamachi / Radmin VPN | -| **Релиз** | **0.7.10 — MESH+ Gate** (см. [`CHANGELOG.md`](CHANGELOG.md)) | +| **Релиз** | **1.0.0 — Mesh Plus** (см. [`CHANGELOG.md`](CHANGELOG.md); на GitHub: [v0.7.7](https://github.com/krwg/BLIP/releases/tag/v0.7.7)) | | **Идентификация** | BLIP ID **1–64** (сетка 8×8) | | **Серверы** | Нет — только UDP broadcast, TCP и WebRTC между пирами | | **Регистрация** | Нет | @@ -387,10 +388,10 @@ The **Minecraft** font is licensed separately under [MIT](https://github.com/bs- | Зачем | Что внутри | |-------|------------| -| **Парное кодирование без облака** | **Блокнот (Pad)** — общие заметки по TCP всем **онлайн**-абонентам (LWW, debounce 300 мс). | -| **Инструменты в стиле BLIP** | Слева каналы: **✦ Блокнот** уже работает; **▦ Доска**, **◻ Холст**, **⧉ Буфер** — в разработке. | +| **Парное кодирование без облака** | **Блокнот** — общие заметки по TCP всем **онлайн**-абонентам; снимки и откат (**МЭШ+**). | +| **Инструменты в стиле BLIP** | **✦ Блокнот** · **▦ Доска** (ПКМ по карточкам) · **◻ Холст** (кисть/заливка) · **⧉ Буфер** (вкл. в **Настройки → Сеть**). Доска/холст/поиск в буфере — **МЭШ+**. | | **Только ЛАН** | Как чат и звонки — без серверов и чужих аккаунтов. | -| **Вкл. по желанию** | По умолчанию скрыто; один переключатель — и **ПРОЕКТЫ** в меню. Команды, которые попробуют, обычно не выключают. | +| **Вкл. по желанию** | По умолчанию скрыто; переключатель **Проекты** в Разработчике — пункт **ПРОЕКТЫ** в меню. | Если внедрять одну фичу BLIP в команду — начните с **Сигнал Корпс**. diff --git a/ach-icons/first_call.svg b/ach-icons/first_call.svg new file mode 100644 index 0000000..7bba111 --- /dev/null +++ b/ach-icons/first_call.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ach-icons/first_file.svg b/ach-icons/first_file.svg new file mode 100644 index 0000000..d07ddf8 --- /dev/null +++ b/ach-icons/first_file.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/ach-icons/first_peer.svg b/ach-icons/first_peer.svg new file mode 100644 index 0000000..8f3feed --- /dev/null +++ b/ach-icons/first_peer.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ach-icons/mesh_5.svg b/ach-icons/mesh_5.svg new file mode 100644 index 0000000..f39f887 --- /dev/null +++ b/ach-icons/mesh_5.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ach-icons/mesh_plus_active.svg b/ach-icons/mesh_plus_active.svg new file mode 100644 index 0000000..a513490 --- /dev/null +++ b/ach-icons/mesh_plus_active.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ach-icons/messages_100.svg b/ach-icons/messages_100.svg new file mode 100644 index 0000000..5034f2f --- /dev/null +++ b/ach-icons/messages_100.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ach-icons/messages_50.svg b/ach-icons/messages_50.svg new file mode 100644 index 0000000..772049f --- /dev/null +++ b/ach-icons/messages_50.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/ach-icons/online_1h.svg b/ach-icons/online_1h.svg new file mode 100644 index 0000000..dc67f36 --- /dev/null +++ b/ach-icons/online_1h.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app-metadata.json b/app-metadata.json index 26ec083..98d96f0 100644 --- a/app-metadata.json +++ b/app-metadata.json @@ -1,6 +1,6 @@ { "displayName": "BLIP", - "codename": "Signal Corps", - "version": "0.10.1", + "codename": "Mesh Plus", + "version": "1.0.0", "githubUrl": "https://github.com/krwg/BLIP" } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7c7430c..cacff2c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -72,11 +72,11 @@ Environment overrides: `BLIP_UDP_PORT`, `BLIP_TCP_PORT`. Separate user data dirs |--------|------| | **Nav entry** | `PROJECTS` between Chat and Settings when the developer toggle is on. | | **Pad (✦)** | Full-height shared textarea; `mesh-proj-pad` broadcasts LWW updates to all **online** peers (not group-scoped). | -| **Board / Canvas** | MESH+ gated kanban + grid (`mesh-proj-board`, `mesh-proj-canvas`). | -| **Clipboard** | Mesh sync; FREE 20 entries, MESH+ 500 + search. | -| **UX** | Tool list in a 140px sidebar; page title uses the standard **?** hint (`projects.hub_hint`) like Peers and Settings. | +| **Board / Canvas** | MESH+ gated kanban + 32×16 canvas (`mesh-proj-board`, `mesh-proj-canvas`); board cards support context menu (delete, move status). | +| **Clipboard** | Mesh sync; gated by `projectsClipboardEnabled` in **Settings → Network**; FREE 20 entries, MESH+ 500 + search. | +| **UX** | Tool sidebar + **?** hint on pad history; locked MESH+ tools show stub + toast (no tier legend). | -Groups (`groups.js`, voice channels) are intentionally separate and remain beta; do not route Signal Corps traffic through group relays. +Groups (`groups.js`, voice channels) are beta and gated by `devGroupsEnabled` in **Settings → Developer**; do not route Signal Corps traffic through group relays. ## Persistence @@ -86,7 +86,8 @@ Groups (`groups.js`, voice channels) are intentionally separate and remain beta; | Chat history | Renderer `localStorage` key `blip_chat_v1`. | | Favorite peer IDs | Renderer `localStorage` key `blip_favorites_v1`. | | Avatar seeds (per BLIP ID) | Renderer `localStorage` key `blip_avatar_seed_v1` (`avatar.js`). | -| Release metadata | `app-metadata.json` (version **0.10.x**, codename **Signal Corps**, repo URL). | +| Release metadata | `app-metadata.json` (version **1.0.0**, codename **Mesh Plus**, repo URL). | +| Achievement icons | `ach-icons/*.svg` → `renderer/achievements-icons.js` (Vite `?url` imports). | | MESH+ overview | [`MESH-PLUS.md`](MESH-PLUS.md) — tier summary (no phased roadmap file). | | Group avatars | Renderer `localStorage` `blip_group_avatar_v1` | | Session stats | Renderer `session-stats.js` + **Settings → Network** | diff --git a/docs/MESH-PLUS.md b/docs/MESH-PLUS.md index 3ba1bc9..1f724bd 100644 --- a/docs/MESH-PLUS.md +++ b/docs/MESH-PLUS.md @@ -13,6 +13,7 @@ MESH+ is a **license tier** (`BLIP-XXXX-…` full key, Ed25519 verify in main). | Signal Corps: Board, Canvas, Pad history, Clipboard 500 + search | ✦ | | Chat export PDF/HTML with BLIP theme | ✦ | | Badge on peers (`meshPlus` in UDP announce) | ✦ | +| Full achievement icon art after unlock (`ach-icons/*.svg`) | shown when **Achievements** enabled | ## Not in app yet (marketing only) @@ -20,14 +21,18 @@ MESH+ is a **license tier** (`BLIP-XXXX-…` full key, Ed25519 verify in main). ## Settings layout -- **MESH+** — activation + benefits carousel. -- **Network** — session stats + activity bars (all users). -- **Achievements** — optional, **off by default**; Steam-style cards; set `iconAsset` per achievement in `renderer/achievements.js`. +- **MESH+** — activation + benefits carousel (premium scenes, sounds, theme editor, icons, Signal Corps, export, GIF, relay planned). +- **Network** — session stats + activity bars; **Signal Corps clipboard** toggle (off by default). +- **Achievements** — optional, **off by default**; **Unlocked** / **Not yet unlocked** lists; icons from `ach-icons/*.svg` via `renderer/achievements-icons.js` (hidden until unlocked). ## Code - Gates: `shared/mesh-plus-gates.js` - License: `main/mesh-plus-license.js` - UI: `renderer/mesh-plus-settings.js`, `renderer/mesh-plus.js` +- Projects: `renderer/projects-view.js`, `renderer/project-tools-ui.js`, `renderer/projects-mesh-wire.js` +- Achievements: `renderer/achievements.js`, `renderer/achievements-icons.js`, `renderer/achievements-settings-panel.js` + +**App version:** `1.0.0` · codename **Mesh Plus** (`app-metadata.json`). *No ROOT build switcher in public repo.* diff --git a/docs/index.html b/docs/index.html index ebd25ab..e3ed65d 100644 --- a/docs/index.html +++ b/docs/index.html @@ -6,7 +6,7 @@ BLIP — GitHub @@ -307,7 +307,7 @@
      -
      v0.7.4 Signal · Slate · LAN · GPLv3
      +
      v1.0.0 Mesh Plus · LAN · GPLv3

      Ты в сети. Ты сигнал.

      Десктоп-мессенджер для LAN / Hamachi / Radmin VPN: чат, группы, голосовые каналы, звонки 1:1 и @@ -392,7 +392,7 @@

      Стек

      (function () { var STR = { ru: { - badge: 'v0.7.4 Signal · Сланец · LAN · GPLv3', + badge: 'v1.0.0 Mesh Plus · LAN · GPLv3', title: 'Ты в сети. Ты сигнал.', sub: 'Десктоп-мессенджер для LAN / Hamachi / Radmin VPN: чат, группы, голосовые каналы, звонки 1:1 и групповые — без облака. UDP + TCP + WebRTC, BLIP ID 1–64.', @@ -422,7 +422,7 @@

      Стек

      navIssues: 'Issues', }, en: { - badge: 'v0.7.4 Signal · Slate · LAN · GPLv3', + badge: 'v1.0.0 Mesh Plus · LAN · GPLv3', title: "You're on the grid. You're the signal.", sub: 'Desktop messenger for LAN / Hamachi / Radmin VPN: chat, groups, voice channels, 1:1 and group calls — no cloud. UDP + TCP + WebRTC, BLIP IDs 1–64.', diff --git a/main/config.js b/main/config.js index e81e156..f405d0f 100644 --- a/main/config.js +++ b/main/config.js @@ -28,7 +28,7 @@ const DEFAULT_CONFIG = { /** Pulse animated wallpaper from mic level (voice / 1:1 call). */ reactiveBackground: false, /** Default emoji for the chat reaction (+) button. */ - defaultReactionEmoji: '➕', + defaultReactionEmoji: '❤️', /** When true, the main window close button hides to tray instead of exiting (Windows default on). */ closeToTray: process.platform === 'win32', /** Launch BLIP when the user signs in to the OS. */ @@ -70,6 +70,10 @@ const DEFAULT_CONFIG = { devMeshTrace: false, /** Projects hub + group project channels (pad, board, canvas, clipboard). */ devProjectsEnabled: false, + /** Signal Corps shared clipboard (LAN mesh). Off by default. */ + projectsClipboardEnabled: false, + /** Groups / conferences beta — off by default. */ + devGroupsEnabled: false, /** Stream quality: low | hd | fhd | max | qhd | uhd */ streamQuality: 'max', /** Fullscreen video target: low | hd | fhd | max | qhd | uhd */ diff --git a/package.json b/package.json index 708a5d1..0f381e1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "blip", - "version": "0.10.1", + "version": "1.0.0", "description": "P2P LAN messenger — text, voice, and video. No cloud, no servers, no accounts.", "author": "krwg", "homepage": "https://krwg.github.io/BLIP/", diff --git a/renderer/achievement-toast.js b/renderer/achievement-toast.js new file mode 100644 index 0000000..34de601 --- /dev/null +++ b/renderer/achievement-toast.js @@ -0,0 +1,66 @@ +import { t } from './i18n.js'; +import { appendAchievementIcon } from './achievements.js'; + +const FADE_MS = 300; + +let stackEl = null; + +function ensureStack() { + if (stackEl?.isConnected) return stackEl; + stackEl = document.createElement('div'); + stackEl.className = 'toast-stack'; + document.body.appendChild(stackEl); + return stackEl; +} + +/** + * @param {import('./achievements.js').AchievementDef} def + * @param {{ durationMs?: number }} [opts] + */ +export function showAchievementUnlockToast(def, opts = {}) { + const durationMs = opts.durationMs ?? 5500; + const stack = ensureStack(); + + const el = document.createElement('div'); + el.className = 'app-toast glass app-toast--achievement app-toast--accent'; + el.setAttribute('role', 'status'); + + const inner = document.createElement('div'); + inner.className = 'achievement-toast__inner'; + + const iconCol = document.createElement('div'); + iconCol.className = 'achievement-toast__icon'; + appendAchievementIcon(iconCol, def); + + const textCol = document.createElement('div'); + textCol.className = 'achievement-toast__text'; + + const kicker = document.createElement('div'); + kicker.className = 'achievement-toast__kicker'; + kicker.dataset.i18n = 'achievements.unlocked_title'; + kicker.textContent = t('achievements.unlocked_title'); + + const title = document.createElement('strong'); + title.className = 'achievement-toast__title'; + title.textContent = t(def.titleKey); + + const desc = document.createElement('p'); + desc.className = 'achievement-toast__desc'; + desc.textContent = t(def.descKey); + + textCol.appendChild(kicker); + textCol.appendChild(title); + textCol.appendChild(desc); + inner.appendChild(iconCol); + inner.appendChild(textCol); + el.appendChild(inner); + + function dismiss() { + el.classList.add('toast-out'); + setTimeout(() => el.remove(), FADE_MS); + } + + stack.appendChild(el); + if (durationMs > 0) setTimeout(dismiss, durationMs); + return { dismiss, el }; +} diff --git a/renderer/achievements-icons.js b/renderer/achievements-icons.js new file mode 100644 index 0000000..1534d3d --- /dev/null +++ b/renderer/achievements-icons.js @@ -0,0 +1,21 @@ +/** Achievement SVG assets (bundled by Vite). */ +import first_peer from '../ach-icons/first_peer.svg?url'; +import mesh_5 from '../ach-icons/mesh_5.svg?url'; +import messages_50 from '../ach-icons/messages_50.svg?url'; +import messages_100 from '../ach-icons/messages_100.svg?url'; +import first_call from '../ach-icons/first_call.svg?url'; +import first_file from '../ach-icons/first_file.svg?url'; +import online_1h from '../ach-icons/online_1h.svg?url'; +import mesh_plus_active from '../ach-icons/mesh_plus_active.svg?url'; + +/** @type {Record} */ +export const ACHIEVEMENT_ICON_BY_ID = { + first_peer, + mesh_5, + messages_50, + messages_100, + first_call, + first_file, + online_1h, + mesh_plus_active, +}; diff --git a/renderer/achievements-settings-panel.js b/renderer/achievements-settings-panel.js index ea7242e..8cd754b 100644 --- a/renderer/achievements-settings-panel.js +++ b/renderer/achievements-settings-panel.js @@ -1,8 +1,9 @@ import { t } from './i18n.js'; -import { buildPanelTitleRow, buildSettingsFieldWithHint } from './settings-ui.js'; +import { buildPanelTitleRow, createPixelToggle } from './settings-ui.js'; import { renderAchievementsGrid } from './achievements.js'; import { clearAllAchievementUnlocks } from './achievements-store.js'; import { showAppToast } from './toasts.js'; +import { openConfirmDialog } from './confirm-dialog.js'; /** * @param {object} state @@ -14,36 +15,32 @@ export function buildSettingsAchievementsPanel(state, api) { frag.appendChild(buildPanelTitleRow('settings.section_achievements', 'achievements.hint')); - const enabledWrap = document.createElement('label'); - enabledWrap.className = 'settings-toggle-row'; - const enabledInput = document.createElement('input'); - enabledInput.type = 'checkbox'; - enabledInput.checked = !!state.config.achievementsEnabled; - const enabledLabel = document.createElement('span'); - enabledLabel.dataset.i18n = 'achievements.enabled'; - enabledLabel.textContent = t('achievements.enabled'); - enabledWrap.appendChild(enabledInput); - enabledWrap.appendChild(enabledLabel); - frag.appendChild( - buildSettingsFieldWithHint('achievements.enabled_label', 'achievements.enabled_hint', enabledWrap) - ); + const togglesRow = document.createElement('div'); + togglesRow.className = 'ach-settings-toggles'; - const notifyWrap = document.createElement('label'); - notifyWrap.className = 'settings-toggle-row'; - const notifyInput = document.createElement('input'); - notifyInput.type = 'checkbox'; - notifyInput.checked = state.config.achievementsNotify !== false; - const notifyLabel = document.createElement('span'); - notifyLabel.dataset.i18n = 'achievements.notify'; - notifyLabel.textContent = t('achievements.notify'); - notifyWrap.appendChild(notifyInput); - notifyWrap.appendChild(notifyLabel); - frag.appendChild( - buildSettingsFieldWithHint('achievements.notify_label', 'achievements.notify_hint', notifyWrap) - ); + const enabledToggle = createPixelToggle({ + checked: !!state.config.achievementsEnabled, + labelKey: 'achievements.enabled', + onChange: async (checked) => { + state.config = await api.saveConfig({ achievementsEnabled: checked }); + refreshGrid(); + }, + }); + + const notifyToggle = createPixelToggle({ + checked: state.config.achievementsNotify !== false, + labelKey: 'achievements.notify', + onChange: async (checked) => { + state.config = await api.saveConfig({ achievementsNotify: checked }); + }, + }); + + togglesRow.appendChild(enabledToggle.el); + togglesRow.appendChild(notifyToggle.el); + frag.appendChild(togglesRow); const gridHost = document.createElement('div'); - gridHost.className = 'ach-grid settings-list-panel'; + gridHost.className = 'ach-sections'; const resetBtn = document.createElement('button'); resetBtn.type = 'button'; @@ -53,22 +50,19 @@ export function buildSettingsAchievementsPanel(state, api) { function refreshGrid() { const on = !!state.config.achievementsEnabled; - gridHost.classList.toggle('ach-grid--disabled', !on); - notifyWrap.classList.toggle('hidden', !on); - renderAchievementsGrid(gridHost); + gridHost.classList.toggle('ach-sections--disabled', !on); + notifyToggle.el.classList.toggle('hidden', !on); + renderAchievementsGrid(gridHost, state.config); } - enabledInput.addEventListener('change', async () => { - state.config = await api.saveConfig({ achievementsEnabled: enabledInput.checked }); - refreshGrid(); - }); - - notifyInput.addEventListener('change', async () => { - state.config = await api.saveConfig({ achievementsNotify: notifyInput.checked }); - }); - - resetBtn.addEventListener('click', () => { - if (!confirm(t('achievements.reset_confirm'))) return; + resetBtn.addEventListener('click', async () => { + const ok = await openConfirmDialog({ + title: t('achievements.reset'), + body: t('achievements.reset_confirm'), + danger: true, + confirmLabel: t('achievements.reset'), + }); + if (!ok) return; clearAllAchievementUnlocks(); refreshGrid(); showAppToast({ title: t('achievements.reset_ok'), durationMs: 3000 }); diff --git a/renderer/achievements-tracker.js b/renderer/achievements-tracker.js index 637ed7f..7fedc6c 100644 --- a/renderer/achievements-tracker.js +++ b/renderer/achievements-tracker.js @@ -1,8 +1,7 @@ import { ACHIEVEMENT_DEFS } from './achievements.js'; import { getSessionStats } from './session-stats.js'; import { isAchievementUnlocked, unlockAchievement } from './achievements-store.js'; -import { t } from './i18n.js'; -import { showAppToast } from './toasts.js'; +import { showAchievementUnlockToast } from './achievement-toast.js'; /** * @param {object} [config] @@ -13,18 +12,14 @@ export function syncAchievements(config) { const stats = getSessionStats(); const newly = []; for (const def of ACHIEVEMENT_DEFS) { - if (!def.check(stats)) continue; + const met = def.checkConfig ? def.checkConfig(config) : def.check?.(stats); + if (!met) continue; if (isAchievementUnlocked(def.id)) continue; if (unlockAchievement(def.id)) newly.push(def); } if (newly.length && config.achievementsNotify !== false) { for (const def of newly) { - showAppToast({ - title: t('achievements.unlocked_title'), - body: t(def.titleKey), - variant: 'accent', - durationMs: 5500, - }); + showAchievementUnlockToast(def); } } return newly; diff --git a/renderer/achievements.js b/renderer/achievements.js index 72bbd1c..7e21d41 100644 --- a/renderer/achievements.js +++ b/renderer/achievements.js @@ -1,126 +1,268 @@ import { t } from './i18n.js'; import { getSessionStats, sessionOnlineHours } from './session-stats.js'; import { isAchievementUnlocked } from './achievements-store.js'; +import { isMeshPlusActive } from './mesh-plus.js'; +import { ACHIEVEMENT_ICON_BY_ID } from './achievements-icons.js'; +import { showAppToast } from './toasts.js'; /** - * @typedef {{ id: string, titleKey: string, descKey: string, iconAsset: string, check: (s: object) => boolean }} AchievementDef + * @typedef {{ id: string, codeKey: string, titleKey: string, descKey: string, glyph: string, iconAsset: string, check?: (s: object) => boolean, checkConfig?: (cfg: object) => boolean }} AchievementDef */ -/** Icon paths — fill with your assets (e.g. `achievements/first_peer.png`). */ +/** Fallback glyph when iconAsset is empty. */ +export const ACHIEVEMENT_GLYPHS = { + first_peer: '◎', + mesh_5: '⬡', + messages_50: '▤', + messages_100: '▦', + first_call: '♪', + first_file: '⬆', + online_1h: '⏱', + mesh_plus_active: '◈', +}; + +function appendAchievementGlyphFallback(wrap, def) { + const glyph = document.createElement('span'); + glyph.className = 'ach-icon-slot__glyph'; + glyph.textContent = def.glyph || ACHIEVEMENT_GLYPHS[def.id] || '◆'; + glyph.title = t('achievements.icon_placeholder'); + glyph.setAttribute('aria-hidden', 'true'); + wrap.appendChild(glyph); +} + +/** + * @param {HTMLElement} parent + * @param {AchievementDef} def + */ +export function appendAchievementIcon(parent, def) { + const wrap = document.createElement('div'); + wrap.className = 'ach-icon-slot'; + const src = def.iconAsset || ACHIEVEMENT_ICON_BY_ID[def.id] || ''; + if (src) { + const img = document.createElement('img'); + img.className = 'ach-icon-slot__img'; + img.src = src; + img.alt = t(def.titleKey); + img.decoding = 'async'; + img.draggable = false; + img.addEventListener('error', () => { + img.remove(); + appendAchievementGlyphFallback(wrap, def); + }); + wrap.appendChild(img); + } else { + appendAchievementGlyphFallback(wrap, def); + } + parent.appendChild(wrap); + return wrap; +} + +/** @param {string} id */ +export function achievementIconUrl(id) { + return ACHIEVEMENT_ICON_BY_ID[id] || ''; +} + +/** Achievement definitions — icons in `/ach-icons/*.svg`. */ /** @type {AchievementDef[]} */ export const ACHIEVEMENT_DEFS = [ { id: 'first_peer', + glyph: '◎', + codeKey: 'achievements.code_first_peer', titleKey: 'achievements.first_peer', descKey: 'achievements.first_peer_desc', - iconAsset: '', + iconAsset: achievementIconUrl('first_peer'), check: (s) => (s.peersMaxOnline || 0) >= 1, }, { id: 'mesh_5', + glyph: '⬡', + codeKey: 'achievements.code_mesh_5', titleKey: 'achievements.mesh_5', descKey: 'achievements.mesh_5_desc', - iconAsset: '', + iconAsset: achievementIconUrl('mesh_5'), check: (s) => (s.peersMaxOnline || 0) >= 5, }, { id: 'messages_50', + glyph: '▤', + codeKey: 'achievements.code_messages_50', titleKey: 'achievements.messages_50', descKey: 'achievements.messages_50_desc', - iconAsset: '', + iconAsset: achievementIconUrl('messages_50'), check: (s) => (s.messagesSent || 0) >= 50, }, { id: 'messages_100', + glyph: '▦', + codeKey: 'achievements.code_messages_100', titleKey: 'achievements.messages_100', descKey: 'achievements.messages_100_desc', - iconAsset: '', + iconAsset: achievementIconUrl('messages_100'), check: (s) => (s.messagesSent || 0) >= 100, }, { id: 'first_call', + glyph: '♪', + codeKey: 'achievements.code_first_call', titleKey: 'achievements.first_call', descKey: 'achievements.first_call_desc', - iconAsset: '', + iconAsset: achievementIconUrl('first_call'), check: (s) => (s.callsStarted || 0) >= 1, }, { id: 'first_file', + glyph: '⬆', + codeKey: 'achievements.code_first_file', titleKey: 'achievements.first_file', descKey: 'achievements.first_file_desc', - iconAsset: '', + iconAsset: achievementIconUrl('first_file'), check: (s) => (s.filesSent || 0) >= 1, }, { id: 'online_1h', + glyph: '⏱', + codeKey: 'achievements.code_online_1h', titleKey: 'achievements.online_1h', descKey: 'achievements.online_1h_desc', - iconAsset: '', + iconAsset: achievementIconUrl('online_1h'), check: () => sessionOnlineHours() >= 1, }, + { + id: 'mesh_plus_active', + glyph: '◈', + codeKey: 'achievements.code_mesh_plus', + titleKey: 'achievements.mesh_plus_active', + descKey: 'achievements.mesh_plus_active_desc', + iconAsset: achievementIconUrl('mesh_plus_active'), + checkConfig: (cfg) => isMeshPlusActive(cfg), + }, ]; +function isProgressMet(def, stats, config) { + if (def.checkConfig) return !!def.checkConfig(config); + return !!def.check?.(stats); +} + /** + * @param {object} [config] * @returns {{ def: AchievementDef, unlocked: boolean, progressMet: boolean }[]} */ -export function getAchievementStates() { +export function getAchievementStates(config) { const stats = getSessionStats(); return ACHIEVEMENT_DEFS.map((def) => { - const progressMet = !!def.check(stats); + const progressMet = isProgressMet(def, stats, config); return { def, progressMet, - unlocked: isAchievementUnlocked(def.id) || progressMet, + unlocked: isAchievementUnlocked(def.id), }; }); } +/** + * @param {{ def: AchievementDef, unlocked: boolean, progressMet: boolean }} item + */ +function createAchievementCard({ def, unlocked, progressMet }) { + const card = document.createElement('div'); + card.className = `ach-card${unlocked ? ' ach-card--unlocked' : ''}${ + progressMet && !unlocked ? ' ach-card--ready' : '' + }`; + card.dataset.achId = def.id; + + const iconWrap = document.createElement('div'); + iconWrap.className = 'ach-card__icon'; + if (unlocked) { + appendAchievementIcon(iconWrap, def); + } else { + const hiddenBtn = document.createElement('button'); + hiddenBtn.type = 'button'; + hiddenBtn.className = 'ach-icon-slot ach-icon-slot--hidden'; + hiddenBtn.setAttribute('aria-label', t('achievements.icon_unlock_to_see')); + const hiddenMark = document.createElement('span'); + hiddenMark.className = 'ach-icon-slot__hidden-mark'; + hiddenMark.textContent = '?'; + hiddenMark.setAttribute('aria-hidden', 'true'); + hiddenBtn.appendChild(hiddenMark); + hiddenBtn.addEventListener('click', () => { + showAppToast({ + title: t('achievements.icon_unlock_to_see'), + durationMs: 5000, + }); + }); + iconWrap.appendChild(hiddenBtn); + } + + const body = document.createElement('div'); + body.className = 'ach-card__body'; + const title = document.createElement('div'); + title.className = 'ach-card__title'; + title.textContent = t(def.titleKey); + const desc = document.createElement('div'); + desc.className = 'ach-card__desc'; + desc.textContent = t(def.descKey); + body.appendChild(title); + body.appendChild(desc); + + const mark = document.createElement('div'); + mark.className = 'ach-card__mark'; + mark.textContent = unlocked ? 'OK' : progressMet ? '…' : '—'; + + card.appendChild(iconWrap); + card.appendChild(body); + card.appendChild(mark); + return card; +} + +/** + * @param {string} titleKey + * @param {{ def: AchievementDef, unlocked: boolean, progressMet: boolean }[]} items + * @param {string} sectionClass + */ +function buildAchievementsSection(titleKey, items, sectionClass) { + const section = document.createElement('section'); + section.className = `ach-section ${sectionClass}`; + + const head = document.createElement('h3'); + head.className = 'ach-section__title section-subtitle'; + head.dataset.i18n = titleKey; + head.textContent = t(titleKey); + + const grid = document.createElement('div'); + grid.className = 'ach-grid'; + + if (!items.length) { + const empty = document.createElement('p'); + empty.className = 'hint ach-section__empty'; + empty.dataset.i18n = + sectionClass === 'ach-section--unlocked' + ? 'achievements.section_unlocked_empty' + : 'achievements.section_locked_empty'; + empty.textContent = t(empty.dataset.i18n); + grid.appendChild(empty); + } else { + for (const item of items) grid.appendChild(createAchievementCard(item)); + } + + section.appendChild(head); + section.appendChild(grid); + return section; +} + /** * @param {HTMLElement} root + * @param {object} [config] */ -export function renderAchievementsGrid(root) { +export function renderAchievementsGrid(root, config) { root.innerHTML = ''; - for (const { def, unlocked, progressMet } of getAchievementStates()) { - const card = document.createElement('div'); - card.className = `ach-card${unlocked ? ' ach-card--unlocked' : ''}${ - progressMet && !unlocked ? ' ach-card--ready' : '' - }`; - card.dataset.achId = def.id; - - const iconWrap = document.createElement('div'); - iconWrap.className = 'ach-card__icon'; - if (def.iconAsset) { - const img = document.createElement('img'); - img.className = 'ach-card__icon-img'; - img.src = def.iconAsset; - img.alt = ''; - iconWrap.appendChild(img); - } else { - const placeholder = document.createElement('span'); - placeholder.className = 'ach-card__icon-placeholder'; - placeholder.textContent = '◆'; - placeholder.title = t('achievements.icon_placeholder'); - iconWrap.appendChild(placeholder); - } - - const body = document.createElement('div'); - body.className = 'ach-card__body'; - const title = document.createElement('div'); - title.className = 'ach-card__title'; - title.textContent = t(def.titleKey); - const desc = document.createElement('div'); - desc.className = 'ach-card__desc'; - desc.textContent = t(def.descKey); - body.appendChild(title); - body.appendChild(desc); - - const mark = document.createElement('div'); - mark.className = 'ach-card__mark'; - mark.textContent = unlocked ? '✓' : progressMet ? '…' : ''; - - card.appendChild(iconWrap); - card.appendChild(body); - card.appendChild(mark); - root.appendChild(card); - } + const states = getAchievementStates(config); + const unlocked = states.filter((s) => s.unlocked); + const locked = states.filter((s) => !s.unlocked); + + root.appendChild( + buildAchievementsSection('achievements.section_unlocked', unlocked, 'ach-section--unlocked') + ); + root.appendChild( + buildAchievementsSection('achievements.section_locked', locked, 'ach-section--locked') + ); } diff --git a/renderer/app-icon-picker.js b/renderer/app-icon-picker.js index c47e7ee..e728320 100644 --- a/renderer/app-icon-picker.js +++ b/renderer/app-icon-picker.js @@ -94,14 +94,6 @@ export function appendAppIconPickerSections(block, state, saveConfig) { btn.appendChild(img); - if (tier === 'mesh_plus' && !isMeshPlusActive(state.config)) { - const lock = document.createElement('span'); - lock.className = 'settings-app-icon-tile__lock'; - lock.textContent = '◆'; - lock.title = t('settings.app_icon_mesh_locked'); - btn.appendChild(lock); - } - btn.addEventListener('contextmenu', (e) => { e.preventDefault(); if (meta?.previewUrl) openAppIconPreview(meta.previewUrl, id); diff --git a/renderer/appearance.js b/renderer/appearance.js index 34247d7..8e8e690 100644 --- a/renderer/appearance.js +++ b/renderer/appearance.js @@ -30,7 +30,7 @@ export const ACCENT_IDS = [ export const ANIMATED_BACKGROUNDS = ['none', 'beacon', 'depths', 'signal', 'ember', 'rift']; /** Art layers (wallpaper-art.css). */ -export const STATIC_ART_BACKGROUNDS = ['skyline', 'bloom', 'horizon', 'void', 'dusk']; +export const STATIC_ART_BACKGROUNDS = ['none', 'skyline', 'bloom', 'horizon', 'void', 'dusk']; export const ALL_BACKGROUNDS = [...ANIMATED_BACKGROUNDS, ...STATIC_ART_BACKGROUNDS.filter((id) => id !== 'none')]; diff --git a/renderer/blip-color-input.js b/renderer/blip-color-input.js new file mode 100644 index 0000000..778e6e3 --- /dev/null +++ b/renderer/blip-color-input.js @@ -0,0 +1,60 @@ +/** + * BLIP-styled color picker (dark frame + swatch preview; native dialog on click). + * @param {{ value?: string, onInput?: (hex: string) => void, title?: string, className?: string }} [opts] + */ +export function createBlipColorInput(opts = {}) { + const wrap = document.createElement('label'); + wrap.className = ['blip-color-input', opts.className].filter(Boolean).join(' '); + if (opts.title) wrap.title = opts.title; + + const preview = document.createElement('span'); + preview.className = 'blip-color-input__preview'; + preview.setAttribute('aria-hidden', 'true'); + + const input = document.createElement('input'); + input.type = 'color'; + input.className = 'blip-color-input__native'; + input.value = normalizePickerHex(opts.value) || '#00ffc8'; + + function syncPreview() { + const hex = normalizePickerHex(input.value) || '#00ffc8'; + preview.style.background = hex; + preview.style.boxShadow = `inset 0 0 0 1px color-mix(in srgb, ${hex} 55%, #000), 3px 3px 0 rgba(0, 0, 0, 0.45)`; + } + + input.addEventListener('input', () => { + syncPreview(); + opts.onInput?.(input.value); + }); + + syncPreview(); + wrap.appendChild(preview); + wrap.appendChild(input); + + return { + el: wrap, + input, + preview, + get value() { + return input.value; + }, + set value(v) { + input.value = normalizePickerHex(v) || '#00ffc8'; + syncPreview(); + }, + syncPreview, + }; +} + +/** @param {string} [hex] */ +function normalizePickerHex(hex) { + const s = String(hex || '').trim(); + if (/^#[0-9a-fA-F]{6}$/.test(s)) return s.toLowerCase(); + if (/^#[0-9a-fA-F]{3}$/.test(s)) { + const r = s[1]; + const g = s[2]; + const b = s[3]; + return `#${r}${r}${g}${g}${b}${b}`.toLowerCase(); + } + return ''; +} diff --git a/renderer/chat.js b/renderer/chat.js index 54cfcba..b583c95 100644 --- a/renderer/chat.js +++ b/renderer/chat.js @@ -23,11 +23,12 @@ import { createMessageId } from './message-id.js'; import { getPinnedMessageId, setPinnedMessageId } from './chat-pins.js'; import { exportPeerChatJson, exportPeerChatPdf, exportPeerChatHtml } from './chat-export.js'; import { isMeshPlusActive, showMeshPlusLockedToast } from './mesh-plus.js'; +import { openAlertDialog, openConfirmDialog } from './confirm-dialog.js'; import { recordMessageSent } from './session-stats.js'; const STORAGE_KEY = 'blip_chat_v1'; const MAX_PER_PEER = 500; -const DEFAULT_REACTION = '➕'; +const DEFAULT_REACTION = '❤️'; export function getDefaultReactionEmoji(config) { const e = config?.defaultReactionEmoji; @@ -276,7 +277,7 @@ export function createChatView( exportPdfThemedItem.type = 'button'; exportPdfThemedItem.className = 'chat-menu-item'; exportPdfThemedItem.dataset.i18n = 'chat.export_pdf_themed'; - exportPdfThemedItem.textContent = `${t('chat.export_pdf_themed')}${isMeshPlusActive(getConfig()) ? '' : ' ◆'}`; + exportPdfThemedItem.textContent = t('chat.export_pdf_themed'); exportPdfThemedItem.addEventListener('click', () => { menu.classList.add('hidden'); if (!isMeshPlusActive(getConfig())) { @@ -290,7 +291,7 @@ export function createChatView( exportHtmlItem.type = 'button'; exportHtmlItem.className = 'chat-menu-item'; exportHtmlItem.dataset.i18n = 'chat.export_html_themed'; - exportHtmlItem.textContent = `${t('chat.export_html_themed')}${isMeshPlusActive(getConfig()) ? '' : ' ◆'}`; + exportHtmlItem.textContent = t('chat.export_html_themed'); exportHtmlItem.addEventListener('click', () => { menu.classList.add('hidden'); if (!isMeshPlusActive(getConfig())) { @@ -305,9 +306,15 @@ export function createChatView( clearItem.className = 'chat-menu-item chat-menu-item--danger'; clearItem.dataset.i18n = 'chat.clear'; clearItem.textContent = t('chat.clear'); - clearItem.addEventListener('click', () => { + clearItem.addEventListener('click', async () => { menu.classList.add('hidden'); - if (!confirm(t('chat.clear_confirm'))) return; + const ok = await openConfirmDialog({ + title: t('chat.clear'), + body: t('chat.clear_confirm'), + danger: true, + confirmLabel: t('chat.clear'), + }); + if (!ok) return; clearPeerMessages(peerId); renderMessages(); }); @@ -590,7 +597,7 @@ export function createChatView( await sendPayload({ text: '', attachment }); } catch (err) { const key = err?.message === 'file_too_big' ? 'chat.attach_too_big' : 'chat.attach_failed'; - alert(t(key)); + void openAlertDialog({ title: t(key) }); } } @@ -606,11 +613,12 @@ export function createChatView( } catch (err) { const limitKey = err?.message === 'file_too_big' ? 'chat.file_too_big_dynamic' : 'chat.attach_failed'; - alert( - err?.message === 'file_too_big' - ? t(limitKey).replace('{limit}', formatFileLimitLabelForChat(getConfig())) - : t('chat.attach_failed') - ); + void openAlertDialog({ + title: + err?.message === 'file_too_big' + ? t(limitKey).replace('{limit}', formatFileLimitLabelForChat(getConfig())) + : t('chat.attach_failed'), + }); return; } const pendingId = createMessageId(); @@ -665,7 +673,7 @@ export function createChatView( if (idx >= 0) list.splice(idx, 1); persist(); renderMessages(); - alert(t('chat.attach_failed')); + void openAlertDialog({ title: t('chat.attach_failed') }); } } diff --git a/renderer/confirm-dialog.js b/renderer/confirm-dialog.js index 3578fb9..bb8eb67 100644 --- a/renderer/confirm-dialog.js +++ b/renderer/confirm-dialog.js @@ -69,3 +69,60 @@ export function openConfirmDialog(opts) { okBtn.focus(); }); } + +/** + * @param {{ title: string, body?: string, okLabel?: string }} opts + * @returns {Promise} + */ +export function openAlertDialog(opts) { + const { title, body = '', okLabel = t('dialog.confirm') } = opts; + + return new Promise((resolve) => { + const backdrop = document.createElement('div'); + backdrop.className = 'blip-modal-backdrop'; + + const modal = document.createElement('div'); + modal.className = 'blip-modal glass'; + modal.setAttribute('role', 'alertdialog'); + modal.setAttribute('aria-modal', 'true'); + + const h = document.createElement('h3'); + h.className = 'blip-modal-title'; + h.textContent = title; + + const actions = document.createElement('div'); + actions.className = 'blip-modal-actions'; + + const okBtn = document.createElement('button'); + okBtn.type = 'button'; + okBtn.className = 'btn btn-accent'; + okBtn.textContent = okLabel; + + let done = false; + function finish() { + if (done) return; + done = true; + backdrop.remove(); + resolve(); + } + + okBtn.addEventListener('click', finish); + backdrop.addEventListener('click', (e) => { + if (e.target === backdrop) finish(); + }); + + modal.appendChild(h); + if (body) { + const p = document.createElement('p'); + p.className = 'hint blip-modal-hint'; + p.textContent = body; + modal.appendChild(p); + } + actions.appendChild(okBtn); + modal.appendChild(actions); + backdrop.appendChild(modal); + document.body.appendChild(backdrop); + + okBtn.focus(); + }); +} diff --git a/renderer/group-chat.js b/renderer/group-chat.js index d1a7866..9fbe209 100644 --- a/renderer/group-chat.js +++ b/renderer/group-chat.js @@ -15,6 +15,7 @@ import { createMessageId } from './message-id.js'; import { addGroupMessage, getGroupMessages, groupDisplayName, amHost } from './groups.js'; import { getOngoingGroupCall } from './group-call-roster.js'; import { isInGroupCall, getActiveGroupCallId } from './group-call-client.js'; +import { openAlertDialog } from './confirm-dialog.js'; function formatChatTime(ts) { try { @@ -171,11 +172,12 @@ export function createGroupChatView( err?.message === 'file_too_big' ? 'chat.file_too_big_dynamic' : 'chat.attach_failed'; - alert( - err?.message === 'file_too_big' - ? t(key).replace('{limit}', formatGroupFileLimit(config)) - : t('chat.attach_failed') - ); + void openAlertDialog({ + title: + err?.message === 'file_too_big' + ? t(key).replace('{limit}', formatGroupFileLimit(config)) + : t('chat.attach_failed'), + }); } return; } @@ -203,11 +205,12 @@ export function createGroupChatView( err?.message === 'file_too_big' ? 'chat.file_too_big_dynamic' : 'chat.attach_failed'; - alert( - err?.message === 'file_too_big' - ? t(key).replace('{limit}', formatGroupFileLimit(config)) - : t('chat.attach_failed') - ); + void openAlertDialog({ + title: + err?.message === 'file_too_big' + ? t(key).replace('{limit}', formatGroupFileLimit(config)) + : t('chat.attach_failed'), + }); } } diff --git a/renderer/group-create-dialog.js b/renderer/group-create-dialog.js index 30d7c9e..21ba9ad 100644 --- a/renderer/group-create-dialog.js +++ b/renderer/group-create-dialog.js @@ -1,5 +1,6 @@ import { t } from './i18n.js'; import { generateGroupId } from './groups.js'; +import { openAlertDialog } from './confirm-dialog.js'; /** * @param {{ selfId: number, peers: Array<{ blipId: number, displayName?: string, online?: boolean }>, seedPeerId?: number }} opts @@ -83,10 +84,10 @@ export function openGroupCreateDialog(opts) { } cancelBtn.addEventListener('click', () => finish(null)); - okBtn.addEventListener('click', () => { + okBtn.addEventListener('click', async () => { const memberIds = [...selected]; if (memberIds.length === 0) { - alert(t('group.pick_one')); + await openAlertDialog({ title: t('group.pick_one') }); return; } finish({ name: nameInput.value.trim() || t('group.unnamed'), memberIds }); diff --git a/renderer/group-projects-store.js b/renderer/group-projects-store.js index 8ee6422..96d9df5 100644 --- a/renderer/group-projects-store.js +++ b/renderer/group-projects-store.js @@ -63,6 +63,11 @@ export function setCanvasPixel(groupId, x, y, color) { emit(groupId, 'canvas'); } +/** After bulk canvas edits (e.g. fill). */ +export function notifyCanvasChanged(groupId) { + emit(groupId, 'canvas'); +} + export function getClipState(groupId) { const k = padKey(groupId); if (!clips.has(k)) clips.set(k, { entries: [] }); @@ -76,6 +81,20 @@ export function pushClipEntry(groupId, entry, maxEntries = CLIP_MAX_FREE) { emit(groupId, 'clipboard'); } +/** + * @param {string} groupId + * @param {string} entryId + * @returns {boolean} + */ +export function removeClipEntry(groupId, entryId) { + const st = getClipState(groupId); + const before = st.entries.length; + st.entries = st.entries.filter((e) => e.id !== entryId); + if (st.entries.length === before) return false; + emit(groupId, 'clipboard'); + return true; +} + export function mergeClipEntries(groupId, entries, maxEntries = CLIP_MAX_FREE) { const st = getClipState(groupId); const cap = Math.max(1, maxEntries); diff --git a/renderer/groups-wire.js b/renderer/groups-wire.js index 8109214..0dbe17a 100644 --- a/renderer/groups-wire.js +++ b/renderer/groups-wire.js @@ -168,6 +168,10 @@ export async function dissolveGroup(api, config, groupId) { } export async function createGroupFromUi(api, config, memberIds, name, seedPeerId) { + if (!config?.devGroupsEnabled) { + showAppToast({ title: t('settings.dev_groups_off'), durationMs: 4000 }); + return null; + } const myId = Number(config.blipId); const groupId = generateGroupId(); const members = normalizeMemberIds([myId, ...memberIds.filter((id) => Number(id) !== myId)]); diff --git a/renderer/i18n.js b/renderer/i18n.js index eab0ff9..19493ad 100644 --- a/renderer/i18n.js +++ b/renderer/i18n.js @@ -397,6 +397,7 @@ const locales = { 'mesh_plus.activate': 'Activate', 'mesh_plus.revoke_key': 'Remove key', 'mesh_plus.no_key_hint': 'No key? Contact the developer.', + 'mesh_plus.activation_hint': 'Paste your BLIP key below. No key? Contact the developer.', 'mesh_plus.inactive_hint': 'Paste your key and tap Activate.', 'mesh_plus.active_ok': 'MESH+ is active on this device.', 'mesh_plus.activate_ok': 'MESH+ activated.', @@ -413,13 +414,19 @@ const locales = { 'mesh_plus.slide_sound': 'Exclusive sound packs', 'mesh_plus.slide_sound_desc': 'Extra chiptune FX and call melodies for your mesh.', 'mesh_plus.slide_relay': 'LAN relay & bridge', - 'mesh_plus.slide_relay_desc': 'Host a relay or bridge two subnets without Hamachi.', + 'mesh_plus.slide_relay_desc': 'Planned: relay host and subnet bridge (carousel preview — not shipped yet).', 'mesh_plus.slide_projects': 'Signal Corps Pro', - 'mesh_plus.slide_projects_desc': 'Full Board & Canvas, Pad history with rollback.', + 'mesh_plus.slide_projects_desc': 'Kanban board, pixel canvas (brush & fill), pad snapshots with restore.', 'mesh_plus.slide_clipboard': 'Clipboard Board', - 'mesh_plus.slide_clipboard_desc': 'Unlimited LAN clipboard history with search.', + 'mesh_plus.slide_clipboard_desc': 'Up to 500 LAN clips with search (enable buffer in Settings → Network).', 'mesh_plus.slide_gif': 'Status GIF', 'mesh_plus.slide_gif_desc': 'Animated profile cloud in peer cards — MESH+ only.', + 'mesh_plus.slide_theme': 'Theme editor', + 'mesh_plus.slide_theme_desc': 'Custom #RRGGBB accent on top of presets — MESH+ only.', + 'mesh_plus.slide_icons': 'App icons mesh-1…6', + 'mesh_plus.slide_icons_desc': 'Six premium tray/window icons in Settings → Appearance.', + 'mesh_plus.slide_export': 'Themed chat export', + 'mesh_plus.slide_export_desc': 'Export threads to PDF/HTML with your BLIP theme colors.', 'mesh_plus.identity_title': 'Mesh identity', 'mesh_plus.identity_hint': 'Customize how you appear on the LAN mesh.', 'mesh_plus.id_color_label': 'BLIP ID color', @@ -539,24 +546,39 @@ const locales = { 'achievements.notify_hint': 'Show a toast when you earn an achievement.', 'achievements.notify': 'Notify on unlock', 'achievements.icon_placeholder': 'Icon slot — add your asset path in achievements.js', + 'achievements.icon_unlock_to_see': 'Unlock this achievement first to see the icon.', 'achievements.reset': 'Reset unlocks', 'achievements.reset_confirm': 'Clear all unlocked achievements on this device?', 'achievements.reset_ok': 'Achievement progress reset.', + 'achievements.section_unlocked': 'Unlocked', + 'achievements.section_locked': 'Not yet unlocked', + 'achievements.section_unlocked_empty': 'No unlocked achievements yet.', + 'achievements.section_locked_empty': 'All achievements unlocked.', 'achievements.unlocked_title': 'Achievement unlocked', - 'achievements.first_peer': 'First contact', - 'achievements.first_peer_desc': 'At least one peer online in this session.', - 'achievements.mesh_5': 'Full mesh', - 'achievements.mesh_5_desc': 'Five peers online at once.', - 'achievements.messages_50': 'Chatter', - 'achievements.messages_50_desc': 'Send 50 messages this session.', - 'achievements.messages_100': 'Signal operator', - 'achievements.messages_100_desc': 'Send 100 messages this session.', + 'achievements.code_first_peer': '1+ peer online', + 'achievements.code_mesh_5': '5 peers at once', + 'achievements.code_messages_50': '50 messages sent', + 'achievements.code_messages_100': '100 messages sent', + 'achievements.code_first_call': 'First voice call', + 'achievements.code_first_file': 'First file sent', + 'achievements.code_online_1h': '1 hour in app', + 'achievements.code_mesh_plus': 'MESH+ license active', + 'achievements.first_peer': 'First on the mesh', + 'achievements.first_peer_desc': 'At least one contact online this session.', + 'achievements.mesh_5': 'Five at once', + 'achievements.mesh_5_desc': 'Five contacts online at the same time.', + 'achievements.messages_50': 'Talkative', + 'achievements.messages_50_desc': 'Send 50 messages in one session.', + 'achievements.messages_100': 'Signal regular', + 'achievements.messages_100_desc': 'Send 100 messages in one session.', 'achievements.first_call': 'On the line', - 'achievements.first_call_desc': 'Start a voice call.', - 'achievements.first_file': 'Payload', + 'achievements.first_call_desc': 'Start your first voice call.', + 'achievements.first_file': 'First payload', 'achievements.first_file_desc': 'Send a file over the mesh.', - 'achievements.online_1h': 'Long haul', + 'achievements.online_1h': 'Marathon session', 'achievements.online_1h_desc': 'Stay in BLIP for one hour this session.', + 'achievements.mesh_plus_active': 'MESH+ activated', + 'achievements.mesh_plus_active_desc': 'Activate a MESH+ subscription on this device.', 'chat.export_pdf_themed': 'Export PDF (BLIP theme)', 'chat.export_html_themed': 'Export HTML (BLIP theme)', 'settings.group_account': 'Account', @@ -677,6 +699,12 @@ const locales = { 'settings.dev_export_done': 'Diagnostics copied to clipboard', 'settings.dev_export_fail': 'Could not copy diagnostics', 'settings.pick_section_hint': 'Choose a section in the list on the left.', + 'settings.nav_group_account': 'Account', + 'settings.nav_group_app': 'App', + 'settings.nav_group_connection': 'Connection', + 'settings.nav_group_system': 'System', + 'settings.dev_panel_hint': 'Beta flags and internal tools — not required for normal use.', + 'settings.dev_projects_callout': 'Signal Corps (Projects)', 'settings.system_na': 'These options are only available on Windows for now.', 'settings.updates_check': 'Check', 'settings.updates_install': 'Restart to update', @@ -708,15 +736,20 @@ const locales = { 'nav.settings': 'SETTINGS', 'projects.hub_title': 'PROJECTS', 'projects.hub_hint': - 'Signal Corps — BLIP’s must-have builder workspace on the mesh (not tied to groups). Shared Pad syncs to online peers on your LAN while you pair-program or debug. Coming: kanban board, pixel canvas, pull clipboard (20 clips). Enable in Settings → Developer → Projects (off by default).', + 'Signal Corps — pad, kanban board, pixel canvas, and LAN clipboard on the mesh. Enable in Settings → Developer → Projects; enable clipboard in Settings → Network (off by default).', 'projects.tool_soon': 'In development — coming in a future Signal Corps drop.', 'projects.mesh_plus_required': 'BOARD and CANVAS are MESH+ tools. Activate in Settings → MESH+.', 'projects.pad_history_title': 'Pad history', 'projects.pad_history_save': 'Save snapshot', 'projects.pad_history_restore': 'Restored', 'projects.pad_history_manual': 'Manual snapshot', - 'projects.pad_history_empty': 'No snapshots yet — type in the pad or tap Save snapshot.', + 'projects.pad_history_hint': + 'While empty — type in the pad or tap Save snapshot.', 'projects.sidebar_title': 'tools', + 'projects.mesh_online': '{n} peers online on the mesh', + 'projects.mesh_online_empty': 'No peers online — tools sync when someone appears.', + 'projects.tier_legend': 'Dimmed tools need MESH+.', + 'projects.open_mesh_plus': 'Open MESH+ settings', 'projects.tool_pad': 'PAD', 'projects.tool_board': 'BOARD', 'projects.tool_canvas': 'CANVAS', @@ -727,13 +760,29 @@ const locales = { 'projects.board_todo': 'TODO', 'projects.board_progress': 'IN PROGRESS', 'projects.board_done': 'DONE', - 'projects.board_card_prompt': 'Task text', + 'projects.board_card_prompt': 'New card', + 'projects.board_card_placeholder': 'Task text…', + 'projects.board_add_card': 'Add card', + 'projects.board_card_delete': 'Delete card', + 'projects.board_card_delete_confirm': 'Remove this card from the board?', + 'projects.board_move_progress': 'Move to in progress', + 'projects.board_move_done': 'Move to done', + 'projects.board_move_todo': 'Move to todo', 'projects.board_cards': '{n} cards', + 'projects.pad_history_delete': 'Delete snapshot', + 'projects.pad_history_delete_confirm': 'Remove this pad snapshot from history?', + 'projects.clipboard_disabled': 'Enable Signal Corps clipboard in Settings → Network.', + 'projects.clip_copy': 'Copy to system clipboard', + 'projects.clip_delete': 'Delete entry', + 'projects.canvas_tool_brush': 'Brush', + 'projects.canvas_tool_bucket': 'Fill', + 'projects.canvas_tool_eraser': 'Eraser', + 'projects.canvas_custom_color': 'Custom color', 'projects.canvas_active': '32×16 · #{id} drawing', 'projects.canvas_size': '32×16 pixel grid', - 'projects.clip_count': '{n} entries · pull model', - 'projects.clip_pull': 'PULL', - 'projects.clip_push': 'PUSH', + 'projects.clip_count': '{n} entries', + 'projects.clip_pull': 'Pull', + 'projects.clip_push': 'Push', 'projects.clip_search': 'Search clipboard…', 'projects.clip_count_limit': '{n} / {max} entries', 'projects.clip_count_search': '{n} shown · {total} total', @@ -747,7 +796,18 @@ const locales = { 'settings.dev_projects_on': 'Projects enabled', 'settings.dev_projects_off': 'Projects hidden', 'settings.dev_projects_hint': - 'Signal Corps — BLIP’s must-have dev workspace. Adds PROJECTS nav: ✦ Pad live; ▦ ◻ ⧉ soon. Mesh-synced on LAN. Off by default.', + 'Signal Corps — shared pad, board, canvas on LAN. Off by default; enable to show PROJECTS in the nav.', + 'settings.dev_groups': 'Groups (conferences)', + 'settings.dev_groups_hint': + 'Beta: group chats are unstable. Off by default — users cannot create groups. On restores the menu item.', + 'settings.dev_groups_on': 'Groups beta enabled', + 'settings.dev_groups_off': 'Groups hidden', + 'settings.projects_clipboard': 'Signal Corps clipboard', + 'settings.projects_clipboard_hint': 'Shared LAN clip history in PROJECTS → Clipboard. Off by default.', + 'settings.projects_clipboard_enable_confirm': + 'Allow BLIP to read the system clipboard when you push clips to the mesh buffer?', + 'settings.projects_clipboard_on': 'Projects clipboard enabled', + 'settings.projects_clipboard_off': 'Projects clipboard disabled', 'group.menu_rename': 'Rename', 'group.rename_prompt': 'Group name', 'group.rename_hint': 'Click to rename', @@ -1164,6 +1224,7 @@ const locales = { 'mesh_plus.activate': 'Активировать', 'mesh_plus.revoke_key': 'Удалить ключ', 'mesh_plus.no_key_hint': 'Нет ключа? Свяжись с разработчиком.', + 'mesh_plus.activation_hint': 'Вставь ключ BLIP ниже. Нет ключа? Свяжись с разработчиком.', 'mesh_plus.inactive_hint': 'Вставь ключ и нажми «Активировать».', 'mesh_plus.active_ok': 'МЭШ ПЛЮС активен на этом устройстве.', 'mesh_plus.activate_ok': 'МЭШ ПЛЮС активирован.', @@ -1180,13 +1241,19 @@ const locales = { 'mesh_plus.slide_sound': 'Эксклюзивные звуки', 'mesh_plus.slide_sound_desc': 'Отдельные ФХ и мелодии звонков для твоего меша.', 'mesh_plus.slide_relay': 'РЕЛЭЙ и мост', - 'mesh_plus.slide_relay_desc': 'Локальный релэй или мост двух подсетей без ХАМАЧИ.', + 'mesh_plus.slide_relay_desc': 'В планах: релэй и мост подсетей (пока только в карусели, не в сборке).', 'mesh_plus.slide_projects': 'Сигнал Корпс ПРО', - 'mesh_plus.slide_projects_desc': 'БОАРД (доска) и КАНВАС полностью, история ПАД (блокнот) с откатом.', + 'mesh_plus.slide_projects_desc': 'Канбан, холст (кисть и заливка), снимки блокнота с восстановлением.', 'mesh_plus.slide_clipboard': 'КЛИПБОАРД (доска)', - 'mesh_plus.slide_clipboard_desc': 'Безлимитная история буфера по ЛАН и поиск.', + 'mesh_plus.slide_clipboard_desc': 'До 500 записей по ЛАН и поиск (включите буфер в Настройки → Сеть).', 'mesh_plus.slide_gif': 'Гифка статуса', 'mesh_plus.slide_gif_desc': 'Анимированное облако в карточке профиля — только МЭШ ПЛЮС.', + 'mesh_plus.slide_theme': 'Редактор темы', + 'mesh_plus.slide_theme_desc': 'Свой акцент #RRGGBB поверх пресетов — только МЭШ ПЛЮС.', + 'mesh_plus.slide_icons': 'Иконки mesh-1…6', + 'mesh_plus.slide_icons_desc': 'Шесть премиум-иконок в Настройки → Внешний вид.', + 'mesh_plus.slide_export': 'Экспорт чата', + 'mesh_plus.slide_export_desc': 'PDF/HTML переписки с цветами вашей темы BLIP.', 'mesh_plus.identity_title': 'Идентичность в меше', 'mesh_plus.identity_hint': 'Как тебя видят в локальной сети.', 'mesh_plus.id_color_label': 'Цвет BLIP ID', @@ -1266,7 +1333,7 @@ const locales = { 'settings.sound_pack_wire': 'ПРОВОД', 'settings.sound_pack_static': 'СТАТИК', 'settings.melody_pack': 'Мелодии звонка', - 'settings.melody_pack_mesh': 'MESH', + 'settings.melody_pack_mesh': 'МЕШ', 'settings.melody_pack_grid': 'СЕТКА', 'settings.melody_pack_beacon': 'МАЯК', 'settings.melody_pack_chime': 'ПЕРЕЗВОН', @@ -1289,7 +1356,7 @@ const locales = { 'settings.section_transfer': 'Файлы', 'settings.section_network': 'Сеть', 'settings.network_stats_title': 'Статистика сессии', - 'settings.network_stats_hint': 'Счётчики текущей сессии BLIP (локально).', + 'settings.network_stats_hint': 'Счётчики текущей сессии БЛИП (локально).', 'settings.network_stats_summary': 'Онлайн {hours} · {messages} сообщ. · {files} файлов · {calls} звонков · пик {peers} в сети', 'settings.network_stats_chart': 'Столбцы активности', @@ -1306,24 +1373,39 @@ const locales = { 'achievements.notify_hint': 'Тост при получении достижения.', 'achievements.notify': 'Уведомлять о разблокировке', 'achievements.icon_placeholder': 'Слот иконки — укажи путь в achievements.js', + 'achievements.icon_unlock_to_see': 'Сначала получите достижение, чтобы увидеть иконку.', 'achievements.reset': 'Сбросить разблокировки', 'achievements.reset_confirm': 'Сбросить все достижения на этом устройстве?', 'achievements.reset_ok': 'Прогресс достижений сброшен.', + 'achievements.section_unlocked': 'Выполненные', + 'achievements.section_locked': 'Невыполненные', + 'achievements.section_unlocked_empty': 'Пока нет выполненных достижений.', + 'achievements.section_locked_empty': 'Все достижения выполнены.', 'achievements.unlocked_title': 'Достижение получено', - 'achievements.first_peer': 'Первый контакт', - 'achievements.first_peer_desc': 'Хотя бы один абонент онлайн за сессию.', - 'achievements.mesh_5': 'Полный меш', - 'achievements.mesh_5_desc': 'Пять абонентов онлайн одновременно.', + 'achievements.code_first_peer': '1+ абонент онлайн', + 'achievements.code_mesh_5': '5 абонентов сразу', + 'achievements.code_messages_50': '50 сообщений', + 'achievements.code_messages_100': '100 сообщений', + 'achievements.code_first_call': 'Первый звонок', + 'achievements.code_first_file': 'Первый файл', + 'achievements.code_online_1h': 'Час в приложении', + 'achievements.code_mesh_plus': 'Подписка МЭШ ПЛЮС', + 'achievements.first_peer': 'Первый в сети', + 'achievements.first_peer_desc': 'Хотя бы один контакт онлайн за эту сессию.', + 'achievements.mesh_5': 'Пятеро в сети', + 'achievements.mesh_5_desc': 'Пять контактов онлайн одновременно.', 'achievements.messages_50': 'Болтун', - 'achievements.messages_50_desc': '50 сообщений за сессию.', - 'achievements.messages_100': 'Оператор сигнала', - 'achievements.messages_100_desc': '100 сообщений за сессию.', + 'achievements.messages_50_desc': 'Отправь 50 сообщений за одну сессию.', + 'achievements.messages_100': 'Оператор эфира', + 'achievements.messages_100_desc': 'Отправь 100 сообщений за одну сессию.', 'achievements.first_call': 'На связи', - 'achievements.first_call_desc': 'Начать голосовой звонок.', - 'achievements.first_file': 'Груз', - 'achievements.first_file_desc': 'Отправить файл по сети.', + 'achievements.first_call_desc': 'Начни первый голосовой звонок.', + 'achievements.first_file': 'Первая передача', + 'achievements.first_file_desc': 'Отправь файл по сети БЛИП.', 'achievements.online_1h': 'Длинная смена', - 'achievements.online_1h_desc': 'Час в BLIP за эту сессию.', + 'achievements.online_1h_desc': 'Проведи в БЛИПе один час за эту сессию.', + 'achievements.mesh_plus_active': 'МЭШ ПЛЮС активирован', + 'achievements.mesh_plus_active_desc': 'Активируй подписку МЭШ+ на этом устройстве.', 'chat.export_pdf_themed': 'Экспорт PDF (тема BLIP)', 'chat.export_html_themed': 'Экспорт HTML (тема BLIP)', 'settings.group_account': 'Аккаунт', @@ -1333,19 +1415,19 @@ const locales = { 'settings.jump_section': 'Раздел', 'settings.file_limit': 'Макс. размер файла', 'settings.file_limit_option': '{gb} ГБ', - 'settings.transfer_hint': 'Передача файлов по LAN mesh. Крупные файлы — по частям (TCP).', + 'settings.transfer_hint': 'Передача файлов по ЛАН МЕШ. Крупные файлы — по частям ТСР).', 'settings.file_speed': 'Скорость передачи', - 'settings.file_speed_hint': 'Медленный режим снижает нагрузку на mesh во время звонка.', + 'settings.file_speed_hint': 'Медленный режим снижает нагрузку на меш во время звонка.', 'settings.file_speed_fast': 'Быстро', 'settings.file_speed_normal': 'Обычно', 'settings.file_speed_slow': 'Медленно (для звонка)', 'settings.stream_quality': 'Качество трансляции', 'settings.stream_quality_low': 'Низкое · 480p', 'settings.stream_quality_hd': 'HD · 720p', - 'settings.stream_quality_fhd': 'Full HD · 1080p', - 'settings.stream_quality_max': 'Макс · 1080p 60fps', + 'settings.stream_quality_fhd': 'ФУЛЛ ХД · 1080p', + 'settings.stream_quality_max': 'МАКС · 1080p 60ФПС', 'settings.stream_quality_qhd': 'QHD · 1440p', - 'settings.stream_quality_uhd': '4K · UHD', + 'settings.stream_quality_uhd': '4K · УЛЬТРА ХД', 'settings.fullscreen_quality': 'Разрешение полного экрана', 'emoji.cat_smileys': 'Смайлы', 'emoji.cat_people': 'Люди', @@ -1360,15 +1442,15 @@ const locales = { 'transfer.hub_out': '↑ к #{id}', 'transfer.hub_cancel': 'Отмена', 'chat.file_cancelled': 'Передача отменена', - 'clipboard.mode': 'Буфер LAN', + 'clipboard.mode': 'Буфер ЛАН', 'clipboard.mode_off': 'Выкл', 'clipboard.mode_active': 'Только открытый чат', 'clipboard.mode_trusted': 'Доверенные абоненты', 'clipboard.hint': - 'Синхронизация буфера по LAN: скопировал у себя — абонент в открытом личном чате мгновенно получает текст (до 32 КБ). Без облака. Выкл = отключено; Активно = только пока чат открыт. Только в сетях, которым доверяешь.', + 'Синхронизация буфера по ЛАН: скопировал у себя — абонент в открытом личном чате мгновенно получает текст (до 32 КБ). Без облака. Выкл = отключено; Активно = только пока чат открыт. Только в сетях, которым доверяешь.', 'clipboard.received': 'Буфер от #{id}', 'settings.call_hint': - 'Качество трансляции — разрешение захвата и битрейт по LAN (выше = чётче, больше нагрузка). Разрешение fullscreen — цель в режиме театра (клавиша F).', + 'Качество трансляции — разрешение захвата и битрейт по ЛАН (выше = чётче, больше нагрузка). Разрешение полноэкранное — цель в режиме театра (клавиша F).', 'settings.noise_suppression': 'Шумоподавление (микрофон)', 'settings.call_mic': 'Микрофон', 'settings.call_mic_default': 'По умолчанию', @@ -1384,7 +1466,7 @@ const locales = { 'settings.network_peers': 'Абоненты', 'settings.network_peers_value': '{online} в сети · {total} известно', 'settings.network_env_hint': 'Порты можно переопределить переменными BLIP_TCP_PORT и BLIP_UDP_PORT.', - 'settings.network_log': 'Журнал активности Mesh', + 'settings.network_log': 'Журнал активности мэш', 'settings.network_log_empty': 'Событий пока нет.', 'settings.network_log_clear': 'Очистить журнал', 'settings.network_unavailable': 'Сведения о сети недоступны.', @@ -1435,15 +1517,21 @@ const locales = { 'settings.dev_beta_updates': 'Получать БЕТА-релизы', 'settings.dev_beta_on': 'БЕТА-обновления включены', 'settings.dev_beta_off': 'Только стабильные релизы', - 'settings.dev_mesh_trace': 'Трассировка mesh-пакетов', - 'settings.dev_mesh_trace_hint': 'Писать тип каждого TCP-кадра в Сеть → журнал.', + 'settings.dev_mesh_trace': 'Трассировка мэш-пакетов', + 'settings.dev_mesh_trace_hint': 'Писать тип каждого ТСР-кадра в Сеть → журнал.', 'settings.dev_mesh_trace_on': 'Трассировка включена', 'settings.dev_mesh_trace_off': 'Трассировка выключена', 'settings.dev_export': 'Экспорт диагностики', 'settings.dev_export_done': 'Диагностика скопирована в буфер', 'settings.dev_export_fail': 'Не удалось скопировать', 'settings.pick_section_hint': 'Чтобы перейти, выберите раздел в списке слева.', - 'settings.system_na': 'Эти настройки пока только для Windows.', + 'settings.nav_group_account': 'Аккаунт', + 'settings.nav_group_app': 'Приложение', + 'settings.nav_group_connection': 'Сеть', + 'settings.nav_group_system': 'Система', + 'settings.dev_panel_hint': 'Бета-флаги и внутренние инструменты — для обычной работы не нужны.', + 'settings.dev_projects_callout': 'Сигнал Корпс (Проекты)', + 'settings.system_na': 'Эти настройки пока только для виндвос.', 'settings.updates_check': 'Проверить', 'settings.updates_install': 'Перезапуск с обновлением', 'settings.updates_releases': 'Релизы', @@ -1459,9 +1547,9 @@ const locales = { 'settings.updates_status_stale_release': 'Устаревший кэш обновлений очищен. Проверяем стабильный канал…', 'settings.updates_recent': 'Последние релизы', - 'settings.updates_releases_error': 'Не удалось загрузить релизы с GitHub.', - 'settings.updates_open_release': 'Открыть на GitHub', - 'error.id_taken': 'ID ЗАНЯТ', + 'settings.updates_releases_error': 'Не удалось загрузить релизы с гитхаба.', + 'settings.updates_open_release': 'Открыть на гитхаб', + 'error.id_taken': 'АЙДИ ЗАНЯТ', 'error.id_taken_hint': 'Этот номер уже используется. Выбери другой.', 'error.connection_failed': 'ОШИБКА ПОДКЛЮЧЕНИЯ', 'status.connected': 'ПОДКЛЮЧЕН', @@ -1474,15 +1562,20 @@ const locales = { 'nav.settings': 'НАСТРОЙКИ', 'projects.hub_title': 'ПРОЕКТЫ', 'projects.hub_hint': - 'Сигнал Корпс — главная фича BLIP для разработчиков в МЕШе (без групп). Общий блокнот синхронизируется с онлайн-абонентами по ЛАН. Скоро: канбан, пиксель-холст, буфер с историей (20 записей). Включите в Настройки → Разработчик → Проекты (по умолчанию выкл).', + 'Сигнал Корпс — блокнот, канбан, холст и буфер по ЛАН в МЕШе. Включите в Настройки → Разработчик → Проекты; буфер — в Настройки → Сеть (по умолчанию выкл.).', 'projects.tool_soon': 'В разработке — появится в следующих версиях Сигнал Корпс.', 'projects.mesh_plus_required': 'ДОСКА и ХОЛСТ — инструменты МЭШ ПЛЮС. Активация в Настройки → МЭШ ПЛЮС.', 'projects.pad_history_title': 'История блокнота', 'projects.pad_history_save': 'Сохранить снимок', 'projects.pad_history_restore': 'Восстановлено', 'projects.pad_history_manual': 'Ручной снимок', - 'projects.pad_history_empty': 'Пока пусто — пиши в блокноте или нажми «Сохранить снимок».', + 'projects.pad_history_hint': + 'Пока пусто — пиши в блокноте или нажми «Сохранить снимок».', 'projects.sidebar_title': 'инструменты', + 'projects.mesh_online': 'В сети: {n}', + 'projects.mesh_online_empty': 'Никого онлайн — синхронизация начнётся, когда появится абонент.', + 'projects.tier_legend': 'Серые инструменты — по подписке МЭШ ПЛЮС.', + 'projects.open_mesh_plus': 'Открыть МЭШ ПЛЮС', 'projects.tool_pad': 'БЛОКНОТ', 'projects.tool_board': 'ДОСКА', 'projects.tool_canvas': 'ХОЛСТ', @@ -1490,15 +1583,31 @@ const locales = { 'projects.pad_synced': 'синхронизировано · {ms}мс', 'projects.pad_editing': 'пир #{id} пишет', 'projects.pad_idle': 'готово', - 'projects.board_todo': 'TODO', + 'projects.board_todo': 'НАДО', 'projects.board_progress': 'В РАБОТЕ', 'projects.board_done': 'ГОТОВО', - 'projects.board_card_prompt': 'Текст задачи', + 'projects.board_card_prompt': 'Новая карточка', + 'projects.board_card_placeholder': 'Текст задачи…', + 'projects.board_add_card': 'Добавить', + 'projects.board_card_delete': 'Удалить карточку', + 'projects.board_card_delete_confirm': 'Удалить эту карточку с доски?', + 'projects.board_move_progress': 'В работе', + 'projects.board_move_done': 'Готово', + 'projects.board_move_todo': 'В «Надо»', 'projects.board_cards': '{n} карточек', + 'projects.pad_history_delete': 'Удалить снимок', + 'projects.pad_history_delete_confirm': 'Удалить этот снимок из истории блокнота?', + 'projects.clipboard_disabled': 'Включите буфер в Настройки → Сеть.', + 'projects.clip_copy': 'Копировать в буфер ОС', + 'projects.clip_delete': 'Удалить запись', + 'projects.canvas_tool_brush': 'Кисть', + 'projects.canvas_tool_bucket': 'Заливка', + 'projects.canvas_tool_eraser': 'Ластик', + 'projects.canvas_custom_color': 'Свой цвет', 'projects.canvas_active': '32×16 · #{id} рисует', 'projects.canvas_size': 'сетка 32×16', - 'projects.clip_count': '{n} записей · pull-модель', - 'projects.clip_pull': 'PULL', + 'projects.clip_count': '{n} записей', + 'projects.clip_pull': 'Загрузить', 'projects.clip_push': 'В БУФЕР', 'projects.clip_search': 'Поиск в буфере…', 'projects.clip_count_limit': '{n} / {max} записей', @@ -1513,7 +1622,18 @@ const locales = { 'settings.dev_projects_on': 'Проекты включены', 'settings.dev_projects_off': 'Проекты скрыты', 'settings.dev_projects_hint': - 'Сигнал Корпс — главная фича BLIP для разработчиков. Пункт ПРОЕКТЫ: ✦ Блокнот работает; ▦ ◻ ⧉ скоро. Синхронизация по МЕШу на ЛАН. По умолчанию выкл.', + 'Сигнал Корпс — общий блокнот, доска и холст по ЛАН. По умолчанию выкл.; включите, чтобы показать ПРОЕКТЫ в меню.', + 'settings.dev_groups': 'Группы (конференции)', + 'settings.dev_groups_hint': + 'Бета: групповые чаты нестабильны. По умолчанию выкл. — создать группу нельзя. Вкл. возвращает пункт меню.', + 'settings.dev_groups_on': 'Группы (бета) включены', + 'settings.dev_groups_off': 'Группы скрыты', + 'settings.projects_clipboard': 'Буфер Сигнал Корпс', + 'settings.projects_clipboard_hint': 'Общая история буфера в ПРОЕКТЫ → Буфер. По умолчанию выкл.', + 'settings.projects_clipboard_enable_confirm': + 'Разрешить БЛИП читать системный буфер при отправке записей в общий буфер меша?', + 'settings.projects_clipboard_on': 'Буфер проектов включён', + 'settings.projects_clipboard_off': 'Буфер проектов выключен', 'group.menu_rename': 'Переименовать', 'group.rename_prompt': 'Название группы', 'group.rename_hint': 'Нажмите, чтобы переименовать', @@ -1571,4 +1691,8 @@ export function applyI18n(root = document) { const key = el.dataset.i18nPlaceholder; if (key) el.placeholder = t(key); }); + root.querySelectorAll('[data-i18n-title]').forEach((el) => { + const key = el.dataset.i18nTitle; + if (key) el.title = t(key); + }); } diff --git a/renderer/mesh-plus-settings.js b/renderer/mesh-plus-settings.js index c986a1e..15a0a5d 100644 --- a/renderer/mesh-plus-settings.js +++ b/renderer/mesh-plus-settings.js @@ -1,15 +1,18 @@ import { t } from './i18n.js'; import { isMeshPlusActive } from './mesh-plus.js'; import { showAppToast } from './toasts.js'; -import { buildPanelTitleRow } from './settings-ui.js'; +import { buildPanelTitleRow, buildSectionSubtitleRow } from './settings-ui.js'; const CAROUSEL_SLIDES = [ { icon: '◈', titleKey: 'mesh_plus.slide_premium_bg', descKey: 'mesh_plus.slide_premium_bg_desc' }, { icon: '♪', titleKey: 'mesh_plus.slide_sound', descKey: 'mesh_plus.slide_sound_desc' }, - { icon: '◎', titleKey: 'mesh_plus.slide_relay', descKey: 'mesh_plus.slide_relay_desc' }, + { icon: '◇', titleKey: 'mesh_plus.slide_theme', descKey: 'mesh_plus.slide_theme_desc' }, + { icon: '⬡', titleKey: 'mesh_plus.slide_icons', descKey: 'mesh_plus.slide_icons_desc' }, { icon: '▦', titleKey: 'mesh_plus.slide_projects', descKey: 'mesh_plus.slide_projects_desc' }, { icon: '⧉', titleKey: 'mesh_plus.slide_clipboard', descKey: 'mesh_plus.slide_clipboard_desc' }, { icon: '▶', titleKey: 'mesh_plus.slide_gif', descKey: 'mesh_plus.slide_gif_desc' }, + { icon: '⎙', titleKey: 'mesh_plus.slide_export', descKey: 'mesh_plus.slide_export_desc' }, + { icon: '◎', titleKey: 'mesh_plus.slide_relay', descKey: 'mesh_plus.slide_relay_desc' }, ]; /** @@ -52,6 +55,14 @@ export function buildSettingsMeshPlusPanel(state, onConfigChange) { carouselTop.appendChild(slideCounter); carousel.appendChild(carouselTop); + const carouselStage = document.createElement('div'); + carouselStage.className = 'mesh-plus-carousel__stage'; + + const carouselMedia = document.createElement('div'); + carouselMedia.className = 'mesh-plus-carousel__media'; + carouselMedia.setAttribute('aria-hidden', 'true'); + carouselStage.appendChild(carouselMedia); + const carouselBody = document.createElement('div'); carouselBody.className = 'mesh-plus-carousel__body'; const prevBtn = document.createElement('button'); @@ -71,11 +82,12 @@ export function buildSettingsMeshPlusPanel(state, onConfigChange) { carouselBody.appendChild(prevBtn); carouselBody.appendChild(carouselInner); carouselBody.appendChild(nextBtn); - carousel.appendChild(carouselBody); + carouselStage.appendChild(carouselBody); const dots = document.createElement('div'); dots.className = 'mesh-plus-carousel__dots'; - carousel.appendChild(dots); + carouselStage.appendChild(dots); + carousel.appendChild(carouselStage); frag.appendChild(carousel); let slideIndex = 0; @@ -136,11 +148,9 @@ export function buildSettingsMeshPlusPanel(state, onConfigChange) { const activationCard = document.createElement('div'); activationCard.className = 'mesh-plus-activation settings-list-panel'; - const activationTitle = document.createElement('h3'); - activationTitle.className = 'section-subtitle'; - activationTitle.dataset.i18n = 'mesh_plus.activation_title'; - activationTitle.textContent = t('mesh_plus.activation_title'); - activationCard.appendChild(activationTitle); + activationCard.appendChild( + buildSectionSubtitleRow('mesh_plus.activation_title', 'mesh_plus.activation_hint') + ); const keyLabel = document.createElement('label'); keyLabel.className = 'settings-field-label'; @@ -177,16 +187,10 @@ export function buildSettingsMeshPlusPanel(state, onConfigChange) { const feedback = document.createElement('p'); feedback.className = 'mesh-plus-feedback hint'; - const hint = document.createElement('p'); - hint.className = 'hint mesh-plus-hint'; - hint.dataset.i18n = 'mesh_plus.no_key_hint'; - hint.textContent = t('mesh_plus.no_key_hint'); - activationCard.appendChild(keyLabel); activationCard.appendChild(keyInput); activationCard.appendChild(btnRow); activationCard.appendChild(feedback); - activationCard.appendChild(hint); frag.appendChild(activationCard); function syncActivationUi() { diff --git a/renderer/mesh-plus.js b/renderer/mesh-plus.js index 3838c4d..f3aa6e4 100644 --- a/renderer/mesh-plus.js +++ b/renderer/mesh-plus.js @@ -61,7 +61,7 @@ export function fillMeshGatedDropdown(select, options, current, feature, cfg, on for (const opt of options) { const o = document.createElement('option'); o.value = opt.value; - o.textContent = opt.meshPlus ? `${opt.label} ◆` : opt.label; + o.textContent = opt.label; if (opt.meshPlus) o.disabled = true; select.appendChild(o); } diff --git a/renderer/pad-history-store.js b/renderer/pad-history-store.js index d8369b0..cc5b572 100644 --- a/renderer/pad-history-store.js +++ b/renderer/pad-history-store.js @@ -66,3 +66,19 @@ export function getPadSnapshotById(scopeId, snapshotId) { if (!hit) return null; return { text: hit.text, updatedAt: hit.updatedAt, from: hit.from }; } + +/** + * @param {string} scopeId + * @param {string} snapshotId + * @returns {boolean} + */ +export function deletePadSnapshot(scopeId, snapshotId) { + const key = String(scopeId); + const all = readAll(); + const prev = Array.isArray(all[key]) ? all[key] : []; + const next = prev.filter((e) => e.id !== snapshotId); + if (next.length === prev.length) return false; + all[key] = next; + writeAll(all); + return true; +} diff --git a/renderer/profile-card.js b/renderer/profile-card.js index f43432f..5cd7774 100644 --- a/renderer/profile-card.js +++ b/renderer/profile-card.js @@ -6,8 +6,8 @@ import { getPeerProfileGifDataUrl } from './peer-gif-cache.js'; import { appendMeshPlusBadgeToNameRow } from './mesh-plus.js'; import { computeGifFramePx } from './gif-frame-size.js'; -const CLOUD_MAX_W = 168; -const CLOUD_MAX_H = 95; +const CLOUD_MAX_W = 88; +const CLOUD_MAX_H = 50; function applyGifCloudSize(cloudEl, imgEl) { const nw = imgEl.naturalWidth || 160; @@ -52,6 +52,8 @@ export function buildProfileCard(peerInput, hooks = {}) { banner.className = 'peer-profile-banner'; if (peer.meshPlus) banner.classList.add('peer-profile-banner--mesh-plus'); if (peer.online) banner.classList.add('peer-profile-banner--online'); + banner.title = t('peers.profile_banner_future'); + banner.dataset.i18nTitle = 'peers.profile_banner_future'; card.appendChild(banner); } @@ -82,8 +84,8 @@ export function buildProfileCard(peerInput, hooks = {}) { const asideStack = document.createElement('div'); asideStack.className = 'peer-profile-aside-stack'; - asideStack.appendChild(avatarWrap); asideStack.appendChild(cloudWrap); + asideStack.appendChild(avatarWrap); aside.appendChild(asideStack); const main = document.createElement('div'); diff --git a/renderer/project-tools-ui.js b/renderer/project-tools-ui.js index fc3f53b..c6c9c08 100644 --- a/renderer/project-tools-ui.js +++ b/renderer/project-tools-ui.js @@ -1,5 +1,21 @@ import { t } from './i18n.js'; -import { getPadState, setPadState, getBoardState, setBoardState, getCanvasState, getClipState, pushClipEntry, subscribeGroupProject } from './group-projects-store.js'; +import { createPixelHintIcon } from './settings-ui.js'; +import { createBlipColorInput } from './blip-color-input.js'; +import { + getPadState, + setPadState, + getBoardState, + setBoardState, + getCanvasState, + setCanvasPixel, + notifyCanvasChanged, + getClipState, + pushClipEntry, + removeClipEntry, + subscribeGroupProject, +} from './group-projects-store.js'; +import { openTextPromptDialog } from './prompt-dialog.js'; +import { openConfirmDialog } from './confirm-dialog.js'; import { broadcastProject, requestClipboardPull } from './group-projects-wire.js'; import { broadcastMeshPad, @@ -9,10 +25,34 @@ import { requestMeshClipboardPull, } from './projects-mesh-wire.js'; import { clipLimitForTier } from './group-projects-store.js'; -import { getPadHistory, pushPadSnapshot, getPadSnapshotById } from './pad-history-store.js'; +import { + getPadHistory, + pushPadSnapshot, + getPadSnapshotById, + deletePadSnapshot, +} from './pad-history-store.js'; import { createMessageId } from './message-id.js'; -const CANVAS_COLORS = ['', '#00ffc8', '#888888', '#ff3366', '#e0e0e0', '#4488ff']; +const CANVAS_QUICK_COLORS = [ + '#000000', + '#1a1a1a', + '#444444', + '#888888', + '#cccccc', + '#ffffff', + '#ff3366', + '#ff8800', + '#ffee00', + '#88ff00', + '#00ffc8', + '#4488ff', + '#8844ff', + '#ff44cc', + '#8b4513', + '#2d5016', +]; + +const CANVAS_TOOLS = ['brush', 'bucket', 'eraser']; function mkStatusBar() { const bar = document.createElement('div'); @@ -102,10 +142,14 @@ export function createPadToolView(group, config, api, meshOpts = null) { if (meshOpts?.meshPlusActive) { const histWrap = document.createElement('div'); histWrap.className = 'proj-pad-history'; - const histTitle = document.createElement('div'); + const histHead = document.createElement('div'); + histHead.className = 'proj-pad-history-head'; + const histTitle = document.createElement('span'); histTitle.className = 'proj-pad-history-title'; histTitle.dataset.i18n = 'projects.pad_history_title'; histTitle.textContent = t('projects.pad_history_title'); + histHead.appendChild(histTitle); + histHead.appendChild(createPixelHintIcon('projects.pad_history_hint')); const histList = document.createElement('div'); histList.className = 'proj-pad-history-list'; const histActions = document.createElement('div'); @@ -159,12 +203,23 @@ export function createPadToolView(group, config, api, meshOpts = null) { applying = false; pushLocal({ historyLabel: t('projects.pad_history_restore') }); }); + row.addEventListener('contextmenu', (e) => { + e.preventDefault(); + void (async () => { + const ok = await openConfirmDialog({ + title: t('projects.pad_history_delete'), + body: t('projects.pad_history_delete_confirm'), + }); + if (!ok) return; + if (deletePadSnapshot(scopeId, item.id)) renderHistory(); + })(); + }); histList.appendChild(row); } }; histActions.appendChild(saveBtn); - histWrap.appendChild(histTitle); + histWrap.appendChild(histHead); histWrap.appendChild(histList); histWrap.appendChild(histActions); wrap.appendChild(ta); @@ -202,6 +257,104 @@ export function createBoardToolView(group, config, api, meshOpts = null) { } } + async function addCard(col) { + const st = getBoardState(scopeId); + const text = await openTextPromptDialog({ + title: t('projects.board_card_prompt'), + placeholder: t('projects.board_card_placeholder'), + }); + if (!text) return; + const next = { + ...st, + cards: [ + ...st.cards, + { + id: createMessageId(), + text, + status: col, + assignee: config.blipId, + }, + ], + }; + setBoardState(scopeId, next); + broadcastBoard(next.cards); + render(); + } + + function setCardStatus(cardId, newStatus) { + const st = getBoardState(scopeId); + const next = { + ...st, + cards: st.cards.map((c) => (c.id === cardId ? { ...c, status: newStatus } : c)), + }; + setBoardState(scopeId, next); + broadcastBoard(next.cards); + render(); + } + + async function deleteCard(cardId) { + const ok = await openConfirmDialog({ + title: t('projects.board_card_delete'), + body: t('projects.board_card_delete_confirm'), + }); + if (!ok) return; + const st = getBoardState(scopeId); + const next = { ...st, cards: st.cards.filter((c) => c.id !== cardId) }; + setBoardState(scopeId, next); + broadcastBoard(next.cards); + render(); + } + + function showBoardCardMenu(e, card) { + e.preventDefault(); + const menu = document.createElement('div'); + menu.className = 'context-menu glass'; + menu.style.left = `${e.clientX}px`; + menu.style.top = `${e.clientY}px`; + + function bindItem(btn, handler) { + btn.addEventListener('mousedown', (ev) => ev.stopPropagation()); + btn.addEventListener('click', (ev) => { + ev.stopPropagation(); + menu.remove(); + handler(); + }); + } + + const delBtn = document.createElement('button'); + delBtn.type = 'button'; + delBtn.textContent = t('projects.board_card_delete'); + bindItem(delBtn, () => void deleteCard(card.id)); + menu.appendChild(delBtn); + + if (card.status !== 'progress') { + const progBtn = document.createElement('button'); + progBtn.type = 'button'; + progBtn.textContent = t('projects.board_move_progress'); + bindItem(progBtn, () => setCardStatus(card.id, 'progress')); + menu.appendChild(progBtn); + } + + if (card.status !== 'done') { + const doneBtn = document.createElement('button'); + doneBtn.type = 'button'; + doneBtn.textContent = t('projects.board_move_done'); + bindItem(doneBtn, () => setCardStatus(card.id, 'done')); + menu.appendChild(doneBtn); + } + + if (card.status !== 'todo') { + const todoBtn = document.createElement('button'); + todoBtn.type = 'button'; + todoBtn.textContent = t('projects.board_move_todo'); + bindItem(todoBtn, () => setCardStatus(card.id, 'todo')); + menu.appendChild(todoBtn); + } + document.body.appendChild(menu); + const close = () => menu.remove(); + setTimeout(() => document.addEventListener('click', close, { once: true }), 0); + } + function render() { const st = getBoardState(scopeId); for (const col of cols) { @@ -219,32 +372,9 @@ export function createBoardToolView(group, config, api, meshOpts = null) { meta.textContent = ` #${card.assignee}`; el.appendChild(meta); } + el.addEventListener('contextmenu', (ev) => showBoardCardMenu(ev, card)); list.appendChild(el); }); - const add = document.createElement('button'); - add.type = 'button'; - add.className = 'btn btn-lang proj-board-add'; - add.textContent = '+'; - add.addEventListener('click', () => { - const text = prompt(t('projects.board_card_prompt')); - if (!text?.trim()) return; - const next = { - ...st, - cards: [ - ...st.cards, - { - id: createMessageId(), - text: text.trim(), - status: col, - assignee: config.blipId, - }, - ], - }; - setBoardState(scopeId, next); - broadcastBoard(next.cards); - render(); - }); - list.appendChild(add); } status.text.textContent = t('projects.board_cards').replace('{n}', String(st.cards.length)); } @@ -256,7 +386,17 @@ export function createBoardToolView(group, config, api, meshOpts = null) { colWrap.className = 'proj-board-col'; const head = document.createElement('div'); head.className = 'proj-board-col-head'; - head.textContent = t(`projects.board_${col}`); + const headLabel = document.createElement('span'); + headLabel.className = 'proj-board-col-title'; + headLabel.textContent = t(`projects.board_${col}`); + const add = document.createElement('button'); + add.type = 'button'; + add.className = 'btn btn-lang proj-board-add'; + add.setAttribute('aria-label', t('projects.board_add_card')); + add.textContent = '+'; + add.addEventListener('click', () => void addCard(col)); + head.appendChild(headLabel); + head.appendChild(add); const list = document.createElement('div'); list.className = 'proj-board-col-list'; colWrap.appendChild(head); @@ -287,8 +427,10 @@ export function createCanvasToolView(group, config, api, meshOpts = null) { const wrap = document.createElement('div'); wrap.className = 'proj-tool proj-tool--canvas'; - let color = CANVAS_COLORS[1]; + let color = CANVAS_QUICK_COLORS[10]; + let tool = 'brush'; let activePainter = null; + let painting = false; function broadcastCanvasPixel(x, y, c) { if (meshOpts?.getBroadcastTargets) { @@ -298,50 +440,151 @@ export function createCanvasToolView(group, config, api, meshOpts = null) { } } + function paintColorAt(x, y) { + const c = tool === 'eraser' ? '' : color; + setCanvasPixel(scopeId, x, y, c); + broadcastCanvasPixel(x, y, c); + activePainter = config.blipId; + } + + function floodFill(x, y, fillColor) { + const st = getCanvasState(scopeId); + const target = st.cells[y * st.w + x] || ''; + const fill = fillColor || ''; + if (target === fill) return; + const stack = [[x, y]]; + const seen = new Set(); + const updates = []; + while (stack.length) { + const [cx, cy] = stack.pop(); + const key = `${cx},${cy}`; + if (seen.has(key)) continue; + if (cx < 0 || cy < 0 || cx >= st.w || cy >= st.h) continue; + const idx = cy * st.w + cx; + if ((st.cells[idx] || '') !== target) continue; + seen.add(key); + st.cells[idx] = fill; + updates.push([cx, cy]); + stack.push([cx + 1, cy], [cx - 1, cy], [cx, cy + 1], [cx, cy - 1]); + } + if (!updates.length) return; + notifyCanvasChanged(scopeId); + for (const [px, py] of updates) broadcastCanvasPixel(px, py, fill); + activePainter = config.blipId; + paint(); + } + + function applyToolAt(x, y) { + if (tool === 'bucket') { + floodFill(x, y, color); + return; + } + paintColorAt(x, y); + paint(); + } + + const workspace = document.createElement('div'); + workspace.className = 'proj-canvas-workspace'; + + const dock = document.createElement('div'); + dock.className = 'proj-canvas-dock'; + + const toolsRow = document.createElement('div'); + toolsRow.className = 'proj-canvas-tools'; + const toolBtns = new Map(); + for (const id of CANVAS_TOOLS) { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'btn btn-lang proj-canvas-tool-btn'; + btn.dataset.tool = id; + btn.dataset.i18n = `projects.canvas_tool_${id}`; + btn.textContent = t(`projects.canvas_tool_${id}`); + btn.addEventListener('click', () => { + tool = id; + toolBtns.forEach((b, k) => b.classList.toggle('proj-canvas-tool-btn--active', k === id)); + }); + if (id === tool) btn.classList.add('proj-canvas-tool-btn--active'); + toolBtns.set(id, btn); + toolsRow.appendChild(btn); + } + + const customColorUi = createBlipColorInput({ + value: color, + title: t('projects.canvas_custom_color'), + className: 'proj-canvas-color-picker', + }); + customColorUi.input.addEventListener('input', () => { + color = customColorUi.value; + syncSwatches(); + }); + const head = document.createElement('div'); head.className = 'proj-canvas-head'; const hint = document.createElement('span'); hint.className = 'proj-canvas-hint'; head.appendChild(hint); + const stage = document.createElement('div'); + stage.className = 'proj-canvas-stage'; + const grid = document.createElement('div'); grid.className = 'proj-canvas-grid'; const cells = []; const st0 = getCanvasState(scopeId); + grid.style.setProperty('--canvas-cols', String(st0.w)); + grid.style.setProperty('--canvas-rows', String(st0.h)); for (let y = 0; y < st0.h; y++) { for (let x = 0; x < st0.w; x++) { const cell = document.createElement('button'); cell.type = 'button'; cell.className = 'proj-canvas-cell'; - const idx = y * st0.w + x; - cell.addEventListener('click', () => { - const c = color || '#00ffc8'; - setCanvasPixel(scopeId, x, y, c); - broadcastCanvasPixel(x, y, c); - activePainter = config.blipId; - paint(); + const onPaint = () => applyToolAt(x, y); + cell.addEventListener('mousedown', (e) => { + e.preventDefault(); + painting = true; + onPaint(); + }); + cell.addEventListener('mouseenter', () => { + if (painting && tool !== 'bucket') onPaint(); }); cells.push(cell); grid.appendChild(cell); } } + const onWindowUp = () => { + painting = false; + }; + window.addEventListener('mouseup', onWindowUp); const palette = document.createElement('div'); palette.className = 'proj-canvas-palette'; - CANVAS_COLORS.filter(Boolean).forEach((c) => { + const swatchEls = []; + + function syncSwatches() { + swatchEls.forEach((sw) => { + sw.classList.toggle('proj-canvas-swatch--active', sw.dataset.color === color); + }); + customColor.value = color.startsWith('#') && color.length >= 7 ? color : '#00ffc8'; + } + + CANVAS_QUICK_COLORS.forEach((c) => { const sw = document.createElement('button'); sw.type = 'button'; sw.className = 'proj-canvas-swatch'; + sw.dataset.color = c; sw.style.background = c; - if (c === color) sw.classList.add('proj-canvas-swatch--active'); sw.addEventListener('click', () => { color = c; - palette.querySelectorAll('.proj-canvas-swatch').forEach((el) => el.classList.remove('proj-canvas-swatch--active')); - sw.classList.add('proj-canvas-swatch--active'); + syncSwatches(); }); + swatchEls.push(sw); palette.appendChild(sw); }); + palette.appendChild(customColorUi.el); + + dock.appendChild(toolsRow); + dock.appendChild(palette); const status = mkStatusBar(); @@ -355,6 +598,7 @@ export function createCanvasToolView(group, config, api, meshOpts = null) { activePainter ? String(activePainter) : '—' ); status.text.textContent = t('projects.canvas_size'); + syncSwatches(); } const unsub = subscribeGroupProject(scopeId, (tool) => { @@ -362,15 +606,19 @@ export function createCanvasToolView(group, config, api, meshOpts = null) { }); paint(); + stage.appendChild(grid); + workspace.appendChild(stage); + workspace.appendChild(dock); wrap.appendChild(head); - wrap.appendChild(grid); - wrap.appendChild(palette); + wrap.appendChild(workspace); wrap.appendChild(status.bar); return { el: wrap, destroy() { unsub(); + painting = false; + window.removeEventListener('mouseup', onWindowUp); }, }; } @@ -417,6 +665,7 @@ export function createClipboardToolView(group, config, api, meshOpts = null) { copyBtn.type = 'button'; copyBtn.className = 'btn btn-lang proj-clip-copy'; copyBtn.textContent = '⧉'; + copyBtn.title = t('projects.clip_copy'); copyBtn.addEventListener('click', async () => { try { await navigator.clipboard.writeText(e.text); @@ -424,9 +673,22 @@ export function createClipboardToolView(group, config, api, meshOpts = null) { /* ignore */ } }); + const delBtn = document.createElement('button'); + delBtn.type = 'button'; + delBtn.className = 'btn btn-danger proj-clip-del'; + delBtn.textContent = '×'; + delBtn.title = t('projects.clip_delete'); + delBtn.addEventListener('click', () => { + if (removeClipEntry(scopeId, e.id)) render(); + }); + row.addEventListener('contextmenu', (ev) => { + ev.preventDefault(); + delBtn.click(); + }); row.appendChild(txt); row.appendChild(meta); row.appendChild(copyBtn); + row.appendChild(delBtn); list.appendChild(row); }); if (meshPlus) { diff --git a/renderer/projects-view.js b/renderer/projects-view.js index 7347f38..4de2f85 100644 --- a/renderer/projects-view.js +++ b/renderer/projects-view.js @@ -8,7 +8,7 @@ import { } from './project-tools-ui.js'; import { clipLimitForTier } from './group-projects-store.js'; import { MESH_PROJECT_SCOPE } from './projects-mesh-wire.js'; -import { isMeshPlusActive, showMeshPlusLockedToast, createMeshPlusBadge } from './mesh-plus.js'; +import { isMeshPlusActive, showMeshPlusLockedToast } from './mesh-plus.js'; const TOOLS = [ { id: 'pad', icon: '✦', labelKey: 'projects.tool_pad', tier: 'free' }, @@ -19,18 +19,19 @@ const TOOLS = [ /** * Standalone Projects workspace (not tied to groups). - * @param {object | (() => object)} configOrGetter — live config (use getter so MESH+ activation applies) + * @param {object | (() => object)} configOrGetter * @param {object} api * @param {() => number[]} getOnlinePeerIds + * @param {{ onOpenMeshPlus?: () => void }} [hooks] */ -export function createProjectsView(configOrGetter, api, getOnlinePeerIds) { +export function createProjectsView(configOrGetter, api, getOnlinePeerIds, hooks = {}) { const getConfig = typeof configOrGetter === 'function' ? configOrGetter : () => configOrGetter; const root = document.createElement('div'); root.className = 'projects-workspace'; const titleRow = document.createElement('div'); - titleRow.className = 'section-title-row'; + titleRow.className = 'section-title-row projects-workspace-head'; const title = document.createElement('h2'); title.className = 'section-title'; title.dataset.i18n = 'projects.hub_title'; @@ -119,7 +120,6 @@ export function createProjectsView(configOrGetter, api, getOnlinePeerIds) { if (opts.locked) { hint.dataset.i18n = 'projects.mesh_plus_required'; hint.textContent = t('projects.mesh_plus_required'); - stubEl.appendChild(createMeshPlusBadge()); } else { hint.dataset.i18n = 'projects.tool_soon'; hint.textContent = t('projects.tool_soon'); @@ -127,6 +127,15 @@ export function createProjectsView(configOrGetter, api, getOnlinePeerIds) { stubEl.appendChild(icon); stubEl.appendChild(stubTitle); stubEl.appendChild(hint); + if (opts.locked) { + const cta = document.createElement('button'); + cta.type = 'button'; + cta.className = 'btn btn-accent projects-stub-cta'; + cta.dataset.i18n = 'projects.open_mesh_plus'; + cta.textContent = t('projects.open_mesh_plus'); + cta.addEventListener('click', () => hooks.onOpenMeshPlus?.()); + stubEl.appendChild(cta); + } main.appendChild(stubEl); } @@ -151,7 +160,23 @@ export function createProjectsView(configOrGetter, api, getOnlinePeerIds) { main.appendChild(canvasView.el); } + function showClipboardDisabled() { + clearMain(); + const stub = document.createElement('div'); + stub.className = 'projects-stub glass'; + const hint = document.createElement('p'); + hint.className = 'hint'; + hint.dataset.i18n = 'projects.clipboard_disabled'; + hint.textContent = t('projects.clipboard_disabled'); + stub.appendChild(hint); + main.appendChild(stub); + } + function showClipboard() { + if (!getConfig().projectsClipboardEnabled) { + showClipboardDisabled(); + return; + } clearMain(); clipboardView = createClipboardToolView(meshGroup(), getConfig(), api, meshOpts()); clipboardView.el.classList.add('glass'); @@ -189,22 +214,29 @@ export function createProjectsView(configOrGetter, api, getOnlinePeerIds) { else showStub(id); } + function syncToolLockClasses() { + toolList.querySelectorAll('.projects-tool-btn').forEach((btn) => { + const def = TOOLS.find((x) => x.id === btn.dataset.tool); + if (!def) return; + const locked = def.tier === 'mesh_plus' && !isMeshPlusActive(getConfig()); + btn.classList.toggle('projects-tool-btn--mesh-locked', locked); + }); + } + TOOLS.forEach((tool) => { const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'projects-tool-btn'; btn.dataset.tool = tool.id; btn.dataset.i18n = tool.labelKey; - const label = document.createElement('span'); - label.textContent = t(tool.labelKey); const iconSpan = document.createElement('span'); iconSpan.className = 'projects-tool-icon'; iconSpan.textContent = tool.icon; + const label = document.createElement('span'); + label.className = 'projects-tool-label'; + label.textContent = t(tool.labelKey); btn.appendChild(iconSpan); btn.appendChild(label); - if (tool.tier === 'mesh_plus' && !isMeshPlusActive(getConfig())) { - btn.classList.add('projects-tool-btn--mesh-locked'); - } btn.addEventListener('click', () => selectTool(tool.id)); toolList.appendChild(btn); }); @@ -217,6 +249,7 @@ export function createProjectsView(configOrGetter, api, getOnlinePeerIds) { root.appendChild(titleRow); root.appendChild(body); + syncToolLockClasses(); selectTool('pad'); return { @@ -228,16 +261,10 @@ export function createProjectsView(configOrGetter, api, getOnlinePeerIds) { boardView?.refresh?.(); }, refreshMeshPlus() { - toolList.querySelectorAll('.projects-tool-btn').forEach((btn) => { - const def = TOOLS.find((x) => x.id === btn.dataset.tool); - if (!def) return; - btn.classList.toggle( - 'projects-tool-btn--mesh-locked', - def.tier === 'mesh_plus' && !isMeshPlusActive(getConfig()) - ); - }); - if (activeTool !== 'pad' && !canUseTool(TOOLS.find((x) => x.id === activeTool) || {})) { - selectTool(activeTool); + syncToolLockClasses(); + if (activeTool !== 'pad') { + const def = TOOLS.find((x) => x.id === activeTool); + if (def && !canUseTool(def)) selectTool(activeTool); } }, }; diff --git a/renderer/prompt-dialog.js b/renderer/prompt-dialog.js new file mode 100644 index 0000000..a7f2f80 --- /dev/null +++ b/renderer/prompt-dialog.js @@ -0,0 +1,92 @@ +import { t } from './i18n.js'; + +/** + * @param {{ title: string, body?: string, placeholder?: string, defaultValue?: string, confirmLabel?: string }} opts + * @returns {Promise} trimmed text or null if cancelled + */ +export function openTextPromptDialog(opts) { + const { + title, + body = '', + placeholder = '', + defaultValue = '', + confirmLabel = t('dialog.confirm'), + } = opts; + + return new Promise((resolve) => { + const backdrop = document.createElement('div'); + backdrop.className = 'blip-modal-backdrop'; + + const modal = document.createElement('div'); + modal.className = 'blip-modal glass'; + modal.setAttribute('role', 'dialog'); + modal.setAttribute('aria-modal', 'true'); + + const h = document.createElement('h3'); + h.className = 'blip-modal-title'; + h.textContent = title; + + const input = document.createElement('input'); + input.type = 'text'; + input.className = 'input blip-modal-input'; + input.maxLength = 200; + input.value = defaultValue; + input.placeholder = placeholder; + + const actions = document.createElement('div'); + actions.className = 'blip-modal-actions'; + + const cancelBtn = document.createElement('button'); + cancelBtn.type = 'button'; + cancelBtn.className = 'btn btn-lang'; + cancelBtn.textContent = t('dialog.cancel'); + + const okBtn = document.createElement('button'); + okBtn.type = 'button'; + okBtn.className = 'btn btn-accent'; + okBtn.textContent = confirmLabel; + + let done = false; + function finish(value) { + if (done) return; + done = true; + backdrop.remove(); + resolve(value); + } + + cancelBtn.addEventListener('click', () => finish(null)); + okBtn.addEventListener('click', () => finish(input.value.trim() || null)); + backdrop.addEventListener('click', (e) => { + if (e.target === backdrop) finish(null); + }); + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + finish(input.value.trim() || null); + } + if (e.key === 'Escape') { + e.preventDefault(); + finish(null); + } + }); + + actions.appendChild(cancelBtn); + actions.appendChild(okBtn); + modal.appendChild(h); + if (body) { + const p = document.createElement('p'); + p.className = 'hint blip-modal-hint'; + p.textContent = body; + modal.appendChild(p); + } + modal.appendChild(input); + modal.appendChild(actions); + backdrop.appendChild(modal); + document.body.appendChild(backdrop); + + requestAnimationFrame(() => { + input.focus(); + input.select(); + }); + }); +} diff --git a/renderer/session-stats-panel.js b/renderer/session-stats-panel.js index 5e535a0..c729474 100644 --- a/renderer/session-stats-panel.js +++ b/renderer/session-stats-panel.js @@ -10,30 +10,56 @@ import { * @param {HTMLElement} parent */ export function appendSessionStatsSection(parent) { - parent.appendChild(buildSectionSubtitleRow('settings.network_stats_title', 'settings.network_stats_hint')); + const statsWrap = document.createElement('div'); + statsWrap.className = 'settings-network-stats-block'; + statsWrap.appendChild( + buildSectionSubtitleRow('settings.network_stats_title', 'settings.network_stats_hint') + ); const card = document.createElement('div'); - card.className = 'session-stats-panel settings-list-panel'; + card.className = 'session-stats-panel settings-list-panel session-stats-panel--styled'; - const summary = document.createElement('div'); - summary.className = 'session-stats-summary'; + const pills = document.createElement('div'); + pills.className = 'session-stats-pills'; const chartWrap = document.createElement('div'); chartWrap.className = 'session-stats-chart'; chartWrap.setAttribute('role', 'img'); chartWrap.setAttribute('aria-label', t('settings.network_stats_chart')); + function formatHours() { + const hours = sessionOnlineHours(); + return hours >= 1 + ? `${hours.toFixed(1)} h` + : `${Math.max(1, Math.round(hours * 60))} min`; + } + function refresh() { const s = getSessionStats(); - const hours = sessionOnlineHours(); - const hStr = - hours >= 1 ? `${hours.toFixed(1)} h` : `${Math.max(1, Math.round(hours * 60))} min`; - summary.textContent = t('settings.network_stats_summary') - .replace('{hours}', hStr) - .replace('{messages}', String(s.messagesSent || 0)) - .replace('{files}', String(s.filesSent || 0)) - .replace('{calls}', String(s.callsStarted || 0)) - .replace('{peers}', String(s.peersMaxOnline || 0)); + pills.innerHTML = ''; + const items = [ + { label: t('settings.network_stats_bar_minutes'), value: formatHours() }, + { label: t('settings.network_stats_bar_messages'), value: String(s.messagesSent || 0) }, + { label: t('settings.network_stats_bar_files'), value: String(s.filesSent || 0) }, + { label: t('settings.network_stats_bar_calls'), value: String(s.callsStarted || 0) }, + { + label: t('settings.network_stats_bar_peers'), + value: String(s.peersMaxOnline || 0), + }, + ]; + for (const item of items) { + const pill = document.createElement('div'); + pill.className = 'session-stats-pill'; + const val = document.createElement('span'); + val.className = 'session-stats-pill__val'; + val.textContent = item.value; + const lab = document.createElement('span'); + lab.className = 'session-stats-pill__label'; + lab.textContent = item.label; + pill.appendChild(val); + pill.appendChild(lab); + pills.appendChild(pill); + } chartWrap.innerHTML = ''; const bars = getSessionStatsChartBars(); @@ -61,9 +87,10 @@ export function appendSessionStatsSection(parent) { } } - card.appendChild(summary); + card.appendChild(pills); card.appendChild(chartWrap); - parent.appendChild(card); + statsWrap.appendChild(card); + parent.appendChild(statsWrap); refresh(); return { refresh }; diff --git a/renderer/settings-nav.js b/renderer/settings-nav.js new file mode 100644 index 0000000..e116f8d --- /dev/null +++ b/renderer/settings-nav.js @@ -0,0 +1,102 @@ +import { t } from './i18n.js'; + +/** @typedef {{ id: string, labelKey?: string }} SettingsNavItem */ +/** @typedef {{ id: string, labelKey: string, items: SettingsNavItem[] }} SettingsNavGroup */ + +/** @type {SettingsNavGroup[]} */ +export const SETTINGS_NAV_GROUPS = [ + { + id: 'account', + labelKey: 'settings.nav_group_account', + items: [ + { id: 'profile' }, + { id: 'achievements' }, + { id: 'mesh_plus' }, + ], + }, + { + id: 'app', + labelKey: 'settings.nav_group_app', + items: [ + { id: 'language' }, + { id: 'appearance' }, + { id: 'sound' }, + { id: 'notifications' }, + ], + }, + { + id: 'connection', + labelKey: 'settings.nav_group_connection', + items: [ + { id: 'network' }, + { id: 'transfer' }, + { id: 'call' }, + { id: 'privacy' }, + ], + }, + { + id: 'system', + labelKey: 'settings.nav_group_system', + items: [ + { id: 'shortcuts' }, + { id: 'system' }, + { id: 'updates' }, + { id: 'about' }, + { id: 'developer' }, + ], + }, +]; + +const ALL_SECTION_IDS = SETTINGS_NAV_GROUPS.flatMap((g) => g.items.map((i) => i.id)); + +/** + * @param {string | null | undefined} section + * @param {() => string[]} getAllowedIds + * @returns {string | null} null = empty “choose a section” hub + */ +export function resolveSettingsSection(section, getAllowedIds) { + if (section == null || section === '') return null; + const allowed = new Set(getAllowedIds()); + return allowed.has(section) ? section : null; +} + +/** + * @param {object} state + * @param {(id: string) => void} onSelect + * @param {() => string[]} getAllowedIds + */ +export function renderSettingsNavAside(state, onSelect, getAllowedIds) { + const allowed = new Set(getAllowedIds()); + const aside = document.createElement('aside'); + aside.className = 'settings-shell__nav glass'; + + for (const group of SETTINGS_NAV_GROUPS) { + const visible = group.items.filter((item) => allowed.has(item.id)); + if (!visible.length) continue; + + const groupEl = document.createElement('div'); + groupEl.className = 'settings-nav-group'; + + const groupLabel = document.createElement('div'); + groupLabel.className = 'settings-nav-group-label'; + groupLabel.dataset.i18n = group.labelKey; + groupLabel.textContent = t(group.labelKey); + groupEl.appendChild(groupLabel); + + for (const item of visible) { + const b = document.createElement('button'); + b.type = 'button'; + b.className = `btn settings-nav-btn${state.settingsSection === item.id ? ' selected' : ''}`; + b.dataset.i18n = `settings.section_${item.id}`; + b.textContent = t(`settings.section_${item.id}`); + b.addEventListener('click', () => onSelect(item.id)); + groupEl.appendChild(b); + } + + aside.appendChild(groupEl); + } + + return aside; +} + +export { ALL_SECTION_IDS }; diff --git a/renderer/settings-profile-panel.js b/renderer/settings-profile-panel.js index 933250f..01f4819 100644 --- a/renderer/settings-profile-panel.js +++ b/renderer/settings-profile-panel.js @@ -70,9 +70,10 @@ export function buildSettingsProfilePanel(state, api, deps = {}) { gifBtn.type = 'button'; gifBtn.className = 'btn btn-lang'; gifBtn.dataset.i18n = 'settings.profile_gif_pick'; - gifBtn.textContent = isMeshPlusActive(state.config) - ? t('settings.profile_gif_pick') - : `${t('settings.profile_gif_pick')} ◆`; + gifBtn.textContent = t('settings.profile_gif_pick'); + if (!isMeshPlusActive(state.config)) { + gifBtn.title = t('mesh_plus.feature_locked'); + } avatarActions.appendChild(fileInput); avatarActions.appendChild(uploadLabel); @@ -83,12 +84,14 @@ export function buildSettingsProfilePanel(state, api, deps = {}) { avatarRow.appendChild(avatarActions); editorCard.appendChild(avatarRow); + const namePresenceRow = document.createElement('div'); + namePresenceRow.className = 'settings-profile-name-presence-row'; + const nameInput = document.createElement('input'); nameInput.type = 'text'; nameInput.className = 'input'; nameInput.value = state.config.displayName || ''; nameInput.placeholder = t('settings.name_placeholder'); - editorCard.appendChild(buildSettingsField('settings.name', nameInput)); const presenceSelect = buildThemedSelect(); fillSettingsDropdown( @@ -105,7 +108,10 @@ export function buildSettingsProfilePanel(state, api, deps = {}) { refreshPreview(); } ); - editorCard.appendChild(buildSettingsField('settings.presence', presenceSelect)); + + namePresenceRow.appendChild(buildSettingsField('settings.name', nameInput)); + namePresenceRow.appendChild(buildSettingsField('settings.presence', presenceSelect)); + editorCard.appendChild(namePresenceRow); const statusInput = document.createElement('input'); statusInput.type = 'text'; @@ -113,7 +119,9 @@ export function buildSettingsProfilePanel(state, api, deps = {}) { statusInput.maxLength = 48; statusInput.placeholder = t('settings.status_placeholder'); statusInput.value = state.config.presenceText || ''; - editorCard.appendChild(buildSettingsField('settings.status_custom', statusInput)); + const statusField = buildSettingsField('settings.status_custom', statusInput); + statusField.classList.add('settings-profile-field--full'); + editorCard.appendChild(statusField); const presetsWrap = document.createElement('div'); presetsWrap.className = 'status-preset-buttons'; diff --git a/renderer/settings-ui.js b/renderer/settings-ui.js index ada926b..c9adaa2 100644 --- a/renderer/settings-ui.js +++ b/renderer/settings-ui.js @@ -1,4 +1,5 @@ import { t } from './i18n.js'; +import { showAppToast } from './toasts.js'; export function buildThemedSelect(className = 'blip-select settings-dropdown') { const sel = document.createElement('select'); @@ -53,14 +54,18 @@ export function buildSettingsField(labelKey, controlEl) { return wrap; } -/** Pixel ? tooltip (title on hover). */ +/** Pixel ? tooltip (title on hover + toast on click). */ export function createPixelHintIcon(hintKey) { const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'pixel-hint-btn'; - btn.setAttribute('aria-label', t(hintKey)); - btn.title = t(hintKey); + const hintText = t(hintKey); + btn.setAttribute('aria-label', hintText); + btn.title = hintText; btn.textContent = '?'; + btn.addEventListener('click', () => { + showAppToast({ title: hintText, durationMs: 6500 }); + }); return btn; } diff --git a/renderer/styles.css b/renderer/styles.css index 5dfb8ac..ff98b3b 100644 --- a/renderer/styles.css +++ b/renderer/styles.css @@ -591,9 +591,9 @@ select.blip-select:focus { display: flex; flex-direction: column; align-items: center; - gap: 20px; - width: 100%; - max-width: 360px; + gap: 16px; + width: min(100%, 280px); + max-width: 280px; text-align: center; } @@ -605,8 +605,8 @@ select.blip-select:focus { .dial-body { display: flex; flex-direction: column; - align-items: center; - gap: 20px; + align-items: stretch; + gap: 14px; width: 100%; } @@ -614,23 +614,23 @@ select.blip-select:focus { font-size: 36px; text-align: center; width: 100%; - min-width: 200px; - max-width: 240px; + min-width: 0; + max-width: none; letter-spacing: 0.12em; padding: 12px 20px; box-sizing: border-box; } .dial-actions { - display: flex; - flex-wrap: wrap; - justify-content: center; + display: grid; + grid-template-columns: 1fr 1fr; gap: 12px; width: 100%; } .dial-actions .btn { - min-width: 120px; + min-width: 0; + width: 100%; } /* Chat hub */ @@ -1564,7 +1564,22 @@ select.blip-select:focus { .settings-panel--updates { display: flex; flex-direction: column; - min-height: min(72vh, 640px); + flex: 1; + min-height: 0; +} + +.settings-shell__main > .settings-panel--updates { + flex: 1; + min-height: 0; +} + +.settings-panel--updates .settings-releases-feed { + flex: 1; + min-height: 120px; + max-height: none; + width: 100%; + max-width: none; + overflow-y: auto; } .settings-release-card { @@ -2238,6 +2253,11 @@ select.blip-select:focus { align-self: stretch; } +.settings-view > .settings-shell { + flex: 1; + min-height: 0; +} + .settings-view > label, .settings-panel > label:not(.pixel-toggle-row) { display: block; @@ -2339,11 +2359,15 @@ select.blip-select:focus { .settings-shell { display: flex; flex-direction: row; - gap: 20px; + gap: 0; align-items: stretch; flex: 1; min-height: 0; width: 100%; + padding: 0; + border: 2px solid color-mix(in srgb, var(--blip-accent) 32%, var(--blip-glass-border)); + background: color-mix(in srgb, var(--blip-bg) 38%, var(--blip-glass)); + box-sizing: border-box; } .settings-shell__main { @@ -2353,6 +2377,7 @@ select.blip-select:focus { display: flex; flex-direction: column; gap: 12px; + padding: 14px 18px; } .settings-shell__main > .settings-panel { @@ -2363,15 +2388,67 @@ select.blip-select:focus { overflow-y: auto; } +.settings-shell__main > .settings-panel--empty { + justify-content: center; + align-items: flex-start; + padding: 24px 12px; +} + .settings-shell__nav { - flex: 0 0 172px; + flex: 0 0 248px; display: flex; flex-direction: column; - gap: 8px; - padding: 12px; - align-self: flex-start; - position: sticky; - top: 0; + gap: 10px; + padding: 14px 12px; + align-self: stretch; + min-height: 0; + overflow-y: auto; + border-right: 1px solid color-mix(in srgb, var(--blip-accent) 22%, var(--blip-glass-border)); + background: color-mix(in srgb, var(--blip-bg) 55%, transparent); + box-sizing: border-box; +} + +.settings-shell__nav.glass { + border-top: none; + border-bottom: none; + border-left: none; +} + +.settings-nav-group { + display: flex; + flex-direction: column; + gap: 4px; +} + +.settings-nav-group-label { + font-size: 9px; + letter-spacing: 0.14em; + text-transform: uppercase; + color: color-mix(in srgb, var(--blip-muted) 88%, var(--blip-accent)); + padding: 4px 2px 2px; + margin-top: 2px; +} + +.settings-nav-group:first-child .settings-nav-group-label { + margin-top: 0; +} + +.settings-dev-callout { + margin-bottom: 14px; + padding: 12px 14px; + border-color: color-mix(in srgb, var(--blip-accent) 35%, var(--blip-glass-border)); +} + +.settings-dev-callout__title { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--blip-accent); + margin-bottom: 6px; +} + +.settings-dev-callout__hint { + margin: 0 0 10px; } .settings-field { @@ -2405,7 +2482,11 @@ select.blip-select:focus { width: 100%; text-align: left; text-transform: uppercase; - font-size: 11px; + font-size: 10px; + letter-spacing: 0.04em; + white-space: nowrap; + padding: 8px 10px; + line-height: 1.25; } .settings-nav-btn.selected { @@ -2488,6 +2569,29 @@ select.blip-select:focus { overflow-y: auto; } +.settings-panel--updates .settings-releases-feed.settings-list-panel { + flex: 1; + min-height: 0; + max-height: none; + overflow-y: auto; +} + +.settings-panel--shortcuts { + flex: 0 1 auto; + min-height: 0; + overflow-y: auto; +} + +.settings-panel--shortcuts .settings-shortcuts-panel { + flex-shrink: 0; + max-height: none; + overflow: visible; +} + +.settings-appearance-wrap .settings-list-panel { + background: color-mix(in srgb, var(--blip-glass) 62%, transparent); +} + .settings-list-panel--empty { min-height: 72px; padding: 14px 16px; @@ -2519,6 +2623,66 @@ select.blip-select:focus { max-width: 520px; } +.settings-list-panel--stretch-x, +.settings-shortcuts-panel, +.settings-panel--privacy .settings-blocked-list { + width: 100%; + max-width: none; +} + +.settings-appearance-wrap { + width: 100%; + max-width: none; +} + +.settings-appearance-wrap > .section-title-row { + margin-top: 20px; +} + +.settings-appearance-wrap > .section-title-row:first-child { + margin-top: 0; +} + +.settings-appearance-accent-group { + display: flex; + flex-direction: column; + background: color-mix(in srgb, var(--blip-glass) 55%, var(--blip-bg)); + border: 2px solid color-mix(in srgb, var(--blip-accent) 22%, var(--blip-glass-border)); + box-shadow: + inset 0 0 0 1px color-mix(in srgb, var(--blip-accent) 8%, transparent), + 3px 3px 0 rgba(0, 0, 0, 0.4); + gap: 4px; + padding: 14px 16px 16px; + margin: 6px 0 8px; + width: 100%; + max-width: none; + box-sizing: border-box; +} + +.settings-appearance-accent-group .section-title-row { + margin-top: 0; + margin-bottom: 10px; +} + +.settings-appearance-control { + margin-bottom: 16px; + width: 100%; +} + +.settings-appearance-control:last-child { + margin-bottom: 0; +} + +.settings-appearance-control .blip-select, +.settings-appearance-control select.blip-select { + width: 100%; + max-width: none; +} + +.settings-appearance-accent-group .settings-theme-editor { + margin-top: 4px; +} + .settings-blocked-row { display: flex; align-items: center; @@ -2849,15 +3013,27 @@ select.blip-select:focus { } .section-subtitle { - margin: 14px 0 8px; - font-size: 12px; + margin: 0; + font-size: 11px; line-height: 1.35; color: var(--blip-accent); text-transform: uppercase; - letter-spacing: 0.06em; + letter-spacing: 0.08em; + flex-shrink: 0; +} + +.section-title-row { + display: flex; + align-items: center; + gap: 8px; + margin: 18px 0 12px; flex-shrink: 0; } +.section-title-row:first-child { + margin-top: 0; +} + .settings-about-line { margin: 0; font-size: 13px; @@ -2926,6 +3102,139 @@ select.blip-select:focus { color: var(--blip-danger); } +.app-toast--achievement { + max-width: min(360px, calc(100vw - 24px)); + padding: 10px 12px; +} + +.achievement-toast__inner { + display: flex; + align-items: stretch; + gap: 12px; +} + +.achievement-toast__icon { + flex: 0 0 56px; +} + +.achievement-toast__icon .ach-icon-slot { + width: 56px; + height: 56px; +} + +.achievement-toast__text { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.achievement-toast__kicker { + font-size: 9px; + letter-spacing: 0.12em; + text-transform: uppercase; + color: color-mix(in srgb, var(--blip-accent) 80%, var(--blip-muted)); +} + +.achievement-toast__title { + display: block; + font-size: 13px; + line-height: 1.35; + color: var(--blip-accent); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.achievement-toast__desc { + margin: 0; + font-size: 11px; + line-height: 1.4; + color: var(--blip-muted); +} + +.ach-icon-slot { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + background: color-mix(in srgb, var(--blip-bg) 78%, var(--blip-glass)); + border: 2px solid var(--blip-glass-border); + box-sizing: border-box; +} + +.ach-card--unlocked .ach-icon-slot { + border-color: color-mix(in srgb, var(--blip-accent) 50%, var(--blip-glass-border)); +} + +.ach-icon-slot__img { + display: block; + width: 100%; + height: 100%; + object-fit: contain; + object-position: center; + padding: 4px; + box-sizing: border-box; + image-rendering: -webkit-optimize-contrast; + image-rendering: crisp-edges; +} + +.ach-icon-slot__glyph { + font-size: 18px; + line-height: 1; + letter-spacing: -0.06em; + color: var(--blip-accent); + font-family: var(--blip-font, 'Minecraft', monospace); + text-shadow: 0 0 8px color-mix(in srgb, var(--blip-accent) 35%, transparent); +} + +.ach-card__icon .ach-icon-slot__glyph { + font-size: 20px; +} + +.ach-card:not(.ach-card--unlocked) .ach-icon-slot__glyph { + color: color-mix(in srgb, var(--blip-accent) 72%, var(--blip-muted)); + opacity: 0.92; + text-shadow: 0 0 6px color-mix(in srgb, var(--blip-accent) 22%, transparent); +} + +.ach-card:not(.ach-card--unlocked) .ach-icon-slot { + background: color-mix(in srgb, var(--blip-bg) 88%, var(--blip-glass)); + border-color: color-mix(in srgb, var(--blip-muted) 40%, var(--blip-glass-border)); +} + +.ach-icon-slot--hidden { + width: 56px; + height: 56px; + padding: 0; + margin: 0; + cursor: pointer; + font: inherit; + background: color-mix(in srgb, var(--blip-bg) 92%, var(--blip-glass)); + border: 2px dashed color-mix(in srgb, var(--blip-muted) 45%, var(--blip-glass-border)); + box-shadow: 2px 2px 0 rgba(0, 0, 0, 0.35); +} + +.ach-icon-slot--hidden:hover, +.ach-icon-slot--hidden:focus-visible { + border-color: color-mix(in srgb, var(--blip-accent) 45%, var(--blip-glass-border)); + outline: none; +} + +.ach-icon-slot__hidden-mark { + font-size: 22px; + line-height: 1; + color: color-mix(in srgb, var(--blip-muted) 75%, var(--blip-text)); + font-family: var(--blip-font, 'Minecraft', monospace); + letter-spacing: 0; + text-shadow: none; +} + +.ach-card--unlocked .ach-icon-slot__img { + filter: drop-shadow(0 0 5px color-mix(in srgb, var(--blip-accent) 45%, transparent)); +} + .toast-actions { display: flex; flex-wrap: wrap; @@ -3052,15 +3361,18 @@ select.blip-select:focus { .profile-gif-cloud-wrap { position: absolute; - left: 50%; - top: -58px; - transform: translateX(-50%); - z-index: 5; + left: auto; + right: -12px; + top: 0; + transform: translateY(-100%); + z-index: 6; width: auto; - max-width: 100%; + max-width: none; display: flex; - justify-content: center; - pointer-events: none; + flex-direction: row; + align-items: flex-end; + justify-content: flex-end; + pointer-events: none; margin: 0; } @@ -3070,37 +3382,50 @@ select.blip-select:focus { .profile-gif-cloud { position: relative; - width: 168px; - height: 95px; - max-width: 100%; - padding: 5px; + width: 84px; + height: 48px; + max-width: none; + margin: 0; + padding: 3px 4px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; + overflow: hidden; border: 2px dashed color-mix(in srgb, var(--blip-accent) 40%, transparent); - border-radius: 4px; - background: color-mix(in srgb, var(--blip-bg, #0a0a0a) 88%, transparent); + border-radius: 4px 10px 10px 10px; + background: color-mix(in srgb, var(--blip-glass) 55%, var(--blip-bg, #0a0a0a)); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.45); } .profile-gif-cloud::after { content: ''; position: absolute; - left: 28px; + left: 50%; + right: auto; bottom: -7px; - width: 12px; - height: 12px; + width: 10px; + height: 10px; + margin-left: -5px; background: inherit; border-right: 2px dashed color-mix(in srgb, var(--blip-accent) 40%, transparent); border-bottom: 2px dashed color-mix(in srgb, var(--blip-accent) 40%, transparent); transform: rotate(45deg); border-radius: 0 0 2px 0; + pointer-events: none; } .profile-gif-cloud--active { border-style: solid; border-color: color-mix(in srgb, var(--blip-accent) 55%, transparent); + background: transparent; + padding: 2px; +} + +.profile-gif-cloud--active::after { + border-style: solid; + border-color: color-mix(in srgb, var(--blip-accent) 55%, transparent); + background: color-mix(in srgb, var(--blip-bg, #0a0a0a) 70%, transparent); } .profile-gif-cloud__img { @@ -3108,7 +3433,9 @@ select.blip-select:focus { width: 100%; height: 100%; object-fit: cover; + object-position: center; image-rendering: pixelated; + border-radius: 2px 8px 8px 8px; } .profile-gif-cloud__img.hidden { @@ -3391,6 +3718,64 @@ select.blip-select:focus { max-width: none; } +.settings-profile-name-presence-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + width: 100%; +} + +.settings-profile-name-presence-row .settings-field { + min-width: 0; + margin-bottom: 0; +} + +.settings-profile-name-presence-row .settings-dropdown, +.settings-profile-name-presence-row .input { + width: 100%; + max-width: 100%; + box-sizing: border-box; +} + +.settings-profile-name-presence-row .blip-select, +.settings-profile-name-presence-row select.blip-select { + box-shadow: none; +} + +.settings-profile-name-presence-row .blip-select:focus, +.settings-profile-name-presence-row select.blip-select:focus, +.settings-profile-name-presence-row .blip-select:active, +.settings-profile-name-presence-row select.blip-select:active { + box-shadow: none; +} + +.settings-panel--profile .input, +.settings-panel--profile .blip-select, +.settings-panel--profile select.blip-select, +.settings-panel--profile .settings-status-input { + width: 100%; + max-width: 100%; + min-height: 44px; + padding: 12px 14px; + font-size: 13px; + line-height: 1.35; + box-sizing: border-box; +} + +.settings-panel--profile .blip-select, +.settings-panel--profile select.blip-select { + padding-right: 40px; +} + +.settings-profile-field--full { + width: 100%; +} + +.settings-profile-field--full .input { + width: 100%; + box-sizing: border-box; +} + .settings-profile-layout { display: flex; flex-direction: column; @@ -3458,14 +3843,45 @@ select.blip-select:focus { } .settings-profile-preview-mount .peer-profile-body { - padding: 0 clamp(16px, 2vw, 32px) 24px; - margin-top: -40px; + padding: 16px clamp(16px, 2vw, 32px) 24px; + margin-top: 0; + overflow: visible; +} + +.settings-profile-preview-mount .peer-profile-banner { + min-height: 48px; + height: 48px; } .settings-profile-preview-mount .peer-profile-layout { max-width: none; } +.settings-profile-preview-mount .peer-profile-aside-stack { + position: relative; + overflow: visible; +} + +.settings-profile-preview-mount .profile-gif-cloud-wrap { + left: auto; + right: -8px; + top: 0; + transform: translateY(-100%); + z-index: 6; +} + +.settings-profile-preview-mount .profile-gif-cloud { + width: 80px; + height: 45px; + padding: 2px; + margin: 0; +} + +.settings-profile-preview-mount .profile-gif-cloud--active { + padding: 2px; + background: transparent; +} + .profile-gif-picker-modal { max-width: min(92vw, 760px); width: 100%; @@ -3831,8 +4247,29 @@ select.blip-select:focus { overflow: hidden; } +.projects-workspace-head { + flex-shrink: 0; +} + +.projects-mesh-strip { + flex-shrink: 0; + padding: 8px 12px; + font-size: 11px; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--blip-accent); + border: 1px solid color-mix(in srgb, var(--blip-accent) 28%, transparent); + background: color-mix(in srgb, var(--blip-accent) 8%, var(--blip-bg)); +} + +.projects-mesh-strip--empty { + color: var(--blip-muted); + border-color: color-mix(in srgb, var(--blip-muted) 40%, transparent); + background: color-mix(in srgb, var(--blip-muted) 12%, var(--blip-bg)); +} + .projects-sidebar { - flex: 0 0 140px; + flex: 0 0 156px; display: flex; flex-direction: column; gap: 6px; @@ -3854,7 +4291,8 @@ select.blip-select:focus { } .projects-tool-btn { - display: flex; + display: grid; + grid-template-columns: auto 1fr; align-items: center; gap: 6px; width: 100%; @@ -3868,6 +4306,29 @@ select.blip-select:focus { text-align: left; } +.projects-tool-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.projects-tool-mesh-badge { + font-size: 10px; + color: #a78bfa; + flex-shrink: 0; +} + +.projects-tool-mesh-badge.hidden { + display: none; +} + +.projects-tier-legend { + margin: 8px 0 0; + font-size: 9px; + line-height: 1.35; +} + .projects-tool-btn:hover { border-color: var(--blip-glass-border); } @@ -4485,8 +4946,8 @@ body.media-viewer-open { box-shadow: 0 0 0 1px var(--blip-accent); } -.section-title-row { - margin-bottom: 8px; +.section-title-row:not(:first-child) { + margin-top: 14px; } .section-title-row .section-title { @@ -4555,14 +5016,26 @@ body.media-viewer-open { } .settings-panel--about > .settings-about-hero, -.settings-panel--about > .btn, -.settings-panel--about > .settings-about-links { +.settings-panel--about > .settings-about-actions { width: 100%; max-width: 420px; margin-left: auto; margin-right: auto; } +.settings-about-actions { + display: flex; + flex-direction: column; + gap: 10px; + margin-top: 4px; +} + +.settings-about-actions > .btn { + width: 100%; + justify-content: center; + text-align: center; +} + .settings-about-hero { display: flex; flex-direction: column; @@ -4597,7 +5070,16 @@ body.media-viewer-open { } .settings-about-links { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + width: 100%; +} + +.settings-about-links .btn { + width: 100%; justify-content: center; + text-align: center; } /* MESH+ badge — platinum plaque + gradient pixel label */ @@ -4636,19 +5118,43 @@ body.media-viewer-open { letter-spacing: 0.1em; text-transform: uppercase; background: linear-gradient( - 180deg, + 105deg, #f5f0ff 0%, + #ddd6fe 14%, #c084fc 28%, - #7c3aed 52%, - #a78bfa 78%, - #e9d5ff 100% + #7c3aed 42%, + #5b21b6 52%, + #a78bfa 66%, + #e9d5ff 80%, + #f5f0ff 100% ); + background-size: 220% 100%; + background-position: 0% 50%; -webkit-background-clip: text; background-clip: text; color: transparent; -webkit-text-fill-color: transparent; paint-order: stroke fill; filter: drop-shadow(0 1px 0 rgba(0, 0, 0, 0.45)); + animation: mesh-plus-badge-gradient 3.2s ease-in-out infinite; +} + +@keyframes mesh-plus-badge-gradient { + 0%, + 100% { + background-position: 0% 50%; + } + 50% { + background-position: 100% 50%; + } +} + +@media (prefers-reduced-motion: reduce) { + .mesh-plus-badge__text { + animation: none; + background-size: 100% 100%; + background-position: 50% 50%; + } } .peer-name .mesh-plus-badge { @@ -4657,7 +5163,8 @@ body.media-viewer-open { /* Settings → MESH+ */ .settings-panel--mesh-plus { - max-width: 580px; + max-width: none; + width: 100%; } .settings-tray-wrap--flat { @@ -4710,10 +5217,40 @@ body.media-viewer-open { } .mesh-plus-carousel { - padding: 12px 12px 10px; + padding: 12px 14px 10px; margin: 0 0 16px; max-height: none; overflow: visible; + width: 100%; + max-width: 100%; + box-sizing: border-box; +} + +.mesh-plus-carousel__stage { + width: 100%; + aspect-ratio: 1; + max-height: min(58vh, 680px); + display: flex; + flex-direction: column; + min-height: 200px; + border: 2px solid color-mix(in srgb, var(--blip-accent) 22%, var(--blip-glass-border)); + background: color-mix(in srgb, var(--blip-bg) 80%, transparent); + box-sizing: border-box; +} + +.mesh-plus-carousel__media { + display: none; + flex: 0 0 auto; + min-height: 0; + margin: 8px 10px 0; + border: 2px dashed color-mix(in srgb, var(--blip-muted) 45%, transparent); + background: color-mix(in srgb, var(--blip-bg) 92%, transparent); +} + +.mesh-plus-carousel__media--active { + display: block; + flex: 1 1 42%; + min-height: 80px; } .mesh-plus-carousel__top { @@ -4734,6 +5271,9 @@ body.media-viewer-open { grid-template-columns: auto 1fr auto; gap: 8px; align-items: center; + flex: 1 1 auto; + min-height: 0; + padding: 0 6px 4px; } .mesh-plus-carousel__track { @@ -4743,7 +5283,7 @@ body.media-viewer-open { justify-content: center; text-align: center; gap: 8px; - min-height: 128px; + min-height: 0; padding: 4px 8px; } @@ -4904,26 +5444,71 @@ body.media-viewer-open { margin: 0; } +.settings-network-stats-block { + margin-top: 16px; + padding-top: 14px; + border-top: 1px solid color-mix(in srgb, var(--blip-glass-border) 80%, transparent); +} + .session-stats-panel { - margin: 12px 0; + margin: 8px 0 0; + width: 100%; + max-width: none; + padding: 14px 16px 16px; } -.session-stats-summary { - font-size: 12px; - line-height: 1.45; - margin-bottom: 12px; +.session-stats-panel--styled { + background: linear-gradient( + 165deg, + color-mix(in srgb, var(--blip-glass) 92%, var(--blip-bg)) 0%, + color-mix(in srgb, var(--blip-bg) 88%, var(--blip-accent) 6%) 100% + ); +} + +.session-stats-pills { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(108px, 1fr)); + gap: 8px; + margin-bottom: 16px; +} + +.session-stats-pill { + display: flex; + flex-direction: column; + gap: 4px; + padding: 10px 10px 8px; + border: 2px solid color-mix(in srgb, var(--blip-accent) 28%, var(--blip-glass-border)); + background: color-mix(in srgb, var(--blip-bg) 75%, var(--blip-glass)); + box-shadow: 2px 2px 0 rgba(0, 0, 0, 0.35); +} + +.session-stats-pill__val { + font-size: 15px; + line-height: 1.2; + color: var(--blip-accent); + letter-spacing: 0.04em; +} + +.session-stats-pill__label { + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--blip-muted); + line-height: 1.3; } .session-stats-chart { display: flex; flex-direction: column; - gap: 8px; + gap: 10px; + padding-top: 4px; + border-top: 1px solid color-mix(in srgb, var(--blip-glass-border) 70%, transparent); } .session-stats-chart__row { display: grid; - grid-template-columns: 88px 1fr 36px; - gap: 8px; + grid-template-columns: minmax(72px, 96px) 1fr 40px; + gap: 10px; align-items: center; } @@ -4935,117 +5520,216 @@ body.media-viewer-open { } .session-stats-chart__track { - height: 10px; - background: color-mix(in srgb, var(--blip-muted) 25%, var(--blip-bg)); - border: 1px solid var(--blip-glass-border); + height: 14px; + background: color-mix(in srgb, var(--blip-muted) 18%, var(--blip-bg)); + border: 2px solid var(--blip-glass-border); + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.25); } .session-stats-chart__fill { height: 100%; background: linear-gradient( 90deg, - color-mix(in srgb, var(--blip-accent) 70%, #1a1a22), - var(--blip-accent) + color-mix(in srgb, var(--blip-accent) 45%, var(--blip-bg)), + var(--blip-accent) 72%, + color-mix(in srgb, var(--blip-accent) 85%, #fff) 100% ); min-width: 2px; - transition: width 0.2s ease; + box-shadow: 0 0 10px color-mix(in srgb, var(--blip-accent) 35%, transparent); + transition: width 0.25s ease; } .session-stats-chart__val { font-size: 11px; + font-weight: bold; text-align: right; color: var(--blip-accent); } -.settings-panel--achievements .ach-grid { +.ach-settings-toggles { display: grid; - grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); - gap: 10px; - margin: 12px 0; - padding: 12px; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 10px 16px; + padding: 12px 14px; + margin-bottom: 12px; + flex-shrink: 0; + min-height: 0; + align-content: start; + background: linear-gradient( + 145deg, + color-mix(in srgb, var(--blip-glass) 92%, var(--blip-bg)) 0%, + color-mix(in srgb, var(--blip-bg) 92%, var(--blip-accent) 4%) 100% + ); + border: 2px solid var(--blip-glass-border); + box-shadow: + inset 0 0 0 1px color-mix(in srgb, var(--blip-accent) 12%, transparent), + 3px 3px 0 rgba(0, 0, 0, 0.45); + overflow: visible; +} + +.ach-settings-toggles .pixel-toggle-row { + margin: 0; + min-height: 44px; + flex-shrink: 0; + align-items: center; +} + +.ach-settings-toggles .pixel-toggle-label { + white-space: normal; + line-height: 1.35; +} + +.settings-panel--achievements .ach-settings-toggles { + flex-shrink: 0; } -.ach-grid--disabled { +.settings-panel--achievements .ach-sections { + display: flex; + flex-direction: column; + gap: 18px; + flex: 1; + min-height: 0; + overflow-y: auto; + margin: 0 0 12px; +} + +.ach-sections--disabled { opacity: 0.45; pointer-events: none; } +.ach-section { + display: flex; + flex-direction: column; + gap: 10px; + min-width: 0; +} + +.ach-section__title { + margin: 0; +} + +.ach-section__empty { + margin: 0; + grid-column: 1 / -1; + text-align: center; + padding: 12px 8px; +} + +.ach-section .ach-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 10px; + padding: 10px 12px 12px; + border: 2px solid var(--blip-glass-border); + background: color-mix(in srgb, var(--blip-glass) 50%, var(--blip-bg)); + box-shadow: + inset 0 0 0 1px color-mix(in srgb, var(--blip-accent) 8%, transparent), + 3px 3px 0 rgba(0, 0, 0, 0.38); +} + +.ach-section--unlocked .ach-grid { + border-color: color-mix(in srgb, var(--blip-accent) 32%, var(--blip-glass-border)); +} + +.ach-section--locked .ach-grid { + border-color: color-mix(in srgb, var(--blip-muted) 35%, var(--blip-glass-border)); +} + .ach-card { display: grid; - grid-template-columns: 64px 1fr auto; + grid-template-columns: 56px 1fr auto; gap: 10px; align-items: center; padding: 10px 12px; + font-family: var(--blip-font, 'Minecraft', monospace); background: linear-gradient( 145deg, - color-mix(in srgb, #1e2030 85%, var(--blip-bg)) 0%, - color-mix(in srgb, #12141c 90%, var(--blip-bg)) 100% + color-mix(in srgb, var(--blip-glass) 88%, var(--blip-bg)) 0%, + color-mix(in srgb, var(--blip-bg) 94%, var(--blip-accent) 5%) 100% ); - border: 1px solid color-mix(in srgb, var(--blip-muted) 40%, transparent); - box-shadow: - inset 0 1px 0 color-mix(in srgb, #fff 8%, transparent), - 0 2px 0 rgba(0, 0, 0, 0.35); + border: 2px solid var(--blip-glass-border); + box-shadow: 3px 3px 0 rgba(0, 0, 0, 0.4); } .ach-card--unlocked { - border-color: color-mix(in srgb, var(--blip-accent) 55%, #4a5568); + border-color: color-mix(in srgb, var(--blip-accent) 65%, var(--blip-glass-border)); background: linear-gradient( 145deg, - color-mix(in srgb, var(--blip-accent) 18%, #1a1c28) 0%, - color-mix(in srgb, #12141c 92%, var(--blip-bg)) 100% + color-mix(in srgb, var(--blip-accent) 14%, var(--blip-bg)) 0%, + color-mix(in srgb, var(--blip-glass) 80%, var(--blip-bg)) 55%, + color-mix(in srgb, var(--blip-bg) 90%, var(--blip-accent) 8%) 100% ); } .ach-card--ready:not(.ach-card--unlocked) { - border-color: color-mix(in srgb, #c9a227 45%, transparent); + border-color: color-mix(in srgb, var(--blip-muted) 55%, var(--blip-glass-border)); } .ach-card__icon { - width: 64px; - height: 64px; + width: 56px; + height: 56px; display: flex; align-items: center; justify-content: center; - background: color-mix(in srgb, #000 55%, transparent); - border: 1px solid color-mix(in srgb, var(--blip-muted) 35%, transparent); + padding: 0; + background: transparent; + border: none; } -.ach-card__icon-img { +.ach-card__icon .ach-icon-slot { width: 56px; height: 56px; +} + +.ach-card__icon-img { + width: 44px; + height: 44px; object-fit: contain; image-rendering: pixelated; } .ach-card__icon-placeholder { - font-size: 22px; - color: color-mix(in srgb, var(--blip-muted) 70%, transparent); - opacity: 0.5; + font-size: 16px; + color: #58a6ff; + opacity: 0.65; +} + +.ach-card__code { + font-size: 10px; + color: #58a6ff; + letter-spacing: 0.02em; + margin-bottom: 3px; } .ach-card__title { - font-size: 12px; + font-size: 11px; font-weight: bold; text-transform: uppercase; - letter-spacing: 0.05em; + letter-spacing: 0.06em; + color: var(--blip-text); } .ach-card__desc { - font-size: 11px; + font-size: 10px; color: var(--blip-muted); - line-height: 1.35; + line-height: 1.4; margin-top: 2px; } .ach-card__mark { - font-size: 18px; + font-size: 11px; color: var(--blip-muted); font-weight: bold; + letter-spacing: 0.08em; + padding: 4px 6px; + border: 2px solid var(--blip-glass-border); + background: color-mix(in srgb, var(--blip-bg) 80%, var(--blip-glass)); } .ach-card--unlocked .ach-card__mark { - color: color-mix(in srgb, #c9a227 85%, var(--blip-accent)); - text-shadow: 0 0 8px color-mix(in srgb, var(--blip-accent) 40%, transparent); + color: var(--blip-accent); + border-color: color-mix(in srgb, var(--blip-accent) 55%, var(--blip-glass-border)); } .avatar-crop-modal { @@ -5290,7 +5974,7 @@ body.media-viewer-open { box-sizing: border-box; padding: 10px 12px; border: 2px solid var(--blip-glass-border); - background: #0d0d0d; + background: var(--blip-input-bg); color: var(--blip-text); font-family: 'BlipPixel', 'Minecraft', monospace; font-size: 11px; @@ -5322,6 +6006,11 @@ body.media-viewer-open { max-height: 200px; } +.proj-pad-history-head { + display: flex; + align-items: center; + gap: 8px; +} .proj-pad-history-title { font-size: 10px; text-transform: uppercase; @@ -5396,10 +6085,28 @@ body.media-viewer-open { min-height: 0; } .proj-board-col-head { - font-size: 9px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + font-size: 12px; text-transform: uppercase; - margin-bottom: 4px; - color: var(--blip-text-muted); + margin-bottom: 6px; + color: var(--blip-text); + min-height: 32px; +} +.proj-board-col-title { + flex: 1; + min-width: 0; + font-weight: bold; +} +.proj-board-add { + flex-shrink: 0; + min-width: 32px; + height: 32px; + padding: 0 10px; + font-size: 16px; + line-height: 1; } .proj-board-col-list { display: flex; @@ -5413,6 +6120,7 @@ body.media-viewer-open { background: #141414; border: 1px solid var(--blip-glass-border); border-left-width: 2px; + cursor: context-menu; } .proj-board-card--todo { border-left-color: #888; @@ -5427,20 +6135,89 @@ body.media-viewer-open { color: var(--blip-text-muted); font-size: 9px; } -.proj-board-add { - align-self: flex-start; +.proj-tool--canvas { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + padding: 6px; + gap: 6px; +} +.proj-canvas-head { + flex-shrink: 0; + font-size: 9px; + color: var(--blip-text-muted); + margin-bottom: 0; +} +.proj-canvas-workspace { + flex: 1; + min-height: 0; + position: relative; + display: flex; + align-items: stretch; + justify-content: stretch; +} +.proj-canvas-dock { + position: absolute; + right: 6px; + bottom: 6px; + z-index: 4; + display: flex; + flex-direction: column; + align-items: stretch; + gap: 8px; + max-height: calc(100% - 12px); + padding: 8px; + border: 2px solid color-mix(in srgb, var(--blip-accent) 28%, var(--blip-glass-border)); + background: color-mix(in srgb, var(--blip-bg) 82%, var(--blip-glass)); + box-shadow: + inset 0 0 0 1px color-mix(in srgb, var(--blip-accent) 10%, transparent), + 3px 3px 0 rgba(0, 0, 0, 0.45); +} +.proj-canvas-tools { + display: flex; + flex-direction: column; + gap: 4px; +} +.proj-canvas-tool-btn { + width: 100%; + min-width: 88px; + text-align: left; +} +.proj-canvas-tool-btn--active { + border-color: var(--blip-accent); + color: var(--blip-accent); +} +.proj-canvas-stage { + flex: 1; + min-height: 0; + width: 100%; + display: flex; + align-items: stretch; + justify-content: stretch; + padding: 4px 96px 4px 4px; + box-sizing: border-box; } .proj-canvas-grid { display: grid; - grid-template-columns: repeat(32, 10px); + grid-template-columns: repeat(var(--canvas-cols, 32), 1fr); + grid-template-rows: repeat(var(--canvas-rows, 16), 1fr); gap: 1px; background: #1a1a1a; padding: 4px; - width: fit-content; + width: 100%; + height: 100%; + min-height: 0; + max-width: none; + aspect-ratio: auto; + box-sizing: border-box; } .proj-canvas-cell { - width: 10px; - height: 10px; + width: 100%; + height: 100%; + aspect-ratio: auto; + min-width: 0; + min-height: 0; padding: 0; border: none; background: #1a1a1a; @@ -5448,23 +6225,41 @@ body.media-viewer-open { } .proj-canvas-palette { display: flex; - gap: 6px; - margin-top: 8px; + flex-direction: column; + flex-wrap: nowrap; + gap: 5px; + align-items: center; + max-height: min(42vh, 280px); + overflow-y: auto; + overflow-x: hidden; + padding: 4px 2px 2px; + border-top: 1px solid color-mix(in srgb, var(--blip-glass-border) 55%, transparent); +} +.proj-canvas-color-picker.blip-color-input { + width: 28px; + height: 28px; + margin-top: 2px; } .proj-canvas-swatch { - width: 18px; - height: 18px; - border: 2px solid transparent; + width: 22px; + height: 22px; + border: 2px solid var(--blip-glass-border); padding: 0; cursor: pointer; + flex-shrink: 0; + background: var(--blip-input-bg, #141414); + box-shadow: 2px 2px 0 rgba(0, 0, 0, 0.4); + image-rendering: pixelated; } -.proj-canvas-swatch--active { - border-color: #fff; +.proj-canvas-swatch:hover { + border-color: color-mix(in srgb, var(--blip-accent) 50%, var(--blip-glass-border)); } -.proj-canvas-head { - font-size: 9px; - color: var(--blip-text-muted); - margin-bottom: 6px; +.proj-canvas-swatch--active { + border-color: var(--blip-accent); + box-shadow: + inset 0 0 0 1px var(--blip-accent), + 0 0 6px color-mix(in srgb, var(--blip-accent) 45%, transparent), + 2px 2px 0 rgba(0, 0, 0, 0.4); } .proj-clip-top { display: flex; @@ -5497,7 +6292,7 @@ body.media-viewer-open { } .proj-clip-row { display: grid; - grid-template-columns: 1fr auto auto; + grid-template-columns: 1fr auto auto auto; gap: 6px; align-items: center; padding: 6px; @@ -5525,13 +6320,87 @@ body.media-viewer-open { align-items: center; } -.settings-theme-editor-color { - width: 44px; +/* BLIP color picker (appearance + projects canvas) */ +.blip-color-input { + position: relative; + display: inline-flex; + flex-shrink: 0; + width: 32px; + height: 32px; + cursor: pointer; + vertical-align: middle; +} + +.blip-color-input__preview { + display: block; + width: 100%; + height: 100%; + box-sizing: border-box; + border: 2px solid var(--blip-glass-border); + background: var(--blip-input-bg, #141414); + box-shadow: + inset 0 0 0 1px color-mix(in srgb, var(--blip-accent) 18%, transparent), + 3px 3px 0 rgba(0, 0, 0, 0.45); + image-rendering: pixelated; + pointer-events: none; +} + +.blip-color-input__native { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + margin: 0; + padding: 0; + opacity: 0; + cursor: pointer; + border: none; + background: transparent; +} + +.blip-color-input:hover .blip-color-input__preview { + border-color: color-mix(in srgb, var(--blip-accent) 55%, var(--blip-glass-border)); +} + +.blip-color-input:focus-within .blip-color-input__preview { + border-color: var(--blip-accent); + box-shadow: + inset 0 0 0 1px var(--blip-accent), + 0 0 0 1px var(--blip-accent), + 3px 3px 0 rgba(0, 0, 0, 0.45); +} + +.settings-theme-editor-color.blip-color-input { + width: 36px; height: 36px; - padding: 2px; +} + +#app input[type='color']:not(.blip-color-input__native) { + appearance: none; + -webkit-appearance: none; + width: 32px; + height: 32px; + padding: 0; border: 2px solid var(--blip-glass-border); - background: var(--blip-bg); + background: var(--blip-input-bg, #141414); + color-scheme: dark; cursor: pointer; + box-shadow: 3px 3px 0 rgba(0, 0, 0, 0.45); +} + +#app input[type='color']:not(.blip-color-input__native)::-webkit-color-swatch-wrapper { + padding: 3px; + background: var(--blip-input-bg, #141414); +} + +#app input[type='color']:not(.blip-color-input__native)::-webkit-color-swatch { + border: 1px solid color-mix(in srgb, var(--blip-accent) 40%, transparent); + border-radius: 0; +} + +#app input[type='color']:not(.blip-color-input__native)::-moz-color-swatch { + border: 1px solid color-mix(in srgb, var(--blip-accent) 40%, transparent); + border-radius: 0; } .settings-theme-editor-hex { @@ -5544,3 +6413,29 @@ body.media-viewer-open { opacity: 0.72; pointer-events: none; } + +/* UI labels CAPS (inputs and chat body stay as typed) */ +#app :is( + h1, + h2, + h3, + .section-title, + .section-subtitle, + .nav-btn, + .btn, + .hint, + .settings-field-label, + .settings-panel-title, + .pixel-toggle-label, + .settings-shortcuts-list dt, + .projects-tool-label, + .projects-sidebar-label, + .mesh-plus-carousel__title, + .blip-modal-title + ):not(:is(input, textarea)) { + text-transform: uppercase; +} + +#app :is(input, textarea, .chat-message-text, .proj-pad-editor, .proj-clip-search, .dial-input) { + text-transform: none; +} diff --git a/renderer/theme-editor.js b/renderer/theme-editor.js index e78ac3f..5737ba1 100644 --- a/renderer/theme-editor.js +++ b/renderer/theme-editor.js @@ -1,7 +1,8 @@ import { t } from './i18n.js'; import { isMeshPlusActive, showMeshPlusLockedToast } from './mesh-plus.js'; import { applyAppearance, normalizeCustomAccentHex } from './appearance.js'; -import { buildSettingsFieldWithHint } from './settings-ui.js'; +import { buildSectionSubtitleRow } from './settings-ui.js'; +import { createBlipColorInput } from './blip-color-input.js'; /** * MESH+ custom accent (#RRGGBB). @@ -19,10 +20,12 @@ export function appendThemeEditorSection(block, getConfig, saveConfig) { hexInput.placeholder = '#00ffc8'; hexInput.dataset.i18nPlaceholder = 'appearance.custom_accent_placeholder'; - const colorPick = document.createElement('input'); - colorPick.type = 'color'; - colorPick.className = 'settings-theme-editor-color'; - colorPick.value = '#00ffc8'; + const colorPickUi = createBlipColorInput({ + value: '#00ffc8', + title: t('appearance.custom_accent_placeholder'), + className: 'settings-theme-editor-color', + }); + const colorPick = colorPickUi.input; const applyBtn = document.createElement('button'); applyBtn.type = 'button'; @@ -38,7 +41,7 @@ export function appendThemeEditorSection(block, getConfig, saveConfig) { const controls = document.createElement('div'); controls.className = 'settings-theme-editor-controls'; - controls.appendChild(colorPick); + controls.appendChild(colorPickUi.el); controls.appendChild(hexInput); controls.appendChild(applyBtn); controls.appendChild(clearBtn); @@ -47,7 +50,7 @@ export function appendThemeEditorSection(block, getConfig, saveConfig) { const cfg = getConfig(); const hex = normalizeCustomAccentHex(cfg?.accentCustomHex) || '#00ffc8'; hexInput.value = cfg?.accentCustomHex ? hex : ''; - colorPick.value = hex; + colorPickUi.value = hex; } function setDisabled(locked) { @@ -86,12 +89,12 @@ export function appendThemeEditorSection(block, getConfig, saveConfig) { row.appendChild(controls); block.appendChild( - buildSettingsFieldWithHint( - 'appearance.custom_accent', - 'appearance.custom_accent_mesh_hint', - row - ) + buildSectionSubtitleRow('appearance.custom_accent', 'appearance.custom_accent_mesh_hint') ); + const customWrap = document.createElement('div'); + customWrap.className = 'settings-appearance-control'; + customWrap.appendChild(row); + block.appendChild(customWrap); syncInputsFromConfig(); setDisabled(!isMeshPlusActive(getConfig())); diff --git a/renderer/ui.js b/renderer/ui.js index 9bdf2e6..9906c86 100644 --- a/renderer/ui.js +++ b/renderer/ui.js @@ -1,6 +1,6 @@ import { t, setLang, getLang, applyLangChange, onLangChange, applyI18n } from './i18n.js'; import { createIdGrid } from './grid.js'; -import { createChatView, getMessages, getDefaultReactionEmoji } from './chat.js'; +import { createChatView, getMessages } from './chat.js'; import { isFavorite, toggleFavorite, comparePeersFavoriteFirst } from './peer-favorites.js'; import { getGroup, @@ -89,6 +89,8 @@ import { fillSettingsDropdown, buildSettingsField, buildSettingsFieldWithHint, + buildPanelTitleRow, + buildSectionSubtitleRow, buildSettingsLabelRow, createPixelToggle, createPixelHintIcon, @@ -125,6 +127,7 @@ import { showAppToast } from './toasts.js'; import { openConfirmDialog } from './confirm-dialog.js'; import { buildPeerProfilePage } from './peer-profile.js'; import { buildSettingsProfilePanel as buildSettingsProfilePanelView } from './settings-profile-panel.js'; +import { renderSettingsNavAside as renderSettingsNavGroups } from './settings-nav.js'; import { setPeerProfileGifDataUrl } from './peer-gif-cache.js'; import { initPeerTrust, @@ -415,6 +418,9 @@ function openGroupChat(groupId) { /** @type {ReturnType | null} */ let projectsViewInstance = null; +/** @type {(() => void) | null} */ +let settingsPanelCleanup = null; + function ensureProjectsView() { if (!projectsViewInstance || !projectsViewInstance.el?.parentElement) { projectsViewInstance?.destroy?.(); @@ -424,7 +430,13 @@ function ensureProjectsView() { () => state.peers .filter((p) => p.online && !isBlocked(p.blipId)) - .map((p) => p.blipId) + .map((p) => p.blipId), + { + onOpenMeshPlus: () => { + state.settingsSection = 'mesh_plus'; + renderView('settings'); + }, + } ); } return projectsViewInstance; @@ -1177,6 +1189,7 @@ function showPeerContextMenu(e, peerOrId, options = {}) { }); if (!result) return; const g = await createGroupFromUi(api, state.config, result.memberIds, result.name); + if (!g) return; openGroupChat(g.id); })(); }); @@ -1217,7 +1230,7 @@ function showPeerContextMenu(e, peerOrId, options = {}) { menu.appendChild(labelItem); menu.appendChild(pingItem); menu.appendChild(copyIdItem); - menu.appendChild(groupItem); + if (state.config?.devGroupsEnabled) menu.appendChild(groupItem); menu.appendChild(favItem); menu.appendChild(blockItem); document.body.appendChild(menu); @@ -1378,6 +1391,13 @@ function buildCloseToTraySection() { return block; } +function appendAppearanceControl(parent, controlEl) { + const wrap = document.createElement('div'); + wrap.className = 'settings-appearance-control'; + wrap.appendChild(controlEl); + parent.appendChild(wrap); +} + function buildAppearanceSection() { const block = document.createElement('div'); block.className = 'settings-appearance-wrap'; @@ -1385,13 +1405,18 @@ function buildAppearanceSection() { const curAccent = normalizeAccentId(state.config.accentId, state.config.themeId); const curBg = normalizeBgId(state.config.animatedBgId); + block.appendChild(buildSectionSubtitleRow('settings.appearance_theme')); const modeOpts = THEME_MODES.map((id) => ({ value: id, label: labelThemeMode(id) })); const modeSelect = buildThemedSelect(); fillSettingsDropdown(modeSelect, modeOpts, curMode, async (id) => { state.config = await api.saveConfig({ themeMode: id }); applyAppearance(state.config); }); - block.appendChild(buildSettingsField('settings.appearance_theme', modeSelect)); + appendAppearanceControl(block, modeSelect); + + const accentGroup = document.createElement('div'); + accentGroup.className = 'settings-appearance-accent-group settings-list-panel'; + accentGroup.appendChild(buildSectionSubtitleRow('settings.appearance_accent')); const accentOpts = ACCENT_IDS.map((id) => ({ value: id, label: labelAccent(id) })); const accentSelect = buildThemedSelect(); @@ -1399,13 +1424,17 @@ function buildAppearanceSection() { state.config = await api.saveConfig({ accentId: id }); applyAppearance(state.config); }); - block.appendChild(buildSettingsField('settings.appearance_accent', accentSelect)); + appendAppearanceControl(accentGroup, accentSelect); - appendThemeEditorSection(block, () => state.config, async (patch) => { + appendThemeEditorSection(accentGroup, () => state.config, async (patch) => { state.config = await api.saveConfig(patch); return state.config; }); + block.appendChild(accentGroup); + block.appendChild( + buildSectionSubtitleRow('settings.bg_animated', 'settings.bg_animated_mesh_hint') + ); const animOpts = markMeshPlusGatedOptions( ANIMATED_BACKGROUNDS.map((id) => ({ value: id, label: labelBg(id) })), MESH_PLUS_FEATURES.animated_bg, @@ -1424,23 +1453,18 @@ function buildAppearanceSection() { applyReactiveWallpaperConfig(state.config); } ); - block.appendChild( - buildSettingsFieldWithHint('settings.bg_animated', 'settings.bg_animated_mesh_hint', animSelect) - ); + appendAppearanceControl(block, animSelect); + block.appendChild(buildSectionSubtitleRow('settings.bg_art')); const artOpts = STATIC_ART_BACKGROUNDS.map((id) => ({ value: id, label: labelBg(id) })); const artSelect = buildThemedSelect(); - fillSettingsDropdown( - artSelect, - artOpts, - STATIC_ART_BACKGROUNDS.includes(curBg) ? curBg : artOpts[0].value, - async (id) => { - state.config = await api.saveConfig({ animatedBgId: normalizeBgId(id) }); - applyAppearance(state.config); - applyReactiveWallpaperConfig(state.config); - } - ); - block.appendChild(buildSettingsField('settings.bg_art', artSelect)); + const artCurrent = STATIC_ART_BACKGROUNDS.includes(curBg) ? curBg : 'none'; + fillSettingsDropdown(artSelect, artOpts, artCurrent, async (id) => { + state.config = await api.saveConfig({ animatedBgId: normalizeBgId(id) }); + applyAppearance(state.config); + applyReactiveWallpaperConfig(state.config); + }); + appendAppearanceControl(block, artSelect); const reactiveToggle = createPixelToggle({ checked: !!state.config.reactiveBackground, @@ -1471,20 +1495,6 @@ function buildAppearanceSection() { motionRow.appendChild(createPixelHintIcon('settings.motion_hint')); block.appendChild(motionRow); - const reactionInput = document.createElement('input'); - reactionInput.type = 'text'; - reactionInput.className = 'settings-text-input settings-reaction-input'; - reactionInput.maxLength = 8; - reactionInput.value = getDefaultReactionEmoji(state.config); - reactionInput.placeholder = '➕'; - reactionInput.addEventListener('change', async () => { - const emoji = reactionInput.value.trim() || '➕'; - state.config = await api.saveConfig({ defaultReactionEmoji: emoji }); - reactionInput.value = getDefaultReactionEmoji(state.config); - for (const chat of state.chatViews.values()) chat.renderMessages?.(); - }); - block.appendChild(buildSettingsField('settings.default_reaction', reactionInput)); - appendAppIconPickerSections(block, state, (patch) => api.saveConfig(patch)); return block; @@ -1632,7 +1642,9 @@ async function runStartupUpdateCheck() { export function navigateToView(view) { if (!state.config?.blipId) return; - if (view === 'settings') state.settingsSection = null; + if (view === 'settings' && state.view !== 'settings') { + state.settingsSection = null; + } if (view === 'chat' && state.view === 'chat' && state.activePeer) { state.activePeer = null; } @@ -1663,7 +1675,9 @@ function setupGlobalShortcuts() { const next = views[e.key]; if (next) { e.preventDefault(); - if (next === 'settings') state.settingsSection = null; + if (next === 'settings') { + state.settingsSection = null; + } if (next === 'chat' && state.view === 'chat' && state.activePeer) { state.activePeer = null; } @@ -1743,11 +1757,7 @@ function buildSettingsLanguagePanel() { const frag = document.createElement('div'); frag.className = 'settings-panel'; - const h = document.createElement('h2'); - h.className = 'settings-panel-title'; - h.dataset.i18n = 'settings.section_language'; - h.textContent = t('settings.section_language'); - frag.appendChild(h); + appendSettingsPanelHeader(frag, 'settings.section_language'); const langSelect = buildThemedSelect(); fillSettingsDropdown( @@ -1775,11 +1785,7 @@ function buildSettingsNotificationsPanel() { const frag = document.createElement('div'); frag.className = 'settings-panel'; - const h = document.createElement('h2'); - h.className = 'settings-panel-title'; - h.dataset.i18n = 'settings.section_notifications'; - h.textContent = t('settings.section_notifications'); - frag.appendChild(h); + appendSettingsPanelHeader(frag, 'settings.section_notifications'); frag.appendChild( createPixelToggle({ @@ -1820,21 +1826,11 @@ function buildSettingsNotificationsPanel() { function buildSettingsPrivacyPanel() { const frag = document.createElement('div'); - frag.className = 'settings-panel'; + frag.className = 'settings-panel settings-panel--privacy'; - const h = document.createElement('h2'); - h.className = 'settings-panel-title'; - h.dataset.i18n = 'settings.section_privacy'; - h.textContent = t('settings.section_privacy'); - frag.appendChild(h); + appendSettingsPanelHeader(frag, 'settings.section_privacy', 'settings.privacy_hint'); - const hint = document.createElement('p'); - hint.className = 'hint'; - hint.dataset.i18n = 'settings.privacy_hint'; - hint.textContent = t('settings.privacy_hint'); - frag.appendChild(hint); - - const list = createSettingsListPanel('settings-blocked-list'); + const list = createSettingsListPanel('settings-blocked-list settings-list-panel--stretch-x'); function renderList() { list.innerHTML = ''; @@ -1898,11 +1894,7 @@ function buildSettingsSoundPanel() { const frag = document.createElement('div'); frag.className = 'settings-panel'; - const h = document.createElement('h2'); - h.className = 'settings-panel-title'; - h.dataset.i18n = 'settings.section_sound'; - h.textContent = t('settings.section_sound'); - frag.appendChild(h); + appendSettingsPanelHeader(frag, 'settings.section_sound'); const enableToggle = createPixelToggle({ checked: state.config.uiSoundsEnabled !== false, @@ -2110,11 +2102,7 @@ function buildSettingsTransferPanel() { const frag = document.createElement('div'); frag.className = 'settings-panel'; - const h = document.createElement('h2'); - h.className = 'settings-panel-title'; - h.dataset.i18n = 'settings.section_transfer'; - h.textContent = t('settings.section_transfer'); - frag.appendChild(h); + appendSettingsPanelHeader(frag, 'settings.section_transfer', 'settings.transfer_hint'); const limitSelect = buildThemedSelect(); const limitOpts = FILE_LIMIT_GB_OPTIONS.map((gb) => ({ @@ -2155,15 +2143,7 @@ function buildSettingsCallPanel() { const frag = document.createElement('div'); frag.className = 'settings-panel'; - const titleRow = document.createElement('div'); - titleRow.className = 'settings-panel-title-row'; - const h = document.createElement('h2'); - h.className = 'settings-panel-title'; - h.dataset.i18n = 'settings.section_call'; - h.textContent = t('settings.section_call'); - titleRow.appendChild(h); - titleRow.appendChild(createPixelHintIcon('settings.call_hint')); - frag.appendChild(titleRow); + appendSettingsPanelHeader(frag, 'settings.section_call', 'settings.call_hint'); const micTest = buildMicTestPanel(state.config, async (patch) => { state.config = await api.saveConfig(patch); @@ -2352,11 +2332,7 @@ function buildSettingsNetworkPanel() { const frag = document.createElement('div'); frag.className = 'settings-panel'; - const h = document.createElement('h2'); - h.className = 'settings-panel-title'; - h.dataset.i18n = 'settings.section_network'; - h.textContent = t('settings.section_network'); - frag.appendChild(h); + appendSettingsPanelHeader(frag, 'settings.section_network'); const clipOpts = CLIPBOARD_SYNC_MODES.map((id) => ({ value: id, @@ -2376,6 +2352,36 @@ function buildSettingsNetworkPanel() { buildSettingsFieldWithHint('clipboard.mode', 'clipboard.hint', clipSelect) ); + const projClipRow = document.createElement('div'); + projClipRow.className = 'settings-toggle-with-hint'; + const projClipToggle = createPixelToggle({ + checked: !!state.config?.projectsClipboardEnabled, + labelKey: 'settings.projects_clipboard', + onChange: async (checked) => { + if (checked) { + const ok = await openConfirmDialog({ + title: t('settings.projects_clipboard'), + body: t('settings.projects_clipboard_enable_confirm'), + }); + if (!ok) { + projClipToggle.input.checked = false; + return; + } + } + state.config = await api.saveConfig({ projectsClipboardEnabled: checked }); + showAppToast({ + title: checked + ? t('settings.projects_clipboard_on') + : t('settings.projects_clipboard_off'), + durationMs: 4000, + }); + if (state.view === 'projects') renderView('projects'); + }, + }); + projClipRow.appendChild(projClipToggle.el); + projClipRow.appendChild(createPixelHintIcon('settings.projects_clipboard_hint')); + frag.appendChild(projClipRow); + const statsUi = appendSessionStatsSection(frag); const actions = document.createElement('div'); @@ -2559,13 +2565,9 @@ function buildSettingsNetworkPanel() { function buildSettingsShortcutsPanel() { const frag = document.createElement('div'); - frag.className = 'settings-panel'; + frag.className = 'settings-panel settings-panel--shortcuts'; - const h = document.createElement('h2'); - h.className = 'settings-panel-title'; - h.dataset.i18n = 'settings.section_shortcuts'; - h.textContent = t('settings.section_shortcuts'); - frag.appendChild(h); + appendSettingsPanelHeader(frag, 'settings.section_shortcuts'); function addShortcutBlock(scopeKey, rows) { const sub = document.createElement('p'); @@ -2585,7 +2587,7 @@ function buildSettingsShortcutsPanel() { list.appendChild(dt); list.appendChild(dd); } - frag.appendChild(wrapInSettingsListPanel(list, 'settings-list-panel--compact')); + frag.appendChild(wrapInSettingsListPanel(list, 'settings-shortcuts-panel')); } addShortcutBlock('settings.shortcuts_main_scope', [ @@ -2640,13 +2642,49 @@ function buildSettingsShortcutsPanel() { function buildSettingsDeveloperPanel() { const frag = document.createElement('div'); - frag.className = 'settings-panel'; + frag.className = 'settings-panel settings-panel--developer'; - const h = document.createElement('h2'); - h.className = 'settings-panel-title'; - h.dataset.i18n = 'settings.section_developer'; - h.textContent = t('settings.section_developer'); - frag.appendChild(h); + frag.appendChild(buildPanelTitleRow('settings.section_developer', 'settings.dev_panel_hint')); + + const projRow = document.createElement('div'); + projRow.className = 'settings-toggle-with-hint'; + const projToggle = createPixelToggle({ + checked: !!state.config?.devProjectsEnabled, + labelKey: 'settings.dev_projects', + onChange: async (checked) => { + state.config = await api.saveConfig({ devProjectsEnabled: checked }); + if (!checked) { + projectsViewInstance?.destroy?.(); + projectsViewInstance = null; + } + showAppToast({ + title: checked ? t('settings.dev_projects_on') : t('settings.dev_projects_off'), + durationMs: 4200, + }); + render(); + }, + }); + projRow.appendChild(projToggle.el); + projRow.appendChild(createPixelHintIcon('settings.dev_projects_hint')); + frag.appendChild(projRow); + + const groupsRow = document.createElement('div'); + groupsRow.className = 'settings-toggle-with-hint'; + const groupsToggle = createPixelToggle({ + checked: !!state.config?.devGroupsEnabled, + labelKey: 'settings.dev_groups', + onChange: async (checked) => { + state.config = await api.saveConfig({ devGroupsEnabled: checked }); + showAppToast({ + title: checked ? t('settings.dev_groups_on') : t('settings.dev_groups_off'), + durationMs: 4500, + }); + render(); + }, + }); + groupsRow.appendChild(groupsToggle.el); + groupsRow.appendChild(createPixelHintIcon('settings.dev_groups_hint')); + frag.appendChild(groupsRow); const betaRow = document.createElement('div'); betaRow.className = 'settings-toggle-with-hint'; @@ -2682,28 +2720,6 @@ function buildSettingsDeveloperPanel() { traceRow.appendChild(createPixelHintIcon('settings.dev_mesh_trace_hint')); frag.appendChild(traceRow); - const projRow = document.createElement('div'); - projRow.className = 'settings-toggle-with-hint'; - const projToggle = createPixelToggle({ - checked: !!state.config?.devProjectsEnabled, - labelKey: 'settings.dev_projects', - onChange: async (checked) => { - state.config = await api.saveConfig({ devProjectsEnabled: checked }); - if (!checked) { - projectsViewInstance?.destroy?.(); - projectsViewInstance = null; - } - showAppToast({ - title: checked ? t('settings.dev_projects_on') : t('settings.dev_projects_off'), - durationMs: 4200, - }); - render(); - }, - }); - projRow.appendChild(projToggle.el); - projRow.appendChild(createPixelHintIcon('settings.dev_projects_hint')); - frag.appendChild(projRow); - const exportBtn = document.createElement('button'); exportBtn.type = 'button'; exportBtn.className = 'btn btn-lang'; @@ -2818,8 +2834,8 @@ function buildSettingsAboutPanel() { } }).catch(() => {}); - const linkRow = document.createElement('div'); - linkRow.className = 'settings-about-links'; + const actionsCol = document.createElement('div'); + actionsCol.className = 'settings-about-actions'; const changelogBtn = document.createElement('button'); changelogBtn.type = 'button'; @@ -2839,11 +2855,14 @@ function buildSettingsAboutPanel() { window.blip.openExternal?.('https://github.com/krwg/BLIP/releases'); }); + const linkRow = document.createElement('div'); + linkRow.className = 'settings-about-links'; linkRow.appendChild(changelogBtn); linkRow.appendChild(releasesAboutBtn); - frag.appendChild(githubBtn); - frag.appendChild(linkRow); + actionsCol.appendChild(githubBtn); + actionsCol.appendChild(linkRow); + frag.appendChild(actionsCol); return frag; } @@ -2851,11 +2870,7 @@ function buildSettingsSystemPanel() { const frag = document.createElement('div'); frag.className = 'settings-panel'; - const h = document.createElement('h2'); - h.className = 'settings-panel-title'; - h.dataset.i18n = 'settings.section_system'; - h.textContent = t('settings.section_system'); - frag.appendChild(h); + appendSettingsPanelHeader(frag, 'settings.section_system'); const tray = buildCloseToTraySection(); if (tray) { @@ -2878,11 +2893,7 @@ function buildSettingsSystemPanel() { function buildAppearancePanelWithTitle() { const wrap = document.createElement('div'); wrap.className = 'settings-panel'; - const h = document.createElement('h2'); - h.className = 'settings-panel-title'; - h.dataset.i18n = 'settings.section_appearance'; - h.textContent = t('settings.section_appearance'); - wrap.appendChild(h); + appendSettingsPanelHeader(wrap, 'settings.section_appearance'); wrap.appendChild(buildAppearanceSection()); return wrap; } @@ -2915,11 +2926,7 @@ function buildSettingsUpdatesPanel() { const frag = document.createElement('div'); frag.className = 'settings-panel settings-panel--updates'; - const h = document.createElement('h2'); - h.className = 'settings-panel-title'; - h.dataset.i18n = 'settings.section_updates'; - h.textContent = t('settings.section_updates'); - frag.appendChild(h); + appendSettingsPanelHeader(frag, 'settings.section_updates'); const autoTitle = document.createElement('h3'); autoTitle.className = 'section-subtitle'; @@ -3129,70 +3136,108 @@ function buildSettingsPlaceholderPanel() { return wrap; } +function runSettingsPanelCleanup() { + settingsPanelCleanup?.(); + settingsPanelCleanup = null; +} + +function attachSettingsPanelCleanup(frag) { + const cleanups = []; + if (typeof frag._profileCleanup === 'function') cleanups.push(frag._profileCleanup); + if (typeof frag._meshPlusCleanup === 'function') cleanups.push(frag._meshPlusCleanup); + if (typeof frag._networkCleanup === 'function') cleanups.push(frag._networkCleanup); + if (cleanups.length) { + frag._settingsCleanup = () => cleanups.forEach((fn) => fn()); + } + return frag; +} + +function appendSettingsPanelHeader(frag, labelKey, hintKey) { + frag.appendChild(hintKey ? buildPanelTitleRow(labelKey, hintKey) : buildPanelTitleRow(labelKey)); +} + function renderSettingsMainPanel() { + runSettingsPanelCleanup(); if (state.settingsSection == null) { + settingsPanelCleanup = null; return buildSettingsPlaceholderPanel(); } - switch (state.settingsSection) { + const section = state.settingsSection; + let frag; + switch (section) { case 'profile': - return buildSettingsProfilePanel(); + frag = buildSettingsProfilePanel(); + break; case 'achievements': - return buildSettingsAchievementsPanel(state, api); + frag = buildSettingsAchievementsPanel(state, api); + break; case 'mesh_plus': - return buildSettingsMeshPlusPanel(state, () => { + frag = buildSettingsMeshPlusPanel(state, () => { applyAppearance(state.config); applySoundPrefsFromConfig(state.config); + ensureProjectsView().refreshMeshPlus?.(); + syncAchievements(state.config); if (state.view === 'peers') renderView('peers'); if (state.view === 'settings') renderView('settings'); }); + break; case 'language': - return buildSettingsLanguagePanel(); + frag = buildSettingsLanguagePanel(); + break; case 'notifications': - return buildSettingsNotificationsPanel(); + frag = buildSettingsNotificationsPanel(); + break; case 'privacy': - return buildSettingsPrivacyPanel(); + frag = buildSettingsPrivacyPanel(); + break; case 'sound': - return buildSettingsSoundPanel(); + frag = buildSettingsSoundPanel(); + break; case 'shortcuts': - return buildSettingsShortcutsPanel(); + frag = buildSettingsShortcutsPanel(); + break; case 'call': - return buildSettingsCallPanel(); + frag = buildSettingsCallPanel(); + break; case 'transfer': - return buildSettingsTransferPanel(); + frag = buildSettingsTransferPanel(); + break; case 'appearance': - return buildAppearancePanelWithTitle(); + frag = buildAppearancePanelWithTitle(); + break; case 'network': - return buildSettingsNetworkPanel(); + frag = buildSettingsNetworkPanel(); + break; case 'system': - return buildSettingsSystemPanel(); + frag = buildSettingsSystemPanel(); + break; case 'updates': - return buildSettingsUpdatesPanel(); + frag = buildSettingsUpdatesPanel(); + break; case 'developer': - return buildSettingsDeveloperPanel(); + frag = buildSettingsDeveloperPanel(); + break; case 'about': - return buildSettingsAboutPanel(); + frag = buildSettingsAboutPanel(); + break; default: return buildSettingsPlaceholderPanel(); } + attachSettingsPanelCleanup(frag); + settingsPanelCleanup = frag._settingsCleanup || null; + return frag; } function renderSettingsNavAside() { - const aside = document.createElement('aside'); - aside.className = 'settings-shell__nav glass'; - - for (const id of getSettingsSectionIds()) { - const b = document.createElement('button'); - b.type = 'button'; - b.className = `btn settings-nav-btn${state.settingsSection === id ? ' selected' : ''}`; - b.dataset.i18n = `settings.section_${id}`; - b.textContent = t(`settings.section_${id}`); - b.addEventListener('click', () => { + return renderSettingsNavGroups( + state, + (id) => { + if (id === state.settingsSection) return; state.settingsSection = id; renderView('settings'); - }); - aside.appendChild(b); - } - return aside; + }, + getSettingsSectionIds + ); } function renderSettingsView() { @@ -3648,6 +3693,10 @@ function renderView(viewName) { view = renderDialView(); } + if (state.view === 'settings' && viewName !== 'settings') { + runSettingsPanelCleanup(); + } + const current = mainContent.firstElementChild; if (current === view) { if (viewName === 'chat') { @@ -3913,6 +3962,9 @@ export function updatePeers({ peers, occupiedIds }) { if (state.view === 'chat' && !state.activePeer && mainContent) { renderView('chat'); } + if (state.view === 'projects') { + projectsViewInstance?.refreshPeers?.(); + } } function handleTypingTcp(msg) { From acb5b25c5ab575b078f712bb1c7c393af9ff0774 Mon Sep 17 00:00:00 2001 From: krwg Date: Thu, 21 May 2026 21:17:26 +0300 Subject: [PATCH 08/25] fix(build): package shared/ for installer; restore NSIS finish page --- build/installer.nsh | 20 ++++++++++++++++++-- electron-builder.yml | 5 +++-- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/build/installer.nsh b/build/installer.nsh index 09f1f3b..9f24dbe 100644 --- a/build/installer.nsh +++ b/build/installer.nsh @@ -1,11 +1,27 @@ -; BLIP NSIS customizations — welcome / finish copy (en + ru via electron-builder languages) +; BLIP NSIS — electron-builder include (see https://www.electron.build/nsis.html) +; customWelcomePage / customFinishPage must insert MUI pages (defines alone are not enough). !macro customWelcomePage !define MUI_WELCOMEPAGE_TITLE "BLIP Setup" !define MUI_WELCOMEPAGE_TEXT "LAN-only P2P messenger — text, voice, and video.$\r$\n$\r$\nNo cloud. No accounts. Your mesh, your rules.$\r$\n$\r$\nPublisher: krwg" + !insertmacro MUI_PAGE_WELCOME !macroend !macro customFinishPage !define MUI_FINISHPAGE_TITLE "BLIP installed" - !define MUI_FINISHPAGE_TEXT "BLIP is ready on your network.$\r$\n$\r$\nOpen firewall UDP 42069 and TCP 42070 if peers are not discovered." + !define MUI_FINISHPAGE_TEXT "BLIP is ready on your network.$\r$\n$\r$\nAllow UDP 42069 and TCP 42070 in the firewall if peers are not discovered." + !define MUI_FINISHPAGE_RUN_TEXT "Launch BLIP" + + Function StartApp + ${if} ${isUpdated} + StrCpy $1 "--updated" + ${else} + StrCpy $1 "" + ${endif} + ${StdUtils.ExecShellAsUser} $0 "$launchLink" "open" "$1" + FunctionEnd + + !define MUI_FINISHPAGE_RUN + !define MUI_FINISHPAGE_RUN_FUNCTION "StartApp" + !insertmacro MUI_PAGE_FINISH !macroend diff --git a/electron-builder.yml b/electron-builder.yml index 870e737..63bfd4e 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -1,7 +1,6 @@ appId: com.blip.messenger productName: BLIP copyright: Copyright © 2026 krwg -description: P2P LAN messenger — text, voice, and video without cloud or accounts. extraMetadata: CompanyName: krwg FileDescription: BLIP — P2P LAN Messenger @@ -17,6 +16,7 @@ directories: buildResources: build files: - main/**/* + - shared/**/* - preload.cjs - dist/**/* - package.json @@ -48,7 +48,8 @@ nsis: createDesktopShortcut: true createStartMenuShortcut: true shortcutName: BLIP - runAfterFinish: true + # false = show “Launch BLIP” checkbox on finish page (customFinishPage defines MUI_FINISHPAGE_RUN) + runAfterFinish: false uninstallDisplayName: BLIP artifactName: ${productName}-Setup-${version}.${ext} displayLanguageSelector: true From f599b83456fe5526c5465a106cb1e0dec378973b Mon Sep 17 00:00:00 2001 From: krwg Date: Fri, 22 May 2026 17:27:06 +0300 Subject: [PATCH 09/25] =?UTF-8?q?release:=201.0.1=20=E2=80=94=20verified?= =?UTF-8?q?=20build=20trust,=20MESH+=20docs=20&=20profile=20GIF=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add verified-build trust cues (Ed25519 build-info, main-process only): purple/cyan for official, red for unofficial; UI reflects trustState via tooltips, no feature blocking - Harden MESH+ entitlement checks across Settings, Signal Corps, and main; dual-layer renderer sync with host state - Fix peer profile open when status GIF is set; safer LAN GIF sync (no TCP session break on large files) - Fix chat hub avatar → correct peer profile; remove ping spam on profile open - Document MESH+ in README (EN/RU), ARCHITECTURE, docs/MESH-PLUS.md, and GitHub Pages landing - Add ENTITLEMENT-MAP.local.md (maintainer map) + build-secret setup scripts for release packaging - Bump to 1.0.1; update CHANGELOG.md --- .gitignore | 8 +- CHANGELOG.md | 18 + CONTRIBUTING.md | 2 +- README.md | 37 +- app-metadata.json | 2 +- build/build-info.json | 7 + docs/ARCHITECTURE.md | 17 +- docs/MESH-PLUS.md | 54 +- docs/index.html | 8 +- electron-builder.yml | 7 + main/app-icons.js | 6 +- main/config-public.js | 6 +- main/config.js | 8 + main/discovery.js | 34 +- main/entitlement-codec.js | 85 +++ main/factory-reset.js | 31 ++ main/giphy-key.js | 42 +- main/index.js | 89 ++- main/mesh-plus-license.js | 146 +---- main/mesh-plus-public-key-loader.js | 78 +++ main/mesh-plus-public-key.js | 9 +- main/paths.js | 19 +- main/profile-gif-store.js | 23 +- main/trust-state.js | 102 ++++ main/verify-build.js | 81 +++ package-lock.json | 806 +++++++++++++++++++++++++++- package.json | 16 +- preload.cjs | 23 + renderer/achievements.js | 4 +- renderer/app-icon-picker.js | 6 +- renderer/appearance.js | 4 +- renderer/call-window-main.js | 28 +- renderer/call-window.html | 1 + renderer/call.js | 21 +- renderer/chat-export.js | 4 +- renderer/chat.js | 23 +- renderer/factory-reset-local.js | 27 + renderer/grid.js | 6 +- renderer/group-call-window-main.js | 22 + renderer/group-call-window.html | 1 + renderer/group-call.js | 11 +- renderer/group-invites.js | 4 + renderer/group-projects-store.js | 7 + renderer/groups.js | 5 + renderer/i18n.js | 62 ++- renderer/index.html | 3 +- renderer/main.js | 10 +- renderer/mesh-plus-settings.js | 12 +- renderer/mesh-plus-verify.js | 32 ++ renderer/mesh-plus.js | 56 +- renderer/peer-favorites.js | 4 + renderer/peer-profile.js | 6 + renderer/profile-card.js | 39 +- renderer/project-tools-ui.js | 2 +- renderer/projects-mesh-wire.js | 6 +- renderer/projects-view.js | 38 +- renderer/settings-profile-panel.js | 21 +- renderer/theme-editor.js | 14 +- renderer/trust-ui.js | 93 ++++ renderer/trust.css | 64 +++ renderer/ui.js | 128 ++++- scripts/mesh-plus-keygen.mjs | 39 +- scripts/obfuscate-after-pack.cjs | 47 ++ scripts/setup-build-secrets.mjs | 25 + scripts/sync-build-info.mjs | 78 +++ scripts/sync-giphy-key.mjs | 30 ++ scripts/sync-mesh-public-key.mjs | 45 ++ shared/mesh-plus-gates.js | 63 ++- shared/trust-levels.js | 13 + 69 files changed, 2475 insertions(+), 393 deletions(-) create mode 100644 build/build-info.json create mode 100644 main/entitlement-codec.js create mode 100644 main/factory-reset.js create mode 100644 main/mesh-plus-public-key-loader.js create mode 100644 main/trust-state.js create mode 100644 main/verify-build.js create mode 100644 renderer/factory-reset-local.js create mode 100644 renderer/mesh-plus-verify.js create mode 100644 renderer/trust-ui.js create mode 100644 renderer/trust.css create mode 100644 scripts/obfuscate-after-pack.cjs create mode 100644 scripts/setup-build-secrets.mjs create mode 100644 scripts/sync-build-info.mjs create mode 100644 scripts/sync-giphy-key.mjs create mode 100644 scripts/sync-mesh-public-key.mjs create mode 100644 shared/trust-levels.js diff --git a/.gitignore b/.gitignore index 96deb4c..0a73211 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,10 @@ MESH-PLUS-PLAN.md scripts/.mesh-plus-private.b64 mesh-plus-private.b64 keysandlicense.md -giphy-api-key.local \ No newline at end of file +giphy-api-key.local +build/giphy-api-key.txt +.env.local +.env.build +mesh-plus-public-key.local +build/mesh-plus-public-key.txt +ENTITLEMENT-MAP.local.md \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index f5c2350..af40024 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,24 @@ Release **version numbers** track [`app-metadata.json`](app-metadata.json) (sync ## [Unreleased] +## [1.0.1] — Security & fixes — 2026-05-21 + +Patch release: MESH+ integrity improvements and profile GIF fixes. + +### Added + +- **Verified Builds & MESH+ trust** — official signed builds show purple/cyan cues; unofficial clients and MESH+ use red accents only (tooltips, no text badges). LAN announce carries build trust; renderer reads `window.trustState` from main. + +### Changed + +- **MESH+** — stronger premium-tier checks across settings, Signal Corps, and main process. +- **Profile GIF** — safer LAN sync for large GIFs; profile page opens reliably when a peer has a status GIF. + +### Fixed + +- **Profile page** — GIF cloud and peer/self GIF URLs; no ping spam when opening a profile. +- **Chat hub** — avatar opens the full peer profile (including status GIF flag). + ## [1.0.0] — Mesh Plus — 2026-05-21 First **1.x** stable line. Codename **Mesh Plus**. Builds on everything since the last public GitHub release **[v0.7.7](https://github.com/krwg/BLIP/releases/tag/v0.7.7)** (pin/edit messages, export). Intermediate versions **0.7.8–0.10.1** were developed on `main` and are summarized here. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 767b27a..a861a95 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -65,7 +65,7 @@ Version and codename live in [`app-metadata.json`](app-metadata.json) (synced in **MESH+** tier notes: [`docs/MESH-PLUS.md`](docs/MESH-PLUS.md). Do not break FREE behavior when gating premium features. -Issue test keys locally: `npm run mesh-plus:keygen` (uses `scripts/.mesh-plus-private.b64`, gitignored). Embed only the **public** key in `main/mesh-plus-public-key.js` when rotating keys. +Release builds: run `npm run setup:build-secrets` once, then configure maintainer build secrets before `electron:build:*` (see `ENTITLEMENT-MAP.local.md`, gitignored). ## TCP payloads (renderer ↔ main) diff --git a/README.md b/README.md index 92520ba..01357a7 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ | Testing (one PC) | [Testing](#en-testing) | [Тестирование](#ru-testing) | | Overview | [Overview](#en-overview) | [Обзор](#ru-overview) | | Features | [Features](#en-features) | [Возможности](#ru-features) | +| MESH+ | [MESH+](#en-mesh-plus) | [MESH+](#ru-mesh-plus) | | Signal Corps | [Signal Corps](#en-signal-corps) | [Сигнал Корпс](#ru-signal-corps) | | Architecture | [Architecture](#en-architecture) | [Архитектура](#ru-architecture) | | Stack | [Stack](#en-stack) | [Стек](#ru-stack) | @@ -62,7 +63,7 @@ Quick VM flow: host runs BLIP (ID **1**), VM runs BLIP (ID **2**), same subnet v | | | |---|---| | **What** | Desktop app: text, voice, and video over LAN / Hamachi / Radmin VPN | -| **Release** | **1.0.0 — Mesh Plus** (see [`CHANGELOG.md`](CHANGELOG.md); last GitHub tag: [v0.7.7](https://github.com/krwg/BLIP/releases/tag/v0.7.7)) | +| **Release** | **1.0.1 — Mesh Plus** (see [`CHANGELOG.md`](CHANGELOG.md); last GitHub tag: [v0.7.7](https://github.com/krwg/BLIP/releases/tag/v0.7.7)) | | **Identity** | BLIP ID **1–64** (8×8 grid, Minecraft-style chunk metaphor) | | **Servers** | None — UDP broadcast, TCP, and WebRTC peer-to-peer only | | **Sign-up** | None | @@ -94,6 +95,20 @@ Quick VM flow: host runs BLIP (ID **1**), VM runs BLIP (ID **2**), same subnet v | **Settings** | Profile, privacy/block list, appearance, network, shortcuts, call devices, transfers | | **Window** | Custom title bar, system tray, close-to-tray (Windows), **launch at login** (Windows) | | **Updates** | Auto-check on startup (packaged builds) | +| **MESH+** | Optional premium tier — themes, sounds, status GIF, Signal Corps Board/Canvas, export styling ([details](docs/MESH-PLUS.md)) | + +

      MESH+

      + +**MESH+** unlocks premium cosmetics and builder tools on top of the free LAN messenger. Enter your `BLIP-XXXX-…` key in **Settings → MESH+**. + +| Category | Examples | +|----------|----------| +| **Look & feel** | Animated backgrounds *Ember* / *Rift*, **Wire** / **Static** FX, **Beacon** / **Chime** melodies, custom `#RRGGBB` accent, six **mesh** app icons | +| **Profile** | **Status GIF** cloud visible to peers on the LAN | +| **Signal Corps** | **Board** (kanban), **Canvas** (32×16), Pad snapshots, Clipboard **500** entries + search | +| **Social** | **MESH+** badge on peers; themed chat export (PDF/HTML) | + +FREE keeps chat, voice/video, groups (beta), Pad, Mesh Pulse, core themes, and Clipboard (20 entries). Full list: [`docs/MESH-PLUS.md`](docs/MESH-PLUS.md).

      Signal Corps — the must-have mesh workspace

      @@ -289,7 +304,8 @@ blip/ │ └── assets/fonts/ # Minecraft woff2/ttf ├── docs/ # ARCHITECTURE.md + GitHub Pages landing ├── build/ # icon.ico, icon.png (generated) -├── app-metadata.json # version 1.0.0, codename Mesh Plus +├── app-metadata.json # version 1.0.1, codename Mesh Plus +├── docs/MESH-PLUS.md # MESH+ feature list ├── ach-icons/ # achievement SVGs (bundled in renderer) ├── preload.cjs # IPC bridge ├── scripts/ # electron-dev, copy-fonts, build-icons, sync metadata @@ -317,6 +333,7 @@ blip/ | [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) | Community standards | | [SECURITY.md](SECURITY.md) | Reporting vulnerabilities | | [CHANGELOG.md](CHANGELOG.md) | Release history | +| [docs/MESH-PLUS.md](docs/MESH-PLUS.md) | MESH+ tier — what’s included | | [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | Technical map | | [Landing site (Pages)](https://krwg.github.io/BLIP/) | Static showcase (`docs/index.html`) | @@ -349,7 +366,7 @@ The **Minecraft** font is licensed separately under [MIT](https://github.com/bs- | | | |---|---| | **Что это** | Desktop-приложение: текст, голос и видео по LAN / Hamachi / Radmin VPN | -| **Релиз** | **1.0.0 — Mesh Plus** (см. [`CHANGELOG.md`](CHANGELOG.md); на GitHub: [v0.7.7](https://github.com/krwg/BLIP/releases/tag/v0.7.7)) | +| **Релиз** | **1.0.1 — Mesh Plus** (см. [`CHANGELOG.md`](CHANGELOG.md); на GitHub: [v0.7.7](https://github.com/krwg/BLIP/releases/tag/v0.7.7)) | | **Идентификация** | BLIP ID **1–64** (сетка 8×8) | | **Серверы** | Нет — только UDP broadcast, TCP и WebRTC между пирами | | **Регистрация** | Нет | @@ -381,6 +398,20 @@ The **Minecraft** font is licensed separately under [MIT](https://github.com/bs- | **Настройки** | Профиль, конфиденциальность/блок, вид, сеть, горячие клавиши, звонок, передачи | | **Окно** | Свой title bar, трей, в трей (Windows), **автозапуск при входе в Windows** | | **Обновления** | Проверка при запуске (собранные сборки) | +| **MESH+** | Опциональный премиум — темы, гифка статуса, Доска/Холст, оформление экспорта ([подробнее](docs/MESH-PLUS.md)) | + +

      MESH+

      + +**MESH+** — премиум-уровень поверх бесплатного LAN-мессенджера. Ключ `BLIP-XXXX-…` в **Настройки → MESH+**. + +| Категория | Примеры | +|-----------|---------| +| **Оформление** | Фоны *Ember* / *Rift*, FX **Wire** / **Static**, мелодии **Beacon** / **Chime**, свой акцент `#RRGGBB`, иконки **mesh-1…6** | +| **Профиль** | **Гифка статуса** в облачке для пиров в LAN | +| **Сигнал Корпс** | **Доска**, **Холст**, снимки блокнота, буфер **500** + поиск | +| **Соц.** | Бейдж **MESH+** у пиров; экспорт чата с темой BLIP | + +FREE: чат, звонки, группы (бета), блокнот, Mesh Pulse, базовые темы, буфер (20 записей). Полный список: [`docs/MESH-PLUS.md`](docs/MESH-PLUS.md).

      Сигнал Корпс — главная фича для разработки в МЕШе

      diff --git a/app-metadata.json b/app-metadata.json index 98d96f0..de4caf5 100644 --- a/app-metadata.json +++ b/app-metadata.json @@ -1,6 +1,6 @@ { "displayName": "BLIP", "codename": "Mesh Plus", - "version": "1.0.0", + "version": "1.0.1", "githubUrl": "https://github.com/krwg/BLIP" } diff --git a/build/build-info.json b/build/build-info.json new file mode 100644 index 0000000..b08c611 --- /dev/null +++ b/build/build-info.json @@ -0,0 +1,7 @@ +{ + "issuer": "krwg-official", + "version": "1.0.1", + "buildTimestamp": 1779459686707, + "buildSignature": "uVDvXDihDiGnP+ypXQWlnJXGD7Y9Bs4rfuDGTR2PLUng/4tf72hQ85suvGcfNGgn+dX9KGz35eCJcmjvGVVZCQ==", + "meshPlusPublicKeyHash": "b91e80f821881373" +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index cacff2c..7a30814 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -64,6 +64,21 @@ Environment overrides: `BLIP_UDP_PORT`, `BLIP_TCP_PORT`. Separate user data dirs | `clipboard-push` | Peer ↔ peer | LAN clipboard text (mode-gated in renderer) | | `mesh-proj-pad` | Mesh (online peers) | Signal Corps shared Pad (LWW text, debounced) — not group-scoped | +## MESH+ (premium tier) + +Optional tier activated in **Settings → MESH+**. Gates live in `shared/mesh-plus-gates.js`; settings UI in `renderer/mesh-plus-settings.js`. Product list: [`MESH-PLUS.md`](MESH-PLUS.md). + +| Area | Modules | +|------|---------| +| Themes & sound | `appearance.js`, `theme-editor.js`, premium IDs in `mesh-plus-gates.js` | +| App icons | `app-icon-picker.js`, `main/app-icons.js` | +| Profile GIF | `profile-gif-store.js`, `profile-card.js`, TCP `profile-gif-*` | +| Signal Corps | Board/Canvas/history gates in `projects-view.js`, `project-tools-ui.js` | +| Export | `chat-export.js` themed PDF/HTML | +| Badge | UDP announce `meshPlus`; `renderer/mesh-plus.js` badge | + +FREE baseline keeps core chat, calls, Pad, Mesh Pulse, and Clipboard (20 entries). + ## Signal Corps (Projects) **Signal Corps** is BLIP’s flagship builder workspace — the feature teams should enable first on a dev LAN. It lives in the main renderer (`projects-view.js`, `project-tools-ui.js`, `projects-mesh-wire.js`), gated by `devProjectsEnabled` in config. @@ -86,7 +101,7 @@ Groups (`groups.js`, voice channels) are beta and gated by `devGroupsEnabled` in | Chat history | Renderer `localStorage` key `blip_chat_v1`. | | Favorite peer IDs | Renderer `localStorage` key `blip_favorites_v1`. | | Avatar seeds (per BLIP ID) | Renderer `localStorage` key `blip_avatar_seed_v1` (`avatar.js`). | -| Release metadata | `app-metadata.json` (version **1.0.0**, codename **Mesh Plus**, repo URL). | +| Release metadata | `app-metadata.json` (version **1.0.1**, codename **Mesh Plus**, repo URL). | | Achievement icons | `ach-icons/*.svg` → `renderer/achievements-icons.js` (Vite `?url` imports). | | MESH+ overview | [`MESH-PLUS.md`](MESH-PLUS.md) — tier summary (no phased roadmap file). | | Group avatars | Renderer `localStorage` `blip_group_avatar_v1` | diff --git a/docs/MESH-PLUS.md b/docs/MESH-PLUS.md index 1f724bd..da0fbd3 100644 --- a/docs/MESH-PLUS.md +++ b/docs/MESH-PLUS.md @@ -1,38 +1,44 @@ # BLIP · MESH+ -MESH+ is a **license tier** (`BLIP-XXXX-…` full key, Ed25519 verify in main). FREE users keep the full baseline chat, calls, and LAN mesh. +**MESH+** is the optional premium tier for BLIP. **FREE** includes the full LAN messenger: chat, calls, groups (beta), Mesh Pulse, avatars, themes, and core sounds. -## Shipped (high level) +Activate in **Settings → MESH+** with your `BLIP-XXXX-…` key. + +## What you get | Area | MESH+ | |------|--------| -| Premium animated backgrounds & sound packs | ✦ | -| Theme editor (custom `#RRGGBB` accent) | ✦ | -| App icons `mesh-1` … `mesh-6` | ✦ | -| Profile status GIF (LAN cloud) | ✦ | -| Signal Corps: Board, Canvas, Pad history, Clipboard 500 + search | ✦ | -| Chat export PDF/HTML with BLIP theme | ✦ | -| Badge on peers (`meshPlus` in UDP announce) | ✦ | -| Full achievement icon art after unlock (`ach-icons/*.svg`) | shown when **Achievements** enabled | +| Animated backgrounds **Ember**, **Rift** | ✦ | +| Sound packs **Wire**, **Static** · melodies **Beacon**, **Chime** | ✦ | +| Theme editor — custom `#RRGGBB` accent | ✦ | +| App icons **mesh-1** … **mesh-6** | ✦ | +| Profile **status GIF** cloud (LAN) | ✦ | +| Signal Corps: **Board**, **Canvas**, Pad snapshots, Clipboard **500** + search | ✦ | +| Chat export PDF/HTML with BLIP styling | ✦ | +| **MESH+** badge on peers in the list and profile | ✦ | +| Achievement icons in full color after unlock | ✦ (with **Achievements** enabled) | + +## Signal Corps (FREE vs MESH+) -## Not in app yet (marketing only) +| Tool | Tier | +|------|------| +| **✦ Pad** — shared mesh notepad | FREE | +| **▦ Board** — kanban | MESH+ | +| **◻ Canvas** — 32×16 pixel board | MESH+ | +| **⧉ Clipboard** — mesh history (20 entries FREE, 500 + search MESH+) | FREE / MESH+ | -- LAN relay, MESH+ Bridge, bandwidth priority — carousel slide only; implementation deferred. +Enable **Settings → Developer → Projects** to show **PROJECTS** in the nav. -## Settings layout +## Settings -- **MESH+** — activation + benefits carousel (premium scenes, sounds, theme editor, icons, Signal Corps, export, GIF, relay planned). -- **Network** — session stats + activity bars; **Signal Corps clipboard** toggle (off by default). -- **Achievements** — optional, **off by default**; **Unlocked** / **Not yet unlocked** lists; icons from `ach-icons/*.svg` via `renderer/achievements-icons.js` (hidden until unlocked). +- **MESH+** — activation and benefits overview. +- **Network** — session stats; optional Signal Corps clipboard mesh sync. +- **Achievements** — optional, off by default. -## Code +## Planned (marketing only) -- Gates: `shared/mesh-plus-gates.js` -- License: `main/mesh-plus-license.js` -- UI: `renderer/mesh-plus-settings.js`, `renderer/mesh-plus.js` -- Projects: `renderer/projects-view.js`, `renderer/project-tools-ui.js`, `renderer/projects-mesh-wire.js` -- Achievements: `renderer/achievements.js`, `renderer/achievements-icons.js`, `renderer/achievements-settings-panel.js` +LAN relay, MESH+ Bridge, bandwidth priority — not implemented yet. -**App version:** `1.0.0` · codename **Mesh Plus** (`app-metadata.json`). +**App version:** `1.0.1` · codename **Mesh Plus** (`app-metadata.json`). -*No ROOT build switcher in public repo.* +*BLIP · FREE for everyone · MESH+ by key* diff --git a/docs/index.html b/docs/index.html index e3ed65d..d931637 100644 --- a/docs/index.html +++ b/docs/index.html @@ -6,7 +6,7 @@ BLIP — GitHub @@ -307,7 +307,7 @@
      -
      v1.0.0 Mesh Plus · LAN · GPLv3
      +
      v1.0.1 Mesh Plus · LAN · GPLv3

      Ты в сети. Ты сигнал.

      Десктоп-мессенджер для LAN / Hamachi / Radmin VPN: чат, группы, голосовые каналы, звонки 1:1 и @@ -392,7 +392,7 @@

      Стек

      (function () { var STR = { ru: { - badge: 'v1.0.0 Mesh Plus · LAN · GPLv3', + badge: 'v1.0.1 Mesh Plus · LAN · GPLv3', title: 'Ты в сети. Ты сигнал.', sub: 'Десктоп-мессенджер для LAN / Hamachi / Radmin VPN: чат, группы, голосовые каналы, звонки 1:1 и групповые — без облака. UDP + TCP + WebRTC, BLIP ID 1–64.', @@ -422,7 +422,7 @@

      Стек

      navIssues: 'Issues', }, en: { - badge: 'v1.0.0 Mesh Plus · LAN · GPLv3', + badge: 'v1.0.1 Mesh Plus · LAN · GPLv3', title: "You're on the grid. You're the signal.", sub: 'Desktop messenger for LAN / Hamachi / Radmin VPN: chat, groups, voice channels, 1:1 and group calls — no cloud. UDP + TCP + WebRTC, BLIP IDs 1–64.', diff --git a/electron-builder.yml b/electron-builder.yml index 63bfd4e..ff4a8db 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -14,6 +14,7 @@ publish: directories: output: dist-electron buildResources: build +afterPack: scripts/obfuscate-after-pack.cjs files: - main/**/* - shared/**/* @@ -22,6 +23,12 @@ files: - package.json - app-metadata.json extraResources: + - from: build/giphy-api-key.txt + to: giphy-api-key.txt + - from: build/mesh-plus-public-key.txt + to: mesh-plus-public-key.txt + - from: build/build-info.json + to: build-info.json - from: build/icon.png to: icons/icon.png - from: build/tray-16.png diff --git a/main/app-icons.js b/main/app-icons.js index dd040ab..992b260 100644 --- a/main/app-icons.js +++ b/main/app-icons.js @@ -1,7 +1,7 @@ import { existsSync } from 'fs'; import { join } from 'path'; import { resolveBuildAsset } from './paths.js'; -import { isMeshPlusActive } from './mesh-plus-license.js'; +import { resolveEntitlementState } from './mesh-plus-license.js'; /** @type {{ id: string, file: string, tier: 'free' | 'mesh_plus' }[]} */ export const APP_ICON_VARIANTS = [ @@ -29,14 +29,14 @@ export function normalizeAppIconVariant(id) { /** @param {object} config */ export function resolveAppIconVariant(config) { let id = normalizeAppIconVariant(config?.appIconVariant); - if (MESH_IDS.has(id) && !isMeshPlusActive(config)) id = 'main'; + if (MESH_IDS.has(id) && !resolveEntitlementState(config)) id = 'main'; return id; } export function canUseAppIconVariant(config, variantId) { const id = normalizeAppIconVariant(variantId); if (FREE_IDS.has(id)) return true; - if (MESH_IDS.has(id)) return isMeshPlusActive(config); + if (MESH_IDS.has(id)) return resolveEntitlementState(config); return false; } diff --git a/main/config-public.js b/main/config-public.js index 9905e8c..9c750b4 100644 --- a/main/config-public.js +++ b/main/config-public.js @@ -1,4 +1,4 @@ -import { getMeshPlusTier, isMeshPlusActive } from './mesh-plus-license.js'; +import { resolveProductTier, resolveEntitlementState } from './mesh-plus-license.js'; const SENSITIVE_KEYS = new Set(['meshPrivateKey', 'meshPlusLicenseSig']); @@ -10,8 +10,8 @@ export function toPublicConfig(config) { if (!config || typeof config !== 'object') return config; const pub = { ...config }; for (const key of SENSITIVE_KEYS) delete pub[key]; - pub.tier = getMeshPlusTier(config); - pub.meshPlusActive = isMeshPlusActive(config); + pub.tier = resolveProductTier(config); + pub.meshPlusActive = resolveEntitlementState(config); if (pub.meshPlusLicenseId) { pub.meshPlusLicenseMasked = `••••-${String(pub.meshPlusLicenseId).slice(-4)}`; } else { diff --git a/main/config.js b/main/config.js index f405d0f..ba057c1 100644 --- a/main/config.js +++ b/main/config.js @@ -132,6 +132,14 @@ export function saveConfig(config) { return merged; } +/** Wipe config to defaults (blipId null — first-run / ID grid). */ +export function resetConfigToDefaults() { + if (!configPath) initConfigPath(); + const fresh = { ...DEFAULT_CONFIG }; + writeFileSync(configPath, JSON.stringify(fresh, null, 2), 'utf8'); + return fresh; +} + export function normalizePeerIp(ip) { if (!ip || typeof ip !== 'string') return ''; return ip.replace(/^::ffff:/i, ''); diff --git a/main/discovery.js b/main/discovery.js index cfb42e0..a4cfe29 100644 --- a/main/discovery.js +++ b/main/discovery.js @@ -8,7 +8,12 @@ import { signCanonical, verifyAnnouncePayload, } from './mesh-identity.js'; -import { isMeshPlusActive } from './mesh-plus-license.js'; +import { resolveEntitlementState } from './mesh-plus-license.js'; +import { + getBuildAnnounceTrust, + peerBuildTrustFromAnnounce, + peerMeshPlusTrustFromAnnounce, +} from './trust-state.js'; const ANNOUNCE_INTERVAL = 5000; const PEER_TIMEOUT = 30000; @@ -150,9 +155,19 @@ export class Discovery { meshPubkey, }); const meshAnnounceSig = signCanonical(this.config, canonical); - const meshPlus = isMeshPlusActive(this.config); + const meshPlus = resolveEntitlementState(this.config); const hasProfileGif = meshPlus && !!this.config?.hasProfileGif; - return { ...base, meshAnnounceSig, meshPlus, hasProfileGif }; + const buildTrust = getBuildAnnounceTrust(); + return { + ...base, + meshAnnounceSig, + meshPlus, + hasProfileGif, + buildVerified: buildTrust.buildVerified, + buildIssuer: buildTrust.buildIssuer, + buildVersion: buildTrust.buildVersion, + meshPlusTrust: meshPlus ? buildTrust.meshPlusTrust : null, + }; } announce() { @@ -215,6 +230,10 @@ export class Discovery { meshPubkey, meshPlus: !!data.meshPlus, hasProfileGif: !!data.meshPlus && !!data.hasProfileGif, + buildTrust: peerBuildTrustFromAnnounce(data), + buildIssuer: String(data.buildIssuer || ''), + buildVerified: !!data.buildVerified, + meshPlusTrust: peerMeshPlusTrustFromAnnounce(data), }; if ( @@ -227,7 +246,10 @@ export class Discovery { existing.meshVerified !== peer.meshVerified || existing.meshLegacy !== peer.meshLegacy || existing.meshPlus !== peer.meshPlus || - existing.hasProfileGif !== peer.hasProfileGif + existing.hasProfileGif !== peer.hasProfileGif || + existing.buildTrust !== peer.buildTrust || + existing.buildVerified !== peer.buildVerified || + existing.meshPlusTrust !== peer.meshPlusTrust ) { this.peers.set(data.blipId, peer); } else { @@ -242,6 +264,10 @@ export class Discovery { existing.meshPubkey = meshPubkey; existing.meshPlus = peer.meshPlus; existing.hasProfileGif = peer.hasProfileGif; + existing.buildTrust = peer.buildTrust; + existing.buildIssuer = peer.buildIssuer; + existing.buildVerified = peer.buildVerified; + existing.meshPlusTrust = peer.meshPlusTrust; } this.occupiedIds.add(data.blipId); diff --git a/main/entitlement-codec.js b/main/entitlement-codec.js new file mode 100644 index 0000000..bd0fabe --- /dev/null +++ b/main/entitlement-codec.js @@ -0,0 +1,85 @@ +/** BLIP entitlement key format — no Electron dependency (safe for Node keygen). */ + +export const ENTITLEMENT_CANON = 'blip-meshplus-v1'; +const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTUVWXYZ'; +const LICENSE_BODY_BYTES = 72; + +function base32DecodeChar(c) { + const i = CROCKFORD.indexOf(c); + return i >= 0 ? i : -1; +} + +/** @param {string} encoded */ +function base32Decode(encoded) { + let bits = 0; + let value = 0; + const out = []; + for (const ch of encoded) { + const v = base32DecodeChar(ch); + if (v < 0) return null; + value = (value << 5) | v; + bits += 5; + while (bits >= 8) { + bits -= 8; + out.push((value >> bits) & 0xff); + } + } + return Buffer.from(out); +} + +/** @param {Buffer} buf */ +function base32Encode(buf) { + let bits = 0; + let value = 0; + let out = ''; + for (const byte of buf) { + value = (value << 8) | byte; + bits += 8; + while (bits >= 5) { + bits -= 5; + out += CROCKFORD[(value >> bits) & 31]; + } + } + if (bits > 0) out += CROCKFORD[(value << (5 - bits)) & 31]; + return out; +} + +/** @param {string} body */ +function formatBlipKey(body) { + const groups = body.match(/.{1,4}/g) || []; + return `BLIP-${groups.join('-')}`; +} + +/** @param {string} raw */ +export function normalizeEntitlementInput(raw) { + if (!raw || typeof raw !== 'string') return null; + let s = raw.trim().toUpperCase().replace(/\s+/g, ''); + if (s.startsWith('BLIP')) s = s.replace(/^BLIP-?/, ''); + s = s.replace(/-/g, ''); + if (!s || !/^[0-9A-HJKMNP-Z]+$/.test(s)) return null; + return s; +} + +/** @param {string} raw */ +export function parseEntitlementBlob(raw) { + const encoded = normalizeEntitlementInput(raw); + if (!encoded) return null; + const buf = base32Decode(encoded); + if (!buf || buf.length !== LICENSE_BODY_BYTES) return null; + const licenseId = buf.subarray(0, 8).toString('hex').toUpperCase(); + const sig = buf.subarray(8, 72); + return { + licenseId, + sigB64: sig.toString('base64'), + }; +} + +/** @param {string} licenseId @param {string} sigB64 */ +export function formatEntitlementDisplay(licenseId, sigB64) { + const idBuf = Buffer.from(licenseId, 'hex'); + if (idBuf.length !== 8) throw new Error('licenseId must be 16 hex chars'); + const sigBuf = Buffer.from(sigB64, 'base64'); + if (sigBuf.length !== 64) throw new Error('signature must be 64 bytes'); + const body = base32Encode(Buffer.concat([idBuf, sigBuf])); + return formatBlipKey(body); +} diff --git a/main/factory-reset.js b/main/factory-reset.js new file mode 100644 index 0000000..e1d8e76 --- /dev/null +++ b/main/factory-reset.js @@ -0,0 +1,31 @@ +import { app } from 'electron'; +import { existsSync, unlinkSync, rmSync } from 'fs'; +import { join } from 'path'; +import { resetConfigToDefaults } from './config.js'; +import { clearCustomAvatar } from './avatar-store.js'; +import { resetGiphyApiKeyCache } from './giphy-key.js'; + +function clearProfileGifStorage() { + const userData = app.getPath('userData'); + const meta = join(userData, 'profile-gif-meta.json'); + const gifsDir = join(userData, 'profile-gifs'); + if (existsSync(meta)) unlinkSync(meta); + if (existsSync(gifsDir)) { + rmSync(gifsDir, { recursive: true, force: true }); + } +} + +/** Remove optional userData artifacts (not packaged resources). */ +export function clearUserDataArtifacts() { + clearCustomAvatar(); + clearProfileGifStorage(); + const giphyKey = join(app.getPath('userData'), 'giphy-api-key.txt'); + if (existsSync(giphyKey)) unlinkSync(giphyKey); + resetGiphyApiKeyCache(); +} + +/** Full factory reset: defaults config + cleared profile media. */ +export function performFactoryReset() { + clearUserDataArtifacts(); + return resetConfigToDefaults(); +} diff --git a/main/giphy-key.js b/main/giphy-key.js index 722c97e..1228f43 100644 --- a/main/giphy-key.js +++ b/main/giphy-key.js @@ -1,11 +1,20 @@ import { existsSync, readFileSync } from 'fs'; -import { join } from 'path'; +import { join, dirname } from 'path'; import { app } from 'electron'; let cached = ''; +function readKeyFile(p) { + try { + if (!existsSync(p)) return ''; + return readFileSync(p, 'utf8').trim(); + } catch { + return ''; + } +} + /** - * Giphy API key: env, then userData/giphy-api-key.txt, then ./giphy-api-key.local (gitignored). + * Giphy API key: env → userData → packaged resources → dev giphy-api-key.local. */ export function getGiphyApiKey() { if (cached) return cached; @@ -18,22 +27,27 @@ export function getGiphyApiKey() { cached = fromEnv; return cached; } - const paths = [ - join(app.getPath('userData'), 'giphy-api-key.txt'), - join(process.cwd(), 'giphy-api-key.local'), - ]; - for (const p of paths) { + + const paths = [join(app.getPath('userData'), 'giphy-api-key.txt')]; + + if (app.isPackaged) { + paths.push(join(process.resourcesPath, 'giphy-api-key.txt')); try { - if (existsSync(p)) { - const k = readFileSync(p, 'utf8').trim(); - if (k) { - cached = k; - return cached; - } - } + paths.push(join(dirname(app.getPath('exe')), 'giphy-api-key.txt')); } catch { /* ignore */ } + } else { + paths.push(join(process.cwd(), 'giphy-api-key.local')); + paths.push(join(app.getAppPath(), '..', 'giphy-api-key.local')); + } + + for (const p of paths) { + const k = readKeyFile(p); + if (k) { + cached = k; + return cached; + } } return ''; } diff --git a/main/index.js b/main/index.js index dccad18..c76c946 100644 --- a/main/index.js +++ b/main/index.js @@ -9,10 +9,15 @@ import { connectToPeer, sendOnSocket, pingPeer } from './tcp-client.js'; import { createTcpLineReader } from './tcp-framing.js'; import { loadConfig, saveConfig, initConfigPath, getLocalIp, getLocalIpv4Set } from './config.js'; import { toPublicConfig } from './config-public.js'; -import { validateMeshPlusActivationKey, isMeshPlusActive } from './mesh-plus-license.js'; +import { confirmEntitlementBlob, resolveEntitlementState } from './mesh-plus-license.js'; import { - meshPlusClampPatch, - sanitizeMeshPlusConfigUpdates, + initAppTrustState, + getAppTrustState, + refreshMeshPlusTrust, +} from './trust-state.js'; +import { + premiumResetPatch, + sanitizePremiumPrefs, } from '../shared/mesh-plus-gates.js'; import { canUseAppIconVariant, @@ -52,6 +57,7 @@ import { clearActiveProfileGif, getActiveProfileGifId, getProfileGifDataUrl, + getProfileGifShareDataUrl, getProfileGifPublicState, hasActiveProfileGif, listProfileGifHistory, @@ -71,6 +77,7 @@ import { resolveDisplaySourceForCallback, setPendingDisplaySource, } from './display-capture.js'; +import { performFactoryReset } from './factory-reset.js'; import os from 'os'; if (process.env.BLIP_USER_DATA_DIR) { @@ -455,6 +462,17 @@ function sendToRenderer(channel, data) { } } +function broadcastTrustState() { + const trust = getAppTrustState(); + sendToRenderer('trust-state', trust); + if (callWindow && !callWindow.isDestroyed()) { + callWindow.webContents.send('trust-state', trust); + } + if (groupCallWindow && !groupCallWindow.isDestroyed()) { + groupCallWindow.webContents.send('trust-state', trust); + } +} + function patchConfig(updates) { config = saveConfig(updates); discovery?.updateConfig(config); @@ -740,6 +758,18 @@ async function bootstrapNetworking() { await discovery.start(); } +function closeAuxiliaryWindows() { + if (callWindow && !callWindow.isDestroyed()) { + callWindow.destroy(); + callWindow = null; + } + if (groupCallWindow && !groupCallWindow.isDestroyed()) { + groupCallWindow.destroy(); + groupCallWindow = null; + groupCallWindowReady = false; + } +} + async function stopNetwork() { discovery?.stop(); discovery = null; @@ -797,10 +827,10 @@ function setupIpc() { ? id : 'main'; } - const meshActive = isMeshPlusActive({ ...config, ...safe }); + const meshActive = resolveEntitlementState({ ...config, ...safe }); Object.assign( safe, - sanitizeMeshPlusConfigUpdates(config, safe, meshActive) + sanitizePremiumPrefs(config, safe, meshActive) ); config = saveConfig(safe); if (safe.appIconVariant !== undefined) refreshAppIcons(); @@ -835,7 +865,7 @@ function setupIpc() { }); ipcMain.handle('activate-mesh-plus', (_, rawKey) => { - const result = validateMeshPlusActivationKey(rawKey); + const result = confirmEntitlementBlob(rawKey); if (!result.ok) return result; config = saveConfig({ meshPlusLicenseId: result.licenseId, @@ -845,6 +875,8 @@ function setupIpc() { discovery?.updateConfig(config); discovery?.announce(); refreshAppIcons(); + refreshMeshPlusTrust(config); + broadcastTrustState(); const pub = toPublicConfig(config); if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('config-updated', pub); @@ -861,13 +893,15 @@ function setupIpc() { if (String(config.appIconVariant || '').startsWith('mesh-')) { patch.appIconVariant = 'main'; } - const prefsPatch = meshPlusClampPatch(config); + const prefsPatch = premiumResetPatch(config); if (prefsPatch) Object.assign(patch, prefsPatch); if (prefsPatch?.hasProfileGif === false) clearActiveProfileGif(); config = saveConfig(patch); discovery?.updateConfig(config); discovery?.announce(); refreshAppIcons(); + refreshMeshPlusTrust(config); + broadcastTrustState(); const pub = toPublicConfig(config); if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('config-updated', pub); @@ -875,6 +909,26 @@ function setupIpc() { return { ok: true, tier: 'free' }; }); + ipcMain.handle('factory-reset', () => { + closeAuxiliaryWindows(); + for (const s of peerSockets.values()) { + if (!s.destroyed) s.destroy(); + } + peerSockets.clear(); + config = performFactoryReset(); + unregisterGlobalShortcuts(); + refreshAppIcons(); + discovery?.updateConfig(config); + discovery?.announce(); + sendToRenderer('peers-updated', { peers: [], occupiedIds: [] }); + const pub = toPublicConfig(config); + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('config-updated', pub); + mainWindow.webContents.send('factory-reset-done', pub); + } + return { ok: true, config: pub }; + }); + ipcMain.handle('get-mesh-plus-status', () => { const pub = toPublicConfig(config); return { @@ -885,6 +939,8 @@ function setupIpc() { }; }); + ipcMain.handle('get-trust-state', () => getAppTrustState()); + ipcMain.handle('get-github-releases', async (_, limit) => fetchGithubReleases(limit ?? 8)); ipcMain.handle('get-peers', () => ({ peers: discovery?.getPeers() || [], @@ -1125,13 +1181,16 @@ function setupIpc() { }); ipcMain.handle('get-profile-gif-active-url', () => getProfileGifDataUrl()); + ipcMain.handle('get-profile-gif-share-url', () => getProfileGifShareDataUrl()); ipcMain.handle('get-profile-gif-history', () => listProfileGifHistory()); ipcMain.handle('is-giphy-configured', () => isGiphyConfigured()); ipcMain.handle('search-giphy', (_, query, offset) => searchGiphy(query, { offset })); ipcMain.handle('trending-giphy', (_, offset) => trendingGiphy({ offset })); ipcMain.handle('save-profile-gif', async (_, dataUrl) => { - if (!isMeshPlusActive(config)) return { ok: false, error: 'mesh_plus_required' }; + if (!resolveEntitlementState(config) || !config?.meshPlusLicenseId) { + return { ok: false, error: 'mesh_plus_required' }; + } try { const id = saveProfileGifFromDataUrl(dataUrl); const pub = getProfileGifPublicState(); @@ -1147,7 +1206,9 @@ function setupIpc() { }); ipcMain.handle('save-profile-gif-bytes', async (_, base64) => { - if (!isMeshPlusActive(config)) return { ok: false, error: 'mesh_plus_required' }; + if (!resolveEntitlementState(config) || !config?.meshPlusLicenseId) { + return { ok: false, error: 'mesh_plus_required' }; + } try { const buf = Buffer.from(String(base64 || ''), 'base64'); if (!buf.length) return { ok: false, error: 'invalid_gif' }; @@ -1165,7 +1226,9 @@ function setupIpc() { }); ipcMain.handle('save-profile-gif-path', async (_, filePath) => { - if (!isMeshPlusActive(config)) return { ok: false, error: 'mesh_plus_required' }; + if (!resolveEntitlementState(config) || !config?.meshPlusLicenseId) { + return { ok: false, error: 'mesh_plus_required' }; + } try { const p = String(filePath || '').trim(); if (!p) return { ok: false, error: 'invalid_gif' }; @@ -1428,6 +1491,7 @@ app.whenReady().then(async () => { initConfigPath(); config = loadConfig(); + initAppTrustState(config); const gifPub = getProfileGifPublicState(); if ( config.hasProfileGif !== gifPub.hasProfileGif || @@ -1438,8 +1502,8 @@ app.whenReady().then(async () => { hasProfileGif: gifPub.hasProfileGif, }); } - const meshClamp = meshPlusClampPatch(config); - if (meshClamp && !isMeshPlusActive(config)) { + const meshClamp = premiumResetPatch(config); + if (meshClamp && !resolveEntitlementState(config)) { config = saveConfig(meshClamp); } const hadMeshKeys = !!(config.meshPrivateKey && config.meshPublicKey); @@ -1464,6 +1528,7 @@ app.whenReady().then(async () => { setupIpc(); createWindow(); + broadcastTrustState(); refreshAppIcons(); installTray(); void ensureCallWindow().catch((e) => console.warn('[BLIP] prewarm call window', e)); diff --git a/main/mesh-plus-license.js b/main/mesh-plus-license.js index 49d54ee..e8e272b 100644 --- a/main/mesh-plus-license.js +++ b/main/mesh-plus-license.js @@ -1,137 +1,49 @@ import { verifyCanonical } from './mesh-identity.js'; -import { MESH_PLUS_LICENSE_PUBLIC_KEY_B64 } from './mesh-plus-public-key.js'; - -export const MESH_PLUS_LICENSE_CANON = 'blip-meshplus-v1'; -const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTUVWXYZ'; -const LICENSE_BODY_BYTES = 72; // 8-byte id + 64-byte Ed25519 signature - -function base32DecodeChar(c) { - const i = CROCKFORD.indexOf(c); - return i >= 0 ? i : -1; -} - -/** @param {string} encoded */ -function base32Decode(encoded) { - let bits = 0; - let value = 0; - const out = []; - for (const ch of encoded) { - const v = base32DecodeChar(ch); - if (v < 0) return null; - value = (value << 5) | v; - bits += 5; - while (bits >= 8) { - bits -= 8; - out.push((value >> bits) & 0xff); - } - } - return Buffer.from(out); -} - -/** @param {Buffer} buf */ -function base32Encode(buf) { - let bits = 0; - let value = 0; - let out = ''; - for (const byte of buf) { - value = (value << 8) | byte; - bits += 8; - while (bits >= 5) { - bits -= 5; - out += CROCKFORD[(value >> bits) & 31]; - } - } - if (bits > 0) out += CROCKFORD[(value << (5 - bits)) & 31]; - return out; -} - -/** @param {string} body */ -function formatBlipKey(body) { - const groups = body.match(/.{1,4}/g) || []; - return `BLIP-${groups.join('-')}`; -} - -/** - * @param {string} raw - * @returns {string | null} - */ -export function normalizeMeshPlusKeyInput(raw) { - if (!raw || typeof raw !== 'string') return null; - let s = raw.trim().toUpperCase().replace(/\s+/g, ''); - if (s.startsWith('BLIP')) s = s.replace(/^BLIP-?/, ''); - s = s.replace(/-/g, ''); - if (!s || !/^[0-9A-HJKMNP-Z]+$/.test(s)) return null; - return s; -} - -/** - * @param {string} raw - * @returns {{ licenseId: string, sigB64: string } | null} - */ -export function parseMeshPlusLicenseKey(raw) { - const encoded = normalizeMeshPlusKeyInput(raw); - if (!encoded) return null; - const buf = base32Decode(encoded); - if (!buf || buf.length !== LICENSE_BODY_BYTES) return null; - const licenseId = buf.subarray(0, 8).toString('hex').toUpperCase(); - const sig = buf.subarray(8, 72); - return { - licenseId, - sigB64: sig.toString('base64'), - }; -} - -/** - * @param {{ licenseId: string, sigB64: string }} parsed - */ -export function verifyMeshPlusLicenseParsed(parsed) { +import { TRUST_ANCHOR_B64 } from './mesh-plus-public-key.js'; +import { + ENTITLEMENT_CANON, + normalizeEntitlementInput, + parseEntitlementBlob, + formatEntitlementDisplay, +} from './entitlement-codec.js'; + +export { + ENTITLEMENT_CANON, + normalizeEntitlementInput, + parseEntitlementBlob, + formatEntitlementDisplay, +}; + +/** @param {{ licenseId: string, sigB64: string }} parsed */ +export function verifyEntitlementDigest(parsed) { if (!parsed?.licenseId || !parsed.sigB64) return false; - const canonical = `${MESH_PLUS_LICENSE_CANON}|${parsed.licenseId}`; - return verifyCanonical( - MESH_PLUS_LICENSE_PUBLIC_KEY_B64, - canonical, - parsed.sigB64 - ); + const pub = TRUST_ANCHOR_B64; + if (!pub) return false; + const canonical = `${ENTITLEMENT_CANON}|${parsed.licenseId}`; + return verifyCanonical(pub, canonical, parsed.sigB64); } /** @param {object} config */ -export function isMeshPlusActive(config) { +export function resolveEntitlementState(config) { if (!config?.meshPlusLicenseId || !config?.meshPlusLicenseSig) return false; - return verifyMeshPlusLicenseParsed({ + return verifyEntitlementDigest({ licenseId: config.meshPlusLicenseId, sigB64: config.meshPlusLicenseSig, }); } /** @returns {'free' | 'mesh_plus'} */ -export function getMeshPlusTier(config) { - return isMeshPlusActive(config) ? 'mesh_plus' : 'free'; -} - -/** - * Build display key for a parsed license (for keygen). - * @param {string} licenseId hex 16 chars - * @param {string} sigB64 - */ -export function formatMeshPlusLicenseKey(licenseId, sigB64) { - const idBuf = Buffer.from(licenseId, 'hex'); - if (idBuf.length !== 8) throw new Error('licenseId must be 16 hex chars'); - const sigBuf = Buffer.from(sigB64, 'base64'); - if (sigBuf.length !== 64) throw new Error('signature must be 64 bytes'); - const body = base32Encode(Buffer.concat([idBuf, sigBuf])); - return formatBlipKey(body); +export function resolveProductTier(config) { + return resolveEntitlementState(config) ? 'mesh_plus' : 'free'; } -/** - * @param {string} rawKey - * @returns {{ ok: boolean, error?: string, licenseId?: string, sigB64?: string }} - */ -export function validateMeshPlusActivationKey(rawKey) { - const parsed = parseMeshPlusLicenseKey(rawKey); +/** @param {string} rawKey */ +export function confirmEntitlementBlob(rawKey) { + const parsed = parseEntitlementBlob(rawKey); if (!parsed) { return { ok: false, error: 'invalid_format' }; } - if (!verifyMeshPlusLicenseParsed(parsed)) { + if (!verifyEntitlementDigest(parsed)) { return { ok: false, error: 'invalid_signature' }; } return { ok: true, licenseId: parsed.licenseId, sigB64: parsed.sigB64 }; diff --git a/main/mesh-plus-public-key-loader.js b/main/mesh-plus-public-key-loader.js new file mode 100644 index 0000000..754b3c4 --- /dev/null +++ b/main/mesh-plus-public-key-loader.js @@ -0,0 +1,78 @@ +import { existsSync, readFileSync } from 'fs'; +import { join, dirname } from 'path'; +import { createRequire } from 'module'; + +const require = createRequire(import.meta.url); + +let cached; + +function readKeyFile(p) { + try { + if (!existsSync(p)) return ''; + return readFileSync(p, 'utf8').trim(); + } catch { + return ''; + } +} + +function electronApp() { + try { + return require('electron')?.app ?? null; + } catch { + return null; + } +} + +/** + * @returns {string | null} + */ +export function loadTrustAnchorB64() { + if (cached !== undefined) return cached || null; + + const fromEnv = (process.env.BLIP_MESH_PUBLIC_KEY || '').trim(); + if (fromEnv) { + cached = fromEnv; + return cached; + } + + const paths = []; + const app = electronApp(); + + if (app?.getPath) { + paths.push(join(app.getPath('userData'), 'mesh-plus-public-key.txt')); + if (app.isPackaged) { + paths.push(join(process.resourcesPath, 'mesh-plus-public-key.txt')); + try { + paths.push(join(dirname(app.getPath('exe')), 'mesh-plus-public-key.txt')); + } catch { + /* ignore */ + } + } else { + paths.push(join(process.cwd(), 'mesh-plus-public-key.local')); + try { + paths.push(join(app.getAppPath(), '..', 'mesh-plus-public-key.local')); + } catch { + /* ignore */ + } + paths.push(join(process.cwd(), 'build', 'mesh-plus-public-key.txt')); + } + } else { + paths.push(join(process.cwd(), 'mesh-plus-public-key.local')); + paths.push(join(process.cwd(), 'build', 'mesh-plus-public-key.txt')); + } + + for (const p of paths) { + const k = readKeyFile(p); + if (k) { + cached = k; + return cached; + } + } + + cached = ''; + return null; +} + +export function resetTrustAnchorCache() { + cached = undefined; +} diff --git a/main/mesh-plus-public-key.js b/main/mesh-plus-public-key.js index 6027278..fd5ab75 100644 --- a/main/mesh-plus-public-key.js +++ b/main/mesh-plus-public-key.js @@ -1,6 +1,3 @@ -/** - * Ed25519 public key for MESH+ license verification (SPKI DER, base64). - * Private key stays local — generate with `node scripts/mesh-plus-keygen.mjs`. - */ -export const MESH_PLUS_LICENSE_PUBLIC_KEY_B64 = - 'MCowBQYDK2VwAyEA1AHZLIEAwoXRtJsGlv5uxNuznWCGqSl2xfJOIsdST6A='; +import { loadTrustAnchorB64 } from './mesh-plus-public-key-loader.js'; + +export const TRUST_ANCHOR_B64 = loadTrustAnchorB64(); diff --git a/main/paths.js b/main/paths.js index cbdb17d..3ed87bc 100644 --- a/main/paths.js +++ b/main/paths.js @@ -5,15 +5,24 @@ import { existsSync } from 'fs'; const rootDir = join(dirname(fileURLToPath(import.meta.url)), '..'); +/** + * Resolve assets under `build/` (dev) or `resources/icons/` (packaged extraResources). + * @param {string} filename — e.g. `icon.png`, `tray-16.png`, `icons/mesh-3.png` + */ export function resolveBuildAsset(filename) { const normalized = filename.replace(/\\/g, '/'); - const devPath = join(rootDir, 'build', normalized); - if (existsSync(devPath)) return devPath; + if (app.isPackaged) { - const prodPath = join(process.resourcesPath, 'icons', normalized); + // electron-builder extraResources: + // build/icon.png, tray-16.png → resources/icons/ + // build/icons/* → resources/icons/* (flat, not resources/icons/icons/) + const prodPath = normalized.startsWith('icons/') + ? join(process.resourcesPath, normalized) + : join(process.resourcesPath, 'icons', normalized); if (existsSync(prodPath)) return prodPath; - const flatProd = join(process.resourcesPath, 'icons', filename); - if (existsSync(flatProd)) return flatProd; + return prodPath; } + + const devPath = join(rootDir, 'build', normalized); return devPath; } diff --git a/main/profile-gif-store.js b/main/profile-gif-store.js index 99c1eee..49e2a4f 100644 --- a/main/profile-gif-store.js +++ b/main/profile-gif-store.js @@ -13,6 +13,8 @@ import { randomBytes } from 'crypto'; const MAX_HISTORY = 5; /** @type {number} max stored GIF size (bytes) */ export const MAX_PROFILE_GIF_BYTES = 8 * 1024 * 1024; +/** Max raw GIF bytes for one TCP JSON line (base64 + framing under 4 MiB). */ +export const MAX_PROFILE_GIF_TCP_BYTES = 2_800_000; const MAX_BYTES = MAX_PROFILE_GIF_BYTES; function gifsDir() { @@ -112,21 +114,34 @@ export function clearActiveProfileGif() { return saveMeta({ ...meta, activeId: '' }); } -/** @param {string} [id] */ -export function getProfileGifDataUrl(id) { +function readGifBuffer(id) { const meta = loadMeta(); const useId = id || meta.activeId; if (!useId) return null; const p = filePathForId(useId); if (!existsSync(p)) return null; try { - const buf = readFileSync(p); - return `data:image/gif;base64,${buf.toString('base64')}`; + return readFileSync(p); } catch { return null; } } +/** @param {string} [id] */ +export function getProfileGifDataUrl(id) { + const buf = readGifBuffer(id); + if (!buf) return null; + return `data:image/gif;base64,${buf.toString('base64')}`; +} + +/** LAN share payload — skips oversized GIFs to avoid TCP line overflow. */ +export function getProfileGifShareDataUrl(id) { + const buf = readGifBuffer(id); + if (!buf) return null; + if (buf.length > MAX_PROFILE_GIF_TCP_BYTES) return null; + return `data:image/gif;base64,${buf.toString('base64')}`; +} + export function getActiveProfileGifId() { return loadMeta().activeId; } diff --git a/main/trust-state.js b/main/trust-state.js new file mode 100644 index 0000000..f3a9b55 --- /dev/null +++ b/main/trust-state.js @@ -0,0 +1,102 @@ +import { + BUILD_TRUST, + MESH_TRUST, + OFFICIAL_BUILD_ISSUER, +} from '../shared/trust-levels.js'; +import { verifyBuildAtStartup } from './verify-build.js'; +import { resolveEntitlementState } from './mesh-plus-license.js'; + +/** @type {{ buildTrust: string, meshPlusTrust: string, buildIssuer: string, buildVersion: string }} */ +let appTrustState = { + buildTrust: BUILD_TRUST.UNVERIFIED_BUILD, + meshPlusTrust: MESH_TRUST.UNVERIFIED_MESH_PLUS, + buildIssuer: '', + buildVersion: '', +}; + +/** + * @param {object} config + */ +export function initAppTrustState(config) { + const build = verifyBuildAtStartup(); + const buildTrust = build.verified + ? BUILD_TRUST.VERIFIED_OFFICIAL + : BUILD_TRUST.UNVERIFIED_BUILD; + + const licenseOk = resolveEntitlementState(config); + let meshPlusTrust = MESH_TRUST.UNVERIFIED_MESH_PLUS; + if (licenseOk) { + meshPlusTrust = + buildTrust === BUILD_TRUST.VERIFIED_OFFICIAL + ? MESH_TRUST.OFFICIAL_MESH_PLUS + : MESH_TRUST.UNVERIFIED_MESH_PLUS; + } + + appTrustState = { + buildTrust, + meshPlusTrust, + buildIssuer: build.issuer || '', + buildVersion: build.version || '', + }; + return getAppTrustState(); +} + +export function getAppTrustState() { + return { + buildTrust: appTrustState.buildTrust, + meshPlusTrust: appTrustState.meshPlusTrust, + }; +} + +export function getBuildAnnounceTrust() { + const official = appTrustState.buildTrust === BUILD_TRUST.VERIFIED_OFFICIAL; + return { + buildVerified: official, + buildIssuer: official ? OFFICIAL_BUILD_ISSUER : appTrustState.buildIssuer || 'unknown', + buildVersion: appTrustState.buildVersion || '', + meshPlusTrust: appTrustState.meshPlusTrust, + }; +} + +/** + * @param {object} data — UDP/mDNS announce + * @returns {string} BUILD_TRUST.* + */ +export function peerBuildTrustFromAnnounce(data) { + if (data?.buildVerified && String(data.buildIssuer) === OFFICIAL_BUILD_ISSUER) { + return BUILD_TRUST.VERIFIED_OFFICIAL; + } + return BUILD_TRUST.UNVERIFIED_BUILD; +} + +/** + * @param {object} data + * @returns {string | null} MESH_TRUST.* or null if peer has no MESH+ + */ +export function peerMeshPlusTrustFromAnnounce(data) { + if (!data?.meshPlus) return null; + if ( + data.buildVerified && + String(data.buildIssuer) === OFFICIAL_BUILD_ISSUER + ) { + return MESH_TRUST.OFFICIAL_MESH_PLUS; + } + return MESH_TRUST.UNVERIFIED_MESH_PLUS; +} + +/** + * Recompute meshPlusTrust after license change (build trust unchanged). + * @param {object} config + */ +export function refreshMeshPlusTrust(config) { + const licenseOk = resolveEntitlementState(config); + if (!licenseOk) { + appTrustState.meshPlusTrust = MESH_TRUST.UNVERIFIED_MESH_PLUS; + } else { + appTrustState.meshPlusTrust = + appTrustState.buildTrust === BUILD_TRUST.VERIFIED_OFFICIAL + ? MESH_TRUST.OFFICIAL_MESH_PLUS + : MESH_TRUST.UNVERIFIED_MESH_PLUS; + } + return getAppTrustState(); +} diff --git a/main/verify-build.js b/main/verify-build.js new file mode 100644 index 0000000..ddf3fe5 --- /dev/null +++ b/main/verify-build.js @@ -0,0 +1,81 @@ +import { existsSync, readFileSync } from 'fs'; +import { createHash } from 'crypto'; +import { join, dirname } from 'path'; +import { createRequire } from 'module'; +import { verifyCanonical } from './mesh-identity.js'; +import { TRUST_ANCHOR_B64 } from './mesh-plus-public-key.js'; +import { OFFICIAL_BUILD_ISSUER } from '../shared/trust-levels.js'; + +const require = createRequire(import.meta.url); + +function electronApp() { + try { + return require('electron')?.app ?? null; + } catch { + return null; + } +} + +function buildInfoPaths() { + const paths = []; + const app = electronApp(); + if (app?.isPackaged) { + paths.push(join(process.resourcesPath, 'build-info.json')); + try { + paths.push(join(dirname(app.getPath('exe')), 'build-info.json')); + } catch { + /* ignore */ + } + } + paths.push(join(process.cwd(), 'build', 'build-info.json')); + return paths; +} + +function loadBuildInfo() { + for (const p of buildInfoPaths()) { + try { + if (!existsSync(p)) continue; + const raw = readFileSync(p, 'utf8'); + const o = JSON.parse(raw); + if (o && typeof o === 'object' && o.buildSignature) return o; + } catch { + /* try next */ + } + } + return null; +} + +/** + * @param {object} info + * @returns {boolean} + */ +export function verifyBuildInfo(info) { + if (!info?.buildSignature || !info?.issuer || !info?.version) return false; + const pub = TRUST_ANCHOR_B64; + if (!pub) return false; + const ts = String(info.buildTimestamp ?? info.timestamp ?? ''); + const canonical = `BLIP|${info.version}|${info.issuer}|${ts}`; + return verifyCanonical(pub, canonical, info.buildSignature); +} + +/** @returns {{ verified: boolean, issuer: string, version: string, meshPlusPublicKeyHash: string }} */ +export function verifyBuildAtStartup() { + const info = loadBuildInfo(); + if (!info) { + return { verified: false, issuer: '', version: '', meshPlusPublicKeyHash: '' }; + } + const verified = + verifyBuildInfo(info) && String(info.issuer) === OFFICIAL_BUILD_ISSUER; + return { + verified, + issuer: String(info.issuer || ''), + version: String(info.version || ''), + meshPlusPublicKeyHash: String(info.meshPlusPublicKeyHash || ''), + }; +} + +/** @param {string} pubB64 */ +export function hashTrustAnchor(pubB64) { + if (!pubB64) return ''; + return createHash('sha256').update(pubB64, 'utf8').digest('hex').slice(0, 16); +} diff --git a/package-lock.json b/package-lock.json index a861628..b36b873 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "blip", - "version": "0.7.9", + "version": "1.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "blip", - "version": "0.7.9", + "version": "1.0.1", "hasInstallScript": true, "dependencies": { "electron-updater": "^6.8.3", @@ -18,6 +18,7 @@ "concurrently": "^9.1.2", "electron": "^35.1.2", "electron-builder": "^25.1.8", + "javascript-obfuscator": "^4.1.1", "sharp": "^0.34.1", "to-ico": "^1.1.5", "typeface-minecraft": "^1.0.0", @@ -1540,6 +1541,35 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/@javascript-obfuscator/escodegen": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@javascript-obfuscator/escodegen/-/escodegen-2.3.0.tgz", + "integrity": "sha512-QVXwMIKqYMl3KwtTirYIA6gOCiJ0ZDtptXqAv/8KWLG9uQU2fZqTVy7a/A5RvcoZhbDoFfveTxuGxJ5ibzQtkw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@javascript-obfuscator/estraverse": "^5.3.0", + "esprima": "^4.0.1", + "esutils": "^2.0.2", + "optionator": "^0.8.1" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/@javascript-obfuscator/estraverse": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@javascript-obfuscator/estraverse/-/estraverse-5.4.0.tgz", + "integrity": "sha512-CZFX7UZVN9VopGbjTx4UXaXsi9ewoM1buL0kY7j1ftYdSs7p2spv9opxFjHlQ/QGTgh4UqufYqJJ0WKLml7b6w==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, "node_modules/@leichtgewicht/ip-codec": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", @@ -2127,6 +2157,13 @@ "@types/node": "*" } }, + "node_modules/@types/minimatch": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.5.tgz", + "integrity": "sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", @@ -2173,6 +2210,13 @@ "@types/node": "*" } }, + "node_modules/@types/validator": { + "version": "13.15.10", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz", + "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/verror": { "version": "1.10.11", "resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.11.tgz", @@ -2216,6 +2260,19 @@ "dev": true, "license": "ISC" }, + "node_modules/acorn": { + "version": "8.8.2", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz", + "integrity": "sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -2519,6 +2576,26 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, + "node_modules/array-differ": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/array-differ/-/array-differ-3.0.0.tgz", + "integrity": "sha512-THtfYS6KtME/yIAhKjZ2ul7XI96lQGHRputJQHO80LAWQnuGP4iCIN8vdMRboGbIEYBwU33q8Tch1os2+X0kMg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/arrify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", @@ -2539,6 +2616,19 @@ "safer-buffer": "~2.1.0" } }, + "node_modules/assert": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/assert/-/assert-2.0.0.tgz", + "integrity": "sha512-se5Cd+js9dXJnu6Ag2JFc00t+HmHOen+8Q+L7O9zI0PqQXr20uk2J0XQqMxZEeo5U50o8Nvmmx7dZrl+Ufr35A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es6-object-assign": "^1.1.0", + "is-nan": "^1.2.1", + "object-is": "^1.0.1", + "util": "^0.12.0" + } + }, "node_modules/assert-plus": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", @@ -2606,6 +2696,22 @@ "node": ">= 4.5.0" } }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/aws-sign2": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", @@ -3068,6 +3174,25 @@ "node": ">=8" } }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -3082,6 +3207,23 @@ "node": ">= 0.4" } }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/canvg": { "version": "3.0.11", "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz", @@ -3149,6 +3291,33 @@ "node": ">=8" } }, + "node_modules/chance": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/chance/-/chance-1.1.9.tgz", + "integrity": "sha512-TfxnA/DcZXRTA4OekA2zL9GH8qscbbl6X0ZqU4tXhGveVY/mXWvEQLt5GwZcYXTEyEFflVtj+pG8nc8EwSm1RQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/charenc": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", + "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, "node_modules/chownr": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", @@ -3182,6 +3351,18 @@ "node": ">=8" } }, + "node_modules/class-validator": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.1.tgz", + "integrity": "sha512-2VEG9JICxIqTpoK1eMzZqaV+u/EiwEJkMGzTrZf6sU/fwsnOITVgYJ8yojSy6CaXtO9V0Cc6ZQZ8h8m4UBuLwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/validator": "^13.11.8", + "libphonenumber-js": "^1.10.53", + "validator": "^13.9.0" + } + }, "node_modules/clean-stack": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", @@ -3543,6 +3724,16 @@ "node": ">= 8" } }, + "node_modules/crypt": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", + "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, "node_modules/css-line-break": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", @@ -3612,6 +3803,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/defaults": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", @@ -3641,7 +3839,6 @@ "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", @@ -3660,7 +3857,6 @@ "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", @@ -4306,6 +4502,13 @@ "license": "MIT", "optional": true }, + "node_modules/es6-object-assign": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es6-object-assign/-/es6-object-assign-1.1.0.tgz", + "integrity": "sha512-MEl9uirslVwqQU369iHNWZXsI8yaZYGg/D65aOgZkeyFJwHYSxilf7rQzXKI7DdDuBPrBXbfk3sl9hJhmd5AUw==", + "dev": true, + "license": "MIT" + }, "node_modules/es6-promise": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-3.3.1.tgz", @@ -4379,6 +4582,77 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/eslint-scope": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.1.1.tgz", + "integrity": "sha512-QKQM/UXpIiHcLqJ5AOyIW7XZmzjkzQXYE54n1++wb0u9V/abW3l9uQnxX8Z5Xd18xyKIMTUAyQ0k1e8pz6LUrw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz", + "integrity": "sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/exif-parser": { "version": "0.1.12", "resolved": "https://registry.npmjs.org/exif-parser/-/exif-parser-0.1.12.tgz", @@ -4445,6 +4719,13 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, "node_modules/fd-slicer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", @@ -4550,6 +4831,22 @@ } } }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -4696,6 +4993,16 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -4972,7 +5279,6 @@ "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { "es-define-property": "^1.0.0" }, @@ -5242,6 +5548,13 @@ "dev": true, "license": "ISC" }, + "node_modules/inversify": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/inversify/-/inversify-6.0.1.tgz", + "integrity": "sha512-B3ex30927698TJENHR++8FfEaJGqoWOgI6ZY5Ht/nLUsFCwHn6akbwtnUAPCgUepAnTpe2qHxhDNjoKLyz6rgQ==", + "dev": true, + "license": "MIT" + }, "node_modules/ip-address": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", @@ -5262,6 +5575,43 @@ "node": ">=0.10.0" } }, + "node_modules/is-arguments": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-ci": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-3.0.1.tgz", @@ -5292,6 +5642,26 @@ "dev": true, "license": "MIT" }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-interactive": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", @@ -5309,6 +5679,58 @@ "dev": true, "license": "MIT" }, + "node_modules/is-nan": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/is-nan/-/is-nan-1.3.2.tgz", + "integrity": "sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.0", + "define-properties": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-typedarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", @@ -5398,6 +5820,82 @@ "node": ">=10" } }, + "node_modules/javascript-obfuscator": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/javascript-obfuscator/-/javascript-obfuscator-4.1.1.tgz", + "integrity": "sha512-gt+KZpIIrrxXHEQGD8xZrL8mTRwRY0U76/xz/YX0gZdPrSqQhT/c7dYLASlLlecT3r+FxE7je/+C0oLnTDCx4A==", + "dev": true, + "hasInstallScript": true, + "license": "BSD-2-Clause", + "dependencies": { + "@javascript-obfuscator/escodegen": "2.3.0", + "@javascript-obfuscator/estraverse": "5.4.0", + "acorn": "8.8.2", + "assert": "2.0.0", + "chalk": "4.1.2", + "chance": "1.1.9", + "class-validator": "0.14.1", + "commander": "10.0.0", + "eslint-scope": "7.1.1", + "eslint-visitor-keys": "3.3.0", + "fast-deep-equal": "3.1.3", + "inversify": "6.0.1", + "js-string-escape": "1.0.1", + "md5": "2.3.0", + "mkdirp": "2.1.3", + "multimatch": "5.0.0", + "opencollective-postinstall": "2.0.3", + "process": "0.11.10", + "reflect-metadata": "0.1.13", + "source-map-support": "0.5.21", + "string-template": "1.0.0", + "stringz": "2.1.0", + "tslib": "2.5.0" + }, + "bin": { + "javascript-obfuscator": "bin/javascript-obfuscator" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/javascript-obfuscator" + } + }, + "node_modules/javascript-obfuscator/node_modules/commander": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.0.tgz", + "integrity": "sha512-zS5PnTI22FIRM6ylNW8G4Ap0IEOyk62fhLSD0+uHRT9McRCLGpkVNvao4bjimpK/GShynyQkFFxHhwMcETmduA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/javascript-obfuscator/node_modules/mkdirp": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-2.1.3.tgz", + "integrity": "sha512-sjAkg21peAG9HS+Dkx7hlG9Ztx7HLeKnvB3NQRcu/mltCVmvkF0pisbiTSfDVYTT86XEfZrTUosLdZLStquZUw==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/javascript-obfuscator/node_modules/tslib": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz", + "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==", + "dev": true, + "license": "0BSD" + }, "node_modules/jimp": { "version": "0.2.28", "resolved": "https://registry.npmjs.org/jimp/-/jimp-0.2.28.tgz", @@ -5497,6 +5995,16 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/js-string-escape": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/js-string-escape/-/js-string-escape-1.0.1.tgz", + "integrity": "sha512-Smw4xcfIQ5LVjAOuJCvN/zIodzA/BBSsluuoSykP+lUvScIi4U6RJLfwHet5cxFnCswUjISV8oAXaqaJDY3chg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/js-yaml": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", @@ -5692,6 +6200,27 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/levn": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", + "integrity": "sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/libphonenumber-js": { + "version": "1.13.3", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.13.3.tgz", + "integrity": "sha512-xMkdAMqcyG7iN2WZZmGIfWbYxW4orRkny+0/AXIbwL0xll2zkDX0Vzo/BXFa6+7mh2UvJl9MbcTtHk0YXkFtBA==", + "dev": true, + "license": "MIT" + }, "node_modules/load-bmfont": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/load-bmfont/-/load-bmfont-1.4.2.tgz", @@ -5926,6 +6455,18 @@ "node": ">= 0.4" } }, + "node_modules/md5": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", + "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "charenc": "0.0.2", + "crypt": "0.0.2", + "is-buffer": "~1.1.6" + } + }, "node_modules/mime": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", @@ -6147,6 +6688,67 @@ "multicast-dns": "cli.js" } }, + "node_modules/multimatch": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/multimatch/-/multimatch-5.0.0.tgz", + "integrity": "sha512-ypMKuglUrZUD99Tk2bUQ+xNQj43lPEfAeX2o9cTteAmShXy2VHDJpuwu1o0xqoKCt9jLVAvwyFKdLTPXKAfJyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/minimatch": "^3.0.3", + "array-differ": "^3.0.0", + "array-union": "^2.1.0", + "arrify": "^2.0.1", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/multimatch/node_modules/arrify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz", + "integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/multimatch/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/multimatch/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/multimatch/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/nanoid": { "version": "3.3.12", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", @@ -6349,13 +6951,29 @@ "node": ">=0.10.0" } }, + "node_modules/object-is": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", + "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/object-keys": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", "dev": true, "license": "MIT", - "optional": true, "engines": { "node": ">= 0.4" } @@ -6386,6 +7004,34 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/opencollective-postinstall": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz", + "integrity": "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==", + "dev": true, + "license": "MIT", + "bin": { + "opencollective-postinstall": "index.js" + } + }, + "node_modules/optionator": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz", + "integrity": "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "~0.1.3", + "fast-levenshtein": "~2.0.6", + "levn": "~0.3.0", + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2", + "word-wrap": "~1.2.3" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/ora": { "version": "5.4.1", "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", @@ -6682,6 +7328,16 @@ "node": ">=4.0.0" } }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/postcss": { "version": "8.5.14", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", @@ -6711,6 +7367,15 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prelude-ls": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", + "integrity": "sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/process": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", @@ -6919,6 +7584,13 @@ "node": ">=10" } }, + "node_modules/reflect-metadata": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.13.tgz", + "integrity": "sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/regenerator-runtime": { "version": "0.13.11", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", @@ -7200,6 +7872,24 @@ ], "license": "MIT" }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -7268,6 +7958,24 @@ "dev": true, "license": "ISC" }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/sharp": { "version": "0.34.5", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", @@ -7596,6 +8304,13 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/string-template": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string-template/-/string-template-1.0.0.tgz", + "integrity": "sha512-SLqR3GBUXuoPP5MmYtD7ompvXiG87QjT6lzOszyXjTM86Uu7At7vNnt2xgyTLq5o9T4IxTYFyGxcULqpsmsfdg==", + "dev": true, + "license": "MIT" + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -7627,6 +8342,16 @@ "node": ">=8" } }, + "node_modules/stringz": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/stringz/-/stringz-2.1.0.tgz", + "integrity": "sha512-KlywLT+MZ+v0IRepfMxRtnSvDCMc3nR1qqCs3m/qIbSOWkNZYT8XHQA31rS3TnKp0c5xjZu3M4GY/2aRKSi/6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "char-regex": "^1.0.2" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -7933,6 +8658,19 @@ "dev": true, "license": "Unlicense" }, + "node_modules/type-check": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", + "integrity": "sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/type-fest": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", @@ -8041,6 +8779,20 @@ "dev": true, "license": "(WTFPL OR MIT)" }, + "node_modules/util": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", + "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "is-arguments": "^1.0.4", + "is-generator-function": "^1.0.7", + "is-typed-array": "^1.1.3", + "which-typed-array": "^1.1.2" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -8069,6 +8821,16 @@ "uuid": "bin/uuid" } }, + "node_modules/validator": { + "version": "13.15.35", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz", + "integrity": "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/verror": { "version": "1.10.1", "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz", @@ -8206,6 +8968,28 @@ "node": ">= 8" } }, + "node_modules/which-typed-array": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/wide-align": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", @@ -8216,6 +9000,16 @@ "string-width": "^1.0.2 || 2 || 3 || 4" } }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", diff --git a/package.json b/package.json index 0f381e1..c23d197 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "blip", - "version": "1.0.0", + "version": "1.0.1", "description": "P2P LAN messenger — text, voice, and video. No cloud, no servers, no accounts.", "author": "krwg", "homepage": "https://krwg.github.io/BLIP/", @@ -16,7 +16,7 @@ "scripts": { "dev": "vite", "copy-fonts": "node scripts/copy-fonts.mjs", - "prebuild": "npm run copy-fonts && node scripts/sync-app-metadata.mjs", + "prebuild": "npm run copy-fonts && node scripts/sync-app-metadata.mjs && node scripts/sync-giphy-key.mjs && node scripts/sync-mesh-public-key.mjs && node scripts/sync-build-info.mjs", "build": "vite build", "postinstall": "npm run copy-fonts", "prestart": "npm run build", @@ -24,17 +24,19 @@ "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", - "electron:build:all": "npm run build && npm run build:icons && electron-builder --win", - "electron:build:dir": "npm run build && npm run build:icons && electron-builder --win --dir", - "mesh-plus:keygen": "node scripts/mesh-plus-keygen.mjs" + "electron:build": "npm run build && node scripts/sync-giphy-key.mjs && node scripts/sync-mesh-public-key.mjs && node scripts/sync-build-info.mjs && npm run build:icons && electron-builder --win nsis", + "electron:build:portable": "npm run build && node scripts/sync-giphy-key.mjs && node scripts/sync-mesh-public-key.mjs && node scripts/sync-build-info.mjs && npm run build:icons && electron-builder --win portable", + "electron:build:all": "npm run build && node scripts/sync-giphy-key.mjs && node scripts/sync-mesh-public-key.mjs && node scripts/sync-build-info.mjs && npm run build:icons && electron-builder --win", + "electron:build:dir": "npm run build && node scripts/sync-giphy-key.mjs && node scripts/sync-mesh-public-key.mjs && node scripts/sync-build-info.mjs && npm run build:icons && electron-builder --win --dir", + "mesh-plus:keygen": "node scripts/mesh-plus-keygen.mjs", + "setup:build-secrets": "node scripts/setup-build-secrets.mjs" }, "devDependencies": { "@fontsource/press-start-2p": "^5.2.7", "concurrently": "^9.1.2", "electron": "^35.1.2", "electron-builder": "^25.1.8", + "javascript-obfuscator": "^4.1.1", "sharp": "^0.34.1", "to-ico": "^1.1.5", "typeface-minecraft": "^1.0.0", diff --git a/preload.cjs b/preload.cjs index e61fcc6..1d1c07f 100644 --- a/preload.cjs +++ b/preload.cjs @@ -3,12 +3,34 @@ const { contextBridge, ipcRenderer } = require('electron'); let groupCallActive = false; let groupCallGroupId = null; +const trustStateLive = { + buildTrust: 'unverified_build', + meshPlusTrust: 'unverified_mesh_plus', +}; + +ipcRenderer.invoke('get-trust-state').then((t) => { + if (t && typeof t === 'object') Object.assign(trustStateLive, t); +}); + +ipcRenderer.on('trust-state', (_, t) => { + if (t && typeof t === 'object') Object.assign(trustStateLive, t); +}); + +contextBridge.exposeInMainWorld('trustState', trustStateLive); + contextBridge.exposeInMainWorld('blip', { platform: process.platform, + getTrustState: () => ipcRenderer.invoke('get-trust-state'), + onTrustState: (cb) => { + const handler = (_, data) => cb(data); + ipcRenderer.on('trust-state', handler); + return () => ipcRenderer.removeListener('trust-state', handler); + }, getConfig: () => ipcRenderer.invoke('get-config'), saveConfig: (config) => ipcRenderer.invoke('save-config', config), activateMeshPlus: (key) => ipcRenderer.invoke('activate-mesh-plus', key), deactivateMeshPlus: () => ipcRenderer.invoke('deactivate-mesh-plus'), + factoryReset: () => ipcRenderer.invoke('factory-reset'), getMeshPlusStatus: () => ipcRenderer.invoke('get-mesh-plus-status'), getAppMetadata: () => ipcRenderer.invoke('get-app-metadata'), getAppIconUrl: () => ipcRenderer.invoke('get-app-icon-url'), @@ -18,6 +40,7 @@ contextBridge.exposeInMainWorld('blip', { saveAvatar: (dataUrl) => ipcRenderer.invoke('save-avatar', dataUrl), clearAvatar: () => ipcRenderer.invoke('clear-avatar'), getProfileGifActiveUrl: () => ipcRenderer.invoke('get-profile-gif-active-url'), + getProfileGifShareUrl: () => ipcRenderer.invoke('get-profile-gif-share-url'), getProfileGifHistory: () => ipcRenderer.invoke('get-profile-gif-history'), saveProfileGif: (dataUrl) => ipcRenderer.invoke('save-profile-gif', dataUrl), saveProfileGifBytes: (base64) => ipcRenderer.invoke('save-profile-gif-bytes', base64), diff --git a/renderer/achievements.js b/renderer/achievements.js index 7e21d41..23bf1b8 100644 --- a/renderer/achievements.js +++ b/renderer/achievements.js @@ -1,7 +1,7 @@ import { t } from './i18n.js'; import { getSessionStats, sessionOnlineHours } from './session-stats.js'; import { isAchievementUnlocked } from './achievements-store.js'; -import { isMeshPlusActive } from './mesh-plus.js'; +import { premiumTierEnabled } from './mesh-plus.js'; import { ACHIEVEMENT_ICON_BY_ID } from './achievements-icons.js'; import { showAppToast } from './toasts.js'; @@ -135,7 +135,7 @@ export const ACHIEVEMENT_DEFS = [ titleKey: 'achievements.mesh_plus_active', descKey: 'achievements.mesh_plus_active_desc', iconAsset: achievementIconUrl('mesh_plus_active'), - checkConfig: (cfg) => isMeshPlusActive(cfg), + checkConfig: (cfg) => premiumTierEnabled(cfg), }, ]; diff --git a/renderer/app-icon-picker.js b/renderer/app-icon-picker.js index e728320..14a2e09 100644 --- a/renderer/app-icon-picker.js +++ b/renderer/app-icon-picker.js @@ -1,5 +1,5 @@ import { t } from './i18n.js'; -import { isMeshPlusActive } from './mesh-plus.js'; +import { premiumTierEnabled } from './mesh-plus.js'; import { showAppToast } from './toasts.js'; import { buildSectionSubtitleRow } from './settings-ui.js'; @@ -78,7 +78,7 @@ export function appendAppIconPickerSections(block, state, saveConfig) { btn.type = 'button'; const selected = (state.config?.appIconVariant || 'main') === id; btn.className = `settings-app-icon-tile${selected ? ' selected' : ''}${ - tier === 'mesh_plus' && !isMeshPlusActive(state.config) + tier === 'mesh_plus' && !premiumTierEnabled(state.config) ? ' settings-app-icon-tile--locked' : '' }`; @@ -100,7 +100,7 @@ export function appendAppIconPickerSections(block, state, saveConfig) { }); btn.addEventListener('click', async () => { - if (tier === 'mesh_plus' && !isMeshPlusActive(state.config)) { + if (tier === 'mesh_plus' && !premiumTierEnabled(state.config)) { showAppToast({ title: t('settings.app_icon_mesh_locked'), durationMs: 4200, diff --git a/renderer/appearance.js b/renderer/appearance.js index 8e8e690..4333bd9 100644 --- a/renderer/appearance.js +++ b/renderer/appearance.js @@ -3,7 +3,7 @@ */ import { t } from './i18n.js'; -import { isMeshPlusTierActive } from '../shared/mesh-plus-gates.js'; +import { uiShowsPremiumTier } from '../shared/mesh-plus-gates.js'; export const THEME_MODES = ['light', 'dark', 'auto']; @@ -140,7 +140,7 @@ export function normalizeCustomAccentHex(raw) { function applyCustomAccentVars(html, config) { const hex = normalizeCustomAccentHex(config?.accentCustomHex); - if (isMeshPlusTierActive(config) && hex) { + if (uiShowsPremiumTier(config) && hex) { html.style.setProperty('--blip-accent', hex); html.style.setProperty('--blip-glass-border', hex); html.style.setProperty('--blip-caret', hex); diff --git a/renderer/call-window-main.js b/renderer/call-window-main.js index 150cb47..a75b9e7 100644 --- a/renderer/call-window-main.js +++ b/renderer/call-window-main.js @@ -5,10 +5,13 @@ import { setLang, applyI18n, onLangChange } from './i18n.js'; import { createCallUI } from './call.js'; import { applyCallWindowAppearance, listenReducedMotion } from './appearance.js'; import { setSoundPrefs } from './audio.js'; +import { setLocalTrustState } from './trust-ui.js'; let callAppearanceRm = null; let callUI = null; let liveConfig = null; +/** @type {object | null} */ +let remotePeerTrust = null; const api = { getConfig: () => window.blip.getConfig(), @@ -59,6 +62,10 @@ async function boot() { } const config = await window.blip.getConfig(); + if (window.blip?.getTrustState) { + setLocalTrustState(await window.blip.getTrustState()); + } + window.blip?.onTrustState?.((trust) => setLocalTrustState(trust)); setSoundPrefs({ enabled: config.uiSoundsEnabled !== false && config.doNotDisturb !== true, volume: typeof config.uiSoundsVolume === 'number' ? config.uiSoundsVolume : 1, @@ -75,6 +82,7 @@ async function boot() { onClosed: () => { window.blip.closeCallWindow?.(); }, + getRemotePeer: () => remotePeerTrust, }); root.appendChild(callUI.el); @@ -91,13 +99,31 @@ async function boot() { onLangChange(() => applyI18n(document)); window.blip.onConfigUpdated?.((cfg) => applyCallWindowChrome(cfg)); + async function cacheRemotePeer(peerId) { + try { + const { peers } = await window.blip.getPeers(); + remotePeerTrust = + peers.find((p) => Number(p.blipId) === Number(peerId)) || { + blipId: peerId, + buildTrust: 'unverified_build', + }; + callUI?.refreshCallAvatar?.(); + } catch { + remotePeerTrust = { blipId: peerId, buildTrust: 'unverified_build' }; + } + } + window.blip.onCallOutgoing?.((payload) => { const peerId = payload?.peerId; const video = !!payload?.video; - if (peerId) callUI.startOutgoing(peerId, video); + if (peerId) { + void cacheRemotePeer(peerId); + callUI.startOutgoing(peerId, video); + } }); window.blip.onIncomingCall((data) => { + if (data?.from) void cacheRemotePeer(data.from); callUI.handleIncoming(data); }); window.blip.onCallAnswer((data) => { diff --git a/renderer/call-window.html b/renderer/call-window.html index 79a9d91..ae507f0 100644 --- a/renderer/call-window.html +++ b/renderer/call-window.html @@ -7,6 +7,7 @@ BLIP — Call + - + + + -
      + + + -
      -
      -
      v1.0.3 Echo · LAN · GPLv3
      -

      Ты в сети. Ты сигнал.

      +
      +
      +
      v1.1.0 Beacon · LAN · GPLv3
      +

      + +

      Десктоп-мессенджер для LAN / Hamachi / Radmin VPN: чат, группы, голосовые каналы, звонки 1:1 и групповые — без облака. UDP + TCP + WebRTC, BLIP ID 1–64. @@ -322,24 +777,22 @@

      Ты в сети. Ты сигнал. Copy

      @@ -347,32 +800,62 @@

      Ты в сети. Ты сигнал.

      -
      +
      +

      P2P LAN

      UDP broadcast + mDNS, TCP между пирами, Mesh Handshake (Ed25519). Без регистрации и без серверов.

      +
      +

      Звонки

      Окна 1:1 и группового звонка, голосовые каналы в группах, демонстрация экрана 720p+, chiptune СИГНАЛ / ПУЛЬС.

      +
      +

      Чат

      Цитаты, медиа-просмотр и YouTube в чате, реакции, Ctrl+F, файлы до 100 ГБ, буфер обмена по LAN.

      +
      +

      Облик

      Свет / тьма / авто, 16 акцентов (Сланец), арт-обои, своё фото профиля по LAN, пиксельные переключатели.

      +
      +

      Mesh Pulse

      Пинг раз в минуту, задержка под абонентом, статус-текст в LAN, избранные и блокировка в настройках.

      +
      +

      Стек

      Electron 35, Vite 6, vanilla JS/CSS. Windows — NSIS и portable; полный интерфейс EN / RU.

      @@ -388,24 +871,26 @@

      Стек

      + + - + \ No newline at end of file diff --git a/main/beacon-store.js b/main/beacon-store.js index 19040a6..b34ac32 100644 --- a/main/beacon-store.js +++ b/main/beacon-store.js @@ -1,6 +1,14 @@ -import { app } from 'electron'; +import { app, dialog } from 'electron'; import { join } from 'path'; -import { mkdir } from 'fs/promises'; +import { + mkdir, + readFile, + writeFile, + readdir, + access, + open, +} from 'fs/promises'; +import { constants } from 'fs'; export function getBeaconSeedsRoot() { return join(app.getPath('userData'), 'seeds'); @@ -13,8 +21,108 @@ export function getSeedDir(seedId) { return join(getBeaconSeedsRoot(), safe || 'unknown'); } +function chunkPath(seedDir, chunkIndex) { + return join(seedDir, `chunk-${String(chunkIndex).padStart(5, '0')}`); +} + export async function ensureBeaconSeedsRoot() { const root = getBeaconSeedsRoot(); await mkdir(root, { recursive: true }); return root; } + +export async function writeSeedMeta(seedId, meta) { + const dir = getSeedDir(seedId); + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, 'meta.json'), JSON.stringify(meta, null, 2), 'utf8'); +} + +export async function readSeedMeta(seedId) { + try { + const raw = await readFile(join(getSeedDir(seedId), 'meta.json'), 'utf8'); + return JSON.parse(raw); + } catch { + return null; + } +} + +export async function writeSeedChunk(seedId, chunkIndex, base64Data) { + const dir = getSeedDir(seedId); + await mkdir(dir, { recursive: true }); + const buf = Buffer.from(String(base64Data || ''), 'base64'); + await writeFile(chunkPath(dir, chunkIndex), buf); +} + +export async function readSeedChunk(seedId, chunkIndex) { + const buf = await readFile(chunkPath(getSeedDir(seedId), chunkIndex)); + return buf.toString('base64'); +} + +export async function chunkExists(seedId, chunkIndex) { + try { + await access(chunkPath(getSeedDir(seedId), chunkIndex), constants.F_OK); + return true; + } catch { + return false; + } +} + +export async function countLocalChunks(seedId, totalChunks) { + let n = 0; + for (let i = 0; i < totalChunks; i++) { + if (await chunkExists(seedId, i)) n++; + } + return n; +} + +export async function listLocalSeedMetas() { + const root = await ensureBeaconSeedsRoot(); + let entries = []; + try { + entries = await readdir(root, { withFileTypes: true }); + } catch { + return []; + } + const metas = []; + for (const e of entries) { + if (!e.isDirectory()) continue; + const meta = await readSeedMeta(e.name); + if (meta) metas.push(meta); + } + return metas; +} + +export async function assembleSeedToPath(seedId, filePath) { + const meta = await readSeedMeta(seedId); + if (!meta) throw new Error('no_meta'); + const total = Number(meta.totalChunks) || 0; + for (let i = 0; i < total; i++) { + if (!(await chunkExists(seedId, i))) throw new Error('incomplete'); + } + const fh = await open(filePath, 'w'); + try { + for (let i = 0; i < total; i++) { + const buf = await readFile(chunkPath(getSeedDir(seedId), i)); + await fh.write(buf); + } + } finally { + await fh.close(); + } +} + +export async function promptSaveAssembledSeed(seedId, defaultName) { + const meta = await readSeedMeta(seedId); + if (!meta) return { ok: false, error: 'no_meta' }; + const total = Number(meta.totalChunks) || 0; + for (let i = 0; i < total; i++) { + if (!(await chunkExists(seedId, i))) { + return { ok: false, error: 'incomplete' }; + } + } + const { canceled, filePath } = await dialog.showSaveDialog({ + defaultPath: defaultName || meta.filename || 'download', + }); + if (canceled || !filePath) return { ok: false, cancelled: true }; + await assembleSeedToPath(seedId, filePath); + return { ok: true, filePath }; +} diff --git a/main/index.js b/main/index.js index 8a49666..08b47b4 100644 --- a/main/index.js +++ b/main/index.js @@ -4,7 +4,18 @@ import { pathToFileURL } from 'url'; import { fileURLToPath } from 'url'; import { existsSync, readFileSync } from 'fs'; import { Discovery } from './discovery.js'; -import { ensureBeaconSeedsRoot, getBeaconSeedsRoot } from './beacon-store.js'; +import { + ensureBeaconSeedsRoot, + getBeaconSeedsRoot, + writeSeedMeta, + readSeedMeta, + writeSeedChunk, + readSeedChunk, + chunkExists, + countLocalChunks, + listLocalSeedMetas, + promptSaveAssembledSeed, +} from './beacon-store.js'; import { createTcpServer } from './tcp-server.js'; import { connectToPeer, sendOnSocket, pingPeer } from './tcp-client.js'; import { createTcpLineReader } from './tcp-framing.js'; @@ -1002,6 +1013,53 @@ function setupIpc() { return true; }); + ipcMain.handle('beacon-write-meta', async (_, { seedId, meta }) => { + if (!seedId || !meta) return { ok: false }; + await writeSeedMeta(seedId, meta); + return { ok: true }; + }); + + ipcMain.handle('beacon-read-meta', async (_, { seedId }) => { + if (!seedId) return null; + return readSeedMeta(seedId); + }); + + ipcMain.handle('beacon-write-chunk', async (_, { seedId, chunkIndex, data }) => { + if (!seedId || chunkIndex == null || !data) return { ok: false }; + await writeSeedChunk(seedId, Number(chunkIndex), data); + return { ok: true }; + }); + + ipcMain.handle('beacon-read-chunk', async (_, { seedId, chunkIndex }) => { + if (!seedId || chunkIndex == null) return { ok: false }; + try { + const data = await readSeedChunk(seedId, Number(chunkIndex)); + return { ok: true, data }; + } catch { + return { ok: false }; + } + }); + + ipcMain.handle('beacon-chunk-exists', async (_, { seedId, chunkIndex }) => { + if (!seedId || chunkIndex == null) return false; + return chunkExists(seedId, Number(chunkIndex)); + }); + + ipcMain.handle('beacon-count-chunks', async (_, { seedId, totalChunks }) => { + if (!seedId) return 0; + return countLocalChunks(seedId, Number(totalChunks) || 0); + }); + + ipcMain.handle('beacon-list-local', async () => listLocalSeedMetas()); + + ipcMain.handle('beacon-save-assembled', async (_, { seedId, defaultName }) => { + try { + return await promptSaveAssembledSeed(seedId, defaultName); + } catch (err) { + return { ok: false, error: err?.message || 'save_failed' }; + } + }); + ipcMain.handle('send-tcp-message', async (_, payload) => { try { const socket = await ensurePeerSocket(payload.to); diff --git a/preload.cjs b/preload.cjs index 02c36d0..ba111ee 100644 --- a/preload.cjs +++ b/preload.cjs @@ -95,6 +95,14 @@ contextBridge.exposeInMainWorld('blip', { }, beaconSendUdp: (payload) => ipcRenderer.invoke('beacon-udp-send', payload), getBeaconPaths: () => ipcRenderer.invoke('beacon-paths'), + beaconWriteMeta: (payload) => ipcRenderer.invoke('beacon-write-meta', payload), + beaconReadMeta: (payload) => ipcRenderer.invoke('beacon-read-meta', payload), + beaconWriteChunk: (payload) => ipcRenderer.invoke('beacon-write-chunk', payload), + beaconReadChunk: (payload) => ipcRenderer.invoke('beacon-read-chunk', payload), + beaconChunkExists: (payload) => ipcRenderer.invoke('beacon-chunk-exists', payload), + beaconCountChunks: (payload) => ipcRenderer.invoke('beacon-count-chunks', payload), + beaconListLocal: () => ipcRenderer.invoke('beacon-list-local'), + beaconSaveAssembled: (payload) => ipcRenderer.invoke('beacon-save-assembled', payload), onNotificationOpenChat: (cb) => { const handler = (_, peerId) => cb(peerId); ipcRenderer.on('notification-open-chat', handler); diff --git a/renderer/beacon-mesh.js b/renderer/beacon-mesh.js index 9c56f2c..e840211 100644 --- a/renderer/beacon-mesh.js +++ b/renderer/beacon-mesh.js @@ -1,16 +1,52 @@ /** - * BEACON / Mesh Library — catalog, UDP announce/pulse, TCP chunk protocol (1.1.0). + * BEACON / Mesh Library — catalog, seeding, parallel chunk download (1.1.0). */ +import { getMaxFileBytes } from './file-transfer-limits.js'; + export const BEACON_CHUNK_SIZE = 262144; const PULSE_INTERVAL_MS = 30_000; +const CHUNK_REQUEST_BATCH = 8; +const MAX_PARALLEL_PEERS = 3; +const CHUNK_TIMEOUT_MS = 20_000; /** @type {Map, leechers?: number, updatedAt: number }>} */ const catalog = new Map(); +/** @type {Set} */ +const localComplete = new Set(); + +/** @type {Map} */ +const jobProgress = new Map(); + +/** @type {Map void, reject: (err: Error) => void, timer: ReturnType }>} */ +const pendingChunks = new Map(); + let pulseTimer = null; let meshApi = null; let getConfig = () => ({}); +let getPeers = () => []; +let getPeerLatency = () => 9999; + +function emitCatalog() { + window.dispatchEvent(new CustomEvent('blip-beacon-catalog')); +} + +function emitProgress(seedId) { + window.dispatchEvent( + new CustomEvent('blip-beacon-progress', { detail: { seedId, ...(jobProgress.get(seedId) || {}) } }) + ); +} + +function setJobProgress(seedId, progress, phase) { + jobProgress.set(seedId, { progress, phase }); + emitProgress(seedId); +} + +function clearJobProgress(seedId) { + jobProgress.delete(seedId); + emitProgress(seedId); +} function upsertCatalogEntry(data) { const seedId = String(data.seedId || '').trim(); @@ -45,18 +81,18 @@ function ingestSeedUdp(data) { switch (data.type) { case 'seed-announce': upsertCatalogEntry(data); - window.dispatchEvent(new CustomEvent('blip-beacon-catalog')); + emitCatalog(); break; case 'seed-pulse': if (Array.isArray(data.seeds)) { for (const s of data.seeds) upsertCatalogEntry({ ...s, blipId: s.blipId ?? data.blipId }); } - window.dispatchEvent(new CustomEvent('blip-beacon-catalog')); + emitCatalog(); break; case 'seed-gone': - if (data.seedId) { + if (data.seedId && !localComplete.has(String(data.seedId))) { catalog.delete(String(data.seedId)); - window.dispatchEvent(new CustomEvent('blip-beacon-catalog')); + emitCatalog(); } break; default: @@ -76,13 +112,16 @@ function startBeaconPulse() { const cfg = getConfig(); if (!cfg?.devBeaconEnabled) return; pulseTimer = setInterval(() => { - const seeds = [...catalog.values()] - .filter((e) => e.seeders.has(cfg.blipId)) - .map((e) => ({ - seedId: e.seedId, - leechers: e.leechers || 0, - blipId: cfg.blipId, - })); + const seeds = [...localComplete] + .map((seedId) => { + const entry = catalog.get(seedId); + return { + seedId, + leechers: entry?.leechers || 0, + blipId: cfg.blipId, + }; + }) + .filter(Boolean); if (!seeds.length || !meshApi?.beaconSendUdp) return; void meshApi.beaconSendUdp({ type: 'seed-pulse', @@ -93,32 +132,232 @@ function startBeaconPulse() { }, PULSE_INTERVAL_MS); } +async function reconcileLocalSeeds() { + for (const seedId of [...localComplete]) { + const meta = await meshApi?.beaconReadMeta?.({ seedId }); + if (!meta?.seedId) { + localComplete.delete(seedId); + continue; + } + const have = await meshApi.beaconCountChunks?.({ + seedId, + totalChunks: meta.totalChunks, + }); + if (have < meta.totalChunks) localComplete.delete(seedId); + } +} + +async function ensureLocalMeta(seedId, entry) { + let meta = await meshApi?.beaconReadMeta?.({ seedId }); + if (meta?.seedId) return meta; + meta = { + seedId, + filename: entry.filename || 'file', + size: entry.size || 0, + chunkSize: entry.chunkSize || BEACON_CHUNK_SIZE, + totalChunks: entry.totalChunks || 0, + mime: 'application/octet-stream', + publishedAt: Date.now(), + }; + const res = await meshApi?.beaconWriteMeta?.({ seedId, meta }); + if (res?.ok === false) throw new Error('no_meta'); + return meta; +} + +async function ipcWriteChunk(seedId, chunkIndex, data) { + const res = await meshApi?.beaconWriteChunk?.({ seedId, chunkIndex, data }); + if (res?.ok === false) throw new Error('chunk_write_failed'); +} + +async function ipcWriteMeta(seedId, meta) { + const res = await meshApi?.beaconWriteMeta?.({ seedId, meta }); + if (res?.ok === false) throw new Error('no_meta'); +} + +async function loadLocalSeeds() { + const list = await meshApi?.beaconListLocal?.(); + if (!Array.isArray(list)) return; + const cfg = getConfig(); + for (const meta of list) { + if (!meta?.seedId) continue; + upsertCatalogEntry({ ...meta, blipId: cfg.blipId }); + const have = await meshApi.beaconCountChunks?.({ + seedId: meta.seedId, + totalChunks: meta.totalChunks, + }); + if (have >= meta.totalChunks) localComplete.add(meta.seedId); + } + await reconcileLocalSeeds(); + emitCatalog(); +} + +export async function refreshBeaconLocalState() { + await reconcileLocalSeeds(); + emitCatalog(); +} + +function readFileSliceAsBase64(file, start, end) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const result = reader.result; + if (typeof result !== 'string') { + reject(new Error('read')); + return; + } + const comma = result.indexOf(','); + resolve(comma >= 0 ? result.slice(comma + 1) : result); + }; + reader.onerror = () => reject(new Error('read')); + reader.readAsDataURL(file.slice(start, end)); + }); +} + +async function computeSeedId(file) { + const buf = await file.arrayBuffer(); + const hash = await crypto.subtle.digest('SHA-256', buf); + return [...new Uint8Array(hash)].map((b) => b.toString(16).padStart(2, '0')).join('').slice(0, 16); +} + +function pickDownloadPeers(seedId) { + const entry = catalog.get(seedId); + const cfg = getConfig(); + if (!entry) return []; + const onlineIds = new Set( + getPeers() + .filter((p) => p.online && p.blipId !== cfg.blipId) + .map((p) => p.blipId) + ); + return [...entry.seeders.keys()] + .filter((id) => onlineIds.has(id)) + .map((id) => ({ id, lat: getPeerLatency(id) ?? 9999 })) + .sort((a, b) => a.lat - b.lat) + .slice(0, MAX_PARALLEL_PEERS); +} + +function waitForChunk(seedId, chunkIndex) { + const key = `${seedId}:${chunkIndex}`; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + pendingChunks.delete(key); + reject(new Error('chunk_timeout')); + }, CHUNK_TIMEOUT_MS); + pendingChunks.set(key, { + resolve: (data) => { + clearTimeout(timer); + pendingChunks.delete(key); + resolve(data); + }, + reject: (err) => { + clearTimeout(timer); + pendingChunks.delete(key); + reject(err); + }, + timer, + }); + }); +} + +async function requestChunksFromPeer(peerId, seedId, indices) { + if (!indices.length || !meshApi?.sendTcpMessage) return; + const cfg = getConfig(); + await meshApi.sendTcpMessage({ + type: 'seed-request', + to: peerId, + from: cfg.blipId, + seedId, + chunks: indices, + }); + for (const idx of indices) { + try { + const data = await waitForChunk(seedId, idx); + await meshApi.beaconWriteChunk?.({ seedId, chunkIndex: idx, data }); + } catch { + /* retry from another peer */ + } + } +} + +async function serveSeedRequest(msg, api, config) { + const from = Number(msg.from); + const seedId = String(msg.seedId || ''); + const chunks = Array.isArray(msg.chunks) ? msg.chunks.map(Number).filter(Number.isFinite) : []; + if (!seedId || !Number.isFinite(from) || !chunks.length) return; + if (!localComplete.has(seedId)) return; + + for (const idx of chunks) { + const exists = await meshApi?.beaconChunkExists?.({ seedId, chunkIndex: idx }); + if (!exists) continue; + const res = await meshApi?.beaconReadChunk?.({ seedId, chunkIndex: idx }); + if (!res?.ok || !res.data) continue; + await api.sendTcpMessage({ + type: 'seed-chunk', + to: from, + from: config.blipId, + seedId, + chunkIndex: idx, + data: res.data, + }); + } +} + +function resolveIncomingChunk(msg) { + const seedId = String(msg.seedId || ''); + const idx = Number(msg.chunkIndex); + const data = String(msg.data || ''); + if (!seedId || !Number.isFinite(idx) || !data) return; + const key = `${seedId}:${idx}`; + const pending = pendingChunks.get(key); + if (pending) pending.resolve(data); +} + /** - * @param {{ api: object, getConfig: () => object }} opts + * @param {{ api: object, getConfig: () => object, getPeers?: () => object[], getPeerLatency?: (id: number) => number }} opts */ export function initBeaconMesh(opts) { meshApi = opts.api; getConfig = opts.getConfig; + getPeers = opts.getPeers || (() => []); + getPeerLatency = opts.getPeerLatency || (() => 9999); opts.api?.onSeedUdp?.((data) => ingestSeedUdp(data)); window.addEventListener('beforeunload', stopBeaconPulse); - startBeaconPulse(); + void loadLocalSeeds().then(() => startBeaconPulse()); } export function refreshBeaconMesh() { - startBeaconPulse(); + void loadLocalSeeds().then(() => startBeaconPulse()); +} + +export function getBeaconJobProgress(seedId) { + return jobProgress.get(seedId) || null; +} + +export function isSeedLocalComplete(seedId) { + return localComplete.has(seedId); } export function getBeaconCatalog() { - return [...catalog.values()].map((e) => ({ - seedId: e.seedId, - filename: e.filename, - size: e.size, - chunkSize: e.chunkSize, - totalChunks: e.totalChunks, - seederCount: e.seeders.size, - leechers: e.leechers || 0, - updatedAt: e.updatedAt, - })); + const cfg = getConfig(); + return [...catalog.values()].map((e) => { + const local = localComplete.has(e.seedId); + const job = jobProgress.get(e.seedId); + return { + seedId: e.seedId, + filename: e.filename, + size: e.size, + chunkSize: e.chunkSize, + totalChunks: e.totalChunks, + seederCount: e.seeders.size, + leechers: e.leechers || 0, + updatedAt: e.updatedAt, + local, + mine: local && e.seeders.has(cfg.blipId), + canSave: local, + progress: job?.progress ?? (local ? 100 : 0), + phase: job?.phase || (local ? 'ready' : ''), + status: job?.phase || (local ? 'seeding' : 'available'), + }; + }); } /** @@ -128,18 +367,18 @@ export function handleBeaconTcp(msg, { api, config }) { if (!config?.devBeaconEnabled) return false; switch (msg.type) { case 'seed-request': + void serveSeedRequest(msg, api, config); + return true; case 'seed-chunk': + resolveIncomingChunk(msg); + return true; case 'seed-have': - // Phase B: serve/read chunks via main IPC return true; default: return false; } } -/** - * Broadcast a new seed (metadata only; chunking in phase B). - */ export async function announceSeed(meta) { const cfg = getConfig(); if (!cfg?.devBeaconEnabled || !meshApi?.beaconSendUdp) return false; @@ -151,6 +390,176 @@ export async function announceSeed(meta) { chunkSize: meta.chunkSize || BEACON_CHUNK_SIZE, timestamp: Date.now(), }); - window.dispatchEvent(new CustomEvent('blip-beacon-catalog')); + emitCatalog(); return true; } + +/** + * Publish a file into the mesh library. + * @param {File} file + */ +export async function publishBeaconFile(file) { + const cfg = getConfig(); + if (!cfg?.devBeaconEnabled) throw new Error('disabled'); + const maxBytes = getMaxFileBytes(cfg); + if (!file || file.size <= 0) throw new Error('empty'); + if (file.size > maxBytes) throw new Error('too_large'); + + const chunkSize = BEACON_CHUNK_SIZE; + const totalChunks = Math.ceil(file.size / chunkSize); + const hashKey = `hash-${Date.now()}`; + setJobProgress(hashKey, 5, 'hashing'); + + let seedId; + try { + seedId = await computeSeedId(file); + } finally { + jobProgress.delete(hashKey); + } + + setJobProgress(seedId, 8, 'publishing'); + + const meta = { + seedId, + filename: file.name || 'file', + size: file.size, + chunkSize, + totalChunks, + mime: file.type || 'application/octet-stream', + publishedAt: Date.now(), + }; + + try { + await ipcWriteMeta(seedId, meta); + + for (let i = 0; i < totalChunks; i++) { + const start = i * chunkSize; + const end = Math.min(file.size, start + chunkSize); + const data = await readFileSliceAsBase64(file, start, end); + await ipcWriteChunk(seedId, i, data); + setJobProgress(seedId, Math.round(8 + ((i + 1) / totalChunks) * 82), 'publishing'); + } + + localComplete.add(seedId); + upsertCatalogEntry({ ...meta, blipId: cfg.blipId }); + await announceSeed(meta); + setJobProgress(seedId, 100, 'ready'); + clearJobProgress(seedId); + return meta; + } catch (err) { + localComplete.delete(seedId); + clearJobProgress(seedId); + throw err; + } +} + +/** + * Download a seed from mesh peers and save via system dialog. + * @param {string} seedId + */ +export async function downloadBeaconSeed(seedId) { + const cfg = getConfig(); + if (!cfg?.devBeaconEnabled) throw new Error('disabled'); + const entry = catalog.get(seedId); + if (!entry) throw new Error('not_found'); + + if (localComplete.has(seedId)) { + await ensureLocalMeta(seedId, entry); + const res = await meshApi.beaconSaveAssembled?.({ + seedId, + defaultName: entry.filename, + }); + if (res?.cancelled) return { cancelled: true }; + if (!res?.ok) { + if (res?.error === 'no_meta') localComplete.delete(seedId); + throw new Error(res?.error || 'save_failed'); + } + return res; + } + + const total = entry.totalChunks; + const missing = new Set(); + for (let i = 0; i < total; i++) { + const exists = await meshApi.beaconChunkExists?.({ seedId, chunkIndex: i }); + if (!exists) missing.add(i); + } + + if (missing.size === 0) { + await ensureLocalMeta(seedId, entry); + localComplete.add(seedId); + upsertCatalogEntry({ ...entry, blipId: cfg.blipId }); + await announceSeed({ + seedId, + filename: entry.filename, + size: entry.size, + chunkSize: entry.chunkSize, + totalChunks: entry.totalChunks, + }); + const res = await meshApi.beaconSaveAssembled?.({ + seedId, + defaultName: entry.filename, + }); + if (res?.cancelled) return { cancelled: true }; + if (!res?.ok) { + if (res?.error === 'no_meta') localComplete.delete(seedId); + throw new Error(res?.error || 'save_failed'); + } + return res; + } + + setJobProgress(seedId, Math.round(((total - missing.size) / total) * 100), 'downloading'); + + let stallRounds = 0; + while (missing.size > 0) { + const peers = pickDownloadPeers(seedId); + if (!peers.length) throw new Error('no_seeders'); + + const before = missing.size; + const tasks = []; + const queue = [...missing]; + for (const peer of peers) { + const batch = queue.splice(0, CHUNK_REQUEST_BATCH); + if (!batch.length) break; + tasks.push(requestChunksFromPeer(peer.id, seedId, batch)); + } + await Promise.allSettled(tasks); + + for (const idx of [...missing]) { + const exists = await meshApi.beaconChunkExists?.({ seedId, chunkIndex: idx }); + if (exists) missing.delete(idx); + } + + setJobProgress(seedId, Math.round(((total - missing.size) / total) * 100), 'downloading'); + + if (missing.size === before) { + stallRounds += 1; + if (stallRounds >= 3) throw new Error('stalled'); + } else { + stallRounds = 0; + } + } + + const meta = await meshApi.beaconReadMeta?.({ seedId }); + if (meta) await meshApi.beaconWriteMeta?.({ seedId, meta }); + + localComplete.add(seedId); + upsertCatalogEntry({ ...entry, blipId: cfg.blipId }); + await ensureLocalMeta(seedId, entry); + await announceSeed({ + seedId, + filename: entry.filename, + size: entry.size, + chunkSize: entry.chunkSize, + totalChunks: entry.totalChunks, + }); + + setJobProgress(seedId, 100, 'saving'); + const res = await meshApi.beaconSaveAssembled?.({ + seedId, + defaultName: entry.filename, + }); + clearJobProgress(seedId); + if (res?.cancelled) return { cancelled: true }; + if (!res?.ok) throw new Error(res?.error || 'save_failed'); + return res; +} diff --git a/renderer/beacon-ui.js b/renderer/beacon-ui.js index 5ed8b65..b00cf22 100644 --- a/renderer/beacon-ui.js +++ b/renderer/beacon-ui.js @@ -1,107 +1,381 @@ import { t } from './i18n.js'; -import { getBeaconCatalog } from './beacon-mesh.js'; +import { + getBeaconCatalog, + publishBeaconFile, + downloadBeaconSeed, + refreshBeaconLocalState, +} from './beacon-mesh.js'; import { formatFileSize } from './file-transfer.js'; +import { showAppToast } from './toasts.js'; +import { createPixelHintIcon } from './settings-ui.js'; -let listRoot = null; +/** @type {'mesh' | 'mine' | 'downloads'} */ +let activeTab = 'mesh'; +let tableBodyEl = null; +let publishBusy = false; +let viewRoot = null; -function renderList() { - const list = listRoot; - if (!list) return; - const items = getBeaconCatalog(); - list.innerHTML = ''; +function statusLabel(item) { + if (item.phase === 'hashing') return t('beacon.status_hashing'); + if (item.phase === 'publishing') return t('beacon.status_publishing'); + if (item.phase === 'downloading') return t('beacon.status_downloading'); + if (item.canSave) return t('beacon.status_seeding'); + return t('beacon.status_available'); +} + +function filterItems(tab) { + const all = getBeaconCatalog(); + if (tab === 'mine') return all.filter((i) => i.mine || i.canSave); + if (tab === 'downloads') return all.filter((i) => i.phase === 'downloading'); + return all; +} + +function renderTableRows() { + if (!tableBodyEl) return; + tableBodyEl.innerHTML = ''; + + if (activeTab === 'downloads') { + const soon = document.createElement('div'); + soon.className = 'beacon-soon-panel glass'; + soon.dataset.i18n = 'beacon.soon_downloads'; + soon.textContent = t('beacon.soon_downloads'); + tableBodyEl.appendChild(soon); + return; + } + + const items = filterItems(activeTab); if (!items.length) { const empty = document.createElement('p'); empty.className = 'beacon-empty'; - empty.dataset.i18n = 'beacon.empty'; - empty.textContent = t('beacon.empty'); - list.appendChild(empty); + empty.dataset.i18n = activeTab === 'mine' ? 'beacon.empty_mine' : 'beacon.empty'; + empty.textContent = t(activeTab === 'mine' ? 'beacon.empty_mine' : 'beacon.empty'); + tableBodyEl.appendChild(empty); return; } + for (const item of items) { const row = document.createElement('div'); - row.className = 'beacon-row glass'; - const strong = document.createElement('strong'); - strong.textContent = item.filename; - const sub = document.createElement('span'); - sub.className = 'beacon-sub'; - sub.textContent = `${formatFileSize(item.size)} · ${item.seederCount} ${t('beacon.seeders')} · ${item.seedId.slice(0, 8)}…`; - row.appendChild(strong); - row.appendChild(sub); - list.appendChild(row); + row.className = 'beacon-table-row glass'; + + const nameCell = document.createElement('div'); + nameCell.className = 'beacon-col beacon-col-name'; + const nameStrong = document.createElement('strong'); + nameStrong.textContent = item.filename; + const nameSub = document.createElement('span'); + nameSub.className = 'beacon-sub'; + nameSub.textContent = item.seedId.slice(0, 8); + nameCell.appendChild(nameStrong); + nameCell.appendChild(nameSub); + + const sizeCell = document.createElement('div'); + sizeCell.className = 'beacon-col beacon-col-size'; + sizeCell.textContent = formatFileSize(item.size); + + const progCell = document.createElement('div'); + progCell.className = 'beacon-col beacon-col-progress'; + if (item.progress > 0 && item.progress < 100 && item.phase) { + const track = document.createElement('div'); + track.className = 'beacon-progress-track'; + const fill = document.createElement('div'); + fill.className = 'beacon-progress-fill'; + fill.style.width = `${item.progress}%`; + track.appendChild(fill); + progCell.appendChild(track); + const pct = document.createElement('span'); + pct.className = 'beacon-sub'; + pct.textContent = `${item.progress}%`; + progCell.appendChild(pct); + } else { + progCell.textContent = item.canSave ? '100%' : '—'; + } + + const seedsCell = document.createElement('div'); + seedsCell.className = 'beacon-col beacon-col-seeds'; + seedsCell.textContent = String(item.seederCount); + + const statusCell = document.createElement('div'); + statusCell.className = 'beacon-col beacon-col-status'; + statusCell.textContent = statusLabel(item); + + const actionsCell = document.createElement('div'); + actionsCell.className = 'beacon-col beacon-col-actions'; + + if (item.phase === 'hashing' || item.phase === 'publishing' || item.phase === 'downloading') { + const wait = document.createElement('span'); + wait.className = 'beacon-sub'; + wait.textContent = '…'; + actionsCell.appendChild(wait); + } else if (item.canSave) { + const saveBtn = document.createElement('button'); + saveBtn.type = 'button'; + saveBtn.className = 'btn btn-lang beacon-row-btn'; + saveBtn.dataset.i18n = 'beacon.save'; + saveBtn.textContent = t('beacon.save'); + saveBtn.addEventListener('click', () => { + void downloadBeaconSeed(item.seedId) + .then((res) => { + if (res?.cancelled) return; + showAppToast({ title: t('beacon.saved'), durationMs: 4000 }); + }) + .catch((err) => { + showAppToast({ + title: t('beacon.failed'), + body: err?.message || '', + variant: 'danger', + durationMs: 5000, + }); + void refreshBeaconLocalState().then(renderTableRows); + }); + }); + actionsCell.appendChild(saveBtn); + } else { + const dlBtn = document.createElement('button'); + dlBtn.type = 'button'; + dlBtn.className = 'btn btn-accent beacon-row-btn'; + dlBtn.dataset.i18n = 'beacon.download'; + dlBtn.textContent = t('beacon.download'); + dlBtn.addEventListener('click', () => { + dlBtn.disabled = true; + void downloadBeaconSeed(item.seedId) + .then((res) => { + if (res?.cancelled) return; + showAppToast({ title: t('beacon.saved'), durationMs: 4000 }); + }) + .catch((err) => { + showAppToast({ + title: t('beacon.failed'), + body: err?.message || '', + variant: 'danger', + durationMs: 5000, + }); + }) + .finally(() => { + dlBtn.disabled = false; + renderTableRows(); + }); + }); + actionsCell.appendChild(dlBtn); + } + + row.appendChild(nameCell); + row.appendChild(sizeCell); + row.appendChild(progCell); + row.appendChild(seedsCell); + row.appendChild(statusCell); + row.appendChild(actionsCell); + tableBodyEl.appendChild(row); + } +} + +function setActiveTab(tab, wrap) { + activeTab = tab; + wrap.querySelectorAll('.beacon-tab').forEach((btn) => { + const on = btn.dataset.tab === tab; + btn.classList.toggle('beacon-tab--active', on); + btn.classList.toggle('beacon-tab--inactive', !on && btn.classList.contains('beacon-tab--stub')); + }); + renderTableRows(); +} + +function wirePublishInput(wrap) { + const input = wrap.querySelector('.beacon-file-input'); + const btn = wrap.querySelector('.beacon-publish-btn'); + if (!input || !btn) return; + + btn.addEventListener('click', () => { + if (publishBusy) return; + input.click(); + }); + + input.addEventListener('change', () => { + const file = input.files?.[0]; + input.value = ''; + if (!file || publishBusy) return; + publishBusy = true; + btn.disabled = true; + void publishBeaconFile(file) + .then(() => { + showAppToast({ + title: t('beacon.published'), + body: file.name, + durationMs: 4500, + }); + activeTab = 'mine'; + setActiveTab('mine', wrap); + }) + .catch((err) => { + showAppToast({ + title: t('beacon.failed'), + body: err?.message || '', + variant: 'danger', + durationMs: 5000, + }); + }) + .finally(() => { + publishBusy = false; + btn.disabled = false; + renderTableRows(); + }); + }); +} + +function buildStatsBar() { + const bar = document.createElement('div'); + bar.className = 'beacon-stats glass beacon-inactive-block'; + bar.innerHTML = ` + ${t('beacon.stat_down')} + ${t('beacon.stat_up')} + ${t('beacon.stat_seeds')} + ${t('beacon.stat_peers')} + `; + return bar; +} + +function buildTabs(wrap) { + const tabs = document.createElement('div'); + tabs.className = 'beacon-tabs'; + const defs = [ + { id: 'mesh', key: 'beacon.tab_mesh', stub: false }, + { id: 'mine', key: 'beacon.tab_mine', stub: false }, + { id: 'downloads', key: 'beacon.tab_downloads', stub: true }, + ]; + for (const def of defs) { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = `beacon-tab btn btn-lang${def.stub ? ' beacon-tab--stub' : ''}${def.id === activeTab ? ' beacon-tab--active' : ''}`; + btn.dataset.tab = def.id; + btn.dataset.i18n = def.key; + btn.textContent = t(def.key); + if (def.stub) btn.classList.add('beacon-tab--inactive'); + btn.addEventListener('click', () => setActiveTab(def.id, wrap)); + tabs.appendChild(btn); } + return tabs; +} + +function buildToolbar(wrap) { + const bar = document.createElement('div'); + bar.className = 'beacon-toolbar'; + + const input = document.createElement('input'); + input.type = 'file'; + input.className = 'beacon-file-input hidden'; + input.setAttribute('aria-hidden', 'true'); + + const publishBtn = document.createElement('button'); + publishBtn.type = 'button'; + publishBtn.className = 'btn btn-accent beacon-publish-btn'; + publishBtn.dataset.i18n = 'beacon.publish'; + publishBtn.textContent = t('beacon.publish'); + + const pauseBtn = document.createElement('button'); + pauseBtn.type = 'button'; + pauseBtn.className = 'btn btn-lang beacon-inactive-btn'; + pauseBtn.disabled = true; + pauseBtn.dataset.i18n = 'beacon.pause_all'; + pauseBtn.textContent = t('beacon.pause_all'); + + const clearBtn = document.createElement('button'); + clearBtn.type = 'button'; + clearBtn.className = 'btn btn-lang beacon-inactive-btn'; + clearBtn.disabled = true; + clearBtn.dataset.i18n = 'beacon.clear_done'; + clearBtn.textContent = t('beacon.clear_done'); + + const search = document.createElement('input'); + search.type = 'search'; + search.className = 'input beacon-search beacon-inactive-input'; + search.disabled = true; + search.placeholder = t('beacon.search_ph'); + search.dataset.i18nPlaceholder = 'beacon.search_ph'; + + bar.appendChild(input); + bar.appendChild(publishBtn); + bar.appendChild(pauseBtn); + bar.appendChild(clearBtn); + bar.appendChild(search); + wrap.appendChild(bar); + wirePublishInput(wrap); +} + +function buildDropZone() { + const zone = document.createElement('div'); + zone.className = 'beacon-dropzone glass beacon-inactive-block'; + const label = document.createElement('span'); + label.dataset.i18n = 'beacon.drop_hint'; + label.textContent = t('beacon.drop_hint'); + zone.appendChild(label); + return zone; +} + +function buildTable() { + const wrap = document.createElement('div'); + wrap.className = 'beacon-table-wrap'; + + const head = document.createElement('div'); + head.className = 'beacon-table-head'; + const cols = [ + 'beacon.col_name', + 'beacon.col_size', + 'beacon.col_progress', + 'beacon.col_seeders', + 'beacon.col_status', + 'beacon.col_actions', + ]; + for (const key of cols) { + const cell = document.createElement('span'); + cell.className = 'beacon-table-head-cell'; + cell.dataset.i18n = key; + cell.textContent = t(key); + head.appendChild(cell); + } + + tableBodyEl = document.createElement('div'); + tableBodyEl.className = 'beacon-table-body'; + + wrap.appendChild(head); + wrap.appendChild(tableBodyEl); + return wrap; +} + +function onCatalogUpdate() { + renderTableRows(); } if (typeof window !== 'undefined') { - window.addEventListener('blip-beacon-catalog', renderList); + window.addEventListener('blip-beacon-catalog', onCatalogUpdate); + window.addEventListener('blip-beacon-progress', onCatalogUpdate); } /** Main nav view — BEACON / МАЯК */ export function renderBeaconView() { + activeTab = 'mesh'; const wrap = document.createElement('div'); wrap.className = 'view beacon-view'; + viewRoot = wrap; - const head = document.createElement('div'); - head.className = 'beacon-view-head'; + const titleRow = document.createElement('div'); + titleRow.className = 'section-title-row'; const title = document.createElement('h2'); - title.className = 'section-title beacon-view-title'; + title.className = 'section-title'; title.dataset.i18n = 'beacon.title'; title.textContent = t('beacon.title'); - const beta = document.createElement('span'); - beta.className = 'nav-beta-badge'; - beta.dataset.i18n = 'nav.beacon_beta'; - beta.textContent = t('nav.beacon_beta'); - head.appendChild(title); - head.appendChild(beta); - - const hint = document.createElement('p'); - hint.className = 'beacon-hint'; - hint.dataset.i18n = 'beacon.hint'; - hint.textContent = t('beacon.hint'); + titleRow.appendChild(title); + titleRow.appendChild(createPixelHintIcon('beacon.hint')); - listRoot = document.createElement('div'); - listRoot.className = 'beacon-list'; + wrap.appendChild(titleRow); + wrap.appendChild(buildStatsBar()); + wrap.appendChild(buildTabs(wrap)); + buildToolbar(wrap); + wrap.appendChild(buildDropZone()); + wrap.appendChild(buildTable()); - wrap.appendChild(head); - wrap.appendChild(hint); - wrap.appendChild(listRoot); - renderList(); + void refreshBeaconLocalState().then(renderTableRows); + renderTableRows(); return wrap; } -/** Legacy floating panel (settings developer button). */ +/** Legacy floating panel. */ export function mountBeaconPanel() { - let panelEl = document.querySelector('.beacon-panel'); - if (panelEl?.isConnected) { - panelEl.classList.remove('hidden'); - renderList(); - return panelEl; - } - panelEl = document.createElement('div'); - panelEl.className = 'beacon-panel glass'; - const closeBtn = document.createElement('button'); - closeBtn.type = 'button'; - closeBtn.className = 'btn btn-lang beacon-close'; - closeBtn.setAttribute('aria-label', 'Close'); - closeBtn.textContent = '×'; - closeBtn.addEventListener('click', () => panelEl.classList.add('hidden')); - const head = document.createElement('div'); - head.className = 'beacon-head'; - const title = document.createElement('span'); - title.className = 'beacon-title'; - title.dataset.i18n = 'beacon.title'; - title.textContent = t('beacon.title'); - head.appendChild(title); - head.appendChild(closeBtn); - const hint = document.createElement('p'); - hint.className = 'beacon-hint'; - hint.dataset.i18n = 'beacon.hint'; - hint.textContent = t('beacon.hint'); - listRoot = document.createElement('div'); - listRoot.className = 'beacon-list'; - panelEl.appendChild(head); - panelEl.appendChild(hint); - panelEl.appendChild(listRoot); - document.body.appendChild(panelEl); - renderList(); - return panelEl; + return renderBeaconView(); } diff --git a/renderer/i18n.js b/renderer/i18n.js index 3885cc0..43b0e79 100644 --- a/renderer/i18n.js +++ b/renderer/i18n.js @@ -738,9 +738,42 @@ const locales = { 'settings.dev_beacon_on': 'BEACON protocol on', 'settings.dev_beacon_off': 'BEACON protocol off', 'beacon.title': 'BEACON', - 'beacon.hint': 'Files announced on the LAN appear here. Publishing and parallel download ship in the next build.', - 'beacon.empty': 'No seeds in mesh yet.', + 'beacon.hint': 'Publish files to the LAN mesh or download from peers. You become a seed after a full download.', + 'beacon.empty': 'No seeds in mesh yet. Publish a file to start.', + 'beacon.empty_mine': 'No local seeds yet.', 'beacon.seeders': 'seeders', + 'beacon.publish': 'Publish file', + 'beacon.download': 'Download', + 'beacon.save': 'Save as…', + 'beacon.publishing': 'Publishing', + 'beacon.downloading': 'Downloading', + 'beacon.published': 'Published to mesh', + 'beacon.saved': 'File saved', + 'beacon.failed': 'BEACON error', + 'beacon.mine': 'My seed', + 'beacon.tab_mesh': 'In mesh', + 'beacon.tab_mine': 'My seeds', + 'beacon.tab_downloads': 'Downloads', + 'beacon.stat_down': '↓ 0 B/s', + 'beacon.stat_up': '↑ 0 B/s', + 'beacon.stat_seeds': '0 seeds', + 'beacon.stat_peers': '0 peers', + 'beacon.pause_all': 'Pause all', + 'beacon.clear_done': 'Clear completed', + 'beacon.search_ph': 'Search…', + 'beacon.drop_hint': 'Drop a file here to publish (coming soon)', + 'beacon.col_name': 'Name', + 'beacon.col_size': 'Size', + 'beacon.col_progress': 'Progress', + 'beacon.col_seeders': 'Seeds', + 'beacon.col_status': 'Status', + 'beacon.col_actions': 'Actions', + 'beacon.status_hashing': 'Hashing…', + 'beacon.status_publishing': 'Publishing', + 'beacon.status_downloading': 'Downloading', + 'beacon.status_seeding': 'Seeding', + 'beacon.status_available': 'Available', + 'beacon.soon_downloads': 'Download queue — coming in a later 1.1 build.', 'settings.dev_export': 'Export diagnostics', 'settings.dev_export_done': 'Diagnostics copied to clipboard', 'settings.dev_export_fail': 'Could not copy diagnostics', @@ -1615,9 +1648,42 @@ const locales = { 'settings.dev_beacon_on': 'Протокол МАЯК включён', 'settings.dev_beacon_off': 'Протокол МАЯК выключен', 'beacon.title': 'МАЯК', - 'beacon.hint': 'Файлы из ЛАН появятся здесь. Публикация и параллельная качка — в следующих сборках.', - 'beacon.empty': 'В сети пока нет сидов.', + 'beacon.hint': 'Публикуй файлы в LAN или качай у пиров. После полной загрузки ты тоже становишься сидом.', + 'beacon.empty': 'В сети пока нет сидов. Опубликуй файл, чтобы начать.', + 'beacon.empty_mine': 'Локальных сидов пока нет.', 'beacon.seeders': 'сидов', + 'beacon.publish': 'Опубликовать файл', + 'beacon.download': 'Скачать', + 'beacon.save': 'Сохранить как…', + 'beacon.publishing': 'Публикация', + 'beacon.downloading': 'Загрузка', + 'beacon.published': 'Опубликовано в mesh', + 'beacon.saved': 'Файл сохранён', + 'beacon.failed': 'Ошибка МАЯК', + 'beacon.mine': 'Мой сид', + 'beacon.tab_mesh': 'В сети', + 'beacon.tab_mine': 'Мои сиды', + 'beacon.tab_downloads': 'Загрузки', + 'beacon.stat_down': '↓ 0 Б/с', + 'beacon.stat_up': '↑ 0 Б/с', + 'beacon.stat_seeds': '0 сидов', + 'beacon.stat_peers': '0 пиров', + 'beacon.pause_all': 'Пауза всем', + 'beacon.clear_done': 'Очистить готовые', + 'beacon.search_ph': 'Поиск…', + 'beacon.drop_hint': 'Перетащи файл для публикации (скоро)', + 'beacon.col_name': 'Имя', + 'beacon.col_size': 'Размер', + 'beacon.col_progress': 'Прогресс', + 'beacon.col_seeders': 'Сиды', + 'beacon.col_status': 'Статус', + 'beacon.col_actions': 'Действия', + 'beacon.status_hashing': 'Хеш…', + 'beacon.status_publishing': 'Публикация', + 'beacon.status_downloading': 'Загрузка', + 'beacon.status_seeding': 'Раздаёт', + 'beacon.status_available': 'Доступен', + 'beacon.soon_downloads': 'Очередь загрузок — в одной из следующих сборок 1.1.', 'settings.dev_export': 'Экспорт диагностики', 'settings.dev_export_done': 'Диагностика скопирована в буфер', 'settings.dev_export_fail': 'Не удалось скопировать', diff --git a/renderer/media-viewer.js b/renderer/media-viewer.js index a220987..00d7d8c 100644 --- a/renderer/media-viewer.js +++ b/renderer/media-viewer.js @@ -132,11 +132,11 @@ function ensureRoot() { }); volRow.appendChild(volLabel); volRow.appendChild(volRange); + controls.appendChild(volRow); root.appendChild(toolbar); root.appendChild(stage); root.appendChild(controls); - root.appendChild(volRow); root.appendChild(seekBar); void readVideoVolume().then(applyVideoVolume); diff --git a/renderer/styles.css b/renderer/styles.css index 0060e5b..0a74277 100644 --- a/renderer/styles.css +++ b/renderer/styles.css @@ -3191,9 +3191,55 @@ select.blip-select:focus { .beacon-row { padding: 10px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.beacon-row-top { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.beacon-row-info { display: flex; flex-direction: column; gap: 4px; + min-width: 0; +} + +.beacon-row-actions { + flex-shrink: 0; +} + +.beacon-toolbar { + display: flex; + gap: 10px; + margin-bottom: 4px; +} + +.beacon-file-input.hidden { + display: none; +} + +.beacon-progress-track { + height: 6px; + border: 1px solid var(--blip-accent); + background: rgba(0, 0, 0, 0.4); +} + +.beacon-progress-fill { + height: 100%; + background: var(--blip-accent); + transition: width 0.12s steps(6); +} + +.beacon-progress-label { + font-size: 10px; + color: var(--blip-muted); + letter-spacing: 0.06em; } .beacon-sub { @@ -3213,57 +3259,167 @@ select.blip-select:focus { flex-direction: column; gap: 12px; max-height: 100%; - overflow: hidden; + overflow: auto; } -.beacon-view-head { +.beacon-stats { display: flex; - align-items: center; - gap: 10px; flex-wrap: wrap; + gap: 12px 20px; + padding: 10px 12px; + font-size: 10px; + letter-spacing: 0.08em; + text-transform: uppercase; } -.beacon-view-title { - margin: 0; +.beacon-stat { + color: var(--blip-muted); } -.nav-beta-badge { +.beacon-inactive-block { + opacity: 0.55; + border-color: var(--blip-muted); +} + +.beacon-inactive-btn, +.beacon-inactive-input { + opacity: 0.45; + border-color: var(--blip-muted) !important; + color: var(--blip-muted) !important; + cursor: not-allowed; +} + +.beacon-tabs { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.beacon-tab { + font-size: 10px; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.beacon-tab--active { + border-color: var(--blip-accent) !important; + color: var(--blip-accent); +} + +.beacon-tab--stub.beacon-tab--inactive { + opacity: 0.45; + border-color: var(--blip-muted); + color: var(--blip-muted); +} + +.beacon-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.beacon-search { + flex: 1; + min-width: 140px; + max-width: 240px; +} + +.beacon-dropzone { + padding: 20px 16px; + text-align: center; + font-size: 11px; + letter-spacing: 0.06em; + color: var(--blip-muted); + border: 2px dashed var(--blip-muted); +} + +.beacon-table-wrap { + display: flex; + flex-direction: column; + gap: 6px; + min-height: 120px; +} + +.beacon-table-head { + display: grid; + grid-template-columns: 1.6fr 0.7fr 0.9fr 0.5fr 0.9fr 0.9fr; + gap: 8px; + padding: 0 10px; font-size: 9px; letter-spacing: 0.1em; + text-transform: uppercase; color: var(--blip-muted); - border: 1px solid var(--blip-muted); - padding: 1px 4px; - vertical-align: middle; } -.nav-btn .nav-beta-badge { - margin-left: 2px; +.beacon-table-body { + display: flex; + flex-direction: column; + gap: 8px; } -.media-viewer-volume { +.beacon-table-row { + display: grid; + grid-template-columns: 1.6fr 0.7fr 0.9fr 0.5fr 0.9fr 0.9fr; + gap: 8px; + align-items: center; + padding: 10px; + font-size: 12px; +} + +.beacon-col-name { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.beacon-col-name strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.beacon-col-progress { + display: flex; + flex-direction: column; + gap: 4px; +} + +.beacon-row-btn { + font-size: 10px; + padding: 4px 8px; +} + +.beacon-soon-panel { + padding: 16px; + font-size: 11px; + color: var(--blip-muted); + text-align: center; +} + +.beacon-view-head { display: flex; align-items: center; gap: 10px; - padding: 0 24px 8px; - max-width: 320px; - margin: 0 auto; + flex-wrap: wrap; } -.media-viewer-volume.hidden { - display: none; +.beacon-view-title { + margin: 0; } -.media-viewer-volume-label { - font-size: 10px; - letter-spacing: 0.08em; - text-transform: uppercase; +.nav-beta-badge { + font-size: 9px; + letter-spacing: 0.1em; color: var(--blip-muted); - flex-shrink: 0; + border: 1px solid var(--blip-muted); + padding: 1px 4px; + vertical-align: middle; } -.media-viewer-volume-range { - flex: 1; - accent-color: var(--blip-accent); +.nav-btn .nav-beta-badge { + margin-left: 2px; } .app-toast--danger { @@ -4981,13 +5137,95 @@ body.media-viewer-open { justify-content: center; align-items: center; gap: 12px; - padding: 12px; + padding: 12px 16px; + flex-wrap: nowrap; } .media-viewer-controls.hidden { display: none; } +.media-viewer-volume { + display: flex; + align-items: center; + gap: 8px; + margin-left: 4px; + padding-left: 14px; + border-left: 2px solid var(--blip-border); + min-width: 0; + flex: 0 1 220px; +} + +.media-viewer-volume.hidden { + display: none; +} + +.media-viewer-volume-label { + font-size: 9px; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--blip-muted); + flex-shrink: 0; + font-family: var(--blip-font, monospace); +} + +.media-viewer-volume-range { + flex: 1; + min-width: 72px; + height: 8px; + margin: 0; + padding: 0; + -webkit-appearance: none; + appearance: none; + background: rgba(0, 0, 0, 0.55); + border: 2px solid var(--blip-border); + border-radius: 0; + image-rendering: pixelated; + cursor: pointer; +} + +.media-viewer-volume-range:focus { + outline: 1px solid var(--blip-accent); + outline-offset: 2px; +} + +.media-viewer-volume-range::-webkit-slider-runnable-track { + height: 8px; + background: transparent; + border: none; + border-radius: 0; +} + +.media-viewer-volume-range::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 10px; + height: 14px; + margin-top: -5px; + background: var(--blip-accent); + border: 2px solid var(--blip-accent); + border-radius: 0; + box-shadow: 2px 2px 0 rgba(0, 0, 0, 0.65); + image-rendering: pixelated; +} + +.media-viewer-volume-range::-moz-range-track { + height: 8px; + background: rgba(0, 0, 0, 0.55); + border: 2px solid var(--blip-border); + border-radius: 0; +} + +.media-viewer-volume-range::-moz-range-thumb { + width: 10px; + height: 14px; + background: var(--blip-accent); + border: 2px solid var(--blip-accent); + border-radius: 0; + box-shadow: 2px 2px 0 rgba(0, 0, 0, 0.65); + image-rendering: pixelated; +} + .media-viewer-ctrl { min-width: 48px; font-family: var(--blip-font, monospace); diff --git a/renderer/ui.js b/renderer/ui.js index 69f14d9..dbfd5c6 100644 --- a/renderer/ui.js +++ b/renderer/ui.js @@ -732,21 +732,8 @@ function createNav(onNavigate) { btn.type = 'button'; btn.className = 'nav-btn'; btn.dataset.view = key; - if (key === 'beacon') { - const label = document.createElement('span'); - label.dataset.i18n = 'nav.beacon'; - label.textContent = t('nav.beacon'); - const beta = document.createElement('span'); - beta.className = 'nav-beta-badge'; - beta.dataset.i18n = 'nav.beacon_beta'; - beta.textContent = t('nav.beacon_beta'); - btn.appendChild(label); - btn.appendChild(document.createTextNode(' ')); - btn.appendChild(beta); - } else { - btn.dataset.i18n = `nav.${key}`; - btn.textContent = t(`nav.${key}`); - } + btn.dataset.i18n = `nav.${key}`; + btn.textContent = t(`nav.${key}`); btn.addEventListener('click', () => onNavigate(key)); nav.appendChild(btn); }); @@ -4311,8 +4298,18 @@ export function initUI(config, blipApi) { ...blipApi, onSeedUdp: (cb) => window.blip?.onSeedUdp?.(cb), beaconSendUdp: (payload) => window.blip?.beaconSendUdp?.(payload), + beaconWriteMeta: (payload) => window.blip?.beaconWriteMeta?.(payload), + beaconReadMeta: (payload) => window.blip?.beaconReadMeta?.(payload), + beaconWriteChunk: (payload) => window.blip?.beaconWriteChunk?.(payload), + beaconReadChunk: (payload) => window.blip?.beaconReadChunk?.(payload), + beaconChunkExists: (payload) => window.blip?.beaconChunkExists?.(payload), + beaconCountChunks: (payload) => window.blip?.beaconCountChunks?.(payload), + beaconListLocal: () => window.blip?.beaconListLocal?.(), + beaconSaveAssembled: (payload) => window.blip?.beaconSaveAssembled?.(payload), }, getConfig: () => state.config, + getPeers: () => state.peers, + getPeerLatency: (id) => peerLatencyMs.get(id) ?? 9999, }); const titleBar = createTitleBar(); From 3e3017dffaae50beb8b2af74a3ee800bbf816903 Mon Sep 17 00:00:00 2001 From: krwg Date: Sun, 24 May 2026 12:01:50 +0300 Subject: [PATCH 17/25] feat(1.1): BEACON stop seeding and DM forward lite Stop sharing seeds with seed-gone while keeping local chunks on disk; peers drop only the seeder from the catalog. Add resume seeding after stop and reflect stopped/paused state in the BEACON table. DM chat: forward via peer picker, forwardFrom on the message wire, and a forwarded preview block. Reply and Pin remain in context menu. i18n EN/RU, styles, ROADMAP, and CHANGELOG updated --- CHANGELOG.md | 3 +- build/build-info.json | 4 +- docs/ROADMAP-1.1-BEACON.md | 10 +- renderer/beacon-mesh.js | 112 ++++++++- renderer/beacon-ui.js | 394 ++++++++++++++++++++++--------- renderer/chat-forward-picker.js | 65 +++++ renderer/chat-message-content.js | 37 +++ renderer/chat.js | 50 +++- renderer/i18n.js | 70 +++++- renderer/styles.css | 326 +++++++++++++++++++++---- renderer/ui.js | 10 +- 11 files changed, 917 insertions(+), 164 deletions(-) create mode 100644 renderer/chat-forward-picker.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b7347e..778af09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,8 @@ Release **version numbers** track [`app-metadata.json`](app-metadata.json) (sync ### Added (1.1.0 Beacon — in progress) - **BEACON / Mesh Library** — publish files to LAN (`seed-announce`), download via `seed-request`/`seed-chunk` (multi-peer, up to 3 seeds), save assembled file; see [`docs/ROADMAP-1.1-BEACON.md`](docs/ROADMAP-1.1-BEACON.md). -- **Chat** — Reply polish, Forward (lite), Pinned messages (planned). +- **Chat (DM)** — Reply, single pin, Forward lite (`forwardFrom` on wire). +- **BEACON** — Stop seeding (`seed-gone`), pause/resume, resume local seed after stop. - **UX** — Compact mode, font size sliders, idle → Away, typing sound (planned). ### Fixed diff --git a/build/build-info.json b/build/build-info.json index e4115d0..5f3d4bb 100644 --- a/build/build-info.json +++ b/build/build-info.json @@ -1,7 +1,7 @@ { "issuer": "krwg-official", "version": "1.1.0-alpha", - "buildTimestamp": 1779497301162, - "buildSignature": "QCgkfpiGph0aEx2BTh1AwWmlwU93kbMv3bIoA3PrT2m4unmwX9+UdjRYcqlt3gFi8dywRymyVYp5f2dZMiIUAQ==", + "buildTimestamp": 1779498984361, + "buildSignature": "MdYdo7/ScoIF9RFmMq+58f5QnrfTIl2T4YPBhUlTLawJrepzY0cqmcfe9tMd/NEz8lAI0sflQDvESsMqriezCw==", "meshPlusPublicKeyHash": "b91e80f821881373" } diff --git a/docs/ROADMAP-1.1-BEACON.md b/docs/ROADMAP-1.1-BEACON.md index 7e037b3..c3dd482 100644 --- a/docs/ROADMAP-1.1-BEACON.md +++ b/docs/ROADMAP-1.1-BEACON.md @@ -172,12 +172,16 @@ Optional: `capabilities: ['beacon','reply','pin']` in announce or first TCP hand - [x] Single-peer download E2E - [x] Multi-seed parallel download (up to 3 peers, batch requests) - [x] `seed-pulse` while seeding (local complete seeds) +- [x] Per-seed **Stop** (`seed-gone`, revoke); local files kept; **Resume seeding** +- [x] **Pause** seeding — per-row + pause all in sidebar ### Phase C — Chat -- [ ] Reply UI polish -- [ ] Pin UI (1 pin) -- [ ] Forward lite + seedId path +- [x] Reply UI (DM: quote, composer, context menu) +- [x] Pin UI (1 pin per DM via `chat-pins.js`) +- [x] Forward lite (`forwardFrom` on `message`; DM picker) +- [ ] Pin + Reply + Forward in **group** chat +- [ ] Forward seed unavailable UX + re-seed prompt ### Phase D — UX + ship diff --git a/renderer/beacon-mesh.js b/renderer/beacon-mesh.js index e840211..0530ae1 100644 --- a/renderer/beacon-mesh.js +++ b/renderer/beacon-mesh.js @@ -16,6 +16,9 @@ const catalog = new Map(); /** @type {Set} */ const localComplete = new Set(); +/** Seeds we have on disk but are not actively sharing (stop seeding). */ +const stoppedSeeding = new Set(); + /** @type {Map} */ const jobProgress = new Map(); @@ -28,6 +31,9 @@ let getConfig = () => ({}); let getPeers = () => []; let getPeerLatency = () => 9999; +/** @type {Set} UI+mesh pause until TCP seed control ships */ +const pausedSeeds = new Set(); + function emitCatalog() { window.dispatchEvent(new CustomEvent('blip-beacon-catalog')); } @@ -89,12 +95,22 @@ function ingestSeedUdp(data) { } emitCatalog(); break; - case 'seed-gone': - if (data.seedId && !localComplete.has(String(data.seedId))) { - catalog.delete(String(data.seedId)); - emitCatalog(); + case 'seed-gone': { + const seedId = String(data.seedId || ''); + const blipId = Number(data.blipId); + if (!seedId) break; + const entry = catalog.get(seedId); + if (entry && Number.isFinite(blipId) && blipId > 0) { + entry.seeders.delete(blipId); + if (entry.seeders.size === 0 && !localComplete.has(seedId)) { + catalog.delete(seedId); + } + } else if (!localComplete.has(seedId)) { + catalog.delete(seedId); } + emitCatalog(); break; + } default: break; } @@ -113,6 +129,7 @@ function startBeaconPulse() { if (!cfg?.devBeaconEnabled) return; pulseTimer = setInterval(() => { const seeds = [...localComplete] + .filter((seedId) => !stoppedSeeding.has(seedId) && !pausedSeeds.has(seedId)) .map((seedId) => { const entry = catalog.get(seedId); return { @@ -283,6 +300,8 @@ async function serveSeedRequest(msg, api, config) { const seedId = String(msg.seedId || ''); const chunks = Array.isArray(msg.chunks) ? msg.chunks.map(Number).filter(Number.isFinite) : []; if (!seedId || !Number.isFinite(from) || !chunks.length) return; + if (pausedSeeds.has(seedId)) return; + if (stoppedSeeding.has(seedId)) return; if (!localComplete.has(seedId)) return; for (const idx of chunks) { @@ -328,6 +347,75 @@ export function refreshBeaconMesh() { void loadLocalSeeds().then(() => startBeaconPulse()); } +export function isSeedPaused(seedId) { + return pausedSeeds.has(seedId); +} + +export function setSeedPaused(seedId, paused) { + if (paused) pausedSeeds.add(seedId); + else pausedSeeds.delete(seedId); + emitCatalog(); +} + +export function setAllSeedsPaused(paused) { + pausedSeeds.clear(); + if (paused) { + for (const seedId of localComplete) pausedSeeds.add(seedId); + } + emitCatalog(); +} + +/** Stop sharing chunks on the mesh; local files stay for Save / re-seed. */ +export async function stopBeaconSeed(seedId) { + const cfg = getConfig(); + if (!cfg?.devBeaconEnabled) throw new Error('disabled'); + seedId = String(seedId || ''); + if (!seedId || !localComplete.has(seedId)) throw new Error('not_seeding'); + + pausedSeeds.delete(seedId); + stoppedSeeding.add(seedId); + + const entry = catalog.get(seedId); + if (entry) { + entry.seeders.delete(cfg.blipId); + if (entry.seeders.size === 0) catalog.delete(seedId); + } + + jobProgress.delete(seedId); + + if (meshApi?.beaconSendUdp) { + await meshApi.beaconSendUdp({ + type: 'seed-gone', + seedId, + blipId: cfg.blipId, + timestamp: Date.now(), + }); + } + + emitCatalog(); + startBeaconPulse(); + return true; +} + +export function isSeedStopped(seedId) { + return stoppedSeeding.has(String(seedId || '')); +} + +/** Re-announce a locally complete seed after stop. */ +export async function resumeBeaconSeeding(seedId) { + const cfg = getConfig(); + if (!cfg?.devBeaconEnabled) throw new Error('disabled'); + seedId = String(seedId || ''); + if (!localComplete.has(seedId)) throw new Error('not_local'); + const meta = await meshApi?.beaconReadMeta?.({ seedId }); + if (!meta?.seedId) throw new Error('no_meta'); + stoppedSeeding.delete(seedId); + pausedSeeds.delete(seedId); + await announceSeed(meta); + startBeaconPulse(); + return true; +} + export function getBeaconJobProgress(seedId) { return jobProgress.get(seedId) || null; } @@ -341,6 +429,9 @@ export function getBeaconCatalog() { return [...catalog.values()].map((e) => { const local = localComplete.has(e.seedId); const job = jobProgress.get(e.seedId); + const paused = pausedSeeds.has(e.seedId); + const stopped = stoppedSeeding.has(e.seedId); + const activelySeeding = local && !stopped && !paused; return { seedId: e.seedId, filename: e.filename, @@ -351,11 +442,17 @@ export function getBeaconCatalog() { leechers: e.leechers || 0, updatedAt: e.updatedAt, local, - mine: local && e.seeders.has(cfg.blipId), + mine: activelySeeding && e.seeders.has(cfg.blipId), canSave: local, + paused, + stopped, progress: job?.progress ?? (local ? 100 : 0), phase: job?.phase || (local ? 'ready' : ''), - status: job?.phase || (local ? 'seeding' : 'available'), + status: stopped + ? 'stopped' + : paused + ? 'paused' + : job?.phase || (activelySeeding ? 'seeding' : local ? 'local' : 'available'), }; }); } @@ -441,6 +538,7 @@ export async function publishBeaconFile(file) { } localComplete.add(seedId); + stoppedSeeding.delete(seedId); upsertCatalogEntry({ ...meta, blipId: cfg.blipId }); await announceSeed(meta); setJobProgress(seedId, 100, 'ready'); @@ -487,6 +585,7 @@ export async function downloadBeaconSeed(seedId) { if (missing.size === 0) { await ensureLocalMeta(seedId, entry); localComplete.add(seedId); + stoppedSeeding.delete(seedId); upsertCatalogEntry({ ...entry, blipId: cfg.blipId }); await announceSeed({ seedId, @@ -543,6 +642,7 @@ export async function downloadBeaconSeed(seedId) { if (meta) await meshApi.beaconWriteMeta?.({ seedId, meta }); localComplete.add(seedId); + stoppedSeeding.delete(seedId); upsertCatalogEntry({ ...entry, blipId: cfg.blipId }); await ensureLocalMeta(seedId, entry); await announceSeed({ diff --git a/renderer/beacon-ui.js b/renderer/beacon-ui.js index b00cf22..68c182e 100644 --- a/renderer/beacon-ui.js +++ b/renderer/beacon-ui.js @@ -4,6 +4,10 @@ import { publishBeaconFile, downloadBeaconSeed, refreshBeaconLocalState, + setSeedPaused, + setAllSeedsPaused, + stopBeaconSeed, + resumeBeaconSeeding, } from './beacon-mesh.js'; import { formatFileSize } from './file-transfer.js'; import { showAppToast } from './toasts.js'; @@ -12,10 +16,38 @@ import { createPixelHintIcon } from './settings-ui.js'; /** @type {'mesh' | 'mine' | 'downloads'} */ let activeTab = 'mesh'; let tableBodyEl = null; +let statsRootEl = null; let publishBusy = false; -let viewRoot = null; + +function showSoonToast(key = 'beacon.soon_feature') { + showAppToast({ title: t(key), durationMs: 4200 }); +} + +function computeStats() { + const items = getBeaconCatalog(); + const mine = items.filter((i) => i.mine || i.canSave).length; + const mesh = items.length; + const seeders = items.reduce((n, i) => n + i.seederCount, 0); + const active = items.filter((i) => i.canSave && !i.paused).length; + return { mine, mesh, seeders, active }; +} + +function refreshStats() { + if (!statsRootEl) return; + const s = computeStats(); + const meshEl = statsRootEl.querySelector('[data-stat="mesh"] strong'); + if (meshEl) meshEl.textContent = String(s.mesh); + const mineEl = statsRootEl.querySelector('[data-stat="mine"] strong'); + if (mineEl) mineEl.textContent = String(s.mine); + const seedEl = statsRootEl.querySelector('[data-stat="seeders"] strong'); + if (seedEl) seedEl.textContent = String(s.seeders); + const actEl = statsRootEl.querySelector('[data-stat="active"] strong'); + if (actEl) actEl.textContent = String(s.active); +} function statusLabel(item) { + if (item.stopped) return t('beacon.status_stopped'); + if (item.paused) return t('beacon.status_paused'); if (item.phase === 'hashing') return t('beacon.status_hashing'); if (item.phase === 'publishing') return t('beacon.status_publishing'); if (item.phase === 'downloading') return t('beacon.status_downloading'); @@ -30,9 +62,33 @@ function filterItems(tab) { return all; } +function mkBtn(labelKey, className, onClick, { disabled = false, soon = false } = {}) { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = className; + btn.dataset.i18n = labelKey; + btn.textContent = t(labelKey); + btn.disabled = disabled; + if (soon) { + btn.classList.add('beacon-soon-btn'); + btn.addEventListener('click', () => showSoonToast()); + return btn; + } + if (onClick) btn.addEventListener('click', onClick); + return btn; +} + +function mkActionGroup(buttons) { + const g = document.createElement('div'); + g.className = 'beacon-action-group'; + for (const b of buttons) g.appendChild(b); + return g; +} + function renderTableRows() { if (!tableBodyEl) return; tableBodyEl.innerHTML = ''; + refreshStats(); if (activeTab === 'downloads') { const soon = document.createElement('div'); @@ -55,7 +111,7 @@ function renderTableRows() { for (const item of items) { const row = document.createElement('div'); - row.className = 'beacon-table-row glass'; + row.className = `beacon-table-row glass${item.paused ? ' beacon-table-row--paused' : ''}`; const nameCell = document.createElement('div'); nameCell.className = 'beacon-col beacon-col-name'; @@ -63,7 +119,7 @@ function renderTableRows() { nameStrong.textContent = item.filename; const nameSub = document.createElement('span'); nameSub.className = 'beacon-sub'; - nameSub.textContent = item.seedId.slice(0, 8); + nameSub.textContent = `${item.seedId.slice(0, 8)}… · ${item.mine ? t('beacon.mine') : ''}`; nameCell.appendChild(nameStrong); nameCell.appendChild(nameSub); @@ -73,21 +129,18 @@ function renderTableRows() { const progCell = document.createElement('div'); progCell.className = 'beacon-col beacon-col-progress'; - if (item.progress > 0 && item.progress < 100 && item.phase) { - const track = document.createElement('div'); - track.className = 'beacon-progress-track'; - const fill = document.createElement('div'); - fill.className = 'beacon-progress-fill'; - fill.style.width = `${item.progress}%`; - track.appendChild(fill); - progCell.appendChild(track); - const pct = document.createElement('span'); - pct.className = 'beacon-sub'; - pct.textContent = `${item.progress}%`; - progCell.appendChild(pct); - } else { - progCell.textContent = item.canSave ? '100%' : '—'; - } + const track = document.createElement('div'); + track.className = 'beacon-progress-track'; + const fill = document.createElement('div'); + fill.className = 'beacon-progress-fill'; + const pctVal = item.progress > 0 && item.progress < 100 && item.phase ? item.progress : item.canSave ? 100 : 0; + fill.style.width = `${pctVal}%`; + track.appendChild(fill); + progCell.appendChild(track); + const pct = document.createElement('span'); + pct.className = 'beacon-sub'; + pct.textContent = pctVal ? `${pctVal}%` : '—'; + progCell.appendChild(pct); const seedsCell = document.createElement('div'); seedsCell.className = 'beacon-col beacon-col-seeds'; @@ -95,23 +148,58 @@ function renderTableRows() { const statusCell = document.createElement('div'); statusCell.className = 'beacon-col beacon-col-status'; - statusCell.textContent = statusLabel(item); + const badge = document.createElement('span'); + badge.className = `beacon-status-badge beacon-status-badge--${item.paused ? 'paused' : item.canSave ? 'seed' : 'avail'}`; + badge.textContent = statusLabel(item); + statusCell.appendChild(badge); const actionsCell = document.createElement('div'); actionsCell.className = 'beacon-col beacon-col-actions'; if (item.phase === 'hashing' || item.phase === 'publishing' || item.phase === 'downloading') { - const wait = document.createElement('span'); - wait.className = 'beacon-sub'; - wait.textContent = '…'; - actionsCell.appendChild(wait); + actionsCell.appendChild(mkActionGroup([])); } else if (item.canSave) { - const saveBtn = document.createElement('button'); - saveBtn.type = 'button'; - saveBtn.className = 'btn btn-lang beacon-row-btn'; - saveBtn.dataset.i18n = 'beacon.save'; - saveBtn.textContent = t('beacon.save'); - saveBtn.addEventListener('click', () => { + const actionBtns = []; + if (item.stopped) { + actionBtns.push( + mkBtn('beacon.resume_seed', 'btn btn-accent beacon-row-btn', () => { + void resumeBeaconSeeding(item.seedId) + .then(() => renderTableRows()) + .catch((err) => { + showAppToast({ + title: t('beacon.failed'), + body: err?.message || '', + variant: 'danger', + durationMs: 5000, + }); + }); + }) + ); + } else { + actionBtns.push( + mkBtn( + item.paused ? 'beacon.resume' : 'beacon.pause', + 'btn btn-lang beacon-row-btn', + () => { + setSeedPaused(item.seedId, !item.paused); + renderTableRows(); + } + ), + mkBtn('beacon.stop', 'btn btn-lang beacon-row-btn', () => { + void stopBeaconSeed(item.seedId) + .then(() => renderTableRows()) + .catch((err) => { + showAppToast({ + title: t('beacon.failed'), + body: err?.message || '', + variant: 'danger', + durationMs: 5000, + }); + }); + }) + ); + } + const saveBtn = mkBtn('beacon.save', 'btn btn-accent beacon-row-btn', () => { void downloadBeaconSeed(item.seedId) .then((res) => { if (res?.cancelled) return; @@ -127,14 +215,10 @@ function renderTableRows() { void refreshBeaconLocalState().then(renderTableRows); }); }); - actionsCell.appendChild(saveBtn); + actionBtns.push(saveBtn); + actionsCell.appendChild(mkActionGroup(actionBtns)); } else { - const dlBtn = document.createElement('button'); - dlBtn.type = 'button'; - dlBtn.className = 'btn btn-accent beacon-row-btn'; - dlBtn.dataset.i18n = 'beacon.download'; - dlBtn.textContent = t('beacon.download'); - dlBtn.addEventListener('click', () => { + const dlBtn = mkBtn('beacon.download', 'btn btn-accent beacon-row-btn', () => { dlBtn.disabled = true; void downloadBeaconSeed(item.seedId) .then((res) => { @@ -154,7 +238,10 @@ function renderTableRows() { renderTableRows(); }); }); - actionsCell.appendChild(dlBtn); + const linkBtn = mkBtn('beacon.copy_link', 'btn btn-lang beacon-row-btn beacon-soon-btn', null, { + soon: true, + }); + actionsCell.appendChild(mkActionGroup([dlBtn, linkBtn])); } row.appendChild(nameCell); @@ -170,9 +257,7 @@ function renderTableRows() { function setActiveTab(tab, wrap) { activeTab = tab; wrap.querySelectorAll('.beacon-tab').forEach((btn) => { - const on = btn.dataset.tab === tab; - btn.classList.toggle('beacon-tab--active', on); - btn.classList.toggle('beacon-tab--inactive', !on && btn.classList.contains('beacon-tab--stub')); + btn.classList.toggle('beacon-tab--active', btn.dataset.tab === tab); }); renderTableRows(); } @@ -195,11 +280,7 @@ function wirePublishInput(wrap) { btn.disabled = true; void publishBeaconFile(file) .then(() => { - showAppToast({ - title: t('beacon.published'), - body: file.name, - durationMs: 4500, - }); + showAppToast({ title: t('beacon.published'), body: file.name, durationMs: 4500 }); activeTab = 'mine'; setActiveTab('mine', wrap); }) @@ -219,41 +300,154 @@ function wirePublishInput(wrap) { }); } +function buildStatCard(statKey, labelKey, value, { muted = false } = {}) { + const card = document.createElement('div'); + card.className = `beacon-stat-card glass${muted ? ' beacon-stat-card--muted' : ''}`; + card.dataset.stat = statKey; + const label = document.createElement('span'); + label.className = 'beacon-stat-label'; + label.dataset.i18n = labelKey; + label.textContent = t(labelKey); + const val = document.createElement('strong'); + val.textContent = value; + card.appendChild(label); + card.appendChild(val); + return card; +} + function buildStatsBar() { const bar = document.createElement('div'); - bar.className = 'beacon-stats glass beacon-inactive-block'; - bar.innerHTML = ` - ${t('beacon.stat_down')} - ${t('beacon.stat_up')} - ${t('beacon.stat_seeds')} - ${t('beacon.stat_peers')} - `; + bar.className = 'beacon-stats-grid'; + statsRootEl = bar; + bar.appendChild(buildStatCard('down', 'beacon.stat_down_label', '0 B/s', { muted: true })); + bar.appendChild(buildStatCard('up', 'beacon.stat_up_label', '0 B/s', { muted: true })); + bar.appendChild(buildStatCard('mesh', 'beacon.stat_mesh_label', '0')); + bar.appendChild(buildStatCard('mine', 'beacon.stat_mine_label', '0')); + bar.appendChild(buildStatCard('seeders', 'beacon.stat_seeders_label', '0')); + bar.appendChild(buildStatCard('active', 'beacon.stat_active_label', '0')); return bar; } +function buildControlSection(titleKey, controls) { + const sec = document.createElement('div'); + sec.className = 'beacon-control-section glass'; + const h = document.createElement('h3'); + h.className = 'beacon-control-title'; + h.dataset.i18n = titleKey; + h.textContent = t(titleKey); + sec.appendChild(h); + for (const el of controls) sec.appendChild(el); + return sec; +} + +function buildSliderRow(labelKey, value, { disabled = true, min = 1, max = 8 } = {}) { + const row = document.createElement('label'); + row.className = `beacon-slider-row${disabled ? ' beacon-slider-row--soon' : ''}`; + const span = document.createElement('span'); + span.dataset.i18n = labelKey; + span.textContent = t(labelKey); + const range = document.createElement('input'); + range.type = 'range'; + range.min = String(min); + range.max = String(max); + range.value = String(value); + range.disabled = disabled; + range.className = 'beacon-range'; + const val = document.createElement('span'); + val.className = 'beacon-slider-val'; + val.textContent = String(value); + if (disabled) { + range.addEventListener('click', (e) => { + e.preventDefault(); + showSoonToast(); + }); + } else { + range.addEventListener('input', () => { + val.textContent = range.value; + }); + } + row.appendChild(span); + row.appendChild(range); + row.appendChild(val); + return row; +} + +function buildAside(wrap) { + const aside = document.createElement('aside'); + aside.className = 'beacon-aside'; + + const transfer = buildControlSection('beacon.panel_transfer', [ + buildSliderRow('beacon.slider_peers', 3), + buildSliderRow('beacon.slider_up_cap', 50, { min: 10, max: 100 }), + ]); + + const seeding = buildControlSection('beacon.panel_seeding', [ + mkBtn( + 'beacon.pause_all', + 'btn btn-lang beacon-aside-btn', + () => { + const items = getBeaconCatalog().filter((i) => i.canSave); + const anyActive = items.some((i) => !i.paused); + setAllSeedsPaused(anyActive); + showAppToast({ + title: anyActive ? t('beacon.paused_all') : t('beacon.resumed_all'), + durationMs: 3200, + }); + renderTableRows(); + }, + { disabled: false } + ), + mkBtn('beacon.stop_all', 'btn btn-lang beacon-aside-btn beacon-soon-btn', null, { soon: true }), + ]); + + const filters = buildControlSection('beacon.panel_filters', [ + (() => { + const search = document.createElement('input'); + search.type = 'search'; + search.className = 'input beacon-search beacon-soon-input'; + search.disabled = true; + search.placeholder = t('beacon.search_ph'); + search.addEventListener('click', () => showSoonToast()); + return search; + })(), + mkBtn('beacon.sort_name', 'btn btn-lang beacon-aside-btn beacon-soon-btn', null, { soon: true }), + ]); + + const refreshBtn = mkBtn( + 'beacon.refresh', + 'btn btn-accent beacon-aside-btn beacon-aside-btn--wide', + () => void refreshBeaconLocalState().then(renderTableRows) + ); + + aside.appendChild(transfer); + aside.appendChild(seeding); + aside.appendChild(filters); + aside.appendChild(refreshBtn); + return aside; +} + function buildTabs(wrap) { - const tabs = document.createElement('div'); + const tabs = document.createElement('nav'); tabs.className = 'beacon-tabs'; const defs = [ - { id: 'mesh', key: 'beacon.tab_mesh', stub: false }, - { id: 'mine', key: 'beacon.tab_mine', stub: false }, - { id: 'downloads', key: 'beacon.tab_downloads', stub: true }, + { id: 'mesh', key: 'beacon.tab_mesh' }, + { id: 'mine', key: 'beacon.tab_mine' }, + { id: 'downloads', key: 'beacon.tab_downloads' }, ]; for (const def of defs) { const btn = document.createElement('button'); btn.type = 'button'; - btn.className = `beacon-tab btn btn-lang${def.stub ? ' beacon-tab--stub' : ''}${def.id === activeTab ? ' beacon-tab--active' : ''}`; + btn.className = `beacon-tab${def.id === activeTab ? ' beacon-tab--active' : ''}`; btn.dataset.tab = def.id; btn.dataset.i18n = def.key; btn.textContent = t(def.key); - if (def.stub) btn.classList.add('beacon-tab--inactive'); btn.addEventListener('click', () => setActiveTab(def.id, wrap)); tabs.appendChild(btn); } return tabs; } -function buildToolbar(wrap) { +function buildToolbar(main, wrap) { const bar = document.createElement('div'); bar.className = 'beacon-toolbar'; @@ -262,67 +456,45 @@ function buildToolbar(wrap) { input.className = 'beacon-file-input hidden'; input.setAttribute('aria-hidden', 'true'); + const primary = document.createElement('div'); + primary.className = 'beacon-toolbar-primary'; + primary.appendChild(input); const publishBtn = document.createElement('button'); publishBtn.type = 'button'; publishBtn.className = 'btn btn-accent beacon-publish-btn'; publishBtn.dataset.i18n = 'beacon.publish'; publishBtn.textContent = t('beacon.publish'); - - const pauseBtn = document.createElement('button'); - pauseBtn.type = 'button'; - pauseBtn.className = 'btn btn-lang beacon-inactive-btn'; - pauseBtn.disabled = true; - pauseBtn.dataset.i18n = 'beacon.pause_all'; - pauseBtn.textContent = t('beacon.pause_all'); - - const clearBtn = document.createElement('button'); - clearBtn.type = 'button'; - clearBtn.className = 'btn btn-lang beacon-inactive-btn'; - clearBtn.disabled = true; - clearBtn.dataset.i18n = 'beacon.clear_done'; - clearBtn.textContent = t('beacon.clear_done'); - - const search = document.createElement('input'); - search.type = 'search'; - search.className = 'input beacon-search beacon-inactive-input'; - search.disabled = true; - search.placeholder = t('beacon.search_ph'); - search.dataset.i18nPlaceholder = 'beacon.search_ph'; - - bar.appendChild(input); - bar.appendChild(publishBtn); - bar.appendChild(pauseBtn); - bar.appendChild(clearBtn); - bar.appendChild(search); - wrap.appendChild(bar); + primary.appendChild(publishBtn); + primary.appendChild(mkBtn('beacon.add_folder', 'btn btn-lang beacon-soon-btn', null, { soon: true })); + primary.appendChild(mkBtn('beacon.clear_done', 'btn btn-lang beacon-soon-btn', null, { soon: true })); + + const drop = document.createElement('div'); + drop.className = 'beacon-drop-compact beacon-soon-block'; + drop.dataset.i18n = 'beacon.drop_hint'; + drop.textContent = t('beacon.drop_hint'); + drop.addEventListener('click', () => showSoonToast('beacon.soon_drop')); + + bar.appendChild(primary); + bar.appendChild(drop); + const table = main.querySelector('.beacon-table-wrap'); + if (table) main.insertBefore(bar, table); wirePublishInput(wrap); } -function buildDropZone() { - const zone = document.createElement('div'); - zone.className = 'beacon-dropzone glass beacon-inactive-block'; - const label = document.createElement('span'); - label.dataset.i18n = 'beacon.drop_hint'; - label.textContent = t('beacon.drop_hint'); - zone.appendChild(label); - return zone; -} - function buildTable() { const wrap = document.createElement('div'); wrap.className = 'beacon-table-wrap'; const head = document.createElement('div'); head.className = 'beacon-table-head'; - const cols = [ + for (const key of [ 'beacon.col_name', 'beacon.col_size', 'beacon.col_progress', 'beacon.col_seeders', 'beacon.col_status', 'beacon.col_actions', - ]; - for (const key of cols) { + ]) { const cell = document.createElement('span'); cell.className = 'beacon-table-head-cell'; cell.dataset.i18n = key; @@ -347,12 +519,10 @@ if (typeof window !== 'undefined') { window.addEventListener('blip-beacon-progress', onCatalogUpdate); } -/** Main nav view — BEACON / МАЯК */ export function renderBeaconView() { activeTab = 'mesh'; const wrap = document.createElement('div'); wrap.className = 'view beacon-view'; - viewRoot = wrap; const titleRow = document.createElement('div'); titleRow.className = 'section-title-row'; @@ -363,19 +533,29 @@ export function renderBeaconView() { titleRow.appendChild(title); titleRow.appendChild(createPixelHintIcon('beacon.hint')); + const body = document.createElement('div'); + body.className = 'beacon-body'; + + const main = document.createElement('div'); + main.className = 'beacon-main'; + main.appendChild(buildTabs(wrap)); + main.appendChild(buildTable()); + + body.appendChild(buildAside(wrap)); + body.appendChild(main); + wrap.appendChild(titleRow); wrap.appendChild(buildStatsBar()); - wrap.appendChild(buildTabs(wrap)); - buildToolbar(wrap); - wrap.appendChild(buildDropZone()); - wrap.appendChild(buildTable()); + wrap.appendChild(body); + + buildToolbar(main, wrap); void refreshBeaconLocalState().then(renderTableRows); renderTableRows(); return wrap; } -/** Legacy floating panel. */ export function mountBeaconPanel() { return renderBeaconView(); } + diff --git a/renderer/chat-forward-picker.js b/renderer/chat-forward-picker.js new file mode 100644 index 0000000..e5ddb91 --- /dev/null +++ b/renderer/chat-forward-picker.js @@ -0,0 +1,65 @@ +import { t } from './i18n.js'; + +/** + * Pick a peer to forward a message to. + * @param {{ id: number, label: string }[]} targets + * @returns {Promise} + */ +export function openForwardPeerPicker(targets) { + return new Promise((resolve) => { + if (!targets?.length) { + resolve(null); + return; + } + + const backdrop = document.createElement('div'); + backdrop.className = 'blip-modal-backdrop'; + + const modal = document.createElement('div'); + modal.className = 'blip-modal glass chat-forward-modal'; + + const title = document.createElement('h3'); + title.className = 'blip-modal-title'; + title.dataset.i18n = 'chat.forward_title'; + title.textContent = t('chat.forward_title'); + + const list = document.createElement('div'); + list.className = 'chat-forward-list'; + + let done = false; + const finish = (id) => { + if (done) return; + done = true; + backdrop.remove(); + resolve(id ?? null); + }; + + for (const peer of targets) { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'btn btn-lang chat-forward-peer-btn'; + btn.textContent = peer.label || `BLIP-${peer.id}`; + btn.addEventListener('click', () => finish(peer.id)); + list.appendChild(btn); + } + + const actions = document.createElement('div'); + actions.className = 'blip-modal-actions'; + const cancelBtn = document.createElement('button'); + cancelBtn.type = 'button'; + cancelBtn.className = 'btn btn-lang'; + cancelBtn.textContent = t('dialog.cancel'); + cancelBtn.addEventListener('click', () => finish(null)); + actions.appendChild(cancelBtn); + + backdrop.addEventListener('click', (e) => { + if (e.target === backdrop) finish(null); + }); + + modal.appendChild(title); + modal.appendChild(list); + modal.appendChild(actions); + backdrop.appendChild(modal); + document.body.appendChild(backdrop); + }); +} diff --git a/renderer/chat-message-content.js b/renderer/chat-message-content.js index 09be5cb..384aa11 100644 --- a/renderer/chat-message-content.js +++ b/renderer/chat-message-content.js @@ -17,6 +17,27 @@ function openExternalUrl(url) { if (window.blip?.openExternal) void window.blip.openExternal(url); } +export function appendForwardBlock(block, forwardFrom) { + if (!forwardFrom) return; + const box = document.createElement('div'); + box.className = 'chat-forward'; + const label = document.createElement('span'); + label.className = 'chat-forward-label'; + label.dataset.i18n = 'chat.forwarded'; + label.textContent = t('chat.forwarded'); + const from = document.createElement('span'); + from.className = 'chat-forward-from'; + const who = forwardFrom.fromLabel || forwardFrom.sourceLabel || ''; + from.textContent = who ? `${t('chat.forward_from')} ${who}` : t('chat.forward_from_unknown'); + const body = document.createElement('span'); + body.className = 'chat-forward-body'; + body.textContent = forwardFrom.preview || forwardFrom.text || ''; + box.appendChild(label); + box.appendChild(from); + box.appendChild(body); + block.appendChild(box); +} + export function appendQuoteBlock(block, replyTo, onQuoteClick) { if (!replyTo?.id) return; const q = document.createElement('div'); @@ -186,6 +207,7 @@ function appendTextWithEmbeds(block, text) { * @param {{ text?: string, attachment?: object, replyTo?: object }} m */ export function appendChatMessageBody(block, m, opts = {}) { + if (m.forwardFrom) appendForwardBlock(block, m.forwardFrom); if (m.replyTo) appendQuoteBlock(block, m.replyTo, opts.onQuoteClick); const att = m.attachment; @@ -220,3 +242,18 @@ export function buildReplyPreview(m, fromId) { export function formatReplyFromLabel(from, displayName) { return displayName || `BLIP-${from}`; } + +/** Lite forward snapshot (wire-safe; no blob re-upload). */ +export function buildForwardSnapshot(sourcePeerId, m, fromLabel) { + const snap = { + sourcePeerId: Number(sourcePeerId), + messageId: m.id || null, + fromLabel: fromLabel || '', + preview: buildReplyPreview(m, sourcePeerId), + text: String(m.text || '').slice(0, 500), + }; + if (m.attachment?.seedId) snap.seedId = String(m.attachment.seedId); + if (m.attachment?.kind) snap.attachmentKind = m.attachment.kind; + if (m.attachment?.name) snap.attachmentName = m.attachment.name; + return snap; +} diff --git a/renderer/chat.js b/renderer/chat.js index 84014b7..9d7cc67 100644 --- a/renderer/chat.js +++ b/renderer/chat.js @@ -13,8 +13,11 @@ import { import { appendChatMessageBody, buildReplyPreview, + buildForwardSnapshot, formatReplyFromLabel, } from './chat-message-content.js'; +import { openForwardPeerPicker } from './chat-forward-picker.js'; +import { showAppToast } from './toasts.js'; import { normalizeFileLimitGb } from './file-transfer-limits.js'; function formatFileLimitLabelForChat(config) { @@ -159,7 +162,8 @@ export function createChatView( onPin, onEdit, onPeerProfile, - getPeer + getPeer, + getForwardTargets ) { registerMediaPlaceholder(t('chat.image_sent')); registerMediaPlaceholder(t('chat.file_sent')); @@ -556,6 +560,7 @@ export function createChatView( outgoing: true, attachment: payload.attachment, replyTo: pendingReply ? { ...pendingReply } : undefined, + forwardFrom: payload.forwardFrom, }; clearReplyTarget(); addMessage(peerId, msg); @@ -834,6 +839,46 @@ export function createChatView( }); } + async function forwardMessage(m) { + if (!m?.id) return; + const raw = + typeof getForwardTargets === 'function' ? getForwardTargets() : []; + const targets = raw.filter((p) => Number(p.id) !== Number(peerId)); + if (!targets.length) { + void openAlertDialog({ title: t('chat.forward_no_peers') }); + return; + } + const targetId = await openForwardPeerPicker(targets); + if (targetId == null) return; + const fromLabel = m.outgoing + ? t('chat.reply_you') + : formatReplyFromLabel(peerId, name.textContent); + const forwardFrom = buildForwardSnapshot(peerId, m, fromLabel); + const outMsg = { + id: createMessageId(), + from: getConfig().blipId, + to: targetId, + text: '', + timestamp: Date.now(), + outgoing: true, + forwardFrom, + }; + addMessage(targetId, outMsg); + recordMessageSent(); + const result = await onSend?.(targetId, outMsg); + if (result?.ok === false) { + const list = getMessages(targetId); + const last = list.pop(); + if (last?.id === outMsg.id) persist(); + } else { + showAppToast({ title: t('chat.forward_sent'), durationMs: 3200 }); + } + if (Number(targetId) === Number(peerId)) { + renderMessages(); + scrollToBottom(); + } + } + async function startEditMessage(m) { if (!m?.id || !m.outgoing) return; const next = await openEditMessageDialog(m); @@ -864,7 +909,9 @@ export function createChatView( menuEl.appendChild(btn); }; if (m.id) { + add('chat.reply', () => setReplyTarget(m)); add('chat.pin', () => setPinMessage(m)); + add('chat.forward', () => void forwardMessage(m)); if (m.outgoing && (m.text || '').trim()) { add('chat.edit', () => void startEditMessage(m)); } @@ -1061,6 +1108,7 @@ export function createChatView( attachment: msg.attachment, reactions: msg.reactions, replyTo: msg.replyTo, + forwardFrom: msg.forwardFrom, editedAt: msg.editedAt, }; addMessage(peerId, incoming); diff --git a/renderer/i18n.js b/renderer/i18n.js index 43b0e79..48d5f5e 100644 --- a/renderer/i18n.js +++ b/renderer/i18n.js @@ -89,6 +89,13 @@ const locales = { 'chat.reply_preview_video': 'Video', 'chat.reply_preview_file': 'File: {name}', 'chat.reply_preview_youtube': 'YouTube', + 'chat.forward': 'Forward', + 'chat.forward_title': 'Forward to', + 'chat.forwarded': 'Forwarded', + 'chat.forward_from': 'from', + 'chat.forward_from_unknown': 'from unknown chat', + 'chat.forward_sent': 'Message forwarded', + 'chat.forward_no_peers': 'No online peers to forward to.', 'media.share': 'Share', 'media.download': 'Download', 'media.close': 'Close', @@ -772,8 +779,35 @@ const locales = { 'beacon.status_publishing': 'Publishing', 'beacon.status_downloading': 'Downloading', 'beacon.status_seeding': 'Seeding', + 'beacon.status_stopped': 'Stopped', + 'beacon.resume_seed': 'Resume seeding', 'beacon.status_available': 'Available', 'beacon.soon_downloads': 'Download queue — coming in a later 1.1 build.', + 'beacon.soon_feature': 'Coming in a later 1.1 build.', + 'beacon.soon_stop': 'Stop seeding failed.', + 'beacon.soon_drop': 'Drag & drop publish — coming soon.', + 'beacon.stat_down_label': 'Download', + 'beacon.stat_up_label': 'Upload', + 'beacon.stat_mesh_label': 'In mesh', + 'beacon.stat_mine_label': 'My seeds', + 'beacon.stat_seeders_label': 'Seeders', + 'beacon.stat_active_label': 'Active', + 'beacon.panel_transfer': 'Transfer', + 'beacon.panel_seeding': 'Seeding', + 'beacon.panel_filters': 'Filters', + 'beacon.slider_peers': 'Parallel peers', + 'beacon.slider_up_cap': 'Upload cap %', + 'beacon.pause': 'Pause', + 'beacon.resume': 'Resume', + 'beacon.stop': 'Stop', + 'beacon.stop_all': 'Stop all seeding', + 'beacon.paused_all': 'All seeds paused', + 'beacon.resumed_all': 'Seeding resumed', + 'beacon.refresh': 'Refresh mesh', + 'beacon.add_folder': 'Add folder', + 'beacon.copy_link': 'Copy link', + 'beacon.sort_name': 'Sort by name', + 'beacon.status_paused': 'Paused', 'settings.dev_export': 'Export diagnostics', 'settings.dev_export_done': 'Diagnostics copied to clipboard', 'settings.dev_export_fail': 'Could not copy diagnostics', @@ -1001,6 +1035,13 @@ const locales = { 'chat.reply_preview_video': 'Видео', 'chat.reply_preview_file': 'Файл: {name}', 'chat.reply_preview_youtube': 'ЮТУБ', + 'chat.forward': 'Переслать', + 'chat.forward_title': 'Переслать', + 'chat.forwarded': 'Переслано', + 'chat.forward_from': 'из', + 'chat.forward_from_unknown': 'из неизвестного чата', + 'chat.forward_sent': 'Сообщение переслано', + 'chat.forward_no_peers': 'Нет онлайн-пиров для пересылки.', 'media.share': 'Поделиться', 'media.download': 'Скачать', 'media.close': 'Закрыть', @@ -1657,7 +1698,7 @@ const locales = { 'beacon.save': 'Сохранить как…', 'beacon.publishing': 'Публикация', 'beacon.downloading': 'Загрузка', - 'beacon.published': 'Опубликовано в mesh', + 'beacon.published': 'Опубликовано в меш', 'beacon.saved': 'Файл сохранён', 'beacon.failed': 'Ошибка МАЯК', 'beacon.mine': 'Мой сид', @@ -1682,8 +1723,35 @@ const locales = { 'beacon.status_publishing': 'Публикация', 'beacon.status_downloading': 'Загрузка', 'beacon.status_seeding': 'Раздаёт', + 'beacon.status_stopped': 'Остановлено', + 'beacon.resume_seed': 'Возобновить раздачу', 'beacon.status_available': 'Доступен', 'beacon.soon_downloads': 'Очередь загрузок — в одной из следующих сборок 1.1.', + 'beacon.soon_feature': 'Появится в одной из следующих сборок 1.1.', + 'beacon.soon_stop': 'Не удалось остановить раздачу.', + 'beacon.soon_drop': 'Публикация перетаскиванием — скоро.', + 'beacon.stat_down_label': 'Загрузка', + 'beacon.stat_up_label': 'Отдача', + 'beacon.stat_mesh_label': 'В сети', + 'beacon.stat_mine_label': 'Мои сиды', + 'beacon.stat_seeders_label': 'Сидов', + 'beacon.stat_active_label': 'Активно', + 'beacon.panel_transfer': 'Передача', + 'beacon.panel_seeding': 'Раздача', + 'beacon.panel_filters': 'Фильтры', + 'beacon.slider_peers': 'Параллельные пиры', + 'beacon.slider_up_cap': 'Лимит отдачи %', + 'beacon.pause': 'Пауза', + 'beacon.resume': 'Продолжить', + 'beacon.stop': 'Стоп', + 'beacon.stop_all': 'Остановить всё', + 'beacon.paused_all': 'Все сиды на паузе', + 'beacon.resumed_all': 'Раздача возобновлена', + 'beacon.refresh': 'Обновить меш', + 'beacon.add_folder': 'Добавить папку', + 'beacon.copy_link': 'Копировать ссылку', + 'beacon.sort_name': 'По имени', + 'beacon.status_paused': 'Пауза', 'settings.dev_export': 'Экспорт диагностики', 'settings.dev_export_done': 'Диагностика скопирована в буфер', 'settings.dev_export_fail': 'Не удалось скопировать', diff --git a/renderer/styles.css b/renderer/styles.css index 0a74277..9fc64b7 100644 --- a/renderer/styles.css +++ b/renderer/styles.css @@ -3254,98 +3254,236 @@ select.blip-select:focus { } .beacon-view { - padding: 16px 20px; + padding: 16px 20px 24px; display: flex; flex-direction: column; - gap: 12px; + gap: 14px; max-height: 100%; overflow: auto; } -.beacon-stats { +.beacon-stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 10px; +} + +.beacon-stat-card { display: flex; - flex-wrap: wrap; - gap: 12px 20px; + flex-direction: column; + gap: 6px; padding: 10px 12px; + border: 2px solid var(--blip-accent); +} + +.beacon-stat-card--muted { + opacity: 0.5; + border-color: var(--blip-muted); +} + +.beacon-stat-card strong { + font-size: 18px; + color: var(--blip-accent); + letter-spacing: 0.04em; +} + +.beacon-stat-card--muted strong { + color: var(--blip-muted); +} + +.beacon-stat-label { + font-size: 9px; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--blip-muted); +} + +.beacon-body { + display: grid; + grid-template-columns: minmax(200px, 240px) minmax(0, 1fr); + gap: 14px; + align-items: start; +} + +@media (max-width: 900px) { + .beacon-body { + grid-template-columns: 1fr; + } +} + +.beacon-aside { + display: flex; + flex-direction: column; + gap: 10px; +} + +.beacon-control-section { + padding: 12px; + display: flex; + flex-direction: column; + gap: 10px; + border: 2px solid var(--blip-border); +} + +.beacon-control-title { + margin: 0; font-size: 10px; - letter-spacing: 0.08em; + letter-spacing: 0.12em; text-transform: uppercase; + color: var(--blip-accent); +} + +.beacon-slider-row { + display: grid; + grid-template-columns: 1fr auto; + grid-template-rows: auto auto; + gap: 4px 10px; + align-items: center; + font-size: 10px; + color: var(--blip-text); } -.beacon-stat { +.beacon-slider-row span:first-child { + grid-column: 1 / -1; color: var(--blip-muted); + letter-spacing: 0.06em; + text-transform: uppercase; } -.beacon-inactive-block { +.beacon-slider-row--soon { opacity: 0.55; - border-color: var(--blip-muted); } -.beacon-inactive-btn, -.beacon-inactive-input { - opacity: 0.45; - border-color: var(--blip-muted) !important; - color: var(--blip-muted) !important; +.beacon-range { + grid-column: 1; + width: 100%; + height: 8px; + margin: 0; + -webkit-appearance: none; + appearance: none; + background: rgba(0, 0, 0, 0.55); + border: 2px solid var(--blip-border); + border-radius: 0; + accent-color: var(--blip-accent); +} + +.beacon-range:disabled { + accent-color: var(--blip-muted); cursor: not-allowed; } +.beacon-slider-val { + font-size: 11px; + min-width: 1.5rem; + text-align: right; + color: var(--blip-muted); +} + +.beacon-aside-btn { + width: 100%; + font-size: 10px; + letter-spacing: 0.08em; +} + +.beacon-aside-btn--wide { + margin-top: 4px; +} + +.beacon-main { + display: flex; + flex-direction: column; + gap: 10px; + min-width: 0; +} + .beacon-tabs { display: flex; - flex-wrap: wrap; - gap: 8px; + gap: 0; + border-bottom: 2px solid var(--blip-border); } .beacon-tab { + flex: 1; + padding: 10px 12px; font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; + background: transparent; + border: none; + border-bottom: 2px solid transparent; + margin-bottom: -2px; + color: var(--blip-muted); + cursor: pointer; + font-family: inherit; } .beacon-tab--active { - border-color: var(--blip-accent) !important; color: var(--blip-accent); + border-bottom-color: var(--blip-accent); } -.beacon-tab--stub.beacon-tab--inactive { - opacity: 0.45; - border-color: var(--blip-muted); - color: var(--blip-muted); +.beacon-tab--soon { + opacity: 0.55; } .beacon-toolbar { display: flex; flex-wrap: wrap; - align-items: center; - gap: 8px; + align-items: stretch; + gap: 10px; + padding: 10px; + border: 2px solid var(--blip-border); + background: rgba(0, 0, 0, 0.25); } -.beacon-search { +.beacon-toolbar-primary { + display: flex; + flex-wrap: wrap; + gap: 8px; flex: 1; - min-width: 140px; - max-width: 240px; } -.beacon-dropzone { - padding: 20px 16px; - text-align: center; - font-size: 11px; +.beacon-drop-compact { + flex: 1; + min-width: 160px; + display: flex; + align-items: center; + justify-content: center; + padding: 8px 12px; + font-size: 10px; letter-spacing: 0.06em; color: var(--blip-muted); border: 2px dashed var(--blip-muted); + cursor: pointer; +} + +.beacon-soon-block, +.beacon-soon-btn, +.beacon-soon-input { + opacity: 0.5; +} + +.beacon-soon-input { + width: 100%; + cursor: not-allowed; +} + +.beacon-file-input.hidden { + display: none; } .beacon-table-wrap { display: flex; flex-direction: column; gap: 6px; - min-height: 120px; + min-height: 160px; } .beacon-table-head { display: grid; - grid-template-columns: 1.6fr 0.7fr 0.9fr 0.5fr 0.9fr 0.9fr; + grid-template-columns: 1.5fr 0.65fr 1fr 0.5fr 0.85fr 1.1fr; gap: 8px; - padding: 0 10px; + padding: 0 12px; font-size: 9px; letter-spacing: 0.1em; text-transform: uppercase; @@ -3360,11 +3498,22 @@ select.blip-select:focus { .beacon-table-row { display: grid; - grid-template-columns: 1.6fr 0.7fr 0.9fr 0.5fr 0.9fr 0.9fr; + grid-template-columns: 1.5fr 0.65fr 1fr 0.5fr 0.85fr 1.1fr; gap: 8px; align-items: center; - padding: 10px; + padding: 12px; font-size: 12px; + border: 2px solid var(--blip-border); + transition: border-color 0.12s steps(2); +} + +.beacon-table-row:hover { + border-color: var(--blip-accent); +} + +.beacon-table-row--paused { + opacity: 0.72; + border-color: var(--blip-muted); } .beacon-col-name { @@ -3386,23 +3535,69 @@ select.blip-select:focus { gap: 4px; } +.beacon-progress-track { + height: 6px; + border: 1px solid var(--blip-accent); + background: rgba(0, 0, 0, 0.45); +} + +.beacon-progress-fill { + height: 100%; + background: var(--blip-accent); + transition: width 0.12s steps(6); +} + +.beacon-status-badge { + display: inline-block; + padding: 2px 6px; + font-size: 9px; + letter-spacing: 0.08em; + text-transform: uppercase; + border: 1px solid var(--blip-muted); +} + +.beacon-status-badge--seed { + border-color: var(--blip-accent); + color: var(--blip-accent); +} + +.beacon-status-badge--avail { + color: var(--blip-muted); +} + +.beacon-status-badge--paused { + border-color: var(--blip-muted); + color: var(--blip-muted); +} + +.beacon-action-group { + display: flex; + flex-wrap: wrap; + gap: 4px; + justify-content: flex-end; +} + .beacon-row-btn { + font-size: 9px; + padding: 4px 7px; + letter-spacing: 0.06em; +} + +.beacon-sub { font-size: 10px; - padding: 4px 8px; + color: var(--blip-muted); } +.beacon-empty, .beacon-soon-panel { - padding: 16px; + padding: 20px; font-size: 11px; color: var(--blip-muted); text-align: center; } -.beacon-view-head { - display: flex; - align-items: center; - gap: 10px; - flex-wrap: wrap; +.beacon-search { + width: 100%; } .beacon-view-title { @@ -5027,6 +5222,53 @@ select.blip-select:focus { overflow: hidden; } +.chat-forward { + border-left: 3px solid rgba(255, 200, 80, 0.85); + padding: 4px 8px; + margin-bottom: 6px; + background: rgba(0, 0, 0, 0.22); + font-size: 0.72rem; +} + +.chat-forward-label { + display: block; + font-weight: 700; + color: rgba(255, 200, 80, 0.95); + margin-bottom: 2px; +} + +.chat-forward-from { + display: block; + opacity: 0.8; + margin-bottom: 2px; +} + +.chat-forward-body { + opacity: 0.85; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.chat-forward-modal { + max-width: 360px; +} + +.chat-forward-list { + display: flex; + flex-direction: column; + gap: 6px; + max-height: 280px; + overflow-y: auto; + margin: 12px 0; +} + +.chat-forward-peer-btn { + width: 100%; + text-align: left; +} + .chat-reply-bar { display: flex; align-items: center; diff --git a/renderer/ui.js b/renderer/ui.js index dbfd5c6..55526e5 100644 --- a/renderer/ui.js +++ b/renderer/ui.js @@ -565,6 +565,7 @@ function ensureChatView(peerId) { timestamp: msg.timestamp, attachment: msg.attachment, replyTo: msg.replyTo, + forwardFrom: msg.forwardFrom, }), () => { state.activePeer = null; @@ -645,7 +646,14 @@ function ensureChatView(peerId) { editedAt: payload.editedAt, }), (profilePeerId) => openPeerProfileFromUi(profilePeerId), - () => findPeerByBlipId(id) + () => findPeerByBlipId(id), + () => + state.peers + .filter((p) => p.online && !isBlocked(p.blipId) && p.blipId !== id) + .map((p) => ({ + id: p.blipId, + label: formatPeerDisplayName(p, p.blipId), + })) ); } catch (err) { console.error('[BLIP] createChatView', id, err); From 068d75b101dcea5ba1e889dcf84fdc98d88e5463 Mon Sep 17 00:00:00 2001 From: krwg Date: Sun, 24 May 2026 17:32:50 +0300 Subject: [PATCH 18/25] =?UTF-8?q?release:=201.1.1=20Beacon=20=E2=80=94=20f?= =?UTF-8?q?ix=20BEACON=20publish=20for=20large/ZIP=20files=20via=20main-pr?= =?UTF-8?q?ocess=20disk=20ingest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BEACON publish: read files from disk in main process instead of renderer memory; resolves could not be read for large files, ZIP, archives - Bump version to 1.1.1 (codename Beacon unchanged) - Update docs: CHANGELOG, README, CONTRIBUTING, ARCHITECTURE for 1.1.0/1.1.1 - Add release-notes-v1.1.0-github.md and copy-paste body for 1.1.1 - Ensure latest.yml included in Assets for auto-update compatibility --- .github/workflows/release.yml | 29 + CHANGELOG.md | 50 +- CONTRIBUTING.md | 19 +- README.md | 38 +- app-metadata.json | 2 +- build/build-info.json | 6 +- build/installer.nsh | 2 +- docs/ARCHITECTURE.md | 21 +- docs/MESH-PLUS.md | 2 +- docs/ROADMAP-1.1-BEACON.md | 29 +- docs/index.html | 8 +- docs/release-notes-v1.0.1-github.md | 47 -- electron-builder.yml | 13 + main/beacon-ingest.js | 124 ++++ main/beacon-store.js | 83 +++ main/blip-open.js | 72 +++ main/config.js | 18 + main/discovery.js | 1 + main/github-releases.js | 88 ++- main/index.js | 221 ++++++- main/tcp-client.js | 1 + main/tcp-server.js | 1 + main/tray.js | 23 +- main/updater.js | 99 ++-- package.json | 4 +- preload.cjs | 26 + renderer/appearance.js | 18 + renderer/audio.js | 10 + renderer/bandwidth-monitor.js | 66 +++ renderer/beacon-bandwidth-graph.js | 172 ++++++ renderer/beacon-mesh.js | 668 +++++++++++++++++++--- renderer/beacon-pixel-icon.js | 21 + renderer/beacon-row-menu.js | 87 +++ renderer/beacon-seed-file.js | 78 +++ renderer/beacon-ui.js | 702 ++++++++++++++++------- renderer/blip-color-input.js | 397 ++++++++++++- renderer/chat-message-content.js | 46 +- renderer/chat-pins.js | 23 +- renderer/chat.js | 29 +- renderer/file-transfer-hub.js | 169 ++++-- renderer/file-transfer.js | 2 + renderer/group-chat.js | 261 ++++++++- renderer/group-community-view.js | 7 +- renderer/groups-wire.js | 37 ++ renderer/groups.js | 5 + renderer/i18n.js | 86 ++- renderer/idle-away.js | 61 ++ renderer/linkify.js | 15 +- renderer/settings-profile-panel.js | 21 +- renderer/styles.css | 853 +++++++++++++++++++++++++--- renderer/theme-editor.js | 4 +- renderer/toast-config.js | 17 + renderer/toasts.js | 5 +- renderer/typing-sound.js | 17 + renderer/ui.js | 259 ++++++++- scripts/publish-release-notes.mjs | 25 + 56 files changed, 4577 insertions(+), 611 deletions(-) create mode 100644 .github/workflows/release.yml delete mode 100644 docs/release-notes-v1.0.1-github.md create mode 100644 main/beacon-ingest.js create mode 100644 main/blip-open.js create mode 100644 renderer/bandwidth-monitor.js create mode 100644 renderer/beacon-bandwidth-graph.js create mode 100644 renderer/beacon-pixel-icon.js create mode 100644 renderer/beacon-row-menu.js create mode 100644 renderer/beacon-seed-file.js create mode 100644 renderer/idle-away.js create mode 100644 renderer/toast-config.js create mode 100644 renderer/typing-sound.js create mode 100644 scripts/publish-release-notes.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..98389af --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,29 @@ +# Build Windows installers and publish to GitHub Releases (needs latest.yml for auto-update). +name: Release + +on: + push: + tags: + - '*' + +permissions: + contents: write + +jobs: + windows: + runs-on: windows-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 and publish to GitHub + run: npm run electron:publish:win + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 778af09..a0f72cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,22 +9,54 @@ Release **version numbers** track [`app-metadata.json`](app-metadata.json) (sync ## [Unreleased] -### Added (1.1.0 Beacon — in progress) +_No changes yet._ -- **BEACON / Mesh Library** — publish files to LAN (`seed-announce`), download via `seed-request`/`seed-chunk` (multi-peer, up to 3 seeds), save assembled file; see [`docs/ROADMAP-1.1-BEACON.md`](docs/ROADMAP-1.1-BEACON.md). -- **Chat (DM)** — Reply, single pin, Forward lite (`forwardFrom` on wire). -- **BEACON** — Stop seeding (`seed-gone`), pause/resume, resume local seed after stop. -- **UX** — Compact mode, font size sliders, idle → Away, typing sound (planned). +## [1.1.1] — Beacon — 2026-05-24 + +Patch (codename **Beacon** unchanged). ### Fixed -- **Toasts** — default auto-dismiss; close (×) and swipe-to-dismiss; message toasts no longer stay forever when actions are shown. -- **MESH TRANSFER hub** — completed transfers auto-clear after 5s; manual dismiss per row and “clear all” on header. +- **BEACON publish** — large files, ZIP and archives: ingest via main-process disk read (`file.path`) instead of `File.arrayBuffer()` in the renderer (fixes «could not be read» / NotReadableError). + +See [`docs/release-notes-v1.1.1-github.md`](docs/release-notes-v1.1.1-github.md). + +## [1.1.0] — Beacon — 2026-05-24 + +Major release (codename **Beacon** / **МАЯК**): LAN mesh file library, chat polish, UX settings, faster BEACON transfers, GitHub auto-update fixes. + +### Added + +- **BEACON / МАЯК** — publish files to LAN (`seed-announce`), multi-seed download (up to **6** peers, rarity scheduler, **1 MiB** chunks, pipelined **`seed-chunks-batch`** IPC), pause/stop/resume seeding, `seed-gone`, drag-and-drop publish, **`blip://seed/`** links, seed image previews, row **⋮** / context menu (copy link, delete local seed, pause, etc.). +- **`.blip` seed files** — torrent-style descriptors; **Open `.blip`** in BEACON; Windows **`.blip` → BLIP** file association after NSIS install (`blip://` protocol). +- **Chat** — Reply, single pin, Forward lite (`forwardFrom`) in DM and groups; `group-pin` sync; forward seed unavailable → open in BEACON or send link. +- **UX (#21–#30)** — Compact mode, UI/chat font scales, idle → Away (5 min default, configurable), local typing sound. +- **MESH TRANSFER hub** — Active/Completed sections, Open folder when path is known, 5s auto-clear. +- **Settings** — toast duration slider (2–60s); **Network** mesh bandwidth graph + sparkline. +- **Tray** — BEACON publish/download progress % in tooltip. +- **BLIP color picker** — custom in-app palette (HSV + hex/RGB) for MESH+ accent and Projects canvas (`blip-color-input.js`). +- **Updates** — GitHub **generic feed** resolver (`latest.yml` per release tag); `npm run electron:publish:win`, `npm run release:assets`, [`.github/workflows/release.yml`](.github/workflows/release.yml) on tag push. ### Changed -- **BEACON / МАЯК** — main nav entry with [BETA] badge; EN **BEACON**, RU **МАЯК** (consistent naming). -- **Video lightbox** — adjustable volume (saved in settings as `videoVolume`). +- **BEACON** in main nav (EN **BEACON**, RU **МАЯК**); stats bar; theme-adaptive accents (`var(--blip-accent)`); table/cell padding. +- **Side nav** — wider min column so labels (e.g. **SETTINGS** / **НАСТРОЙКИ**) do not clip. +- **Video lightbox** — volume slider (`videoVolume`). + +### Fixed + +- **Toasts** — auto-dismiss, × and swipe dismiss. +- **Transfer hub** — completed rows visible before auto-clear; no instant delete at 100%. +- **BEACON** — copy `blip://seed/…` to clipboard; stop seeding no longer deletes local files; mesh speed / batch IPC regressions. +- **Windows auto-update** — unsigned GitHub installers (`verifyUpdateCodeSignature = false`); stale-cache recovery; **Portable** builds show manual-update hint (no in-app install). + +### Notes + +- **1.0.3 ↔ 1.1.0** on the same LAN: old clients ignore `seed-*` traffic; chat/calls stay compatible — [`docs/COMPAT-1.0.3.md`](docs/COMPAT-1.0.3.md). +- **GitHub Release** must include **`latest.yml`** + **`BLIP-Setup-1.1.0.exe`** for in-app updates (see [`CONTRIBUTING.md`](CONTRIBUTING.md)). +- Still on **v1.0.0** Windows: install **Setup once by hand**; later versions auto-update from the app. + +See [`docs/ROADMAP-1.1-BEACON.md`](docs/ROADMAP-1.1-BEACON.md) and [`docs/release-notes-v1.1.0-github.md`](docs/release-notes-v1.1.0-github.md). ## [1.0.3] — Echo — 2026-05-23 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 40a38e4..ad6b8d5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -55,10 +55,27 @@ Requires Windows for the current electron-builder targets: ```bash npm run electron:build # NSIS installer npm run electron:build:portable +npm run electron:build:all # Setup + portable + latest.yml (auto-update manifest) ``` Outputs go to `dist-electron/` (see `electron-builder.yml`). +### Publishing a GitHub Release + +In-app updates need **`latest.yml`** and **`BLIP-Setup-.exe`** on the **same** release tag as `app-metadata.json` (e.g. tag `1.1.1`, not `v1.1.1`, unless you use that tag consistently). + +| Method | Command | +|--------|---------| +| **CI (recommended)** | Push git tag `1.1.1` → [`.github/workflows/release.yml`](.github/workflows/release.yml) runs `electron:publish:win` | +| **Local publish** | `$env:GH_TOKEN = "ghp_…"; npm run electron:publish:win` | +| **Manual upload** | `npm run electron:build:all` then `npm run release:assets` — attach listed files to the GitHub Release | + +Copy release notes from [`docs/release-notes-v1.1.1-github.md`](docs/release-notes-v1.1.1-github.md) (update per version). + +**Portable** builds do not receive in-app updates — users must download a new portable or install Setup once. + +**Dev vs packaged UI:** `npm run electron:dev` loads live sources from Vite; `electron:build:all` snapshots `dist/` into the exe. Always run `npm run build` before shipping (the build scripts do this automatically). + ## Release metadata Version and codename live in [`app-metadata.json`](app-metadata.json) (synced into `package.json` on `npm run build`). Update [`CHANGELOG.md`](CHANGELOG.md) when shipping. @@ -69,7 +86,7 @@ Release builds: run `npm run setup:build-secrets` once, then configure maintaine ## TCP payloads (renderer ↔ main) -Chat and signalling use newline-delimited JSON on TCP port **42070**. Common `type` values: `message`, `typing`, `ping`/`pong`, `call-*`, `group-*`, `group-call-*`, `file-*`, `clipboard-push`. See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). +Chat and signalling use newline-delimited JSON on TCP port **42070**. Common `type` values: `message`, `typing`, `ping`/`pong`, `call-*`, `group-*`, `group-call-*`, `file-*`, `clipboard-push`, `seed-*` (BEACON). See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). The renderer Vite build produces three HTML entry points: `index.html`, `call-window.html`, `group-call-window.html`. diff --git a/README.md b/README.md index 2a2331a..0f2fb11 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ Quick VM flow: host runs BLIP (ID **1**), VM runs BLIP (ID **2**), same subnet v | | | |---|---| | **What** | Desktop app: text, voice, and video over LAN / Hamachi / Radmin VPN | -| **Release** | **1.0.3 — Echo** (see [`CHANGELOG.md`](CHANGELOG.md); last GitHub tag: [v0.7.7](https://github.com/krwg/BLIP/releases/tag/v0.7.7)) | +| **Release** | **1.1.1 — Beacon** (see [`CHANGELOG.md`](CHANGELOG.md); [Releases](https://github.com/krwg/BLIP/releases)) | | **Identity** | BLIP ID **1–64** (8×8 grid, Minecraft-style chunk metaphor) | | **Servers** | None — UDP broadcast, TCP, and WebRTC peer-to-peer only | | **Sign-up** | None | @@ -88,6 +88,7 @@ Quick VM flow: host runs BLIP (ID **1**), VM runs BLIP (ID **2**), same subnet v | **Themes** | Light/dark palettes + animated backgrounds (EN/RU names) | | **Sound** | Chiptune Web Audio — **SIGNAL** / **PULSE** FX packs, **MESH** / **GRID** call melodies; preview in **Settings → Sound**; DND mutes all | | **Files** | P2P send in chat (1–100 GB limit in Settings, chunked); drag & drop; group inline ≤768 KB + chunked to all members | +| **BEACON** | LAN mesh file library (**МАЯК**): publish seeds, multi-peer download, pause/resume seeding, `blip://seed/…`, `.blip` descriptors, transfer hub | | **Clipboard** | Optional LAN clipboard sync — **Settings → Network** (off / active chat / trusted peers) | | **Status** | Custom status line on LAN (Profile) — “In game”, AFK, etc. | | **Handshake** | Ed25519 signed announce + TCP mesh handshake (0.5+); VPN/Tailscale IP routes update discovery; block list enforced in main | @@ -95,7 +96,7 @@ Quick VM flow: host runs BLIP (ID **1**), VM runs BLIP (ID **2**), same subnet v | **Languages** | Full **English / Russian** UI (including group call chrome and badges) | | **Settings** | Profile, privacy/block list, appearance, network, shortcuts, call devices, transfers | | **Window** | Custom title bar, system tray, close-to-tray (Windows), **launch at login** (Windows) | -| **Updates** | Auto-check on startup (packaged builds) | +| **Updates** | Auto-check on startup (NSIS **Setup** builds; attach `latest.yml` on GitHub — see [`CONTRIBUTING.md`](CONTRIBUTING.md)) | | **MESH+** | Optional test tier — extra customization, early features, profile badge; keys free from the author ([details](docs/MESH-PLUS.md)) |

      MESH+

      @@ -218,9 +219,10 @@ Icons: root `icon.svg` → `npm run build:icons` → `build/icon.ico`. | Command | Output | |---------|--------| -| `npm run electron:build` | **`BLIP-Setup-0.6.0.exe`** — full NSIS installer (version from `app-metadata.json`) | -| `npm run electron:build:portable` | **`BLIP-0.6.0-Portable.exe`** — single-file portable | -| `npm run electron:build:all` | Both artifacts | +| `npm run electron:build` | **`BLIP-Setup-.exe`** — NSIS installer (`app-metadata.json`) | +| `npm run electron:build:portable` | **`BLIP--Portable.exe`** — portable (no in-app auto-update) | +| `npm run electron:build:all` | Installer + portable + `latest.yml` | +| `npm run electron:publish:win` | Build + upload to GitHub Releases (`GH_TOKEN`) | | `npm run electron:build:dir` | `dist-electron/win-unpacked/BLIP.exe` (debug folder) | - **Installer (NSIS):** choose install folder, Start Menu shortcut, optional desktop shortcut, icon from `icon.svg`. @@ -237,7 +239,9 @@ Icons: root `icon.svg` → `npm run build:icons` → `build/icon.ico`. | `npm run build:icons` | `icon.svg` → `build/icon.ico` + PNG | | `npm run electron:build` | NSIS installer | | `npm run electron:build:portable` | Portable `.exe` | -| `npm run electron:build:all` | Installer + portable | +| `npm run electron:build:all` | Installer + portable + `latest.yml` | +| `npm run electron:publish:win` | Build + publish to GitHub (`GH_TOKEN`) | +| `npm run release:assets` | List files to attach to a manual release | | `npm run electron:build:dir` | Unpacked app folder | | `npm run copy-fonts` | Copy Minecraft font from npm package | @@ -311,7 +315,7 @@ blip/ │ └── assets/fonts/ # Minecraft woff2/ttf ├── docs/ # ARCHITECTURE.md + GitHub Pages landing ├── build/ # icon.ico, icon.png (generated) -├── app-metadata.json # version 1.0.3, codename Echo +├── app-metadata.json # version 1.1.1, codename Beacon ├── docs/MESH-PLUS.md # MESH+ feature list ├── ach-icons/ # achievement SVGs (bundled in renderer) ├── preload.cjs # IPC bridge @@ -342,6 +346,8 @@ blip/ | [CHANGELOG.md](CHANGELOG.md) | Release history | | [docs/MESH-PLUS.md](docs/MESH-PLUS.md) | MESH+ tier — what’s included | | [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | Technical map | +| [docs/ROADMAP-1.1-BEACON.md](docs/ROADMAP-1.1-BEACON.md) | 1.1.0 Beacon scope | +| [docs/release-notes-v1.1.1-github.md](docs/release-notes-v1.1.1-github.md) | GitHub Release body (1.1.1) | | [Landing site (Pages)](https://krwg.github.io/BLIP/) | Static showcase (`docs/index.html`) |

      License

      @@ -373,7 +379,7 @@ The **Minecraft** font is licensed separately under [MIT](https://github.com/bs- | | | |---|---| | **Что это** | Desktop-приложение: текст, голос и видео по LAN / Hamachi / Radmin VPN | -| **Релиз** | **1.0.3 — Echo** (см. [`CHANGELOG.md`](CHANGELOG.md); на GitHub: [v0.7.7](https://github.com/krwg/BLIP/releases/tag/v0.7.7)) | +| **Релиз** | **1.1.1 — Beacon** (см. [`CHANGELOG.md`](CHANGELOG.md); [Releases](https://github.com/krwg/BLIP/releases)) | | **Идентификация** | BLIP ID **1–64** (сетка 8×8) | | **Серверы** | Нет — только UDP broadcast, TCP и WebRTC между пирами | | **Регистрация** | Нет | @@ -397,6 +403,7 @@ The **Minecraft** font is licensed separately under [MIT](https://github.com/bs- | **Темы** | Светлые/тёмные палитры и анимированные фоны (названия EN/RU) | | **Звук** | Chiptune (Web Audio): наборы **СИГНАЛ** / **ПУЛЬС**, мелодии **MESH** / **СЕТКА**; прослушивание в **Настройки → Звук**; DND отключает | | **Файлы** | P2P в чате (лимит 1–100 ГБ в настройках, чанки); drag & drop; в группе ≤768 КБ inline + чанки всем | +| **МАЯК (BEACON)** | Меш-библиотека файлов в LAN: сиды, мульти-загрузка, пауза/стоп раздачи, `blip://seed/…`, дескрипторы `.blip`, хаб передач | | **Буфер обмена** | Синхронизация по LAN — **Настройки → Сеть** (выкл / активный чат / доверенные) | | **Статус-текст** | Своя строка в LAN (Профиль) — «в игре», AFK и т.д. | | **Handshake** | Подписанный announce + TCP mesh-handshake (0.5+); IP VPN/Tailscale обновляет discovery; блокировка в main | @@ -404,7 +411,7 @@ The **Minecraft** font is licensed separately under [MIT](https://github.com/bs- | **Языки** | Полный интерфейс **EN / RU** (включая групповой звонок и бейджи) | | **Настройки** | Профиль, конфиденциальность/блок, вид, сеть, горячие клавиши, звонок, передачи | | **Окно** | Свой title bar, трей, в трей (Windows), **автозапуск при входе в Windows** | -| **Обновления** | Проверка при запуске (собранные сборки) | +| **Обновления** | Автопроверка при запуске (установщик **Setup**; на GitHub нужен `latest.yml` — см. [`CONTRIBUTING.md`](CONTRIBUTING.md)) | | **MESH+** | Тестовая подписка — кастомизация, ранний доступ к фичам, бейдж; ключи бесплатно от автора ([подробнее](docs/MESH-PLUS.md)) |

      MESH+

      @@ -497,9 +504,10 @@ npx electron . | Команда | Результат | |---------|-----------| -| `npm run electron:build` | **`BLIP-Setup-0.6.0.exe`** — установщик NSIS (версия из `app-metadata.json`) | -| `npm run electron:build:portable` | **`BLIP-0.6.0-Portable.exe`** — portable | -| `npm run electron:build:all` | Оба файла | +| `npm run electron:build` | **`BLIP-Setup-.exe`** — установщик NSIS (`app-metadata.json`) | +| `npm run electron:build:portable` | **`BLIP--Portable.exe`** — portable (без автообновления в приложении) | +| `npm run electron:build:all` | Setup + portable + `latest.yml` | +| `npm run electron:publish:win` | Сборка + выкладка на GitHub Releases (`GH_TOKEN`) | | `npm run electron:build:dir` | `dist-electron/win-unpacked/BLIP.exe` | - **Установщик:** выбор папки, ярлык в «Пуск», опция ярлыка на рабочем столе. @@ -516,7 +524,9 @@ npx electron . | `npm run build:icons` | `icon.svg` → `build/icon.ico` + PNG | | `npm run electron:build` | NSIS-установщик | | `npm run electron:build:portable` | Portable `.exe` | -| `npm run electron:build:all` | Установщик + portable | +| `npm run electron:build:all` | Setup + portable + `latest.yml` | +| `npm run electron:publish:win` | Сборка + публикация на GitHub (`GH_TOKEN`) | +| `npm run release:assets` | Список файлов для ручного релиза | | `npm run electron:build:dir` | Распакованная папка | | `npm run copy-fonts` | Скопировать Minecraft из npm-пакета | @@ -614,6 +624,8 @@ blip/ | [SECURITY.md](SECURITY.md) | Как сообщить об уязвимости | | [CHANGELOG.md](CHANGELOG.md) | История версий | | [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | Архитектура кода | +| [docs/ROADMAP-1.1-BEACON.md](docs/ROADMAP-1.1-BEACON.md) | Скоуп 1.1.0 Beacon | +| [docs/release-notes-v1.1.1-github.md](docs/release-notes-v1.1.1-github.md) | Текст GitHub Release (1.1.1) | | [Landing (Pages)](https://krwg.github.io/BLIP/) | Статический сайт-витрина (`docs/index.html`) |

      Лицензия

      diff --git a/app-metadata.json b/app-metadata.json index ed45305..aaec87f 100644 --- a/app-metadata.json +++ b/app-metadata.json @@ -1,6 +1,6 @@ { "displayName": "BLIP", "codename": "Beacon", - "version": "1.1.0-alpha", + "version": "1.1.1", "githubUrl": "https://github.com/krwg/BLIP" } diff --git a/build/build-info.json b/build/build-info.json index 5f3d4bb..d0ee680 100644 --- a/build/build-info.json +++ b/build/build-info.json @@ -1,7 +1,7 @@ { "issuer": "krwg-official", - "version": "1.1.0-alpha", - "buildTimestamp": 1779498984361, - "buildSignature": "MdYdo7/ScoIF9RFmMq+58f5QnrfTIl2T4YPBhUlTLawJrepzY0cqmcfe9tMd/NEz8lAI0sflQDvESsMqriezCw==", + "version": "1.1.1", + "buildTimestamp": 1779632926033, + "buildSignature": "nw5pDEsvyfsvJBfBpNbLK8nfcT81bGox1E3g1NuMIp0cQbFqqz8OLfIRTWl0URb4GUZqtwm1Onfq1i8NOi6bAA==", "meshPlusPublicKeyHash": "b91e80f821881373" } diff --git a/build/installer.nsh b/build/installer.nsh index 9f24dbe..aa4b846 100644 --- a/build/installer.nsh +++ b/build/installer.nsh @@ -9,7 +9,7 @@ !macro customFinishPage !define MUI_FINISHPAGE_TITLE "BLIP installed" - !define MUI_FINISHPAGE_TEXT "BLIP is ready on your network.$\r$\n$\r$\nAllow UDP 42069 and TCP 42070 in the firewall if peers are not discovered." + !define MUI_FINISHPAGE_TEXT "BLIP is ready on your network.$\r$\n$\r$\nAllow UDP 42069 and TCP 42070 in the firewall if peers are not discovered.$\r$\n$\r$\nFiles .blip open in BLIP automatically after install." !define MUI_FINISHPAGE_RUN_TEXT "Launch BLIP" Function StartApp diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9b1add4..4203229 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -101,7 +101,7 @@ Groups (`groups.js`, voice channels) are beta and gated by `devGroupsEnabled` in | Chat history | Renderer `localStorage` key `blip_chat_v1`. | | Favorite peer IDs | Renderer `localStorage` key `blip_favorites_v1`. | | Avatar seeds (per BLIP ID) | Renderer `localStorage` key `blip_avatar_seed_v1` (`avatar.js`). | -| Release metadata | `app-metadata.json` (version **1.0.3**, codename **Echo**, repo URL). | +| Release metadata | `app-metadata.json` (version **1.1.1**, codename **Beacon**, repo URL). | | Achievement icons | `ach-icons/*.svg` → `renderer/achievements-icons.js` (Vite `?url` imports). | | MESH+ overview | [`MESH-PLUS.md`](MESH-PLUS.md) — tier summary (no phased roadmap file). | | Group avatars | Renderer `localStorage` `blip_group_avatar_v1` | @@ -174,6 +174,23 @@ Legacy peers without `meshProto` appear with `meshLegacy` — TCP mesh with 0.5 Images use `kind: 'image'` (JPEG resize). Group `group-msg` relays inline attachments; large files use chunked mesh to each member. +## BEACON — mesh file library (1.1.0) + +Codename **Beacon** / UI **BEACON** (EN) · **МАЯК** (RU). LAN-wide seeds separate from 1:1 chat file offers. + +| Piece | Role | +|--------|------| +| `renderer/beacon-mesh.js` | Publish, download, `seed-have` rarity scheduler, pause/stop, batch chunk I/O | +| `renderer/beacon-ui.js` | Library table, tabs, drag-drop, `.blip` open, row menu | +| `renderer/beacon-seed-file.js` | `.blip` JSON descriptor parse/serialize | +| `main/beacon-store.js` | Chunk files on disk under `userData` | +| `main/blip-open.js` | `.blip` / `blip://seed/` routing, single-instance | +| `discovery.js` | UDP `seed-announce`, `seed-pulse`, `seed-gone` | + +Wire: TCP `seed-request`, `seed-chunk`, `seed-chunks-batch`, `seed-have`; default **1 MiB** chunks for new seeds; up to **6** parallel leech peers. Peers on **1.0.3** ignore unknown `seed-*` types (see [`COMPAT-1.0.3.md`](COMPAT-1.0.3.md)). + +Updates: packaged **NSIS** builds use `electron-updater` with a **generic** GitHub feed (`latest.yml` per release tag). **Portable** exe does not apply in-app updates. + ## LAN clipboard (0.5.8+) Config `clipboardSyncMode`: `off` | `active` (open 1:1 chat) | `trusted`. Renderer `clipboard-sync.js` sends `clipboard-push` (text, max 32 KB). Main forwards like other TCP types. @@ -274,5 +291,5 @@ Static showcase: [`docs/index.html`](index.html) → [krwg.github.io/BLIP](https ## Future seams -- CI packaging smoke jobs, mobile client, optional STUN/TURN only if LAN host candidates fail across complex VPN topologies (1.0.3 stays host-candidate / no public STUN). +- CI packaging smoke jobs, mobile client, optional STUN/TURN only if LAN host candidates fail across complex VPN topologies (host-candidate / no public STUN by default). - macOS/Linux autostart parity beyond Windows login items. diff --git a/docs/MESH-PLUS.md b/docs/MESH-PLUS.md index 133006d..595d21a 100644 --- a/docs/MESH-PLUS.md +++ b/docs/MESH-PLUS.md @@ -53,6 +53,6 @@ Enable **Settings → Developer → Projects** to show **PROJECTS** in the nav. LAN relay, MESH+ Bridge, bandwidth priority — not implemented yet. -**App version:** `1.0.3` · codename **Echo** (`app-metadata.json`). +**App version:** `1.1.1` · codename **Beacon** (`app-metadata.json`). *BLIP · FREE for everyone · MESH+ — free keys via [blipteam@icloud.com](mailto:blipteam@icloud.com)* diff --git a/docs/ROADMAP-1.1-BEACON.md b/docs/ROADMAP-1.1-BEACON.md index c3dd482..2d80c33 100644 --- a/docs/ROADMAP-1.1-BEACON.md +++ b/docs/ROADMAP-1.1-BEACON.md @@ -3,7 +3,7 @@ **Codename:** Beacon **UI naming:** EN **BEACON** · RU **МАЯК** (not mixed) **Version line:** `1.1.0` (no “MVP” in user-facing naming) -**Status:** In development (`app-metadata.json` → `1.1.0-alpha`) +**Status:** **Released** 2026-05-24 (`app-metadata.json` → `1.1.0`, GitHub tag `1.1.0`) --- @@ -39,6 +39,8 @@ Leecher picks peers using **two factors**, not latency alone: Default **N = 3** parallel peers; rare chunks from fast partial seeders, bulk from full seeders. +**Implemented (1.1.0):** `seed-have` bitmap + rarity-weighted peer pick; up to **6** parallel peers, pipelined batches (**16** chunks × **3** per peer), parallel serve/receive, **`seed-chunks-batch`** TCP packing, **1 MiB** chunks for new seeds. + ### Forward (lite) — file availability When forwarding a message with a Beacon attachment: @@ -120,11 +122,11 @@ Optional: `capabilities: ['beacon','reply','pin']` in announce or first TCP hand | Item | Notes | |------|--------| | Pixel nav icon | Lighthouse / tower, not generic folder | -| Drag-to-seed | Drop file on BEACON view → Publish? | -| Seed preview | Thumbnail from first chunk (images/video) | +| Drag-to-seed | Drop file on BEACON view → Publish | +| Seed preview | Thumbnail from first chunk (images) | | Tray progress | Windows tray tooltip % on large downloads | | `blip://seed/` | Copy link → paste in chat → open in Library | -| Bandwidth graph | Settings → Network, Mesh Pulse style | +| Bandwidth graph | Settings → Network, live mesh throughput | --- @@ -153,8 +155,8 @@ Optional: `capabilities: ['beacon','reply','pin']` in announce or first TCP hand - [x] Toasts: ~9s default, × and swipe dismiss. - [x] Transfer hub: 5s after 100%, per-row ×, clear all. -- [ ] Completed section + “Open folder” (phase B). -- [ ] Settings → toast duration slider (1.1.1). +- [x] Completed section + “Open folder” (when path known) +- [x] Settings → toast duration slider --- @@ -166,7 +168,7 @@ Optional: `capabilities: ['beacon','reply','pin']` in announce or first TCP hand - [x] UDP `seed-*` in `discovery.js`, TCP forward, `beacon-mesh.js` skeleton - [x] Main nav **BEACON / МАЯК** with [BETA] -### Phase B — Beacon core (in progress) +### Phase B — Beacon core ✓ - [x] Publish file → announce + local seed - [x] Single-peer download E2E @@ -180,14 +182,17 @@ Optional: `capabilities: ['beacon','reply','pin']` in announce or first TCP hand - [x] Reply UI (DM: quote, composer, context menu) - [x] Pin UI (1 pin per DM via `chat-pins.js`) - [x] Forward lite (`forwardFrom` on `message`; DM picker) -- [ ] Pin + Reply + Forward in **group** chat -- [ ] Forward seed unavailable UX + re-seed prompt +- [x] Pin + Reply + Forward in **group** chat +- [x] Forward seed unavailable UX + open in BEACON +- [x] `blip://seed/` links in chat +- [x] BEACON drag-and-drop publish ### Phase D — UX + ship -- [ ] 21, 22, 29, 30 -- [ ] i18n EN/RU, CHANGELOG 1.1.0, tag `v1.1.0` -- [ ] 1.0.3 ↔ 1.1.0 compat smoke test +- [x] 21 Compact, 22 Font scale, 29 Idle → Away, 30 Typing sound +- [x] i18n EN/RU, CHANGELOG 1.1.0 +- [x] Compat checklist [`COMPAT-1.0.3.md`](COMPAT-1.0.3.md) +- [x] Tag `1.1.0` on GitHub (attach `latest.yml` + Setup + Portable) --- diff --git a/docs/index.html b/docs/index.html index 9d6e308..7bdbe0d 100644 --- a/docs/index.html +++ b/docs/index.html @@ -4,7 +4,7 @@ BLIP — P2P LAN Messenger - + @@ -760,7 +760,7 @@
      -
      v1.1.0 Beacon · LAN · GPLv3
      +
      v1.1.1 Beacon · LAN · GPLv3

      @@ -880,7 +880,7 @@

      Стек

      // ─── i18n Data ─── const STR = { ru: { - badge: 'v1.1.0 Beacon · LAN · GPLv3', + badge: 'v1.1.1 Beacon · LAN · GPLv3', title: 'Ты в сети. Ты сигнал.', sub: 'Десктоп-мессенджер для LAN / Hamachi / Radmin VPN: чат, группы, голосовые каналы, звонки 1:1 и групповые — без облака. UDP + TCP + WebRTC, BLIP ID 1–64.', star: 'Star на GitHub', @@ -907,7 +907,7 @@

      Стек

      navIssues: 'Issues', }, en: { - badge: 'v1.1.0 Beacon · LAN · GPLv3', + badge: 'v1.1.1 Beacon · LAN · GPLv3', title: "You're on the grid. You're the signal.", sub: 'Desktop messenger for LAN / Hamachi / Radmin VPN: chat, groups, voice channels, 1:1 and group calls — no cloud. UDP + TCP + WebRTC, BLIP IDs 1–64.', star: 'Star on GitHub', diff --git a/docs/release-notes-v1.0.1-github.md b/docs/release-notes-v1.0.1-github.md deleted file mode 100644 index 72b21b1..0000000 --- a/docs/release-notes-v1.0.1-github.md +++ /dev/null @@ -1,47 +0,0 @@ -# GitHub Release v1.0.1 — copy-paste body - -Paste into the **v1.0.1** release description on GitHub (edit release → description). - ---- - -## ⚠️ Hotfix (если BLIP не запускается после установки 1.0.1) - -Ошибка **`Cannot assign to read only property 'trustState'`** — скачайте **свежий** `BLIP-Setup-1.0.1.exe` с этого релиза (пересобран после исправления) или дождитесь **1.0.2**. - ---- - -## ⚠️ Обновление с v1.0.0 (Windows) - -**Автообновление с 1.0.0 не сработает** — это ограничение уже установленной версии, не баг релиза. - -1. Скачайте **`BLIP-Setup-1.0.1.exe`** в **Assets** ниже. -2. Запустите установщик **один раз** (можно поверх BLIP 1.0.0). -3. Настройки, чаты и конфиг сохраняются. - -С **v1.0.1** следующие патчи (1.0.2 и т.д.) снова можно получать через **Настройки → Обновления**. - ---- - -## ⚠️ Upgrading from v1.0.0 (Windows) - -**Auto-update from 1.0.0 will not install this release.** Download **`BLIP-Setup-1.0.1.exe`** from **Assets** and run it once (over your current install). From **v1.0.1** onward, later versions can update from the app again. - ---- - -## What's new - -- **Verified Builds & MESH+ trust** — color-only cues for official vs unofficial build and subscription (no feature blocking). -- **MESH+** — stronger entitlement checks. -- **Profile GIF** — safer LAN sync; profile page and chat hub fixes. - -Full list: [CHANGELOG.md#101--security--fixes--2026-05-21](../CHANGELOG.md#101--security--fixes--2026-05-21). - -### Install - -| OS | File | -|----|------| -| Windows | `BLIP-Setup-1.0.1.exe` (recommended) or portable | -| macOS | `BLIP-1.0.1.dmg` | -| Linux | `.AppImage` / package from Assets | - -Windows builds are not code-signed (SmartScreen may warn). macOS: use **Open Anyway** in Privacy & Security if Gatekeeper blocks the app. diff --git a/electron-builder.yml b/electron-builder.yml index 3be6995..aec0418 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -70,3 +70,16 @@ nsis: license: LICENSE portable: artifactName: ${productName}-${version}-Portable.${ext} + +fileAssociations: + - ext: blip + name: BLIPSeed + description: BLIP Mesh Seed + mimeType: application/vnd.blip.seed+json + role: Editor + icon: icon.ico + +protocols: + - name: BLIP + schemes: + - blip diff --git a/main/beacon-ingest.js b/main/beacon-ingest.js new file mode 100644 index 0000000..ba3a03c --- /dev/null +++ b/main/beacon-ingest.js @@ -0,0 +1,124 @@ +import { createHash } from 'crypto'; +import { createReadStream } from 'fs'; +import { stat, open, readFile } from 'fs/promises'; +import { basename, extname } from 'path'; +import { getSeedDir, writeSeedMeta } from './beacon-store.js'; +import { mkdir, writeFile } from 'fs/promises'; +import { join } from 'path'; + +function chunkFilePath(seedDir, chunkIndex) { + return join(seedDir, `chunk-${String(chunkIndex).padStart(5, '0')}`); +} + +async function writeRawChunk(seedId, chunkIndex, buf) { + const dir = getSeedDir(seedId); + await mkdir(dir, { recursive: true }); + await writeFile(chunkFilePath(dir, chunkIndex), buf); +} + +/** + * Streaming SHA-256 (full hex). + * @param {string} filePath + * @param {number} chunkSize + */ +export function hashFilePath(filePath, chunkSize) { + return new Promise((resolve, reject) => { + const hash = createHash('sha256'); + const rs = createReadStream(filePath, { highWaterMark: chunkSize }); + rs.on('data', (chunk) => hash.update(chunk)); + rs.on('end', () => resolve(hash.digest('hex'))); + rs.on('error', reject); + }); +} + +function guessMime(filename) { + const ext = extname(filename || '').toLowerCase(); + const map = { + '.zip': 'application/zip', + '.7z': 'application/x-7z-compressed', + '.rar': 'application/vnd.rar', + '.pdf': 'application/pdf', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.mp4': 'video/mp4', + '.mkv': 'video/x-matroska', + '.exe': 'application/octet-stream', + '.iso': 'application/octet-stream', + }; + return map[ext] || 'application/octet-stream'; +} + +/** + * Ingest a file from disk into seed storage (no renderer File API). + * @param {string} filePath + * @param {{ chunkSize?: number, maxBytes?: number, onProgress?: (p: { phase: string, percent?: number }) => void }} [opts] + */ +export async function ingestPublishFromPath(filePath, opts = {}) { + const chunkSize = opts.chunkSize || 1048576; + const onProgress = opts.onProgress; + + let st; + try { + st = await stat(filePath); + } catch { + throw new Error('not_found'); + } + if (!st.isFile()) throw new Error('not_file'); + if (st.size <= 0) throw new Error('empty'); + if (opts.maxBytes && st.size > opts.maxBytes) throw new Error('too_large'); + + onProgress?.({ phase: 'hashing', percent: 2 }); + const fullHash = await hashFilePath(filePath, chunkSize); + const seedId = fullHash.slice(0, 16); + + const filename = basename(filePath); + const totalChunks = Math.ceil(st.size / chunkSize); + const meta = { + seedId, + filename, + size: st.size, + chunkSize, + totalChunks, + mime: guessMime(filename), + publishedAt: Date.now(), + }; + + onProgress?.({ phase: 'publishing', percent: 8 }); + + const fh = await open(filePath, 'r'); + try { + for (let i = 0; i < totalChunks; i++) { + const offset = i * chunkSize; + const len = Math.min(chunkSize, st.size - offset); + const buf = Buffer.allocUnsafe(len); + const { bytesRead } = await fh.read(buf, 0, len, offset); + if (bytesRead <= 0) throw new Error('read'); + await writeRawChunk(seedId, i, buf.subarray(0, bytesRead)); + const pct = Math.round(8 + ((i + 1) / totalChunks) * 82); + onProgress?.({ phase: 'publishing', percent: pct }); + } + } finally { + await fh.close(); + } + + await writeSeedMeta(seedId, meta); + onProgress?.({ phase: 'ready', percent: 100 }); + return meta; +} + +/** Small image preview (optional). */ +export async function tryReadImagePreviewB64(filePath, maxBytes = 8 * 1024 * 1024) { + const ext = extname(filePath).toLowerCase(); + if (!['.png', '.jpg', '.jpeg', '.gif', '.webp'].includes(ext)) return null; + try { + const st = await stat(filePath); + if (st.size > maxBytes) return null; + const buf = await readFile(filePath); + return buf.toString('base64'); + } catch { + return null; + } +} diff --git a/main/beacon-store.js b/main/beacon-store.js index b34ac32..7df08e3 100644 --- a/main/beacon-store.js +++ b/main/beacon-store.js @@ -7,6 +7,7 @@ import { readdir, access, open, + rm, } from 'fs/promises'; import { constants } from 'fs'; @@ -46,6 +47,22 @@ export async function readSeedMeta(seedId) { } } +export async function writeSeedPreview(seedId, base64Jpeg) { + if (!base64Jpeg) return; + const dir = getSeedDir(seedId); + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, 'preview.jpg'), Buffer.from(String(base64Jpeg), 'base64')); +} + +export async function readSeedPreview(seedId) { + try { + const buf = await readFile(join(getSeedDir(seedId), 'preview.jpg')); + return buf.toString('base64'); + } catch { + return null; + } +} + export async function writeSeedChunk(seedId, chunkIndex, base64Data) { const dir = getSeedDir(seedId); await mkdir(dir, { recursive: true }); @@ -53,11 +70,62 @@ export async function writeSeedChunk(seedId, chunkIndex, base64Data) { await writeFile(chunkPath(dir, chunkIndex), buf); } +/** Write many chunks in one IPC round-trip. */ +export async function writeSeedChunksBatch(seedId, chunks) { + const dir = getSeedDir(seedId); + await mkdir(dir, { recursive: true }); + await Promise.all( + (chunks || []).map(async ({ chunkIndex, data }) => { + if (chunkIndex == null || !data) return; + const buf = Buffer.from(String(data), 'base64'); + await writeFile(chunkPath(dir, chunkIndex), buf); + }) + ); +} + export async function readSeedChunk(seedId, chunkIndex) { const buf = await readFile(chunkPath(getSeedDir(seedId), chunkIndex)); return buf.toString('base64'); } +export async function readSeedChunksBatch(seedId, chunkIndices) { + const dir = getSeedDir(seedId); + const out = []; + await Promise.all( + (chunkIndices || []).map(async (chunkIndex) => { + try { + const buf = await readFile(chunkPath(dir, chunkIndex)); + out.push({ chunkIndex, data: buf.toString('base64'), ok: true }); + } catch { + out.push({ chunkIndex, ok: false }); + } + }) + ); + return out; +} + +export async function buildSeedHaveBitmap(seedId, totalChunks) { + const total = Number(totalChunks) || 0; + if (total <= 0) return ''; + const dir = getSeedDir(seedId); + const bytes = Math.ceil(total / 8); + const bitmap = new Uint8Array(bytes); + let entries = []; + try { + entries = await readdir(dir); + } catch { + return Buffer.from(bitmap).toString('base64'); + } + for (const name of entries) { + const m = /^chunk-(\d+)$/.exec(name); + if (!m) continue; + const i = Number(m[1]); + if (!Number.isFinite(i) || i < 0 || i >= total) continue; + bitmap[i >> 3] |= 1 << (i & 7); + } + return Buffer.from(bitmap).toString('base64'); +} + export async function chunkExists(seedId, chunkIndex) { try { await access(chunkPath(getSeedDir(seedId), chunkIndex), constants.F_OK); @@ -126,3 +194,18 @@ export async function promptSaveAssembledSeed(seedId, defaultName) { await assembleSeedToPath(seedId, filePath); return { ok: true, filePath }; } + +export async function localSeedExists(seedId) { + try { + await access(getSeedDir(seedId), constants.F_OK); + return true; + } catch { + return false; + } +} + +/** Remove seed directory (chunks, meta, preview). */ +export async function deleteLocalSeed(seedId) { + const dir = getSeedDir(seedId); + await rm(dir, { recursive: true, force: true }); +} diff --git a/main/blip-open.js b/main/blip-open.js new file mode 100644 index 0000000..ff6b43a --- /dev/null +++ b/main/blip-open.js @@ -0,0 +1,72 @@ +import { existsSync, readFileSync } from 'fs'; + +/** + * @param {string} raw + * @returns {{ seedId: string, filename: string, size: number, chunkSize: number, totalChunks: number } | null} + */ +export function parseBlipSeedText(raw) { + const text = String(raw || '').trim(); + if (!text) return null; + const jsonStart = text.indexOf('{'); + if (jsonStart < 0) return null; + let doc; + try { + doc = JSON.parse(text.slice(jsonStart)); + } catch { + return null; + } + const seedId = String(doc?.seedId || '') + .replace(/[^a-f0-9]/gi, '') + .slice(0, 64); + if (!seedId || doc?.type !== 'blip-seed') return null; + return { + seedId, + filename: String(doc.filename || 'file'), + size: Number(doc.size) || 0, + chunkSize: Number(doc.chunkSize) || 0, + totalChunks: Number(doc.totalChunks) || 0, + }; +} + +/** + * @param {string} path + */ +export function isBlipFilePath(path) { + return typeof path === 'string' && /\.blip$/i.test(path) && existsSync(path); +} + +/** + * @param {string[]} argv + * @returns {string | null} + */ +export function extractBlipFileFromArgv(argv) { + for (const arg of argv || []) { + if (!arg || arg.startsWith('-')) continue; + const cleaned = arg.replace(/^"|"$/g, ''); + if (isBlipFilePath(cleaned)) return cleaned; + } + return null; +} + +/** + * @param {string[]} argv + * @returns {string | null} seedId from blip://seed/… + */ +export function extractBlipSeedIdFromArgv(argv) { + for (const arg of argv || []) { + if (!arg || !arg.includes('blip://')) continue; + const m = /blip:\/\/seed\/([a-f0-9]{8,64})/i.exec(arg); + if (m) return m[1].toLowerCase(); + } + return null; +} + +/** + * @param {string} filePath + */ +export function readBlipSeedFile(filePath) { + const text = readFileSync(filePath, 'utf8'); + const doc = parseBlipSeedText(text); + if (!doc) throw new Error('invalid_blip'); + return { filePath, text, doc }; +} diff --git a/main/config.js b/main/config.js index 0e22757..5a49734 100644 --- a/main/config.js +++ b/main/config.js @@ -107,6 +107,24 @@ const DEFAULT_CONFIG = { achievementsEnabled: false, /** Toast when an achievement unlocks. */ achievementsNotify: true, + /** In-app toast auto-dismiss (seconds, 2–60). */ + toastDurationSec: 9, + /** BEACON parallel download peers (1–8). */ + beaconParallelPeers: 6, + /** BEACON upload throttle % (10–100). */ + beaconUploadCapPercent: 100, + /** comfortable | compact — tighter spacing (#21) */ + uiDensity: 'comfortable', + /** UI font scale 0.85–1.25 (#22) */ + uiFontScale: 1, + /** Chat message font scale 0.85–1.35 (#22) */ + chatFontScale: 1, + /** Local composer key click (#30) */ + typingSoundEnabled: false, + /** Auto away after N minutes of inactivity; 0 = off (#29) */ + idleAwayMinutes: 5, + /** Set by idle timer when effective presence is away while manual status is online */ + idleAwayActive: false, }; let configPath = null; diff --git a/main/discovery.js b/main/discovery.js index 9dde8e8..91f41a0 100644 --- a/main/discovery.js +++ b/main/discovery.js @@ -129,6 +129,7 @@ export class Discovery { const { udpPort, tcpPort } = resolvePorts(this.config); let presence = this.config.presenceStatus || 'online'; if (this.config.doNotDisturb) presence = 'busy'; + else if (presence === 'online' && this.config.idleAwayActive) presence = 'away'; const ip = getLocalIp(); const meshAnnounceTs = Date.now(); const meshPubkey = this.config.meshPublicKey || ''; diff --git a/main/github-releases.js b/main/github-releases.js index 77a867e..bf597aa 100644 --- a/main/github-releases.js +++ b/main/github-releases.js @@ -28,6 +28,87 @@ export function getGithubPublishConfig() { return { provider: 'github', owner: owner || 'krwg', repo: repo || 'BLIP' }; } +/** @param {string} tag */ +export function releaseTagCandidates(tag) { + const raw = String(tag || '').trim(); + if (!raw) return []; + /** @type {string[]} */ + const out = []; + const add = (t) => { + if (t && !out.includes(t)) out.push(t); + }; + add(raw); + const bare = raw.replace(/^v/i, ''); + add(bare); + if (bare) add(`v${bare}`); + return out; +} + +/** + * GitHub asset URLs must use the release tag exactly (1.1.0 vs v1.1.0). + * @param {string} tag + * @returns {Promise} tag string that has latest.yml + */ +export async function releaseHasUpdateManifest(tag) { + const repo = loadGithubRepo(); + for (const t of releaseTagCandidates(tag)) { + const url = `https://github.com/${repo}/releases/download/${encodeURIComponent(t)}/latest.yml`; + try { + const res = await fetch(url, { + method: 'HEAD', + redirect: 'follow', + headers: { 'User-Agent': 'BLIP-Desktop' }, + }); + if (res.ok) return t; + } catch { + /* try next candidate */ + } + } + return null; +} + +/** + * Generic feed URL for electron-updater (latest.yml + Setup on the release). + * @param {{ receiveBetaUpdates?: boolean }} [config] + * @returns {Promise<{ provider: string, url?: string, owner?: string, repo?: string, channelTag?: string | null }>} + */ +export async function resolveUpdateFeedUrl(config = {}) { + const receiveBeta = !!config?.receiveBetaUpdates; + const repo = loadGithubRepo(); + const result = await fetchGithubReleases(25); + + if (result.ok && result.releases?.length) { + const stable = result.releases.filter((r) => !r.prerelease); + const betas = result.releases.filter((r) => r.prerelease); + const queues = receiveBeta ? [betas, stable] : [stable]; + + for (const list of queues) { + for (const release of list) { + const tag = await releaseHasUpdateManifest(release.tag); + if (tag) { + return { + provider: 'generic', + url: `https://github.com/${repo}/releases/download/${encodeURIComponent(tag)}/`, + channelTag: tag, + }; + } + } + } + } + + return { ...getGithubPublishConfig(), channelTag: null }; +} + +function githubApiHeaders() { + const token = (process.env.GH_TOKEN || process.env.GITHUB_TOKEN || '').trim(); + const headers = { + Accept: 'application/vnd.github+json', + 'User-Agent': 'BLIP-Desktop', + }; + if (token) headers.Authorization = `Bearer ${token}`; + return headers; +} + /** * @param {number} [limit] * @returns {Promise<{ ok: boolean, releases?: object[], error?: string }>} @@ -36,12 +117,7 @@ export async function fetchGithubReleases(limit = 8) { const repo = loadGithubRepo(); const url = `https://api.github.com/repos/${repo}/releases?per_page=${Math.min(Math.max(limit, 1), 20)}`; try { - const res = await fetch(url, { - headers: { - Accept: 'application/vnd.github+json', - 'User-Agent': 'BLIP-Desktop', - }, - }); + const res = await fetch(url, { headers: githubApiHeaders() }); if (!res.ok) { return { ok: false, error: `HTTP ${res.status}` }; } diff --git a/main/index.js b/main/index.js index 08b47b4..4e81202 100644 --- a/main/index.js +++ b/main/index.js @@ -9,13 +9,24 @@ import { getBeaconSeedsRoot, writeSeedMeta, readSeedMeta, + writeSeedPreview, + readSeedPreview, writeSeedChunk, readSeedChunk, + readSeedChunksBatch, + writeSeedChunksBatch, + buildSeedHaveBitmap, chunkExists, countLocalChunks, listLocalSeedMetas, promptSaveAssembledSeed, + deleteLocalSeed, + localSeedExists, } from './beacon-store.js'; +import { + ingestPublishFromPath, + tryReadImagePreviewB64, +} from './beacon-ingest.js'; import { createTcpServer } from './tcp-server.js'; import { connectToPeer, sendOnSocket, pingPeer } from './tcp-client.js'; import { createTcpLineReader } from './tcp-framing.js'; @@ -48,12 +59,13 @@ import { initInboundSession, } from './mesh-handshake.js'; import { isPeerBlocked } from './trust-policy.js'; -import { createTray, destroyTray } from './tray.js'; +import { createTray, destroyTray, setTrayTransferProgress } from './tray.js'; import { setupAutoUpdater, checkForUpdatesNow, quitAndInstallUpdater, configureAutoUpdater, + isPortableInstall, } from './updater.js'; import { resolveBuildAsset } from './paths.js'; import { resolvePorts } from './ports.js'; @@ -90,6 +102,11 @@ import { setPendingDisplaySource, } from './display-capture.js'; import { performFactoryReset } from './factory-reset.js'; +import { + extractBlipFileFromArgv, + extractBlipSeedIdFromArgv, + readBlipSeedFile, +} from './blip-open.js'; import os from 'os'; if (process.env.BLIP_USER_DATA_DIR) { @@ -138,6 +155,91 @@ const peerSockets = new Map(); const peerSocketConnectInflight = new Map(); /** Set in `before-quit` so the main window can distinguish Quit from close-to-tray hide. */ let appIsQuitting = false; +/** @type {Array<() => void>} */ +const pendingRendererDeliveries = []; +/** macOS: .blip opened before app.ready */ +let pendingBlipFilePath = null; + +function queueRendererDelivery(fn) { + if ( + mainWindow && + !mainWindow.isDestroyed() && + !mainWindow.webContents.isLoading() + ) { + fn(); + return; + } + pendingRendererDeliveries.push(fn); +} + +function flushPendingRendererDeliveries() { + while (pendingRendererDeliveries.length) { + const fn = pendingRendererDeliveries.shift(); + try { + fn(); + } catch (e) { + console.warn('[BLIP] pending delivery', e); + } + } +} + +function deliverBeaconOpenSeed(seedId) { + if (!seedId) return; + queueRendererDelivery(() => { + sendToRenderer('beacon-open-seed', { seedId: String(seedId) }); + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.show(); + mainWindow.focus(); + } + }); +} + +function deliverBeaconOpenBlipFile(filePath) { + try { + const { text, doc } = readBlipSeedFile(filePath); + queueRendererDelivery(() => { + sendToRenderer('beacon-open-blip-file', { filePath, text, doc }); + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.show(); + mainWindow.focus(); + } + }); + } catch (e) { + console.warn('[BLIP] open .blip file', filePath, e?.message || e); + } +} + +function handleOpenRequestFromArgv(argv) { + const blipFile = extractBlipFileFromArgv(argv); + if (blipFile) { + deliverBeaconOpenBlipFile(blipFile); + return; + } + const seedId = extractBlipSeedIdFromArgv(argv); + if (seedId) deliverBeaconOpenSeed(seedId); +} + +const gotSingleInstanceLock = app.requestSingleInstanceLock(); +if (!gotSingleInstanceLock) { + app.quit(); +} else { + app.on('second-instance', (_event, argv) => { + handleOpenRequestFromArgv(argv); + if (mainWindow && !mainWindow.isDestroyed()) { + if (!extractBlipFileFromArgv(argv) && !extractBlipSeedIdFromArgv(argv)) { + mainWindow.show(); + mainWindow.focus(); + } + } + }); +} + +app.on('open-file', (event, filePath) => { + if (!/\.blip$/i.test(filePath || '')) return; + event.preventDefault(); + if (app.isReady()) deliverBeaconOpenBlipFile(filePath); + else pendingBlipFilePath = filePath; +}); function getRendererUrl() { if (useViteDev) return 'http://localhost:5173'; @@ -218,6 +320,7 @@ function createWindow() { mainWindow.webContents.once('did-finish-load', () => { refreshGlobalShortcuts(); + flushPendingRendererDeliveries(); }); } @@ -670,7 +773,9 @@ function handleTcpPayload(msg, fromBlipId) { break; case 'seed-request': case 'seed-chunk': + case 'seed-chunks-batch': case 'seed-have': + case 'seed-have-request': case 'file-offer': case 'file-chunk': case 'file-done': @@ -1013,6 +1118,35 @@ function setupIpc() { return true; }); + ipcMain.handle('beacon-publish-from-path', async (event, { filePath, maxBytes, chunkSize }) => { + if (typeof filePath !== 'string' || !filePath.trim()) { + return { ok: false, error: 'no_path' }; + } + const wc = event.sender; + try { + const meta = await ingestPublishFromPath(filePath.trim(), { + maxBytes: Number(maxBytes) || undefined, + chunkSize: Number(chunkSize) || 1048576, + onProgress: (p) => { + try { + wc.send('beacon-ingest-progress', p); + } catch { + /* window gone */ + } + }, + }); + const previewB64 = await tryReadImagePreviewB64(filePath.trim()); + if (previewB64) { + meta.previewB64 = previewB64.length > 14000 ? previewB64.slice(0, 14000) : previewB64; + await writeSeedPreview(meta.seedId, meta.previewB64); + } + return { ok: true, meta }; + } catch (e) { + const msg = e?.message || String(e); + return { ok: false, error: msg }; + } + }); + ipcMain.handle('beacon-write-meta', async (_, { seedId, meta }) => { if (!seedId || !meta) return { ok: false }; await writeSeedMeta(seedId, meta); @@ -1024,12 +1158,35 @@ function setupIpc() { return readSeedMeta(seedId); }); + ipcMain.handle('beacon-read-preview', async (_, { seedId }) => { + if (!seedId) return { ok: false }; + const data = await readSeedPreview(seedId); + return data ? { ok: true, data } : { ok: false }; + }); + + ipcMain.handle('beacon-write-preview', async (_, { seedId, data }) => { + if (!seedId || !data) return { ok: false }; + await writeSeedPreview(seedId, data); + return { ok: true }; + }); + + ipcMain.handle('set-tray-transfer-progress', (_, info) => { + setTrayTransferProgress(info); + return { ok: true }; + }); + ipcMain.handle('beacon-write-chunk', async (_, { seedId, chunkIndex, data }) => { if (!seedId || chunkIndex == null || !data) return { ok: false }; await writeSeedChunk(seedId, Number(chunkIndex), data); return { ok: true }; }); + ipcMain.handle('beacon-write-chunks-batch', async (_, { seedId, chunks }) => { + if (!seedId || !Array.isArray(chunks) || !chunks.length) return { ok: false }; + await writeSeedChunksBatch(seedId, chunks); + return { ok: true, count: chunks.length }; + }); + ipcMain.handle('beacon-read-chunk', async (_, { seedId, chunkIndex }) => { if (!seedId || chunkIndex == null) return { ok: false }; try { @@ -1040,6 +1197,18 @@ function setupIpc() { } }); + ipcMain.handle('beacon-read-chunks-batch', async (_, { seedId, chunkIndices }) => { + if (!seedId || !Array.isArray(chunkIndices)) return { ok: false, chunks: [] }; + const chunks = await readSeedChunksBatch(seedId, chunkIndices.map(Number)); + return { ok: true, chunks }; + }); + + ipcMain.handle('beacon-have-bitmap', async (_, { seedId, totalChunks }) => { + if (!seedId) return { ok: false, bitmap: '' }; + const bitmap = await buildSeedHaveBitmap(seedId, Number(totalChunks) || 0); + return { ok: true, bitmap }; + }); + ipcMain.handle('beacon-chunk-exists', async (_, { seedId, chunkIndex }) => { if (!seedId || chunkIndex == null) return false; return chunkExists(seedId, Number(chunkIndex)); @@ -1060,6 +1229,31 @@ function setupIpc() { } }); + ipcMain.handle('beacon-delete-seed', async (_, { seedId }) => { + if (!seedId) return { ok: false }; + try { + if (await localSeedExists(seedId)) await deleteLocalSeed(seedId); + return { ok: true }; + } catch (err) { + return { ok: false, error: err?.message || 'delete_failed' }; + } + }); + + ipcMain.handle('beacon-seed-exists', async (_, { seedId }) => { + if (!seedId) return { exists: false }; + return { exists: await localSeedExists(seedId) }; + }); + + ipcMain.handle('beacon-read-blip-file', async (_, { filePath }) => { + if (!filePath || typeof filePath !== 'string') return { ok: false }; + try { + const raw = readFileSync(filePath, 'utf8'); + return { ok: true, text: raw }; + } catch (err) { + return { ok: false, error: err?.message || 'read_failed' }; + } + }); + ipcMain.handle('send-tcp-message', async (_, payload) => { try { const socket = await ensurePeerSocket(payload.to); @@ -1251,6 +1445,7 @@ function setupIpc() { ipcMain.handle('get-app-metadata', () => ({ ...loadAppMetadata(), isPackaged: app.isPackaged, + isPortable: isPortableInstall(), })); ipcMain.handle('get-app-icon-url', () => { @@ -1430,6 +1625,16 @@ function setupIpc() { return { ok: true }; }); + ipcMain.handle('show-item-in-folder', async (_, filePath) => { + if (typeof filePath !== 'string' || !filePath.trim()) return { ok: false }; + try { + shell.showItemInFolder(filePath); + return { ok: true }; + } catch { + return { ok: false }; + } + }); + ipcMain.handle('get-group-for-call', async (_, groupId) => readGroupFromMainWindow(groupId)); ipcMain.handle('open-call-outgoing', async (_, payload) => { @@ -1646,6 +1851,20 @@ app.whenReady().then(async () => { void ensureGroupCallWindow().catch((e) => console.warn('[BLIP] prewarm group call window', e)); setupAutoUpdater(() => mainWindow, () => config); + if (!app.isPackaged || process.platform === 'win32') { + try { + app.setAsDefaultProtocolClient('blip'); + } catch (e) { + console.warn('[BLIP] protocol client', e?.message || e); + } + } + + handleOpenRequestFromArgv(process.argv); + if (pendingBlipFilePath) { + deliverBeaconOpenBlipFile(pendingBlipFilePath); + pendingBlipFilePath = null; + } + app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); }); diff --git a/main/tcp-client.js b/main/tcp-client.js index 6481358..74d4419 100644 --- a/main/tcp-client.js +++ b/main/tcp-client.js @@ -11,6 +11,7 @@ export function connectToPeer(ip, blipId, tcpPort = DEFAULT_TCP_PORT) { const promise = new Promise((resolve, reject) => { const socket = net.createConnection({ host: ip, port: tcpPort }, () => { + socket.setNoDelay(true); resolve(socket); }); diff --git a/main/tcp-server.js b/main/tcp-server.js index 135704d..59743ec 100644 --- a/main/tcp-server.js +++ b/main/tcp-server.js @@ -7,6 +7,7 @@ const connections = new Map(); export function createTcpServer(handlers, tcpPort = DEFAULT_TCP_PORT) { const server = net.createServer((socket) => { + socket.setNoDelay(true); const remoteIp = socket.remoteAddress?.replace('::ffff:', '') || ''; initInboundSession(socket, remoteIp); diff --git a/main/tray.js b/main/tray.js index b1de7fc..a312634 100644 --- a/main/tray.js +++ b/main/tray.js @@ -4,6 +4,7 @@ import { resolveBuildAsset } from './paths.js'; let tray = null; let trayIconPath = null; +let baseTrayTooltip = 'BLIP'; function createTrayIconFromPath(trayPath) { if (trayPath && existsSync(trayPath)) { @@ -30,6 +31,25 @@ function createTrayIcon() { return createTrayIconFromPath(trayIconPath) || createTrayIconFallback(); } +export function setTrayBaseTooltip(tooltip) { + baseTrayTooltip = tooltip || 'BLIP'; + if (tray && !tray.isDestroyed?.()) { + tray.setToolTip(baseTrayTooltip); + } +} + +/** @param {{ percent?: number, label?: string } | null} info */ +export function setTrayTransferProgress(info) { + if (!tray || tray.isDestroyed?.()) return; + const pct = Math.round(Number(info?.percent) || 0); + if (info && pct > 0 && pct < 100) { + const label = String(info.label || 'Transfer').trim(); + tray.setToolTip(`${baseTrayTooltip} — ${label} ${pct}%`); + } else { + tray.setToolTip(baseTrayTooltip); + } +} + export function destroyTray() { if (!tray) return; try { @@ -71,7 +91,8 @@ export function createTray(opts) { const L = { show: labels?.show || 'Show', quit: labels?.quit || 'Quit' }; try { tray = new Tray(createTrayIconFromPath(trayIconPath) || createTrayIconFallback()); - tray.setToolTip(tooltip || 'BLIP'); + baseTrayTooltip = tooltip || 'BLIP'; + tray.setToolTip(baseTrayTooltip); const showMain = () => { const w = getMainWindow(); diff --git a/main/updater.js b/main/updater.js index a3a4839..3386bbd 100644 --- a/main/updater.js +++ b/main/updater.js @@ -3,9 +3,9 @@ import { join } from 'path'; import { existsSync, readFileSync, rmSync } from 'fs'; import electronUpdater from 'electron-updater'; import { - fetchGithubReleases, - getGithubPublishConfig, - loadGithubRepo, + releaseHasUpdateManifest, + releaseTagCandidates, + resolveUpdateFeedUrl, } from './github-releases.js'; const updaterModule = electronUpdater.default ?? electronUpdater; @@ -15,11 +15,20 @@ let getMainWindow = () => null; let getConfigRef = () => null; let listenersAttached = false; let retryingAfterStaleFeed = false; +/** @type {string | null} */ +let activeFeedTag = null; + +export function isPortableInstall() { + return !!( + process.env.PORTABLE_EXECUTABLE_DIR || + process.env.PORTABLE_EXECUTABLE_FILE + ); +} /** @param {object} [config] */ -export function configureAutoUpdater(config) { - if (!app.isPackaged) return Promise.resolve(); - return applyUpdateFeed(config); +export async function configureAutoUpdater(config) { + if (!app.isPackaged || isPortableInstall()) return; + await applyUpdateFeed(config); } function getUpdaterCacheDir() { @@ -43,19 +52,6 @@ export function clearUpdaterCache() { } } -async function releaseHasUpdateManifest(tag) { - const version = String(tag || '').replace(/^v/i, ''); - if (!version) return false; - const repo = loadGithubRepo(); - const url = `https://github.com/${repo}/releases/download/${version}/latest.yml`; - try { - const res = await fetch(url, { method: 'HEAD', redirect: 'follow' }); - return res.ok; - } catch { - return false; - } -} - async function sanitizeUpdaterCacheOnStartup() { const pendingInfo = join(getUpdaterCacheDir(), 'pending', 'update-info.json'); if (!existsSync(pendingInfo)) return; @@ -67,19 +63,21 @@ async function sanitizeUpdaterCacheOnStartup() { fileName.match(/BLIP-(.+?)-Portable/i) || fileName.match(/(\d+\.\d+\.\d+[^\s/]*)/); const version = match?.[1]?.replace(/^v/i, ''); - if (version && !(await releaseHasUpdateManifest(version))) { - clearUpdaterCache(); + if (version) { + let ok = false; + for (const tag of releaseTagCandidates(version)) { + if (await releaseHasUpdateManifest(tag)) { + ok = true; + break; + } + } + if (!ok) clearUpdaterCache(); } } catch { clearUpdaterCache(); } } -/** - * electron-updater with allowPrerelease reads releases.atom, which still lists - * deleted releases (orphan tags). Use GitHub REST + generic feed for betas instead. - * @param {object} [config] - */ function isUnsignedInstallerError(err) { const msg = err?.message || String(err); return /not signed by the application owner|not digitally signed|SignerCertificate/i.test( @@ -87,40 +85,36 @@ function isUnsignedInstallerError(err) { ); } +/** + * @param {object} [config] + */ async function applyUpdateFeed(config) { autoUpdater.allowPrerelease = false; autoUpdater.autoDownload = config?.autoDownloadUpdates !== false; - // GitHub releases are not Authenticode-signed; without this, Windows blocks install. + autoUpdater.autoInstallOnAppQuit = config?.autoDownloadUpdates !== false; + autoUpdater.disableDifferentialDownload = true; + if (process.platform === 'win32') { autoUpdater.verifyUpdateCodeSignature = false; } - const receiveBeta = !!config?.receiveBetaUpdates; - if (receiveBeta) { - const result = await fetchGithubReleases(15); - if (result.ok) { - for (const release of result.releases) { - if (!release.prerelease || !release.tag) continue; - const tag = release.tag.replace(/^v/i, ''); - if (await releaseHasUpdateManifest(tag)) { - const repo = loadGithubRepo(); - autoUpdater.setFeedURL({ - provider: 'generic', - url: `https://github.com/${repo}/releases/download/${tag}/`, - }); - return; - } - } - } - console.warn('[BLIP] No valid prerelease with latest.yml; using stable feed'); + const feed = await resolveUpdateFeedUrl(config); + activeFeedTag = feed.channelTag || null; + + if (feed.provider === 'generic' && feed.url) { + autoUpdater.setFeedURL({ provider: 'generic', url: feed.url }); + console.info('[BLIP] updater feed', feed.url); + return; } - autoUpdater.setFeedURL(getGithubPublishConfig()); + const { provider, owner, repo } = feed; + autoUpdater.setFeedURL({ provider, owner, repo }); + console.info('[BLIP] updater feed github', `${owner}/${repo}`); } function isStaleFeedError(err) { const msg = err?.message || String(err); - return /latest\.yml|CHANNEL_FILE_NOT_FOUND|release artifacts/i.test(msg); + return /latest\.yml|CHANNEL_FILE_NOT_FOUND|release artifacts|404/i.test(msg); } function notify(payload) { @@ -190,7 +184,7 @@ function attachListeners() { export function setupAutoUpdater(getWindow, getConfig) { getMainWindow = getWindow; getConfigRef = getConfig; - if (!app.isPackaged) return; + if (!app.isPackaged || isPortableInstall()) return; attachListeners(); void (async () => { @@ -205,18 +199,21 @@ export function setupAutoUpdater(getWindow, getConfig) { } export async function checkForUpdatesNow(getConfig) { - if (!app.isPackaged) return { ok: false, skipped: true }; + if (!app.isPackaged) return { ok: false, skipped: true, reason: 'dev' }; + if (isPortableInstall()) { + return { ok: false, skipped: true, reason: 'portable' }; + } attachListeners(); if (typeof getConfig === 'function') await configureAutoUpdater(getConfig()); try { await autoUpdater.checkForUpdates(); - return { ok: true }; + return { ok: true, channelTag: activeFeedTag }; } catch (e) { return { ok: false, error: e?.message || String(e) }; } } export function quitAndInstallUpdater() { - if (!app.isPackaged) return; + if (!app.isPackaged || isPortableInstall()) return; autoUpdater.quitAndInstall(false, true); } diff --git a/package.json b/package.json index 6cfd48a..9c2fadf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "blip", - "version": "1.1.0-alpha", + "version": "1.1.1", "description": "P2P LAN messenger — text, voice, and video. No cloud, no servers, no accounts.", "author": "krwg", "homepage": "https://krwg.github.io/BLIP/", @@ -27,6 +27,8 @@ "electron:build": "npm run build && node scripts/sync-giphy-key.mjs && node scripts/sync-mesh-public-key.mjs && node scripts/sync-build-info.mjs && npm run build:icons && node scripts/run-electron-builder.mjs --win nsis", "electron:build:portable": "npm run build && node scripts/sync-giphy-key.mjs && node scripts/sync-mesh-public-key.mjs && node scripts/sync-build-info.mjs && npm run build:icons && node scripts/run-electron-builder.mjs --win portable", "electron:build:all": "npm run build && node scripts/sync-giphy-key.mjs && node scripts/sync-mesh-public-key.mjs && node scripts/sync-build-info.mjs && npm run build:icons && node scripts/run-electron-builder.mjs --win", + "electron:publish:win": "npm run build && node scripts/sync-giphy-key.mjs && node scripts/sync-mesh-public-key.mjs && node scripts/sync-build-info.mjs && npm run build:icons && node scripts/run-electron-builder.mjs --win --publish always", + "release:assets": "node scripts/publish-release-notes.mjs", "electron:build:dir": "npm run build && node scripts/sync-giphy-key.mjs && node scripts/sync-mesh-public-key.mjs && node scripts/sync-build-info.mjs && npm run build:icons && node scripts/run-electron-builder.mjs --win --dir", "mesh-plus:keygen": "node scripts/mesh-plus-keygen.mjs", "setup:build-secrets": "node scripts/setup-build-secrets.mjs" diff --git a/preload.cjs b/preload.cjs index ba111ee..b68afe8 100644 --- a/preload.cjs +++ b/preload.cjs @@ -52,6 +52,7 @@ contextBridge.exposeInMainWorld('blip', { trendingGiphy: (offset) => ipcRenderer.invoke('trending-giphy', offset), importGiphyGif: (url) => ipcRenderer.invoke('import-giphy-gif', url), openExternal: (url) => ipcRenderer.invoke('open-external', url), + showItemInFolder: (filePath) => ipcRenderer.invoke('show-item-in-folder', filePath), listDisplaySources: () => ipcRenderer.invoke('list-display-sources'), prepareDisplayCapture: (sourceId) => ipcRenderer.invoke('prepare-display-capture', sourceId), getPeers: () => ipcRenderer.invoke('get-peers'), @@ -95,14 +96,39 @@ contextBridge.exposeInMainWorld('blip', { }, beaconSendUdp: (payload) => ipcRenderer.invoke('beacon-udp-send', payload), getBeaconPaths: () => ipcRenderer.invoke('beacon-paths'), + beaconPublishFromPath: (payload) => ipcRenderer.invoke('beacon-publish-from-path', payload), + onBeaconIngestProgress: (cb) => { + const handler = (_, data) => cb(data); + ipcRenderer.on('beacon-ingest-progress', handler); + return () => ipcRenderer.removeListener('beacon-ingest-progress', handler); + }, beaconWriteMeta: (payload) => ipcRenderer.invoke('beacon-write-meta', payload), beaconReadMeta: (payload) => ipcRenderer.invoke('beacon-read-meta', payload), + beaconReadPreview: (payload) => ipcRenderer.invoke('beacon-read-preview', payload), + beaconWritePreview: (payload) => ipcRenderer.invoke('beacon-write-preview', payload), + setTrayTransferProgress: (info) => ipcRenderer.invoke('set-tray-transfer-progress', info), beaconWriteChunk: (payload) => ipcRenderer.invoke('beacon-write-chunk', payload), + beaconWriteChunksBatch: (payload) => ipcRenderer.invoke('beacon-write-chunks-batch', payload), beaconReadChunk: (payload) => ipcRenderer.invoke('beacon-read-chunk', payload), + beaconReadChunksBatch: (payload) => ipcRenderer.invoke('beacon-read-chunks-batch', payload), + beaconHaveBitmap: (payload) => ipcRenderer.invoke('beacon-have-bitmap', payload), beaconChunkExists: (payload) => ipcRenderer.invoke('beacon-chunk-exists', payload), beaconCountChunks: (payload) => ipcRenderer.invoke('beacon-count-chunks', payload), beaconListLocal: () => ipcRenderer.invoke('beacon-list-local'), beaconSaveAssembled: (payload) => ipcRenderer.invoke('beacon-save-assembled', payload), + beaconDeleteSeed: (payload) => ipcRenderer.invoke('beacon-delete-seed', payload), + beaconSeedExists: (payload) => ipcRenderer.invoke('beacon-seed-exists', payload), + beaconReadBlipFile: (payload) => ipcRenderer.invoke('beacon-read-blip-file', payload), + onBeaconOpenSeed: (cb) => { + const handler = (_, data) => cb(data); + ipcRenderer.on('beacon-open-seed', handler); + return () => ipcRenderer.removeListener('beacon-open-seed', handler); + }, + onBeaconOpenBlipFile: (cb) => { + const handler = (_, data) => cb(data); + ipcRenderer.on('beacon-open-blip-file', handler); + return () => ipcRenderer.removeListener('beacon-open-blip-file', handler); + }, onNotificationOpenChat: (cb) => { const handler = (_, peerId) => cb(peerId); ipcRenderer.on('notification-open-chat', handler); diff --git a/renderer/appearance.js b/renderer/appearance.js index 4333bd9..c8afaca 100644 --- a/renderer/appearance.js +++ b/renderer/appearance.js @@ -169,6 +169,24 @@ export function applyAppearance(config) { config?.reactiveBackground === true && bg !== 'none' ? '1' : '0'; applyCustomAccentVars(html, config); syncReducedMotion(config); + applyUiPreferences(config); +} + +function clampScale(n, min, max, fallback = 1) { + const v = Number(n); + if (!Number.isFinite(v)) return fallback; + return Math.min(max, Math.max(min, v)); +} + +/** Density + font scales (#21, #22). */ +export function applyUiPreferences(config) { + const html = document.documentElement; + const density = config?.uiDensity === 'compact' ? 'compact' : 'comfortable'; + html.dataset.uiDensity = density; + const uiScale = clampScale(config?.uiFontScale, 0.85, 1.25, 1); + const chatScale = clampScale(config?.chatFontScale, 0.85, 1.35, 1); + html.style.setProperty('--ui-font-scale', String(uiScale)); + html.style.setProperty('--chat-font-scale', String(chatScale)); } export function applyCallWindowAppearance(config) { diff --git a/renderer/audio.js b/renderer/audio.js index aad3cea..994834f 100644 --- a/renderer/audio.js +++ b/renderer/audio.js @@ -635,3 +635,13 @@ export const sounds = { preview, ensureAudioReady, }; + +/** Local composer click (#30) — ignores global uiSoundsEnabled. */ +export async function playTypingClick() { + const ready = await ensureAudioReady(); + if (!ready) return; + const prev = soundsVolume; + soundsVolume = Math.min(1, Math.max(0.05, prev * 0.35)); + playFxKey('uiClick', true); + soundsVolume = prev; +} diff --git a/renderer/bandwidth-monitor.js b/renderer/bandwidth-monitor.js new file mode 100644 index 0000000..0051086 --- /dev/null +++ b/renderer/bandwidth-monitor.js @@ -0,0 +1,66 @@ +/** + * Rolling LAN throughput samples (BEACON + file transfer). + */ + +const HISTORY_LEN = 60; +const TICK_MS = 1000; + +/** @type {{ downBps: number, upBps: number, t: number }[]} */ +const history = []; +let currentDownBps = 0; +let currentUpBps = 0; +let tickTimer = null; + +/** @type {Set<() => void>} */ +const listeners = new Set(); + +function emit() { + for (const fn of listeners) fn(); +} + +function pushSample() { + history.push({ downBps: currentDownBps, upBps: currentUpBps, t: Date.now() }); + if (history.length > HISTORY_LEN) history.shift(); + currentDownBps = 0; + currentUpBps = 0; + emit(); +} + +function ensureTick() { + if (tickTimer) return; + tickTimer = setInterval(pushSample, TICK_MS); +} + +export function subscribeBandwidth(cb) { + listeners.add(cb); + ensureTick(); + return () => listeners.delete(cb); +} + +export function recordBandwidthSample({ downBps = 0, upBps = 0 } = {}) { + if (downBps > 0) currentDownBps = Math.max(currentDownBps, downBps); + if (upBps > 0) currentUpBps = Math.max(currentUpBps, upBps); + ensureTick(); + emit(); +} + +export function getBandwidthRates() { + const last = history[history.length - 1]; + return { + downBps: currentDownBps || last?.downBps || 0, + upBps: currentUpBps || last?.upBps || 0, + }; +} + +export function getBandwidthHistory() { + return [...history]; +} + +/** Peak combined bps in history (for graph scale). */ +export function getBandwidthHistoryPeak() { + let peak = 1; + for (const s of history) { + peak = Math.max(peak, s.downBps + s.upBps); + } + return Math.max(peak, currentDownBps + currentUpBps, 1); +} diff --git a/renderer/beacon-bandwidth-graph.js b/renderer/beacon-bandwidth-graph.js new file mode 100644 index 0000000..7b014e4 --- /dev/null +++ b/renderer/beacon-bandwidth-graph.js @@ -0,0 +1,172 @@ +import { t } from './i18n.js'; +import { formatTransferSpeed } from './file-transfer-speed.js'; +import { + getBandwidthHistory, + getBandwidthHistoryPeak, + getBandwidthRates, + subscribeBandwidth, +} from './bandwidth-monitor.js'; + +/** + * Pixel-style dual sparkline (down + up). + * @param {HTMLElement} container + * @param {{ height?: number, compact?: boolean }} [opts] + */ +export function mountBandwidthSparkline(container, opts = {}) { + const height = opts.height ?? 56; + const canvas = document.createElement('canvas'); + canvas.className = 'beacon-bw-canvas'; + canvas.width = 280; + canvas.height = height; + canvas.setAttribute('role', 'img'); + canvas.setAttribute('aria-label', t('settings.bandwidth_chart')); + + const legend = document.createElement('div'); + legend.className = 'beacon-bw-legend'; + const downEl = document.createElement('span'); + downEl.className = 'beacon-bw-legend__down'; + const upEl = document.createElement('span'); + upEl.className = 'beacon-bw-legend__up'; + legend.appendChild(downEl); + legend.appendChild(upEl); + + container.appendChild(canvas); + if (!opts.compact) container.appendChild(legend); + + const ctx = canvas.getContext('2d'); + const accent = getComputedStyle(document.documentElement) + .getPropertyValue('--blip-accent') + .trim(); + const muted = getComputedStyle(document.documentElement) + .getPropertyValue('--blip-muted') + .trim(); + const barSlots = 56; + + function draw() { + const w = canvas.width; + const h = canvas.height; + const mid = Math.floor(h / 2); + ctx.clearRect(0, 0, w, h); + + ctx.fillStyle = 'rgba(0,0,0,0.35)'; + ctx.fillRect(0, 0, w, h); + ctx.strokeStyle = muted || '#666'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(0, mid); + ctx.lineTo(w, mid); + ctx.stroke(); + + const hist = getBandwidthHistory(); + const peak = getBandwidthHistoryPeak(); + const samples = hist.length ? hist : [{ downBps: 0, upBps: 0, t: 0 }]; + const n = samples.length; + const barW = Math.max(2, Math.floor(w / barSlots)); + const slots = Math.min(barSlots, w); + + for (let i = 0; i < slots; i++) { + const idx = Math.max(0, n - slots + i); + const s = samples[idx] || { downBps: 0, upBps: 0 }; + const downH = Math.round(((s.downBps / peak) * (mid - 4)) | 0); + const upH = Math.round(((s.upBps / peak) * (mid - 4)) | 0); + const x = i * barW; + ctx.fillStyle = accent || '#0f8'; + ctx.fillRect(x, mid - downH, barW - 1, downH); + ctx.fillStyle = 'rgba(120,200,255,0.85)'; + ctx.fillRect(x, mid + 1, barW - 1, upH); + } + + const rates = getBandwidthRates(); + downEl.textContent = `↓ ${formatTransferSpeed(rates.downBps)}`; + upEl.textContent = `↑ ${formatTransferSpeed(rates.upBps)}`; + } + + draw(); + const unsub = subscribeBandwidth(draw); + return { canvas, refresh: draw, destroy: () => unsub() }; +} + +/** + * Settings → Network bandwidth block (live bars + sparkline). + * @param {HTMLElement} parent + */ +export function appendBandwidthGraphSection(parent) { + const wrap = document.createElement('div'); + wrap.className = 'settings-bandwidth-block'; + + const title = document.createElement('h3'); + title.className = 'settings-subsection-title'; + title.dataset.i18n = 'settings.bandwidth_title'; + title.textContent = t('settings.bandwidth_title'); + wrap.appendChild(title); + + const hint = document.createElement('p'); + hint.className = 'settings-hint'; + hint.dataset.i18n = 'settings.bandwidth_hint'; + hint.textContent = t('settings.bandwidth_hint'); + wrap.appendChild(hint); + + const card = document.createElement('div'); + card.className = 'settings-list-panel bandwidth-graph-panel glass'; + const sparkHost = document.createElement('div'); + sparkHost.className = 'bandwidth-graph-panel__spark'; + card.appendChild(sparkHost); + + const bars = document.createElement('div'); + bars.className = 'bandwidth-graph-panel__bars'; + const downRow = document.createElement('div'); + downRow.className = 'bandwidth-graph-panel__row'; + const upRow = document.createElement('div'); + upRow.className = 'bandwidth-graph-panel__row'; + bars.appendChild(downRow); + bars.appendChild(upRow); + card.appendChild(bars); + + wrap.appendChild(card); + parent.appendChild(wrap); + + const spark = mountBandwidthSparkline(sparkHost, { height: 64 }); + + function refreshBars() { + const peak = getBandwidthHistoryPeak(); + const rates = getBandwidthRates(); + renderBarRow(downRow, t('settings.bandwidth_down'), rates.downBps, peak, 'down'); + renderBarRow(upRow, t('settings.bandwidth_up'), rates.upBps, peak, 'up'); + } + + refreshBars(); + const unsub = subscribeBandwidth(() => { + spark.refresh(); + refreshBars(); + }); + + return { + refresh: () => { + spark.refresh(); + refreshBars(); + }, + destroy: () => { + unsub(); + spark.destroy(); + }, + }; +} + +function renderBarRow(row, label, bps, peak, kind) { + row.innerHTML = ''; + const lab = document.createElement('span'); + lab.className = 'bandwidth-graph-panel__label'; + lab.textContent = label; + const track = document.createElement('div'); + track.className = 'bandwidth-graph-panel__track'; + const fill = document.createElement('div'); + fill.className = `bandwidth-graph-panel__fill bandwidth-graph-panel__fill--${kind}`; + fill.style.width = `${Math.round((bps / peak) * 100)}%`; + track.appendChild(fill); + const val = document.createElement('span'); + val.className = 'bandwidth-graph-panel__val'; + val.textContent = formatTransferSpeed(bps); + row.appendChild(lab); + row.appendChild(track); + row.appendChild(val); +} diff --git a/renderer/beacon-mesh.js b/renderer/beacon-mesh.js index 0530ae1..7279960 100644 --- a/renderer/beacon-mesh.js +++ b/renderer/beacon-mesh.js @@ -3,12 +3,22 @@ */ import { getMaxFileBytes } from './file-transfer-limits.js'; +import { parseBlipSeedFile } from './beacon-seed-file.js'; -export const BEACON_CHUNK_SIZE = 262144; +export const BEACON_CHUNK_SIZE = 1048576; const PULSE_INTERVAL_MS = 30_000; -const CHUNK_REQUEST_BATCH = 8; -const MAX_PARALLEL_PEERS = 3; -const CHUNK_TIMEOUT_MS = 20_000; +const CHUNK_REQUEST_BATCH = 16; +const MAX_PARALLEL_PEERS = 6; +const PIPELINED_BATCHES_PER_PEER = 3; +/** Max chunks per TCP JSON line (stay under 4 MiB framing limit). */ +const SEED_TCP_BATCH = 2; +const CHUNK_TIMEOUT_MS = 45_000; +const HAVE_QUERY_MS = 12_000; + +/** peerId -> base64 bitmap for seedId */ +const peerHaveBitmaps = new Map(); +/** `${seedId}:${peerId}` -> pending have resolve */ +const pendingHave = new Map(); /** @type {Map, leechers?: number, updatedAt: number }>} */ const catalog = new Map(); @@ -16,6 +26,9 @@ const catalog = new Map(); /** @type {Set} */ const localComplete = new Set(); +/** @type {Set} */ +const localSeedIds = new Set(); + /** Seeds we have on disk but are not actively sharing (stop seeding). */ const stoppedSeeding = new Set(); @@ -34,6 +47,54 @@ let getPeerLatency = () => 9999; /** @type {Set} UI+mesh pause until TCP seed control ships */ const pausedSeeds = new Set(); +let activeServeRequests = 0; + +function getMaxParallelPeers() { + const cfg = getConfig(); + const n = Number(cfg?.beaconParallelPeers); + if (Number.isFinite(n) && n >= 1) return Math.min(8, Math.round(n)); + return MAX_PARALLEL_PEERS; +} + +function getMaxConcurrentServes() { + const cfg = getConfig(); + const cap = Number(cfg?.beaconUploadCapPercent); + const pct = Number.isFinite(cap) ? Math.max(10, Math.min(100, cap)) : 100; + return Math.max(2, Math.round(12 * (pct / 100))); +} + +async function acquireServeSlot() { + const max = getMaxConcurrentServes(); + while (activeServeRequests >= max) { + await new Promise((r) => setTimeout(r, 8)); + } + activeServeRequests += 1; +} + +function releaseServeSlot() { + activeServeRequests = Math.max(0, activeServeRequests - 1); +} + +function syncTrayProgress() { + let best = null; + for (const [, job] of jobProgress) { + if ( + job?.progress > 0 && + job.progress < 100 && + (job.phase === 'downloading' || job.phase === 'publishing' || job.phase === 'hashing') + ) { + if (!best || job.progress >= best.progress) best = job; + } + } + if (best) { + const label = + best.phase === 'publishing' || best.phase === 'hashing' ? 'BEACON ↑' : 'BEACON ↓'; + void meshApi?.setTrayTransferProgress?.({ percent: best.progress, label }); + } else { + void meshApi?.setTrayTransferProgress?.(null); + } +} + function emitCatalog() { window.dispatchEvent(new CustomEvent('blip-beacon-catalog')); } @@ -45,12 +106,15 @@ function emitProgress(seedId) { } function setJobProgress(seedId, progress, phase) { - jobProgress.set(seedId, { progress, phase }); + const prev = jobProgress.get(seedId) || {}; + jobProgress.set(seedId, { ...prev, progress, phase }); + syncTrayProgress(); emitProgress(seedId); } function clearJobProgress(seedId) { jobProgress.delete(seedId); + syncTrayProgress(); emitProgress(seedId); } @@ -69,6 +133,7 @@ function upsertCatalogEntry(data) { seeders: new Map(), leechers: Number(data.leechers) || 0, updatedAt: Date.now(), + previewB64: data.previewB64 || data.preview || '', }; catalog.set(seedId, entry); } @@ -78,6 +143,7 @@ function upsertCatalogEntry(data) { if (data.totalChunks) entry.totalChunks = Number(data.totalChunks); if (Number.isFinite(blipId) && blipId > 0) entry.seeders.set(blipId, Date.now()); if (data.leechers != null) entry.leechers = Number(data.leechers) || 0; + if (data.previewB64 || data.preview) entry.previewB64 = String(data.previewB64 || data.preview); entry.updatedAt = Date.now(); } @@ -197,7 +263,8 @@ async function loadLocalSeeds() { const cfg = getConfig(); for (const meta of list) { if (!meta?.seedId) continue; - upsertCatalogEntry({ ...meta, blipId: cfg.blipId }); + localSeedIds.add(meta.seedId); + upsertCatalogEntry({ ...meta, blipId: cfg.blipId, previewB64: meta.previewB64 }); const have = await meshApi.beaconCountChunks?.({ seedId: meta.seedId, totalChunks: meta.totalChunks, @@ -219,37 +286,240 @@ function readFileSliceAsBase64(file, start, end) { reader.onload = () => { const result = reader.result; if (typeof result !== 'string') { - reject(new Error('read')); + reject(new Error('not_readable')); return; } const comma = result.indexOf(','); resolve(comma >= 0 ? result.slice(comma + 1) : result); }; - reader.onerror = () => reject(new Error('read')); - reader.readAsDataURL(file.slice(start, end)); + reader.onerror = () => { + const err = reader.error; + reject(err instanceof Error ? err : new Error('not_readable')); + }; + try { + reader.readAsDataURL(file.slice(start, end)); + } catch (e) { + reject(e instanceof Error ? e : new Error('not_readable')); + } }); } +/** @param {File} file */ +function resolvePublishFilePath(file) { + if (!file) return ''; + const p = file.path; + if (typeof p === 'string' && p.trim()) return p.trim(); + return ''; +} + async function computeSeedId(file) { const buf = await file.arrayBuffer(); const hash = await crypto.subtle.digest('SHA-256', buf); return [...new Uint8Array(hash)].map((b) => b.toString(16).padStart(2, '0')).join('').slice(0, 16); } -function pickDownloadPeers(seedId) { +/** + * Publish via main-process disk read (large ZIP, archives, etc.). + * @param {string} filePath + * @param {File} file — for display name fallback + */ +async function publishBeaconFileFromPath(filePath, file) { + const cfg = getConfig(); + const maxBytes = getMaxFileBytes(cfg); + let unsub = () => {}; + if (typeof meshApi?.onBeaconIngestProgress === 'function') { + unsub = meshApi.onBeaconIngestProgress((p) => { + if (p?.phase === 'hashing') setJobProgress('path-ingest', 5, 'hashing'); + else if (p?.phase === 'publishing' && p.percent != null) { + setJobProgress('path-ingest', p.percent, 'publishing'); + } + }); + } + try { + const res = await meshApi.beaconPublishFromPath({ + filePath, + maxBytes, + chunkSize: BEACON_CHUNK_SIZE, + }); + if (!res?.ok || !res.meta) { + const code = res?.error || 'publish_failed'; + if (code === 'not_found' || code === 'read' || /could not be read/i.test(code)) { + throw new Error('not_readable'); + } + if (code === 'too_large') throw new Error('too_large'); + throw new Error(code); + } + const meta = res.meta; + clearJobProgress('path-ingest'); + localSeedIds.add(meta.seedId); + localComplete.add(meta.seedId); + stoppedSeeding.delete(meta.seedId); + upsertCatalogEntry({ ...meta, blipId: cfg.blipId }); + await announceSeed(meta); + setJobProgress(meta.seedId, 100, 'ready'); + clearJobProgress(meta.seedId); + return meta; + } finally { + unsub(); + clearJobProgress('path-ingest'); + } +} + +function previewDataUrlFromB64(b64) { + if (!b64) return null; + return `data:image/jpeg;base64,${b64}`; +} + +async function generatePreviewFromFile(file) { + if (!file?.type?.startsWith('image/')) return null; + return new Promise((resolve) => { + const img = new Image(); + const url = URL.createObjectURL(file); + img.onload = () => { + try { + const size = 48; + const canvas = document.createElement('canvas'); + canvas.width = size; + canvas.height = size; + const ctx = canvas.getContext('2d'); + if (!ctx) { + resolve(null); + return; + } + const scale = Math.max(size / img.width, size / img.height); + const w = img.width * scale; + const h = img.height * scale; + ctx.fillStyle = '#0a0f14'; + ctx.fillRect(0, 0, size, size); + ctx.drawImage(img, (size - w) / 2, (size - h) / 2, w, h); + const data = canvas.toDataURL('image/jpeg', 0.7); + const b64 = data.split(',')[1] || ''; + resolve(b64.length > 14000 ? b64.slice(0, 14000) : b64); + } catch { + resolve(null); + } finally { + URL.revokeObjectURL(url); + } + }; + img.onerror = () => { + URL.revokeObjectURL(url); + resolve(null); + }; + img.src = url; + }); +} + +export async function resolveBeaconPreviewUrl(seedId, previewB64) { + if (previewB64) return previewDataUrlFromB64(previewB64); + const res = await meshApi?.beaconReadPreview?.({ seedId }); + if (res?.ok && res.data) return previewDataUrlFromB64(res.data); + return null; +} + +function peerHaveKey(seedId, peerId) { + return `${seedId}:${peerId}`; +} + +function decodeBitmap(bitmapB64, totalChunks) { + if (!bitmapB64 || totalChunks <= 0) return new Set(); + try { + const bytes = Uint8Array.from(atob(bitmapB64), (c) => c.charCodeAt(0)); + const have = new Set(); + for (let i = 0; i < totalChunks; i++) { + if (bytes[i >> 3] & (1 << (i & 7))) have.add(i); + } + return have; + } catch { + return new Set(); + } +} + +function countBitmapOverlap(bitmapB64, missingSet, totalChunks) { + if (!bitmapB64 || !missingSet.size) return 0; + const have = decodeBitmap(bitmapB64, totalChunks); + let n = 0; + for (const idx of missingSet) { + if (have.has(idx)) n++; + } + return n; +} + +function waitForPeerHave(seedId, peerId) { + const key = peerHaveKey(seedId, peerId); + return new Promise((resolve) => { + const timer = setTimeout(() => { + pendingHave.delete(key); + resolve(null); + }, HAVE_QUERY_MS); + pendingHave.set(key, (bitmap) => { + clearTimeout(timer); + pendingHave.delete(key); + resolve(bitmap); + }); + }); +} + +async function refreshPeerHaves(seedId, peers, totalChunks) { + const cfg = getConfig(); + if (!meshApi?.sendTcpMessage) return; + await Promise.allSettled( + peers.map(async (peer) => { + const wait = waitForPeerHave(seedId, peer.id); + await meshApi.sendTcpMessage({ + type: 'seed-have-request', + to: peer.id, + from: cfg.blipId, + seedId, + totalChunks, + }); + const bitmap = await wait; + if (bitmap) peerHaveBitmaps.set(peerHaveKey(seedId, peer.id), bitmap); + }) + ); +} + +function pickDownloadPeers(seedId, missingSet) { const entry = catalog.get(seedId); const cfg = getConfig(); if (!entry) return []; + const total = entry.totalChunks || 0; const onlineIds = new Set( getPeers() .filter((p) => p.online && p.blipId !== cfg.blipId) .map((p) => p.blipId) ); - return [...entry.seeders.keys()] + const candidates = [...entry.seeders.keys()] .filter((id) => onlineIds.has(id)) - .map((id) => ({ id, lat: getPeerLatency(id) ?? 9999 })) - .sort((a, b) => a.lat - b.lat) - .slice(0, MAX_PARALLEL_PEERS); + .map((id) => { + const bitmap = peerHaveBitmaps.get(peerHaveKey(seedId, id)); + const overlap = bitmap + ? countBitmapOverlap(bitmap, missingSet, total) + : missingSet.size; + return { + id, + lat: getPeerLatency(id) ?? 9999, + overlap, + }; + }) + .sort((a, b) => { + if (b.overlap !== a.overlap) return b.overlap - a.overlap; + return a.lat - b.lat; + }) + .slice(0, getMaxParallelPeers()); + return candidates; +} + +function planChunkAssignments(peers, missingSet, batchSize) { + const queue = [...missingSet]; + const assignments = []; + for (const peer of peers) { + for (let p = 0; p < PIPELINED_BATCHES_PER_PEER; p++) { + if (!queue.length) break; + const chunks = queue.splice(0, batchSize); + if (chunks.length) assignments.push({ peerId: peer.id, chunks }); + } + } + return assignments; } function waitForChunk(seedId, chunkIndex) { @@ -275,24 +545,56 @@ function waitForChunk(seedId, chunkIndex) { }); } +async function collectMissingChunks(seedId, total) { + if (meshApi?.beaconHaveBitmap) { + const res = await meshApi.beaconHaveBitmap({ seedId, totalChunks: total }); + const have = decodeBitmap(res?.bitmap || '', total); + const missing = new Set(); + for (let i = 0; i < total; i++) { + if (!have.has(i)) missing.add(i); + } + return missing; + } + const missing = new Set(); + for (let i = 0; i < total; i++) { + const exists = await meshApi.beaconChunkExists?.({ seedId, chunkIndex: i }); + if (!exists) missing.add(i); + } + return missing; +} + async function requestChunksFromPeer(peerId, seedId, indices) { - if (!indices.length || !meshApi?.sendTcpMessage) return; - const cfg = getConfig(); - await meshApi.sendTcpMessage({ + if (!indices.length || !meshApi?.sendTcpMessage) return []; + + const waiters = indices.map((idx) => + waitForChunk(seedId, idx).then((data) => ({ chunkIndex: idx, data })) + ); + + void meshApi.sendTcpMessage({ type: 'seed-request', to: peerId, - from: cfg.blipId, + from: getConfig().blipId, seedId, chunks: indices, }); - for (const idx of indices) { - try { - const data = await waitForChunk(seedId, idx); - await meshApi.beaconWriteChunk?.({ seedId, chunkIndex: idx, data }); - } catch { - /* retry from another peer */ - } + + const settled = await Promise.allSettled(waiters); + const chunks = settled + .filter((r) => r.status === 'fulfilled') + .map((r) => r.value) + .filter((c) => c?.data); + + if (!chunks.length) return []; + + if (meshApi.beaconWriteChunksBatch) { + await meshApi.beaconWriteChunksBatch({ seedId, chunks }); + } else { + await Promise.all( + chunks.map((c) => meshApi.beaconWriteChunk?.({ seedId, chunkIndex: c.chunkIndex, data: c.data })) + ); } + localSeedIds.add(seedId); + return chunks.map((c) => c.chunkIndex); } async function serveSeedRequest(msg, api, config) { @@ -304,19 +606,98 @@ async function serveSeedRequest(msg, api, config) { if (stoppedSeeding.has(seedId)) return; if (!localComplete.has(seedId)) return; - for (const idx of chunks) { - const exists = await meshApi?.beaconChunkExists?.({ seedId, chunkIndex: idx }); - if (!exists) continue; - const res = await meshApi?.beaconReadChunk?.({ seedId, chunkIndex: idx }); - if (!res?.ok || !res.data) continue; - await api.sendTcpMessage({ - type: 'seed-chunk', - to: from, - from: config.blipId, - seedId, - chunkIndex: idx, - data: res.data, - }); + await acquireServeSlot(); + try { + let payloads = []; + if (meshApi?.beaconReadChunksBatch) { + const res = await meshApi.beaconReadChunksBatch({ seedId, chunkIndices: chunks }); + payloads = (res?.chunks || []).filter((c) => c?.ok && c.data); + } else { + payloads = ( + await Promise.all( + chunks.map(async (idx) => { + const exists = await meshApi?.beaconChunkExists?.({ seedId, chunkIndex: idx }); + if (!exists) return null; + const res = await meshApi?.beaconReadChunk?.({ seedId, chunkIndex: idx }); + if (!res?.ok || !res.data) return null; + return { chunkIndex: idx, data: res.data }; + }) + ) + ).filter(Boolean); + } + + const sends = []; + for (let i = 0; i < payloads.length; i += SEED_TCP_BATCH) { + const batch = payloads.slice(i, i + SEED_TCP_BATCH); + if (batch.length === 1) { + const { chunkIndex, data } = batch[0]; + sends.push( + api.sendTcpMessage({ + type: 'seed-chunk', + to: from, + from: config.blipId, + seedId, + chunkIndex, + data, + }) + ); + } else { + sends.push( + api.sendTcpMessage({ + type: 'seed-chunks-batch', + to: from, + from: config.blipId, + seedId, + chunks: batch, + }) + ); + } + } + void Promise.allSettled(sends); + const bytesUp = payloads.reduce((n, p) => n + (p.data?.length || 0) * 0.75, 0); + if (bytesUp > 0) recordBandwidthSample({ upBps: bytesUp * 8 }); + } finally { + releaseServeSlot(); + } +} + +async function respondSeedHaveRequest(msg, api, config) { + const from = Number(msg.from); + const seedId = String(msg.seedId || ''); + const totalChunks = Number(msg.totalChunks) || 0; + if (!seedId || !Number.isFinite(from) || !localComplete.has(seedId)) return; + let bitmap = ''; + if (meshApi?.beaconHaveBitmap) { + const res = await meshApi.beaconHaveBitmap({ seedId, totalChunks }); + bitmap = res?.bitmap || ''; + } + await api.sendTcpMessage({ + type: 'seed-have', + to: from, + from: config.blipId, + seedId, + totalChunks, + bitmap, + }); +} + +function ingestPeerHave(msg) { + const seedId = String(msg.seedId || ''); + const from = Number(msg.from); + const bitmap = String(msg.bitmap || ''); + if (!seedId || !Number.isFinite(from)) return; + peerHaveBitmaps.set(peerHaveKey(seedId, from), bitmap); + const pending = pendingHave.get(peerHaveKey(seedId, from)); + if (pending) pending(bitmap); +} + +function resolveIncomingChunksBatch(msg) { + const seedId = String(msg.seedId || ''); + const chunks = Array.isArray(msg.chunks) ? msg.chunks : []; + for (const c of chunks) { + if (c?.data != null && Number.isFinite(Number(c.chunkIndex))) { + resolveIncomingChunk({ seedId, chunkIndex: c.chunkIndex, data: c.data }); + } } } @@ -365,6 +746,105 @@ export function setAllSeedsPaused(paused) { emitCatalog(); } +export async function stopAllBeaconSeeds() { + for (const seedId of [...localComplete]) { + if (stoppedSeeding.has(seedId)) continue; + try { + await stopBeaconSeed(seedId); + } catch { + /* skip */ + } + } + return true; +} + +export function buildBeaconSeedLink(seedId) { + return `blip://seed/${String(seedId || '').trim()}`; +} + +export function buildBeaconAttachment(meta) { + if (!meta?.seedId) return null; + return { + kind: 'file', + name: meta.filename || 'file', + size: meta.size || 0, + seedId: meta.seedId, + beacon: true, + }; +} + +export function hasLocalSeedData(seedId) { + return localSeedIds.has(String(seedId || '')); +} + +/** + * Delete local seed data and remove from catalog. Stops mesh sharing first if needed. + */ +export async function deleteBeaconSeed(seedId) { + const cfg = getConfig(); + if (!cfg?.devBeaconEnabled) throw new Error('disabled'); + seedId = String(seedId || '').trim(); + if (!seedId) throw new Error('invalid'); + + if (localComplete.has(seedId) && !stoppedSeeding.has(seedId)) { + try { + await stopBeaconSeed(seedId); + } catch { + /* still delete local */ + } + } + + pausedSeeds.delete(seedId); + stoppedSeeding.delete(seedId); + localComplete.delete(seedId); + localSeedIds.delete(seedId); + jobProgress.delete(seedId); + catalog.delete(seedId); + peerHaveBitmaps.forEach((_, key) => { + if (key.startsWith(`${seedId}:`)) peerHaveBitmaps.delete(key); + }); + + await meshApi?.beaconDeleteSeed?.({ seedId }); + syncTrayProgress(); + emitCatalog(); + return true; +} + +/** Register a .blip descriptor and return seedId (starts download via UI). */ +export function registerBlipSeedDescriptor(doc) { + if (!doc?.seedId) throw new Error('invalid'); + upsertCatalogEntry({ + seedId: doc.seedId, + filename: doc.filename, + size: doc.size, + chunkSize: doc.chunkSize || BEACON_CHUNK_SIZE, + totalChunks: doc.totalChunks, + }); + emitCatalog(); + return doc.seedId; +} + +export async function openBlipSeedFileContent(text) { + const doc = parseBlipSeedFile(text); + if (!doc) throw new Error('invalid_blip'); + registerBlipSeedDescriptor(doc); + return doc; +} + +export async function getBeaconSeedExportMeta(seedId) { + const meta = await meshApi?.beaconReadMeta?.({ seedId }); + if (meta?.seedId) return meta; + const entry = catalog.get(seedId); + if (!entry) return null; + return { + seedId, + filename: entry.filename, + size: entry.size, + chunkSize: entry.chunkSize, + totalChunks: entry.totalChunks, + }; +} + /** Stop sharing chunks on the mesh; local files stay for Save / re-seed. */ export async function stopBeaconSeed(seedId) { const cfg = getConfig(); @@ -448,6 +928,9 @@ export function getBeaconCatalog() { stopped, progress: job?.progress ?? (local ? 100 : 0), phase: job?.phase || (local ? 'ready' : ''), + previewB64: e.previewB64 || '', + previewUrl: previewDataUrlFromB64(e.previewB64), + hasLocalData: localSeedIds.has(e.seedId), status: stopped ? 'stopped' : paused @@ -466,10 +949,17 @@ export function handleBeaconTcp(msg, { api, config }) { case 'seed-request': void serveSeedRequest(msg, api, config); return true; + case 'seed-have-request': + void respondSeedHaveRequest(msg, api, config); + return true; case 'seed-chunk': resolveIncomingChunk(msg); return true; + case 'seed-chunks-batch': + resolveIncomingChunksBatch(msg); + return true; case 'seed-have': + ingestPeerHave(msg); return true; default: return false; @@ -480,13 +970,17 @@ export async function announceSeed(meta) { const cfg = getConfig(); if (!cfg?.devBeaconEnabled || !meshApi?.beaconSendUdp) return false; upsertCatalogEntry({ ...meta, blipId: cfg.blipId }); - await meshApi.beaconSendUdp({ + const packet = { type: 'seed-announce', ...meta, blipId: cfg.blipId, chunkSize: meta.chunkSize || BEACON_CHUNK_SIZE, timestamp: Date.now(), - }); + }; + if (meta.previewB64 && meta.previewB64.length < 4000) { + packet.previewB64 = meta.previewB64; + } + await meshApi.beaconSendUdp(packet); emitCatalog(); return true; } @@ -495,6 +989,9 @@ export async function announceSeed(meta) { * Publish a file into the mesh library. * @param {File} file */ +/** Renderer-only publish for small files without a disk path (e.g. some browser-like picks). */ +const RENDERER_PUBLISH_MAX = 48 * 1024 * 1024; + export async function publishBeaconFile(file) { const cfg = getConfig(); if (!cfg?.devBeaconEnabled) throw new Error('disabled'); @@ -502,6 +999,14 @@ export async function publishBeaconFile(file) { if (!file || file.size <= 0) throw new Error('empty'); if (file.size > maxBytes) throw new Error('too_large'); + const diskPath = resolvePublishFilePath(file); + if (diskPath && meshApi?.beaconPublishFromPath) { + return publishBeaconFileFromPath(diskPath, file); + } + if (!diskPath && file.size > RENDERER_PUBLISH_MAX) { + throw new Error('no_path'); + } + const chunkSize = BEACON_CHUNK_SIZE; const totalChunks = Math.ceil(file.size / chunkSize); const hashKey = `hash-${Date.now()}`; @@ -526,18 +1031,41 @@ export async function publishBeaconFile(file) { publishedAt: Date.now(), }; + let previewB64 = null; + if (file.size <= 8 * 1024 * 1024) { + previewB64 = await generatePreviewFromFile(file); + } + if (previewB64) { + meta.previewB64 = previewB64; + await meshApi?.beaconWritePreview?.({ seedId, data: previewB64 }); + } + try { await ipcWriteMeta(seedId, meta); + localSeedIds.add(seedId); for (let i = 0; i < totalChunks; i++) { - const start = i * chunkSize; - const end = Math.min(file.size, start + chunkSize); - const data = await readFileSliceAsBase64(file, start, end); - await ipcWriteChunk(seedId, i, data); - setJobProgress(seedId, Math.round(8 + ((i + 1) / totalChunks) * 82), 'publishing'); + const batch = []; + const end = Math.min(totalChunks, i + 8); + for (let j = i; j < end; j++) { + const start = j * chunkSize; + const sliceEnd = Math.min(file.size, start + chunkSize); + const data = await readFileSliceAsBase64(file, start, sliceEnd); + batch.push({ chunkIndex: j, data }); + } + if (meshApi?.beaconWriteChunksBatch) { + await meshApi.beaconWriteChunksBatch({ seedId, chunks: batch }); + } else { + for (const { chunkIndex, data } of batch) { + await ipcWriteChunk(seedId, chunkIndex, data); + } + } + i = end - 1; + setJobProgress(seedId, Math.round(8 + (end / totalChunks) * 82), 'publishing'); } localComplete.add(seedId); + localSeedIds.add(seedId); stoppedSeeding.delete(seedId); upsertCatalogEntry({ ...meta, blipId: cfg.blipId }); await announceSeed(meta); @@ -576,15 +1104,12 @@ export async function downloadBeaconSeed(seedId) { } const total = entry.totalChunks; - const missing = new Set(); - for (let i = 0; i < total; i++) { - const exists = await meshApi.beaconChunkExists?.({ seedId, chunkIndex: i }); - if (!exists) missing.add(i); - } + const missing = await collectMissingChunks(seedId, total); if (missing.size === 0) { await ensureLocalMeta(seedId, entry); localComplete.add(seedId); + localSeedIds.add(seedId); stoppedSeeding.delete(seedId); upsertCatalogEntry({ ...entry, blipId: cfg.blipId }); await announceSeed({ @@ -608,31 +1133,45 @@ export async function downloadBeaconSeed(seedId) { setJobProgress(seedId, Math.round(((total - missing.size) / total) * 100), 'downloading'); + const allPeers = pickDownloadPeers(seedId, missing); + if (allPeers.length) await refreshPeerHaves(seedId, allPeers, total); + let stallRounds = 0; + let downloadedBytes = 0; + const dlStarted = Date.now(); while (missing.size > 0) { - const peers = pickDownloadPeers(seedId); + const peers = pickDownloadPeers(seedId, missing); if (!peers.length) throw new Error('no_seeders'); const before = missing.size; - const tasks = []; - const queue = [...missing]; - for (const peer of peers) { - const batch = queue.splice(0, CHUNK_REQUEST_BATCH); - if (!batch.length) break; - tasks.push(requestChunksFromPeer(peer.id, seedId, batch)); - } - await Promise.allSettled(tasks); - - for (const idx of [...missing]) { - const exists = await meshApi.beaconChunkExists?.({ seedId, chunkIndex: idx }); - if (exists) missing.delete(idx); + const assignments = planChunkAssignments(peers, missing, CHUNK_REQUEST_BATCH); + const got = await Promise.all( + assignments.map(({ peerId, chunks }) => requestChunksFromPeer(peerId, seedId, chunks)) + ); + for (const indices of got) { + for (const idx of indices) missing.delete(idx); + downloadedBytes += indices.length * (entry.chunkSize || BEACON_CHUNK_SIZE); } + const elapsed = Math.max(0.001, (Date.now() - dlStarted) / 1000); + const speedBps = downloadedBytes / elapsed; setJobProgress(seedId, Math.round(((total - missing.size) / total) * 100), 'downloading'); + jobProgress.set(seedId, { + progress: Math.round(((total - missing.size) / total) * 100), + phase: 'downloading', + speedBps, + }); + emitProgress(seedId); + recordBandwidthSample({ downBps: speedBps }); + syncTrayProgress(); if (missing.size === before) { stallRounds += 1; - if (stallRounds >= 3) throw new Error('stalled'); + if (stallRounds >= 4) { + await refreshPeerHaves(seedId, peers, total); + stallRounds = 0; + if (missing.size === before) throw new Error('stalled'); + } } else { stallRounds = 0; } @@ -659,6 +1198,7 @@ export async function downloadBeaconSeed(seedId) { defaultName: entry.filename, }); clearJobProgress(seedId); + void meshApi?.setTrayTransferProgress?.(null); if (res?.cancelled) return { cancelled: true }; if (!res?.ok) throw new Error(res?.error || 'save_failed'); return res; diff --git a/renderer/beacon-pixel-icon.js b/renderer/beacon-pixel-icon.js new file mode 100644 index 0000000..1e897a8 --- /dev/null +++ b/renderer/beacon-pixel-icon.js @@ -0,0 +1,21 @@ +/** + * CSS pixel-art lighthouse for BEACON nav and hero. + */ + +export function createBeaconPixelIcon(className = 'beacon-pixel-icon') { + const el = document.createElement('span'); + el.className = className; + el.setAttribute('aria-hidden', 'true'); + return el; +} + +export function createBeaconHeroTower() { + const wrap = document.createElement('div'); + wrap.className = 'beacon-hero-tower'; + wrap.appendChild(createBeaconPixelIcon('beacon-pixel-icon beacon-pixel-icon--hero')); + const beam = document.createElement('span'); + beam.className = 'beacon-hero-beam'; + beam.setAttribute('aria-hidden', 'true'); + wrap.appendChild(beam); + return wrap; +} diff --git a/renderer/beacon-row-menu.js b/renderer/beacon-row-menu.js new file mode 100644 index 0000000..9459f1f --- /dev/null +++ b/renderer/beacon-row-menu.js @@ -0,0 +1,87 @@ +import { t } from './i18n.js'; + +let openMenu = null; + +function closeOpenMenu() { + if (!openMenu) return; + openMenu.el.remove(); + openMenu.cleanup?.(); + openMenu = null; +} + +/** + * @param {HTMLElement} anchor + * @param {Array<{ id: string, label: string, onClick: () => void, danger?: boolean, disabled?: boolean }>} items + */ +export function openBeaconRowMenu(anchor, items) { + closeOpenMenu(); + + const menu = document.createElement('div'); + menu.className = 'beacon-row-menu glass'; + menu.setAttribute('role', 'menu'); + + for (const item of items) { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = `beacon-row-menu-item${item.danger ? ' beacon-row-menu-item--danger' : ''}`; + btn.textContent = item.label; + btn.disabled = !!item.disabled; + btn.setAttribute('role', 'menuitem'); + btn.addEventListener('click', (e) => { + e.stopPropagation(); + if (item.disabled) return; + closeOpenMenu(); + item.onClick?.(); + }); + menu.appendChild(btn); + } + + document.body.appendChild(menu); + + const rect = anchor.getBoundingClientRect(); + const mw = menu.offsetWidth; + const mh = menu.offsetHeight; + let left = rect.right - mw; + let top = rect.bottom + 4; + if (left < 8) left = 8; + if (top + mh > window.innerHeight - 8) top = rect.top - mh - 4; + menu.style.left = `${Math.round(left)}px`; + menu.style.top = `${Math.round(top)}px`; + + const onDoc = (e) => { + if (menu.contains(e.target) || anchor.contains(e.target)) return; + closeOpenMenu(); + }; + const onKey = (e) => { + if (e.key === 'Escape') closeOpenMenu(); + }; + const onScroll = () => closeOpenMenu(); + + setTimeout(() => { + document.addEventListener('pointerdown', onDoc, true); + document.addEventListener('keydown', onKey); + window.addEventListener('scroll', onScroll, true); + }, 0); + + openMenu = { + el: menu, + cleanup: () => { + document.removeEventListener('pointerdown', onDoc, true); + document.removeEventListener('keydown', onKey); + window.removeEventListener('scroll', onScroll, true); + }, + }; +} + +export function createBeaconRowMenuButton(onOpen) { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'beacon-row-menu-btn'; + btn.setAttribute('aria-label', t('beacon.actions_menu')); + btn.setAttribute('aria-haspopup', 'menu'); + btn.addEventListener('click', (e) => { + e.stopPropagation(); + onOpen(btn); + }); + return btn; +} diff --git a/renderer/beacon-seed-file.js b/renderer/beacon-seed-file.js new file mode 100644 index 0000000..bc4a796 --- /dev/null +++ b/renderer/beacon-seed-file.js @@ -0,0 +1,78 @@ +/** + * .blip seed descriptor files (torrent-style magnet for BEACON). + */ + +const BLIP_SEED_V = 1; + +/** + * @param {object} meta + * @returns {string} + */ +export function serializeBlipSeedFile(meta) { + const seedId = String(meta?.seedId || '').trim(); + const doc = { + v: BLIP_SEED_V, + type: 'blip-seed', + seedId, + filename: String(meta?.filename || 'file'), + size: Number(meta?.size) || 0, + chunkSize: Number(meta?.chunkSize) || 0, + totalChunks: Number(meta?.totalChunks) || 0, + link: `blip://seed/${seedId}`, + }; + return `# BLIP Seed\n${JSON.stringify(doc, null, 2)}\n`; +} + +/** + * @param {string} raw + * @returns {{ seedId: string, filename: string, size: number, chunkSize: number, totalChunks: number, link: string } | null} + */ +export function parseBlipSeedFile(raw) { + const text = String(raw || '').trim(); + if (!text) return null; + const jsonStart = text.indexOf('{'); + if (jsonStart < 0) return null; + let doc; + try { + doc = JSON.parse(text.slice(jsonStart)); + } catch { + return null; + } + const seedId = String(doc?.seedId || '') + .replace(/[^a-f0-9]/gi, '') + .slice(0, 64); + if (!seedId || doc?.type !== 'blip-seed') return null; + return { + seedId, + filename: String(doc.filename || 'file'), + size: Number(doc.size) || 0, + chunkSize: Number(doc.chunkSize) || 0, + totalChunks: Number(doc.totalChunks) || 0, + link: String(doc.link || `blip://seed/${seedId}`), + }; +} + +/** + * @param {object} meta + * @param {string} [suggestedName] + */ +export function downloadBlipSeedFile(meta, suggestedName) { + const seedId = String(meta?.seedId || '').trim(); + const base = String(suggestedName || meta?.filename || 'seed') + .replace(/\.[^.]+$/, '') + .replace(/[^\w.\-()+\s]/g, '_') + .slice(0, 80); + const name = `${base || seedId.slice(0, 8)}.blip`; + const blob = new Blob([serializeBlipSeedFile(meta)], { + type: 'application/vnd.blip.seed+json', + }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = name; + a.rel = 'noopener'; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); +} diff --git a/renderer/beacon-ui.js b/renderer/beacon-ui.js index 68c182e..2b49833 100644 --- a/renderer/beacon-ui.js +++ b/renderer/beacon-ui.js @@ -7,17 +7,234 @@ import { setSeedPaused, setAllSeedsPaused, stopBeaconSeed, + stopAllBeaconSeeds, resumeBeaconSeeding, + buildBeaconSeedLink, + getBeaconJobProgress, + resolveBeaconPreviewUrl, + deleteBeaconSeed, + hasLocalSeedData, + openBlipSeedFileContent, + getBeaconSeedExportMeta, } from './beacon-mesh.js'; import { formatFileSize } from './file-transfer.js'; +import { formatTransferSpeed } from './file-transfer-speed.js'; import { showAppToast } from './toasts.js'; import { createPixelHintIcon } from './settings-ui.js'; +import { copyTextToClipboard } from './settings-ui.js'; +import { getBandwidthRates, subscribeBandwidth } from './bandwidth-monitor.js'; +import { openBeaconRowMenu, createBeaconRowMenuButton } from './beacon-row-menu.js'; +import { downloadBlipSeedFile } from './beacon-seed-file.js'; /** @type {'mesh' | 'mine' | 'downloads'} */ let activeTab = 'mesh'; let tableBodyEl = null; let statsRootEl = null; +let bwUnsub = null; +let searchQuery = ''; let publishBusy = false; +/** @type {() => object} */ +let getUiConfig = () => ({}); + +async function copySeedLink(seedId) { + const link = buildBeaconSeedLink(seedId); + const ok = await copyTextToClipboard(link); + showAppToast({ + title: ok ? t('beacon.link_copied') : t('beacon.copy_failed'), + body: link, + variant: ok ? 'accent' : 'danger', + durationMs: ok ? 3200 : 5000, + }); +} + +async function confirmDeleteSeed(item) { + const name = item.filename || item.seedId.slice(0, 8); + const ok = window.confirm(t('beacon.delete_confirm').replace('{name}', name)); + if (!ok) return; + try { + await deleteBeaconSeed(item.seedId); + showAppToast({ title: t('beacon.deleted'), durationMs: 3500 }); + renderTableRows(); + } catch (err) { + showAppToast({ + title: t('beacon.failed'), + body: err?.message || '', + variant: 'danger', + durationMs: 5000, + }); + } +} + +function buildSeedMenuItems(item) { + const busy = + item.phase === 'hashing' || item.phase === 'publishing' || item.phase === 'downloading'; + + if (busy) { + if (item.hasLocalData || hasLocalSeedData(item.seedId)) { + return [ + { + id: 'delete', + label: t('beacon.delete'), + danger: true, + onClick: () => void confirmDeleteSeed(item), + }, + ]; + } + return []; + } + + const items = []; + + if (!busy && !item.canSave && item.seederCount > 0) { + items.push({ + id: 'download', + label: t('beacon.download'), + onClick: () => { + void downloadBeaconSeed(item.seedId) + .then((res) => { + if (res?.cancelled) return; + showAppToast({ title: t('beacon.saved'), durationMs: 4000 }); + }) + .catch((err) => { + showAppToast({ + title: t('beacon.failed'), + body: err?.message || '', + variant: 'danger', + durationMs: 5000, + }); + }) + .finally(renderTableRows); + }, + }); + } + + if (!busy && item.canSave) { + items.push({ + id: 'save', + label: t('beacon.save'), + onClick: () => { + void downloadBeaconSeed(item.seedId) + .then((res) => { + if (res?.cancelled) return; + showAppToast({ title: t('beacon.saved'), durationMs: 4000 }); + }) + .catch((err) => { + showAppToast({ + title: t('beacon.failed'), + body: err?.message || '', + variant: 'danger', + durationMs: 5000, + }); + }); + }, + }); + } + + if (!busy && item.canSave) { + if (item.stopped) { + items.push({ + id: 'resume_seed', + label: t('beacon.resume_seed'), + onClick: () => { + void resumeBeaconSeeding(item.seedId) + .then(renderTableRows) + .catch((err) => { + showAppToast({ + title: t('beacon.failed'), + body: err?.message || '', + variant: 'danger', + durationMs: 5000, + }); + }); + }, + }); + } else { + items.push({ + id: 'pause', + label: item.paused ? t('beacon.resume') : t('beacon.pause'), + onClick: () => { + setSeedPaused(item.seedId, !item.paused); + renderTableRows(); + }, + }); + items.push({ + id: 'stop', + label: t('beacon.stop'), + onClick: () => { + void stopBeaconSeed(item.seedId) + .then(renderTableRows) + .catch((err) => { + showAppToast({ + title: t('beacon.failed'), + body: err?.message || '', + variant: 'danger', + durationMs: 5000, + }); + }); + }, + }); + } + } + + if (!busy) { + items.push({ + id: 'copy', + label: t('beacon.copy_link'), + onClick: () => void copySeedLink(item.seedId), + }); + items.push({ + id: 'blip', + label: t('beacon.save_blip_file'), + onClick: () => { + void getBeaconSeedExportMeta(item.seedId).then((meta) => { + if (!meta?.seedId) { + showAppToast({ title: t('beacon.failed'), variant: 'danger', durationMs: 4000 }); + return; + } + downloadBlipSeedFile(meta, meta.filename); + showAppToast({ title: t('beacon.blip_saved'), durationMs: 3500 }); + }); + }, + }); + } + + if (!busy && item.canSave) { + items.push({ + id: 'share', + label: t('beacon.share_chat'), + onClick: () => shareSeedInChat(item), + }); + } + + if (item.hasLocalData || hasLocalSeedData(item.seedId)) { + items.push({ + id: 'delete', + label: t('beacon.delete'), + danger: true, + onClick: () => void confirmDeleteSeed(item), + }); + } + + return items; +} + +function openRowActions(anchor, item) { + const menuItems = buildSeedMenuItems(item); + if (!menuItems.length) return; + openBeaconRowMenu(anchor, menuItems); +} + +function shareSeedInChat(item) { + window.dispatchEvent( + new CustomEvent('blip-beacon-share-chat', { + detail: { + seedId: item.seedId, + filename: item.filename, + size: item.size, + }, + }) + ); +} function showSoonToast(key = 'beacon.soon_feature') { showAppToast({ title: t(key), durationMs: 4200 }); @@ -28,13 +245,18 @@ function computeStats() { const mine = items.filter((i) => i.mine || i.canSave).length; const mesh = items.length; const seeders = items.reduce((n, i) => n + i.seederCount, 0); - const active = items.filter((i) => i.canSave && !i.paused).length; + const active = items.filter((i) => i.canSave && !i.paused && !i.stopped).length; return { mine, mesh, seeders, active }; } function refreshStats() { if (!statsRootEl) return; const s = computeStats(); + const rates = getBandwidthRates(); + const downEl = statsRootEl.querySelector('[data-stat="down"] strong'); + if (downEl) downEl.textContent = formatTransferSpeed(rates.downBps); + const upEl = statsRootEl.querySelector('[data-stat="up"] strong'); + if (upEl) upEl.textContent = formatTransferSpeed(rates.upBps); const meshEl = statsRootEl.querySelector('[data-stat="mesh"] strong'); if (meshEl) meshEl.textContent = String(s.mesh); const mineEl = statsRootEl.querySelector('[data-stat="mine"] strong'); @@ -46,6 +268,10 @@ function refreshStats() { } function statusLabel(item) { + const job = getBeaconJobProgress(item.seedId); + if (item.phase === 'downloading' && job?.speedBps) { + return formatTransferSpeed(job.speedBps); + } if (item.stopped) return t('beacon.status_stopped'); if (item.paused) return t('beacon.status_paused'); if (item.phase === 'hashing') return t('beacon.status_hashing'); @@ -56,9 +282,23 @@ function statusLabel(item) { } function filterItems(tab) { - const all = getBeaconCatalog(); + const q = searchQuery.trim().toLowerCase(); + const match = (i) => + !q || + i.filename.toLowerCase().includes(q) || + i.seedId.toLowerCase().includes(q); + + const all = getBeaconCatalog().filter(match); if (tab === 'mine') return all.filter((i) => i.mine || i.canSave); - if (tab === 'downloads') return all.filter((i) => i.phase === 'downloading'); + if (tab === 'downloads') { + return all.filter( + (i) => + i.phase === 'downloading' || + i.phase === 'publishing' || + i.phase === 'hashing' || + (i.progress > 0 && i.progress < 100) + ); + } return all; } @@ -78,11 +318,34 @@ function mkBtn(labelKey, className, onClick, { disabled = false, soon = false } return btn; } -function mkActionGroup(buttons) { - const g = document.createElement('div'); - g.className = 'beacon-action-group'; - for (const b of buttons) g.appendChild(b); - return g; + +function createPreviewThumb(item) { + const thumb = document.createElement('div'); + thumb.className = 'beacon-preview-thumb'; + const img = document.createElement('img'); + img.className = 'beacon-preview-img hidden'; + img.alt = ''; + img.loading = 'lazy'; + const glyph = document.createElement('span'); + glyph.className = 'beacon-preview-glyph pixel-glyph--file'; + glyph.setAttribute('aria-hidden', 'true'); + thumb.appendChild(img); + thumb.appendChild(glyph); + + const url = item.previewUrl; + if (url) { + img.src = url; + img.classList.remove('hidden'); + glyph.classList.add('hidden'); + } else { + void resolveBeaconPreviewUrl(item.seedId, item.previewB64).then((resolved) => { + if (!resolved || !thumb.isConnected) return; + img.src = resolved; + img.classList.remove('hidden'); + glyph.classList.add('hidden'); + }); + } + return thumb; } function renderTableRows() { @@ -90,38 +353,39 @@ function renderTableRows() { tableBodyEl.innerHTML = ''; refreshStats(); - if (activeTab === 'downloads') { - const soon = document.createElement('div'); - soon.className = 'beacon-soon-panel glass'; - soon.dataset.i18n = 'beacon.soon_downloads'; - soon.textContent = t('beacon.soon_downloads'); - tableBodyEl.appendChild(soon); - return; - } - const items = filterItems(activeTab); if (!items.length) { const empty = document.createElement('p'); empty.className = 'beacon-empty'; - empty.dataset.i18n = activeTab === 'mine' ? 'beacon.empty_mine' : 'beacon.empty'; - empty.textContent = t(activeTab === 'mine' ? 'beacon.empty_mine' : 'beacon.empty'); + const key = + activeTab === 'mine' + ? 'beacon.empty_mine' + : activeTab === 'downloads' + ? 'beacon.empty_downloads' + : 'beacon.empty'; + empty.dataset.i18n = key; + empty.textContent = t(key); tableBodyEl.appendChild(empty); return; } for (const item of items) { const row = document.createElement('div'); - row.className = `beacon-table-row glass${item.paused ? ' beacon-table-row--paused' : ''}`; + row.className = `beacon-table-row glass${item.paused ? ' beacon-table-row--paused' : ''}${item.phase === 'downloading' || item.phase === 'publishing' ? ' beacon-table-row--active' : ''}`; const nameCell = document.createElement('div'); nameCell.className = 'beacon-col beacon-col-name'; + nameCell.appendChild(createPreviewThumb(item)); + const nameText = document.createElement('div'); + nameText.className = 'beacon-col-name-text'; const nameStrong = document.createElement('strong'); nameStrong.textContent = item.filename; const nameSub = document.createElement('span'); nameSub.className = 'beacon-sub'; - nameSub.textContent = `${item.seedId.slice(0, 8)}… · ${item.mine ? t('beacon.mine') : ''}`; - nameCell.appendChild(nameStrong); - nameCell.appendChild(nameSub); + nameSub.textContent = `${item.seedId.slice(0, 8)}…${item.mine ? ` · ${t('beacon.mine')}` : ''}`; + nameText.appendChild(nameStrong); + nameText.appendChild(nameSub); + nameCell.appendChild(nameText); const sizeCell = document.createElement('div'); sizeCell.className = 'beacon-col beacon-col-size'; @@ -130,10 +394,15 @@ function renderTableRows() { const progCell = document.createElement('div'); progCell.className = 'beacon-col beacon-col-progress'; const track = document.createElement('div'); - track.className = 'beacon-progress-track'; + track.className = 'beacon-progress-track beacon-progress-track--pixel'; const fill = document.createElement('div'); fill.className = 'beacon-progress-fill'; - const pctVal = item.progress > 0 && item.progress < 100 && item.phase ? item.progress : item.canSave ? 100 : 0; + const pctVal = + item.progress > 0 && item.progress < 100 && item.phase + ? item.progress + : item.canSave + ? 100 + : 0; fill.style.width = `${pctVal}%`; track.appendChild(fill); progCell.appendChild(track); @@ -144,112 +413,31 @@ function renderTableRows() { const seedsCell = document.createElement('div'); seedsCell.className = 'beacon-col beacon-col-seeds'; - seedsCell.textContent = String(item.seederCount); + seedsCell.innerHTML = `${item.seederCount}`; const statusCell = document.createElement('div'); statusCell.className = 'beacon-col beacon-col-status'; const badge = document.createElement('span'); - badge.className = `beacon-status-badge beacon-status-badge--${item.paused ? 'paused' : item.canSave ? 'seed' : 'avail'}`; + badge.className = `beacon-status-badge beacon-status-badge--${item.paused ? 'paused' : item.canSave ? 'seed' : item.phase ? 'active' : 'avail'}`; badge.textContent = statusLabel(item); statusCell.appendChild(badge); - const actionsCell = document.createElement('div'); - actionsCell.className = 'beacon-col beacon-col-actions'; - - if (item.phase === 'hashing' || item.phase === 'publishing' || item.phase === 'downloading') { - actionsCell.appendChild(mkActionGroup([])); - } else if (item.canSave) { - const actionBtns = []; - if (item.stopped) { - actionBtns.push( - mkBtn('beacon.resume_seed', 'btn btn-accent beacon-row-btn', () => { - void resumeBeaconSeeding(item.seedId) - .then(() => renderTableRows()) - .catch((err) => { - showAppToast({ - title: t('beacon.failed'), - body: err?.message || '', - variant: 'danger', - durationMs: 5000, - }); - }); - }) - ); - } else { - actionBtns.push( - mkBtn( - item.paused ? 'beacon.resume' : 'beacon.pause', - 'btn btn-lang beacon-row-btn', - () => { - setSeedPaused(item.seedId, !item.paused); - renderTableRows(); - } - ), - mkBtn('beacon.stop', 'btn btn-lang beacon-row-btn', () => { - void stopBeaconSeed(item.seedId) - .then(() => renderTableRows()) - .catch((err) => { - showAppToast({ - title: t('beacon.failed'), - body: err?.message || '', - variant: 'danger', - durationMs: 5000, - }); - }); - }) - ); - } - const saveBtn = mkBtn('beacon.save', 'btn btn-accent beacon-row-btn', () => { - void downloadBeaconSeed(item.seedId) - .then((res) => { - if (res?.cancelled) return; - showAppToast({ title: t('beacon.saved'), durationMs: 4000 }); - }) - .catch((err) => { - showAppToast({ - title: t('beacon.failed'), - body: err?.message || '', - variant: 'danger', - durationMs: 5000, - }); - void refreshBeaconLocalState().then(renderTableRows); - }); - }); - actionBtns.push(saveBtn); - actionsCell.appendChild(mkActionGroup(actionBtns)); - } else { - const dlBtn = mkBtn('beacon.download', 'btn btn-accent beacon-row-btn', () => { - dlBtn.disabled = true; - void downloadBeaconSeed(item.seedId) - .then((res) => { - if (res?.cancelled) return; - showAppToast({ title: t('beacon.saved'), durationMs: 4000 }); - }) - .catch((err) => { - showAppToast({ - title: t('beacon.failed'), - body: err?.message || '', - variant: 'danger', - durationMs: 5000, - }); - }) - .finally(() => { - dlBtn.disabled = false; - renderTableRows(); - }); - }); - const linkBtn = mkBtn('beacon.copy_link', 'btn btn-lang beacon-row-btn beacon-soon-btn', null, { - soon: true, - }); - actionsCell.appendChild(mkActionGroup([dlBtn, linkBtn])); - } + const menuCell = document.createElement('div'); + menuCell.className = 'beacon-col beacon-col-menu'; + const menuBtn = createBeaconRowMenuButton((anchor) => openRowActions(anchor, item)); + menuCell.appendChild(menuBtn); + + row.addEventListener('contextmenu', (e) => { + e.preventDefault(); + openRowActions(menuBtn, item); + }); row.appendChild(nameCell); row.appendChild(sizeCell); row.appendChild(progCell); row.appendChild(seedsCell); row.appendChild(statusCell); - row.appendChild(actionsCell); + row.appendChild(menuCell); tableBodyEl.appendChild(row); } } @@ -262,6 +450,68 @@ function setActiveTab(tab, wrap) { renderTableRows(); } +function handleBlipFile(file) { + if (!file) return false; + const name = String(file.name || '').toLowerCase(); + if (!name.endsWith('.blip')) return false; + const reader = new FileReader(); + reader.onload = () => { + void (async () => { + try { + const doc = await openBlipSeedFileContent(String(reader.result || '')); + window.dispatchEvent( + new CustomEvent('blip-open-beacon-seed', { detail: { seedId: doc.seedId } }) + ); + showAppToast({ + title: t('beacon.blip_opened'), + body: doc.filename, + durationMs: 4500, + }); + } catch { + showAppToast({ title: t('beacon.blip_invalid'), variant: 'danger', durationMs: 5000 }); + } + })(); + }; + reader.readAsText(file); + return true; +} + +function runPublish(file, wrap) { + if (handleBlipFile(file)) return; + const btn = wrap.querySelector('.beacon-publish-btn'); + if (!file || publishBusy) return; + publishBusy = true; + if (btn) btn.disabled = true; + void publishBeaconFile(file) + .then(() => { + showAppToast({ title: t('beacon.published'), body: file.name, durationMs: 4500 }); + activeTab = 'mine'; + setActiveTab('mine', wrap); + }) + .catch((err) => { + const code = err?.message || ''; + let body = code; + if (code === 'not_readable' || /could not be read/i.test(code)) { + body = t('beacon.err_not_readable'); + } else if (code === 'no_path') { + body = t('beacon.err_no_path'); + } else if (code === 'too_large') { + body = t('beacon.err_too_large'); + } + showAppToast({ + title: t('beacon.failed'), + body, + variant: 'danger', + durationMs: 7000, + }); + }) + .finally(() => { + publishBusy = false; + if (btn) btn.disabled = false; + renderTableRows(); + }); +} + function wirePublishInput(wrap) { const input = wrap.querySelector('.beacon-file-input'); const btn = wrap.querySelector('.beacon-publish-btn'); @@ -275,34 +525,43 @@ function wirePublishInput(wrap) { input.addEventListener('change', () => { const file = input.files?.[0]; input.value = ''; - if (!file || publishBusy) return; - publishBusy = true; - btn.disabled = true; - void publishBeaconFile(file) - .then(() => { - showAppToast({ title: t('beacon.published'), body: file.name, durationMs: 4500 }); - activeTab = 'mine'; - setActiveTab('mine', wrap); - }) - .catch((err) => { - showAppToast({ - title: t('beacon.failed'), - body: err?.message || '', - variant: 'danger', - durationMs: 5000, - }); - }) - .finally(() => { - publishBusy = false; - btn.disabled = false; - renderTableRows(); - }); + runPublish(file, wrap); }); } -function buildStatCard(statKey, labelKey, value, { muted = false } = {}) { +function wireBeaconDrop(wrap, dropEl) { + if (!dropEl) return; + dropEl.addEventListener('click', () => { + if (publishBusy) return; + wrap.querySelector('.beacon-file-input')?.click(); + }); + let depth = 0; + dropEl.addEventListener('dragenter', (e) => { + e.preventDefault(); + depth += 1; + dropEl.classList.add('beacon-drop--active'); + }); + dropEl.addEventListener('dragleave', (e) => { + e.preventDefault(); + depth = Math.max(0, depth - 1); + if (depth === 0) dropEl.classList.remove('beacon-drop--active'); + }); + dropEl.addEventListener('dragover', (e) => { + e.preventDefault(); + if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; + }); + dropEl.addEventListener('drop', (e) => { + e.preventDefault(); + depth = 0; + dropEl.classList.remove('beacon-drop--active'); + const file = e.dataTransfer?.files?.[0]; + runPublish(file, wrap); + }); +} + +function buildStatCard(statKey, labelKey, value, { accent = false } = {}) { const card = document.createElement('div'); - card.className = `beacon-stat-card glass${muted ? ' beacon-stat-card--muted' : ''}`; + card.className = `beacon-stat-card glass${accent ? ' beacon-stat-card--accent' : ''}`; card.dataset.stat = statKey; const label = document.createElement('span'); label.className = 'beacon-stat-label'; @@ -319,8 +578,8 @@ function buildStatsBar() { const bar = document.createElement('div'); bar.className = 'beacon-stats-grid'; statsRootEl = bar; - bar.appendChild(buildStatCard('down', 'beacon.stat_down_label', '0 B/s', { muted: true })); - bar.appendChild(buildStatCard('up', 'beacon.stat_up_label', '0 B/s', { muted: true })); + bar.appendChild(buildStatCard('down', 'beacon.stat_down_label', '0 B/s', { accent: true })); + bar.appendChild(buildStatCard('up', 'beacon.stat_up_label', '0 B/s', { accent: true })); bar.appendChild(buildStatCard('mesh', 'beacon.stat_mesh_label', '0')); bar.appendChild(buildStatCard('mine', 'beacon.stat_mine_label', '0')); bar.appendChild(buildStatCard('seeders', 'beacon.stat_seeders_label', '0')); @@ -328,21 +587,9 @@ function buildStatsBar() { return bar; } -function buildControlSection(titleKey, controls) { - const sec = document.createElement('div'); - sec.className = 'beacon-control-section glass'; - const h = document.createElement('h3'); - h.className = 'beacon-control-title'; - h.dataset.i18n = titleKey; - h.textContent = t(titleKey); - sec.appendChild(h); - for (const el of controls) sec.appendChild(el); - return sec; -} - -function buildSliderRow(labelKey, value, { disabled = true, min = 1, max = 8 } = {}) { +function buildSliderRow(labelKey, value, { min, max, onChange }) { const row = document.createElement('label'); - row.className = `beacon-slider-row${disabled ? ' beacon-slider-row--soon' : ''}`; + row.className = 'beacon-slider-row'; const span = document.createElement('span'); span.dataset.i18n = labelKey; span.textContent = t(labelKey); @@ -351,34 +598,54 @@ function buildSliderRow(labelKey, value, { disabled = true, min = 1, max = 8 } = range.min = String(min); range.max = String(max); range.value = String(value); - range.disabled = disabled; range.className = 'beacon-range'; const val = document.createElement('span'); val.className = 'beacon-slider-val'; val.textContent = String(value); - if (disabled) { - range.addEventListener('click', (e) => { - e.preventDefault(); - showSoonToast(); - }); - } else { - range.addEventListener('input', () => { - val.textContent = range.value; - }); - } + range.addEventListener('input', () => { + val.textContent = range.value; + }); + range.addEventListener('change', () => { + onChange?.(Number(range.value)); + }); row.appendChild(span); row.appendChild(range); row.appendChild(val); return row; } +function buildControlSection(titleKey, controls) { + const sec = document.createElement('div'); + sec.className = 'beacon-control-section glass'; + const h = document.createElement('h3'); + h.className = 'beacon-control-title'; + h.dataset.i18n = titleKey; + h.textContent = t(titleKey); + sec.appendChild(h); + for (const el of controls) sec.appendChild(el); + return sec; +} + function buildAside(wrap) { const aside = document.createElement('aside'); aside.className = 'beacon-aside'; + const cfg = getUiConfig(); const transfer = buildControlSection('beacon.panel_transfer', [ - buildSliderRow('beacon.slider_peers', 3), - buildSliderRow('beacon.slider_up_cap', 50, { min: 10, max: 100 }), + buildSliderRow('beacon.slider_peers', cfg.beaconParallelPeers ?? 6, { + min: 1, + max: 8, + onChange: (v) => { + void window.blip?.saveConfig?.({ beaconParallelPeers: v }); + }, + }), + buildSliderRow('beacon.slider_up_cap', cfg.beaconUploadCapPercent ?? 100, { + min: 10, + max: 100, + onChange: (v) => { + void window.blip?.saveConfig?.({ beaconUploadCapPercent: v }); + }, + }), ]); const seeding = buildControlSection('beacon.panel_seeding', [ @@ -397,21 +664,24 @@ function buildAside(wrap) { }, { disabled: false } ), - mkBtn('beacon.stop_all', 'btn btn-lang beacon-aside-btn beacon-soon-btn', null, { soon: true }), + mkBtn('beacon.stop_all', 'btn btn-lang beacon-aside-btn', () => { + void stopAllBeaconSeeds() + .then(() => renderTableRows()) + .catch(() => showSoonToast('beacon.soon_stop')); + }), ]); - const filters = buildControlSection('beacon.panel_filters', [ - (() => { - const search = document.createElement('input'); - search.type = 'search'; - search.className = 'input beacon-search beacon-soon-input'; - search.disabled = true; - search.placeholder = t('beacon.search_ph'); - search.addEventListener('click', () => showSoonToast()); - return search; - })(), - mkBtn('beacon.sort_name', 'btn btn-lang beacon-aside-btn beacon-soon-btn', null, { soon: true }), - ]); + const search = document.createElement('input'); + search.type = 'search'; + search.className = 'input beacon-search'; + search.placeholder = t('beacon.search_ph'); + search.value = searchQuery; + search.addEventListener('input', () => { + searchQuery = search.value; + renderTableRows(); + }); + + const filters = buildControlSection('beacon.panel_filters', [search]); const refreshBtn = mkBtn( 'beacon.refresh', @@ -449,7 +719,7 @@ function buildTabs(wrap) { function buildToolbar(main, wrap) { const bar = document.createElement('div'); - bar.className = 'beacon-toolbar'; + bar.className = 'beacon-toolbar glass'; const input = document.createElement('input'); input.type = 'file'; @@ -465,25 +735,47 @@ function buildToolbar(main, wrap) { publishBtn.dataset.i18n = 'beacon.publish'; publishBtn.textContent = t('beacon.publish'); primary.appendChild(publishBtn); - primary.appendChild(mkBtn('beacon.add_folder', 'btn btn-lang beacon-soon-btn', null, { soon: true })); - primary.appendChild(mkBtn('beacon.clear_done', 'btn btn-lang beacon-soon-btn', null, { soon: true })); + const openBlipBtn = document.createElement('button'); + openBlipBtn.type = 'button'; + openBlipBtn.className = 'btn btn-lang'; + openBlipBtn.dataset.i18n = 'beacon.open_blip_file'; + openBlipBtn.textContent = t('beacon.open_blip_file'); + const blipInput = document.createElement('input'); + blipInput.type = 'file'; + blipInput.accept = '.blip,application/vnd.blip.seed+json'; + blipInput.className = 'beacon-file-input hidden'; + blipInput.setAttribute('aria-hidden', 'true'); + openBlipBtn.addEventListener('click', () => blipInput.click()); + blipInput.addEventListener('change', () => { + const file = blipInput.files?.[0]; + blipInput.value = ''; + handleBlipFile(file); + }); + primary.appendChild(openBlipBtn); + primary.appendChild(blipInput); const drop = document.createElement('div'); - drop.className = 'beacon-drop-compact beacon-soon-block'; - drop.dataset.i18n = 'beacon.drop_hint'; - drop.textContent = t('beacon.drop_hint'); - drop.addEventListener('click', () => showSoonToast('beacon.soon_drop')); + drop.className = 'beacon-drop-zone'; + const dropIcon = document.createElement('span'); + dropIcon.className = 'beacon-drop-icon pixel-glyph--upload'; + dropIcon.setAttribute('aria-hidden', 'true'); + const dropText = document.createElement('span'); + dropText.dataset.i18n = 'beacon.drop_hint'; + dropText.textContent = t('beacon.drop_hint'); + drop.appendChild(dropIcon); + drop.appendChild(dropText); bar.appendChild(primary); bar.appendChild(drop); const table = main.querySelector('.beacon-table-wrap'); if (table) main.insertBefore(bar, table); wirePublishInput(wrap); + wireBeaconDrop(wrap, drop); } function buildTable() { const wrap = document.createElement('div'); - wrap.className = 'beacon-table-wrap'; + wrap.className = 'beacon-table-wrap glass'; const head = document.createElement('div'); head.className = 'beacon-table-head'; @@ -493,7 +785,7 @@ function buildTable() { 'beacon.col_progress', 'beacon.col_seeders', 'beacon.col_status', - 'beacon.col_actions', + 'beacon.col_menu', ]) { const cell = document.createElement('span'); cell.className = 'beacon-table-head-cell'; @@ -514,13 +806,22 @@ function onCatalogUpdate() { renderTableRows(); } +function teardownBeaconView() { + bwUnsub?.(); + bwUnsub = null; +} + if (typeof window !== 'undefined') { window.addEventListener('blip-beacon-catalog', onCatalogUpdate); window.addEventListener('blip-beacon-progress', onCatalogUpdate); } -export function renderBeaconView() { +export function renderBeaconView(config) { + teardownBeaconView(); + getUiConfig = () => config || {}; activeTab = 'mesh'; + searchQuery = ''; + const wrap = document.createElement('div'); wrap.className = 'view beacon-view'; @@ -533,6 +834,9 @@ export function renderBeaconView() { titleRow.appendChild(title); titleRow.appendChild(createPixelHintIcon('beacon.hint')); + wrap.appendChild(titleRow); + wrap.appendChild(buildStatsBar()); + const body = document.createElement('div'); body.className = 'beacon-body'; @@ -544,18 +848,16 @@ export function renderBeaconView() { body.appendChild(buildAside(wrap)); body.appendChild(main); - wrap.appendChild(titleRow); - wrap.appendChild(buildStatsBar()); wrap.appendChild(body); - buildToolbar(main, wrap); + bwUnsub = subscribeBandwidth(refreshStats); + void refreshBeaconLocalState().then(renderTableRows); renderTableRows(); return wrap; } -export function mountBeaconPanel() { - return renderBeaconView(); +export function mountBeaconPanel(config) { + return renderBeaconView(config); } - diff --git a/renderer/blip-color-input.js b/renderer/blip-color-input.js index 778e6e3..6e66214 100644 --- a/renderer/blip-color-input.js +++ b/renderer/blip-color-input.js @@ -1,53 +1,338 @@ +/** @type {(() => void) | null} */ +let closeOpenPopover = null; + /** - * BLIP-styled color picker (dark frame + swatch preview; native dialog on click). + * BLIP-styled color picker (custom popover: SV field, hue, hex, RGB). * @param {{ value?: string, onInput?: (hex: string) => void, title?: string, className?: string }} [opts] */ export function createBlipColorInput(opts = {}) { - const wrap = document.createElement('label'); + const wrap = document.createElement('div'); wrap.className = ['blip-color-input', opts.className].filter(Boolean).join(' '); - if (opts.title) wrap.title = opts.title; - const preview = document.createElement('span'); - preview.className = 'blip-color-input__preview'; - preview.setAttribute('aria-hidden', 'true'); + const trigger = document.createElement('button'); + trigger.type = 'button'; + trigger.className = 'blip-color-input__preview'; + trigger.setAttribute('aria-haspopup', 'dialog'); + if (opts.title) { + trigger.title = opts.title; + trigger.setAttribute('aria-label', opts.title); + } const input = document.createElement('input'); input.type = 'color'; input.className = 'blip-color-input__native'; - input.value = normalizePickerHex(opts.value) || '#00ffc8'; + input.tabIndex = -1; + input.setAttribute('aria-hidden', 'true'); + + let hsv = { h: 160, s: 1, v: 1 }; + let disabled = false; + let popover = null; + let draftHex = normalizePickerHex(opts.value) || '#00ffc8'; + + function applyHex(hex, emit = true) { + const norm = normalizePickerHex(hex); + if (!norm) return; + draftHex = norm; + hsv = rgbToHsv(hexToRgb(norm)); + input.value = norm; + syncPreview(); + if (emit) { + input.dispatchEvent(new Event('input', { bubbles: true })); + opts.onInput?.(norm); + } + } function syncPreview() { - const hex = normalizePickerHex(input.value) || '#00ffc8'; - preview.style.background = hex; - preview.style.boxShadow = `inset 0 0 0 1px color-mix(in srgb, ${hex} 55%, #000), 3px 3px 0 rgba(0, 0, 0, 0.45)`; + const hex = normalizePickerHex(draftHex) || '#00ffc8'; + trigger.style.background = hex; + trigger.style.boxShadow = `inset 0 0 0 1px color-mix(in srgb, ${hex} 55%, #000), 3px 3px 0 rgba(0, 0, 0, 0.45)`; + if (popover) syncPopoverFields(hex); } - input.addEventListener('input', () => { - syncPreview(); - opts.onInput?.(input.value); + function closePopover() { + if (!popover) return; + popover.remove(); + popover = null; + wrap.classList.remove('blip-color-input--open'); + document.removeEventListener('pointerdown', onDocPointer, true); + document.removeEventListener('keydown', onDocKey, true); + if (closeOpenPopover === closePopover) closeOpenPopover = null; + } + + function onDocPointer(e) { + if (!popover) return; + const t = /** @type {Node} */ (e.target); + if (popover.contains(t) || wrap.contains(t)) return; + closePopover(); + } + + function onDocKey(e) { + if (e.key === 'Escape') closePopover(); + } + + function syncPopoverFields(hex) { + if (!popover) return; + const rgb = hexToRgb(hex); + popover.querySelector('.blip-color-popover__hex').value = hex; + popover.querySelector('.blip-color-popover__r').value = String(rgb.r); + popover.querySelector('.blip-color-popover__g').value = String(rgb.g); + popover.querySelector('.blip-color-popover__b').value = String(rgb.b); + updateSvUi(); + updateHueUi(); + } + + function updateSvUi() { + if (!popover) return; + const sv = popover.querySelector('.blip-color-popover__sv'); + const cursor = popover.querySelector('.blip-color-popover__sv-cursor'); + sv.style.backgroundColor = `hsl(${Math.round(hsv.h)} 100% 50%)`; + const rect = sv.getBoundingClientRect(); + cursor.style.left = `${hsv.s * rect.width}px`; + cursor.style.top = `${(1 - hsv.v) * rect.height}px`; + const preview = popover.querySelector('.blip-color-popover__current'); + preview.style.background = draftHex; + } + + function updateHueUi() { + if (!popover) return; + const hueEl = popover.querySelector('.blip-color-popover__hue'); + const cursor = popover.querySelector('.blip-color-popover__hue-cursor'); + const rect = hueEl.getBoundingClientRect(); + cursor.style.left = `${(hsv.h / 360) * rect.width}px`; + } + + function openPopover() { + if (disabled) return; + closeOpenPopover?.(); + closeOpenPopover = closePopover; + + popover = document.createElement('div'); + popover.className = 'blip-color-popover glass'; + popover.setAttribute('role', 'dialog'); + popover.setAttribute('aria-label', opts.title || 'Color'); + + const sv = document.createElement('div'); + sv.className = 'blip-color-popover__sv'; + const svCursor = document.createElement('span'); + svCursor.className = 'blip-color-popover__sv-cursor'; + sv.appendChild(svCursor); + + const hue = document.createElement('div'); + hue.className = 'blip-color-popover__hue'; + const hueCursor = document.createElement('span'); + hueCursor.className = 'blip-color-popover__hue-cursor'; + hue.appendChild(hueCursor); + + const fields = document.createElement('div'); + fields.className = 'blip-color-popover__fields'; + + const current = document.createElement('span'); + current.className = 'blip-color-popover__current'; + current.setAttribute('aria-hidden', 'true'); + + const hexInput = document.createElement('input'); + hexInput.type = 'text'; + hexInput.className = 'input blip-color-popover__hex'; + hexInput.maxLength = 7; + hexInput.spellcheck = false; + + const rgbWrap = document.createElement('div'); + rgbWrap.className = 'blip-color-popover__rgb'; + for (const [key, cls] of [ + ['R', 'r'], + ['G', 'g'], + ['B', 'b'], + ]) { + const lab = document.createElement('span'); + lab.className = 'blip-color-popover__rgb-label'; + lab.textContent = key; + const inp = document.createElement('input'); + inp.type = 'number'; + inp.min = '0'; + inp.max = '255'; + inp.className = `input blip-color-popover__${cls}`; + rgbWrap.appendChild(lab); + rgbWrap.appendChild(inp); + } + + fields.appendChild(current); + fields.appendChild(hexInput); + fields.appendChild(rgbWrap); + + const actions = document.createElement('div'); + actions.className = 'blip-color-popover__actions'; + const dropBtn = document.createElement('button'); + dropBtn.type = 'button'; + dropBtn.className = 'btn btn-lang blip-color-popover__eyedropper'; + dropBtn.title = 'Eyedropper'; + dropBtn.textContent = '◎'; + dropBtn.hidden = typeof window.EyeDropper !== 'function'; + const doneBtn = document.createElement('button'); + doneBtn.type = 'button'; + doneBtn.className = 'btn btn-accent blip-color-popover__done'; + doneBtn.textContent = 'OK'; + + actions.appendChild(dropBtn); + actions.appendChild(doneBtn); + + popover.appendChild(sv); + popover.appendChild(hue); + popover.appendChild(fields); + popover.appendChild(actions); + document.body.appendChild(popover); + wrap.classList.add('blip-color-input--open'); + + function setFromHsv(emit = true) { + const rgb = hsvToRgb(hsv); + applyHex(rgbToHex(rgb), emit); + } + + function pickSv(clientX, clientY) { + const rect = sv.getBoundingClientRect(); + const s = clamp01((clientX - rect.left) / rect.width); + const v = clamp01(1 - (clientY - rect.top) / rect.height); + hsv = { ...hsv, s, v }; + setFromHsv(); + } + + function pickHue(clientX) { + const rect = hue.getBoundingClientRect(); + const h = clamp01((clientX - rect.left) / rect.width) * 360; + hsv = { ...hsv, h }; + setFromHsv(); + } + + let dragSv = false; + let dragHue = false; + + sv.addEventListener('pointerdown', (e) => { + e.preventDefault(); + dragSv = true; + sv.setPointerCapture(e.pointerId); + pickSv(e.clientX, e.clientY); + }); + sv.addEventListener('pointermove', (e) => { + if (!dragSv) return; + pickSv(e.clientX, e.clientY); + }); + sv.addEventListener('pointerup', () => { + dragSv = false; + }); + sv.addEventListener('pointercancel', () => { + dragSv = false; + }); + + hue.addEventListener('pointerdown', (e) => { + e.preventDefault(); + dragHue = true; + hue.setPointerCapture(e.pointerId); + pickHue(e.clientX); + }); + hue.addEventListener('pointermove', (e) => { + if (!dragHue) return; + pickHue(e.clientX); + }); + hue.addEventListener('pointerup', () => { + dragHue = false; + }); + hue.addEventListener('pointercancel', () => { + dragHue = false; + }); + + hexInput.addEventListener('change', () => { + const norm = normalizePickerHex(hexInput.value.trim()); + if (norm) applyHex(norm); + else hexInput.value = draftHex; + }); + + for (const cls of ['r', 'g', 'b']) { + const inp = popover.querySelector(`.blip-color-popover__${cls}`); + inp.addEventListener('change', () => { + const r = clampByte(popover.querySelector('.blip-color-popover__r').value); + const g = clampByte(popover.querySelector('.blip-color-popover__g').value); + const b = clampByte(popover.querySelector('.blip-color-popover__b').value); + applyHex(rgbToHex({ r, g, b })); + }); + } + + dropBtn.addEventListener('click', async () => { + try { + const ed = new window.EyeDropper(); + const res = await ed.open(); + if (res?.sRGBHex) applyHex(res.sRGBHex); + } catch { + /* cancelled */ + } + }); + + doneBtn.addEventListener('click', () => closePopover()); + + syncPopoverFields(draftHex); + positionPopover(trigger, popover); + + requestAnimationFrame(() => { + document.addEventListener('pointerdown', onDocPointer, true); + document.addEventListener('keydown', onDocKey, true); + }); + } + + trigger.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + if (popover) closePopover(); + else openPopover(); }); - syncPreview(); - wrap.appendChild(preview); + applyHex(draftHex, false); + wrap.appendChild(trigger); wrap.appendChild(input); return { el: wrap, input, - preview, + preview: trigger, get value() { - return input.value; + return draftHex; }, set value(v) { - input.value = normalizePickerHex(v) || '#00ffc8'; - syncPreview(); + applyHex(v, false); }, syncPreview, + get disabled() { + return disabled; + }, + set disabled(v) { + disabled = Boolean(v); + trigger.disabled = disabled; + if (disabled) closePopover(); + }, }; } +/** @param {HTMLElement} anchor @param {HTMLElement} popover */ +function positionPopover(anchor, popover) { + const gap = 8; + const pad = 10; + const ar = anchor.getBoundingClientRect(); + popover.style.visibility = 'hidden'; + popover.style.top = '0'; + popover.style.left = '0'; + const pr = popover.getBoundingClientRect(); + let top = ar.bottom + gap; + let left = ar.left; + if (left + pr.width > window.innerWidth - pad) { + left = Math.max(pad, window.innerWidth - pr.width - pad); + } + if (top + pr.height > window.innerHeight - pad) { + top = Math.max(pad, ar.top - pr.height - gap); + } + popover.style.top = `${top}px`; + popover.style.left = `${left}px`; + popover.style.visibility = ''; +} + /** @param {string} [hex] */ -function normalizePickerHex(hex) { +export function normalizePickerHex(hex) { const s = String(hex || '').trim(); if (/^#[0-9a-fA-F]{6}$/.test(s)) return s.toLowerCase(); if (/^#[0-9a-fA-F]{3}$/.test(s)) { @@ -58,3 +343,75 @@ function normalizePickerHex(hex) { } return ''; } + +/** @param {string} hex */ +function hexToRgb(hex) { + const n = normalizePickerHex(hex) || '#000000'; + return { + r: parseInt(n.slice(1, 3), 16), + g: parseInt(n.slice(3, 5), 16), + b: parseInt(n.slice(5, 7), 16), + }; +} + +/** @param {{ r: number, g: number, b: number }} rgb */ +function rgbToHex(rgb) { + const r = clampByte(rgb.r).toString(16).padStart(2, '0'); + const g = clampByte(rgb.g).toString(16).padStart(2, '0'); + const b = clampByte(rgb.b).toString(16).padStart(2, '0'); + return `#${r}${g}${b}`; +} + +/** @param {{ r: number, g: number, b: number }} rgb */ +function rgbToHsv(rgb) { + const r = rgb.r / 255; + const g = rgb.g / 255; + const b = rgb.b / 255; + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + const d = max - min; + let h = 0; + if (d > 0) { + if (max === r) h = ((g - b) / d) % 6; + else if (max === g) h = (b - r) / d + 2; + else h = (r - g) / d + 4; + h *= 60; + if (h < 0) h += 360; + } + const s = max === 0 ? 0 : d / max; + return { h, s, v: max }; +} + +/** @param {{ h: number, s: number, v: number }} hsv */ +function hsvToRgb(hsv) { + const h = ((hsv.h % 360) + 360) % 360; + const s = clamp01(hsv.s); + const v = clamp01(hsv.v); + const c = v * s; + const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); + const m = v - c; + let rp = 0; + let gp = 0; + let bp = 0; + if (h < 60) [rp, gp, bp] = [c, x, 0]; + else if (h < 120) [rp, gp, bp] = [x, c, 0]; + else if (h < 180) [rp, gp, bp] = [0, c, x]; + else if (h < 240) [rp, gp, bp] = [0, x, c]; + else if (h < 300) [rp, gp, bp] = [x, 0, c]; + else [rp, gp, bp] = [c, 0, x]; + return { + r: Math.round((rp + m) * 255), + g: Math.round((gp + m) * 255), + b: Math.round((bp + m) * 255), + }; +} + +/** @param {number} n */ +function clamp01(n) { + return Math.min(1, Math.max(0, Number(n) || 0)); +} + +/** @param {number|string} n */ +function clampByte(n) { + return Math.min(255, Math.max(0, Math.round(Number(n) || 0))); +} diff --git a/renderer/chat-message-content.js b/renderer/chat-message-content.js index 384aa11..f1e2f88 100644 --- a/renderer/chat-message-content.js +++ b/renderer/chat-message-content.js @@ -27,7 +27,10 @@ export function appendForwardBlock(block, forwardFrom) { label.textContent = t('chat.forwarded'); const from = document.createElement('span'); from.className = 'chat-forward-from'; - const who = forwardFrom.fromLabel || forwardFrom.sourceLabel || ''; + let who = forwardFrom.fromLabel || forwardFrom.sourceLabel || ''; + if (forwardFrom.sourceGroupName) { + who = who ? `${forwardFrom.sourceGroupName} · ${who}` : forwardFrom.sourceGroupName; + } from.textContent = who ? `${t('chat.forward_from')} ${who}` : t('chat.forward_from_unknown'); const body = document.createElement('span'); body.className = 'chat-forward-body'; @@ -38,6 +41,42 @@ export function appendForwardBlock(block, forwardFrom) { block.appendChild(box); } +export function appendForwardSeedNotice(block, forwardFrom, opts = {}) { + const seedId = forwardFrom?.seedId; + if (!seedId) return; + if (opts.isSeedAvailable?.(seedId)) return; + const row = document.createElement('div'); + row.className = 'chat-forward-seed-unavail'; + const text = document.createElement('span'); + text.className = 'chat-forward-seed-text'; + text.dataset.i18n = 'chat.forward_seed_unavail'; + text.textContent = t('chat.forward_seed_unavail'); + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'btn btn-lang chat-forward-seed-btn'; + btn.dataset.i18n = 'chat.forward_seed_request'; + btn.textContent = t('chat.forward_seed_request'); + btn.addEventListener('click', (e) => { + e.stopPropagation(); + opts.onRequestSeed?.(seedId); + }); + row.appendChild(text); + row.appendChild(btn); + if (opts.onShareSeedLink) { + const linkBtn = document.createElement('button'); + linkBtn.type = 'button'; + linkBtn.className = 'btn btn-accent chat-forward-seed-btn'; + linkBtn.dataset.i18n = 'chat.forward_seed_link'; + linkBtn.textContent = t('chat.forward_seed_link'); + linkBtn.addEventListener('click', (e) => { + e.stopPropagation(); + opts.onShareSeedLink(seedId, forwardFrom); + }); + row.appendChild(linkBtn); + } + block.appendChild(row); +} + export function appendQuoteBlock(block, replyTo, onQuoteClick) { if (!replyTo?.id) return; const q = document.createElement('div'); @@ -208,6 +247,7 @@ function appendTextWithEmbeds(block, text) { */ export function appendChatMessageBody(block, m, opts = {}) { if (m.forwardFrom) appendForwardBlock(block, m.forwardFrom); + if (m.forwardFrom) appendForwardSeedNotice(block, m.forwardFrom, opts); if (m.replyTo) appendQuoteBlock(block, m.replyTo, opts.onQuoteClick); const att = m.attachment; @@ -244,7 +284,7 @@ export function formatReplyFromLabel(from, displayName) { } /** Lite forward snapshot (wire-safe; no blob re-upload). */ -export function buildForwardSnapshot(sourcePeerId, m, fromLabel) { +export function buildForwardSnapshot(sourcePeerId, m, fromLabel, opts = {}) { const snap = { sourcePeerId: Number(sourcePeerId), messageId: m.id || null, @@ -252,6 +292,8 @@ export function buildForwardSnapshot(sourcePeerId, m, fromLabel) { preview: buildReplyPreview(m, sourcePeerId), text: String(m.text || '').slice(0, 500), }; + if (opts.groupId) snap.sourceGroupId = String(opts.groupId); + if (opts.groupName) snap.sourceGroupName = String(opts.groupName); if (m.attachment?.seedId) snap.seedId = String(m.attachment.seedId); if (m.attachment?.kind) snap.attachmentKind = m.attachment.kind; if (m.attachment?.name) snap.attachmentName = m.attachment.name; diff --git a/renderer/chat-pins.js b/renderer/chat-pins.js index 9706e73..c8a1092 100644 --- a/renderer/chat-pins.js +++ b/renderer/chat-pins.js @@ -19,18 +19,35 @@ function saveAll(o) { } } -function key(peerId) { +function dmKey(peerId) { return String(peerId); } +function groupKey(groupId) { + return `g:${groupId}`; +} + export function getPinnedMessageId(peerId) { - const id = loadAll()[key(peerId)]; + const id = loadAll()[dmKey(peerId)]; return id ? String(id) : null; } export function setPinnedMessageId(peerId, messageId) { const o = loadAll(); - const k = key(peerId); + const k = dmKey(peerId); + if (!messageId) delete o[k]; + else o[k] = String(messageId); + saveAll(o); +} + +export function getGroupPinnedMessageId(groupId) { + const id = loadAll()[groupKey(groupId)]; + return id ? String(id) : null; +} + +export function setGroupPinnedMessageId(groupId, messageId) { + const o = loadAll(); + const k = groupKey(groupId); if (!messageId) delete o[k]; else o[k] = String(messageId); saveAll(o); diff --git a/renderer/chat.js b/renderer/chat.js index 9d7cc67..18999b9 100644 --- a/renderer/chat.js +++ b/renderer/chat.js @@ -28,7 +28,9 @@ import { getPinnedMessageId, setPinnedMessageId } from './chat-pins.js'; import { exportPeerChatJson, exportPeerChatPdf, exportPeerChatHtml } from './chat-export.js'; import { premiumTierEnabled, showPremiumLockedToast } from './mesh-plus.js'; import { openAlertDialog, openConfirmDialog } from './confirm-dialog.js'; +import { attachTypingSound } from './typing-sound.js'; import { recordMessageSent } from './session-stats.js'; +import { getBeaconCatalog, isSeedLocalComplete, buildBeaconSeedLink, buildBeaconAttachment } from './beacon-mesh.js'; const STORAGE_KEY = 'blip_chat_v1'; const MAX_PER_PEER = 500; @@ -451,6 +453,7 @@ export function createChatView( input.placeholder = t('chat.input_placeholder'); input.dataset.i18nPlaceholder = 'chat.input_placeholder'; attachEmojiPicker(emojiBtn, input); + attachTypingSound(input, getConfig); const sendBtn = document.createElement('button'); sendBtn.type = 'button'; @@ -1010,7 +1013,31 @@ export function createChatView( block.className = `chat-block ${m.outgoing ? 'outgoing' : 'incoming'}`; block.dataset.messageId = m.id || ''; - appendChatMessageBody(block, m, { onQuoteClick: (id) => scrollToMessageId(id) }); + appendChatMessageBody(block, m, { + onQuoteClick: (id) => scrollToMessageId(id), + isSeedAvailable: (seedId) => { + if (isSeedLocalComplete(seedId)) return true; + return getBeaconCatalog().some( + (e) => e.seedId === seedId && (e.seederCount > 0 || e.mine) + ); + }, + onRequestSeed: (seedId) => { + window.dispatchEvent( + new CustomEvent('blip-open-beacon-seed', { detail: { seedId } }) + ); + }, + onShareSeedLink: (seedId, forwardFrom) => { + const att = buildBeaconAttachment({ + seedId, + filename: forwardFrom?.attachmentName || 'file', + size: 0, + }); + void sendPayload({ + text: buildBeaconSeedLink(seedId), + attachment: att || undefined, + }); + }, + }); if (m.editedAt) { const edited = document.createElement('span'); diff --git a/renderer/file-transfer-hub.js b/renderer/file-transfer-hub.js index f28389d..c3e9971 100644 --- a/renderer/file-transfer-hub.js +++ b/renderer/file-transfer-hub.js @@ -4,7 +4,7 @@ import { formatTransferSpeed } from './file-transfer-speed.js'; const AUTO_CLEAR_MS = 5000; -/** @type {Map void }>} */ +/** @type {Map} */ const active = new Map(); /** @type {Map>} */ const autoClearTimers = new Map(); @@ -47,6 +47,11 @@ function clearAllTransfers() { render(); } +function openSavedFolder(filePath) { + if (!filePath || !window.blip?.showItemInFolder) return; + void window.blip.showItemInFolder(filePath); +} + function ensureDom() { if (rootEl?.isConnected) return; rootEl = document.createElement('div'); @@ -81,6 +86,87 @@ function ensureDom() { document.body.appendChild(rootEl); } +function renderJobRow(job) { + const row = document.createElement('div'); + row.className = `transfer-hub-row${job.progress >= 100 ? ' transfer-hub-row--done' : ''}`; + + const top = document.createElement('div'); + top.className = 'transfer-hub-row-top'; + + const meta = document.createElement('div'); + meta.className = 'transfer-hub-meta'; + const name = document.createElement('span'); + name.className = 'transfer-hub-name'; + name.textContent = job.name || 'file'; + const sub = document.createElement('span'); + sub.className = 'transfer-hub-sub'; + const dir = + job.direction === 'in' + ? t('transfer.hub_in').replace('{id}', String(job.peerId)) + : t('transfer.hub_out').replace('{id}', String(job.peerId)); + const speedPart = + job.speedBps && job.progress > 0 && job.progress < 100 + ? ` · ${formatTransferSpeed(job.speedBps)}` + : ''; + const donePart = job.progress >= 100 ? ` · ${t('transfer.hub_done')}` : ` · ${job.progress}%`; + sub.textContent = `${dir} · ${formatFileSize(job.size)}${speedPart}${donePart}`; + meta.appendChild(name); + meta.appendChild(sub); + top.appendChild(meta); + + const dismissBtn = document.createElement('button'); + dismissBtn.type = 'button'; + dismissBtn.className = 'btn btn-lang transfer-hub-dismiss'; + dismissBtn.setAttribute('aria-label', t('transfer.hub_dismiss')); + dismissBtn.textContent = '×'; + dismissBtn.addEventListener('click', (e) => { + e.stopPropagation(); + dismissTransfer(job.id); + }); + top.appendChild(dismissBtn); + + row.appendChild(top); + + if (job.progress < 100) { + const track = document.createElement('div'); + track.className = 'transfer-hub-track'; + const fill = document.createElement('div'); + fill.className = 'transfer-hub-fill'; + fill.style.width = `${Math.min(100, Math.max(0, job.progress))}%`; + track.appendChild(fill); + row.appendChild(track); + } + + if (job.cancellable && job.progress < 100 && job.onCancel) { + const cancelBtn = document.createElement('button'); + cancelBtn.type = 'button'; + cancelBtn.className = 'btn btn-lang transfer-hub-cancel'; + cancelBtn.dataset.i18n = 'transfer.hub_cancel'; + cancelBtn.textContent = t('transfer.hub_cancel'); + cancelBtn.addEventListener('click', (e) => { + e.stopPropagation(); + e.preventDefault(); + job.onCancel?.(); + }); + row.appendChild(cancelBtn); + } + + if (job.progress >= 100 && job.savedPath) { + const openBtn = document.createElement('button'); + openBtn.type = 'button'; + openBtn.className = 'btn btn-accent transfer-hub-open'; + openBtn.dataset.i18n = 'transfer.hub_open_folder'; + openBtn.textContent = t('transfer.hub_open_folder'); + openBtn.addEventListener('click', (e) => { + e.stopPropagation(); + openSavedFolder(job.savedPath); + }); + row.appendChild(openBtn); + } + + return row; +} + function render() { ensureDom(); if (!listEl) return; @@ -91,69 +177,26 @@ function render() { } rootEl.classList.remove('hidden'); - for (const job of active.values()) { - const row = document.createElement('div'); - row.className = 'transfer-hub-row'; - - const top = document.createElement('div'); - top.className = 'transfer-hub-row-top'; - - const meta = document.createElement('div'); - meta.className = 'transfer-hub-meta'; - const name = document.createElement('span'); - name.className = 'transfer-hub-name'; - name.textContent = job.name || 'file'; - const sub = document.createElement('span'); - sub.className = 'transfer-hub-sub'; - const dir = - job.direction === 'in' - ? t('transfer.hub_in').replace('{id}', String(job.peerId)) - : t('transfer.hub_out').replace('{id}', String(job.peerId)); - const speedPart = - job.speedBps && job.progress > 0 && job.progress < 100 - ? ` · ${formatTransferSpeed(job.speedBps)}` - : ''; - sub.textContent = `${dir} · ${formatFileSize(job.size)}${speedPart} · ${job.progress}%`; - meta.appendChild(name); - meta.appendChild(sub); - top.appendChild(meta); - - const dismissBtn = document.createElement('button'); - dismissBtn.type = 'button'; - dismissBtn.className = 'btn btn-lang transfer-hub-dismiss'; - dismissBtn.setAttribute('aria-label', t('transfer.hub_dismiss')); - dismissBtn.textContent = '×'; - dismissBtn.addEventListener('click', (e) => { - e.stopPropagation(); - dismissTransfer(job.id); - }); - top.appendChild(dismissBtn); + const jobs = [...active.values()]; + const inProgress = jobs.filter((j) => j.progress < 100); + const completed = jobs.filter((j) => j.progress >= 100); - const track = document.createElement('div'); - track.className = 'transfer-hub-track'; - const fill = document.createElement('div'); - fill.className = 'transfer-hub-fill'; - fill.style.width = `${Math.min(100, Math.max(0, job.progress))}%`; - track.appendChild(fill); - - row.appendChild(top); - row.appendChild(track); + if (inProgress.length) { + const head = document.createElement('div'); + head.className = 'transfer-hub-section-head'; + head.dataset.i18n = 'transfer.hub_active'; + head.textContent = t('transfer.hub_active'); + listEl.appendChild(head); + for (const job of inProgress) listEl.appendChild(renderJobRow(job)); + } - if (job.cancellable && job.progress < 100 && job.onCancel) { - const cancelBtn = document.createElement('button'); - cancelBtn.type = 'button'; - cancelBtn.className = 'btn btn-lang transfer-hub-cancel'; - cancelBtn.dataset.i18n = 'transfer.hub_cancel'; - cancelBtn.textContent = t('transfer.hub_cancel'); - cancelBtn.addEventListener('click', (e) => { - e.stopPropagation(); - e.preventDefault(); - job.onCancel?.(); - }); - row.appendChild(cancelBtn); - } - - listEl.appendChild(row); + if (completed.length) { + const head = document.createElement('div'); + head.className = 'transfer-hub-section-head transfer-hub-section-head--done'; + head.dataset.i18n = 'transfer.hub_completed'; + head.textContent = t('transfer.hub_completed'); + listEl.appendChild(head); + for (const job of completed) listEl.appendChild(renderJobRow(job)); } } @@ -170,6 +213,7 @@ export function trackTransferStart(peerId, transferId, meta = {}) { size: meta.size || 0, startedAt: Date.now(), speedBps: 0, + savedPath: meta.savedPath || '', cancellable: meta.cancellable !== false && meta.direction === 'out', onCancel: meta.onCancel, }); @@ -187,6 +231,7 @@ export function trackTransferProgress(peerId, transferId, progress, meta = {}) { job.progress = Math.min(100, Math.max(0, Math.round(progress))); if (meta.name) job.name = meta.name; if (meta.size) job.size = meta.size; + if (meta.savedPath) job.savedPath = meta.savedPath; if (meta.speedBps) job.speedBps = meta.speedBps; else if (job.size && job.startedAt) { const elapsed = Math.max(0.001, (Date.now() - job.startedAt) / 1000); diff --git a/renderer/file-transfer.js b/renderer/file-transfer.js index f030dfb..7f9ba19 100644 --- a/renderer/file-transfer.js +++ b/renderer/file-transfer.js @@ -6,6 +6,7 @@ import { inferAttachmentKind, } from './chat-attachments.js'; import { getChunkDelayMs } from './file-transfer-speed.js'; +import { recordBandwidthSample } from './bandwidth-monitor.js'; const CHUNK_RAW_BYTES = 48 * 1024; @@ -156,6 +157,7 @@ export async function sendChatFile(api, config, peerId, file, onProgress, opts = bytesSent = end; const elapsed = Math.max(0.001, (Date.now() - startedAt) / 1000); const speedBps = bytesSent / elapsed; + recordBandwidthSample({ upBps: speedBps }); onProgress?.(Math.round(((i + 1) / chunkCount) * 100), { speedBps, bytesSent }); await delay(chunkDelay); } diff --git a/renderer/group-chat.js b/renderer/group-chat.js index 34499f2..3aad78f 100644 --- a/renderer/group-chat.js +++ b/renderer/group-chat.js @@ -9,13 +9,27 @@ import { registerMediaPlaceholder, INLINE_FILE_BYTES, } from './chat-attachments.js'; -import { appendChatMessageBody } from './chat-message-content.js'; +import { + appendChatMessageBody, + buildReplyPreview, + buildForwardSnapshot, +} from './chat-message-content.js'; import { getMaxFileBytes } from './file-transfer-limits.js'; import { createMessageId } from './message-id.js'; -import { addGroupMessage, getGroupMessages, groupDisplayName, amHost } from './groups.js'; +import { + addGroupMessage, + findGroupMessage, + getGroupMessages, + groupDisplayName, + amHost, +} from './groups.js'; import { getOngoingGroupCall } from './group-call-roster.js'; import { isInGroupCall, getActiveGroupCallId } from './group-call-client.js'; import { openAlertDialog } from './confirm-dialog.js'; +import { attachTypingSound } from './typing-sound.js'; +import { getGroupPinnedMessageId, setGroupPinnedMessageId } from './chat-pins.js'; +import { openForwardPeerPicker } from './chat-forward-picker.js'; +import { showAppToast } from './toasts.js'; function formatChatTime(ts) { try { @@ -30,6 +44,11 @@ function formatGroupFileLimit(config) { return `${gb} GB`; } +function memberLabel(from, config) { + const mine = Number(from) === Number(config.blipId); + return mine ? t('group.you') : `#${from}`; +} + export function createGroupChatView( group, config, @@ -38,12 +57,17 @@ export function createGroupChatView( onGroupCall, onGroupMenu, onJoinOngoingCall, - onSendFile + onSendFile, + { onPin, getForwardTargets, onForwardToPeer } = {} ) { + registerMediaPlaceholder(t('chat.image_sent')); + registerMediaPlaceholder(t('chat.file_sent')); + registerMediaPlaceholder(t('chat.file_received')); + const wrap = document.createElement('div'); wrap.className = 'chat-view group-chat-view'; - const header = document.createElement("div"); + const header = document.createElement('div'); header.className = 'chat-header glass'; if (onBack) { @@ -55,14 +79,16 @@ export function createGroupChatView( header.appendChild(backBtn); } - const meta = document.createElement("div"); + const meta = document.createElement('div'); meta.className = 'chat-peer-meta'; const name = document.createElement('span'); name.className = 'chat-peer-name'; name.textContent = groupDisplayName(group); const sub = document.createElement('span'); sub.className = 'chat-peer-id'; - const hostLabel = amHost(group, config.blipId) ? t('group.you_host') : t('group.host_line').replace('{id}', String(group.hostId)); + const hostLabel = amHost(group, config.blipId) + ? t('group.you_host') + : t('group.host_line').replace('{id}', String(group.hostId)); sub.textContent = `${t('group.members')}: ${group.members.length} · ${hostLabel}`; meta.appendChild(name); meta.appendChild(sub); @@ -109,10 +135,50 @@ export function createGroupChatView( ongoingBar.appendChild(ongoingBody); ongoingBar.appendChild(ongoingJoin); + const pinBar = document.createElement('div'); + pinBar.className = 'chat-pin-bar glass hidden'; + const pinLabel = document.createElement('span'); + pinLabel.className = 'chat-pin-label'; + pinLabel.dataset.i18n = 'chat.pinned'; + pinLabel.textContent = t('chat.pinned'); + const pinPreview = document.createElement('button'); + pinPreview.type = 'button'; + pinPreview.className = 'chat-pin-preview btn btn-lang'; + const pinUnpin = document.createElement('button'); + pinUnpin.type = 'button'; + pinUnpin.className = 'chat-pin-unpin btn btn-lang chat-reply-btn--pixel'; + pinUnpin.title = t('chat.unpin'); + pinUnpin.textContent = '×'; + pinBar.appendChild(pinLabel); + pinBar.appendChild(pinPreview); + pinBar.appendChild(pinUnpin); + const messagesEl = document.createElement('div'); messagesEl.className = 'chat-messages glass'; - const inputRow = document.createElement("div"); + let stickToBottom = true; + messagesEl.addEventListener('scroll', () => { + const gap = messagesEl.scrollHeight - messagesEl.scrollTop - messagesEl.clientHeight; + stickToBottom = gap < 80; + }); + + const replyBar = document.createElement('div'); + replyBar.className = 'chat-reply-bar hidden'; + const replyLabel = document.createElement('span'); + replyLabel.className = 'chat-reply-label'; + replyLabel.dataset.i18n = 'chat.replying'; + replyLabel.textContent = t('chat.replying'); + const replyPreview = document.createElement('span'); + replyPreview.className = 'chat-reply-preview'; + const replyCancel = document.createElement('button'); + replyCancel.type = 'button'; + replyCancel.className = 'chat-reply-cancel btn btn-lang'; + replyCancel.textContent = '×'; + replyBar.appendChild(replyLabel); + replyBar.appendChild(replyPreview); + replyBar.appendChild(replyCancel); + + const inputRow = document.createElement('div'); inputRow.className = 'chat-input-row'; const fileInput = document.createElement('input'); @@ -139,17 +205,134 @@ export function createGroupChatView( input.maxLength = 2000; input.placeholder = t('chat.input_placeholder'); attachEmojiPicker(emojiBtn, input); + attachTypingSound(input, () => config); const sendBtn = document.createElement('button'); sendBtn.type = 'button'; sendBtn.className = 'btn btn-accent'; sendBtn.textContent = t('chat.send'); + let pendingReply = null; + + function clearReplyTarget() { + pendingReply = null; + replyBar.classList.add('hidden'); + } + + function setReplyTarget(m) { + if (!m?.id) return; + const mine = Number(m.from) === Number(config.blipId); + pendingReply = { + id: m.id, + from: m.from, + fromLabel: memberLabel(m.from, config), + preview: buildReplyPreview(m, m.from), + text: m.text || '', + }; + replyPreview.textContent = pendingReply.preview; + replyBar.classList.remove('hidden'); + input.focus(); + } + + replyCancel.addEventListener('click', clearReplyTarget); + + function scrollToMessageId(messageId) { + if (!messageId) return; + const el = messagesEl.querySelector(`[data-message-id="${messageId}"]`); + if (!el) return; + el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + el.classList.add('chat-block--highlight'); + setTimeout(() => el.classList.remove('chat-block--highlight'), 1600); + } + + function renderPinBar() { + const pinId = getGroupPinnedMessageId(group.id); + const pinned = pinId ? findGroupMessage(group.id, pinId) : null; + if (!pinned) { + pinBar.classList.add('hidden'); + return; + } + pinBar.classList.remove('hidden'); + const who = memberLabel(pinned.from, config); + pinPreview.textContent = `${who}: ${buildReplyPreview(pinned, pinned.from).slice(0, 80)}`; + pinPreview.onclick = () => scrollToMessageId(pinId); + } + + pinUnpin.addEventListener('click', () => { + setGroupPinnedMessageId(group.id, null); + void onPin?.(group.id, { messageId: null, pinned: false }); + renderPinBar(); + }); + + function setPinMessage(m) { + if (!m?.id) return; + setGroupPinnedMessageId(group.id, m.id); + void onPin?.(group.id, { messageId: m.id, pinned: true }); + renderPinBar(); + } + + async function forwardMessage(m) { + if (!m?.id || typeof onForwardToPeer !== 'function') return; + const raw = typeof getForwardTargets === 'function' ? getForwardTargets() : []; + if (!raw.length) { + void openAlertDialog({ title: t('chat.forward_no_peers') }); + return; + } + const targetId = await openForwardPeerPicker(raw); + if (targetId == null) return; + const mine = Number(m.from) === Number(config.blipId); + const fromLabel = memberLabel(m.from, config); + const forwardFrom = buildForwardSnapshot(m.from, m, fromLabel, { + groupId: group.id, + groupName: groupDisplayName(group), + }); + await onForwardToPeer(targetId, { forwardFrom, sourceMessage: m }); + showAppToast({ title: t('chat.forward_sent'), durationMs: 3200 }); + } + + function openMessageMenu(e, m) { + e.preventDefault(); + const menuEl = document.createElement('div'); + menuEl.className = 'chat-msg-menu'; + const add = (key, fn) => { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'chat-menu-item'; + btn.dataset.i18n = key; + btn.textContent = t(key); + btn.addEventListener('click', () => { + menuEl.remove(); + fn(); + }); + menuEl.appendChild(btn); + }; + if (m.id) { + add('chat.reply', () => setReplyTarget(m)); + add('chat.pin', () => setPinMessage(m)); + if (onForwardToPeer) add('chat.forward', () => void forwardMessage(m)); + if (m.replyTo?.id) add('chat.jump_reply', () => scrollToMessageId(m.replyTo.id)); + } + if (!menuEl.childElementCount) return; + document.body.appendChild(menuEl); + menuEl.style.position = 'fixed'; + menuEl.style.left = `${Math.min(e.clientX, window.innerWidth - 180)}px`; + menuEl.style.top = `${Math.min(e.clientY, window.innerHeight - 120)}px`; + menuEl.style.zIndex = '600'; + requestAnimationFrame(() => { + document.addEventListener('click', () => menuEl.remove(), { once: true }); + }); + } + async function publish(msg) { - addGroupMessage(group.id, msg); + const payload = { + ...msg, + replyTo: pendingReply ? { ...pendingReply } : undefined, + }; + clearReplyTarget(); + addGroupMessage(group.id, payload); renderMessages(); sounds.messageSent(); - await onSend?.(group.id, msg); + await onSend?.(group.id, payload); } async function send() { @@ -312,38 +495,74 @@ export function createGroupChatView( wrap.appendChild(header); wrap.appendChild(ongoingBar); + wrap.appendChild(pinBar); wrap.appendChild(messagesEl); wrap.appendChild(dropOverlay); + wrap.appendChild(replyBar); wrap.appendChild(inputRow); function renderMessages() { const msgs = getGroupMessages(group.id); + const scrollPos = messagesEl.scrollTop; messagesEl.innerHTML = ''; if (!msgs.length) { const p = document.createElement('p'); p.className = 'chat-empty'; p.textContent = t('chat.empty'); messagesEl.appendChild(p); + renderPinBar(); return; } msgs.forEach((m) => { - const block = document.createElement("div"); + const block = document.createElement('div'); const mine = Number(m.from) === Number(config.blipId); block.className = `chat-block ${mine ? 'outgoing' : 'incoming'}`; + block.dataset.messageId = m.id || ''; + const who = document.createElement('span'); who.className = 'group-msg-from'; - who.textContent = mine ? t('group.you') : `#${m.from}`; + who.textContent = memberLabel(m.from, config); block.appendChild(who); - appendChatMessageBody(block, m); + + appendChatMessageBody(block, m, { onQuoteClick: (id) => scrollToMessageId(id) }); + + block.addEventListener('contextmenu', (e) => openMessageMenu(e, m)); + + const actions = document.createElement('div'); + actions.className = 'chat-block-actions'; + if (m.id) { + const replyBtn = document.createElement('button'); + replyBtn.type = 'button'; + replyBtn.className = 'btn btn-lang chat-reply-btn chat-reply-btn--pixel'; + replyBtn.title = t('chat.reply'); + replyBtn.innerHTML = ''; + replyBtn.addEventListener('click', (e) => { + e.stopPropagation(); + setReplyTarget(m); + }); + actions.appendChild(replyBtn); + } + + const metaRow = document.createElement('div'); + metaRow.className = 'chat-meta-row'; if (m.timestamp) { const time = document.createElement('span'); time.className = 'chat-time'; time.textContent = formatChatTime(m.timestamp); - block.appendChild(time); + metaRow.appendChild(time); } + if (actions.childElementCount) block.appendChild(actions); + block.appendChild(metaRow); + messagesEl.appendChild(block); }); - messagesEl.scrollTop = messagesEl.scrollHeight; + + if (stickToBottom) { + messagesEl.scrollTop = messagesEl.scrollHeight; + } else { + messagesEl.scrollTop = scrollPos; + } + renderPinBar(); } renderMessages(); @@ -354,14 +573,26 @@ export function createGroupChatView( handleIncoming(msg) { const stored = addGroupMessage(group.id, { ...msg, outgoing: false }); if (!stored) return; + stickToBottom = true; renderMessages(); sounds.messageReceived(); }, + handlePin(msg) { + if (msg.pinned === false || !msg.messageId) { + setGroupPinnedMessageId(group.id, null); + } else { + setGroupPinnedMessageId(group.id, String(msg.messageId)); + } + renderPinBar(); + renderMessages(); + }, updateGroup(next) { group.hostId = next.hostId; group.members = next.members; sub.textContent = `${t('group.members')}: ${group.members.length} · ${ - amHost(group, config.blipId) ? t('group.you_host') : t('group.host_line').replace('{id}', String(group.hostId)) + amHost(group, config.blipId) + ? t('group.you_host') + : t('group.host_line').replace('{id}', String(group.hostId)) }`; refreshOngoingBar(); }, diff --git a/renderer/group-community-view.js b/renderer/group-community-view.js index e8b0bad..00d964b 100644 --- a/renderer/group-community-view.js +++ b/renderer/group-community-view.js @@ -123,7 +123,8 @@ export function createGroupCommunityView( onBack, onGroupMenu, onSendFile, - api + api, + chatOpts = {} ) { const root = document.createElement('div'); root.className = 'group-community'; @@ -273,7 +274,8 @@ export function createGroupCommunityView( null, onGroupMenu, null, - onSendFile + onSendFile, + chatOpts ); chat.el.classList.add('group-community-chat'); chat.el.querySelector('.group-call-ongoing')?.remove(); @@ -399,6 +401,7 @@ export function createGroupCommunityView( el: root, renderMessages: () => chat.renderMessages(), handleIncoming: (msg) => chat.handleIncoming(msg), + handlePin: (msg) => chat.handlePin?.(msg), updateGroup(next) { group.hostId = next.hostId; group.members = next.members; diff --git a/renderer/groups-wire.js b/renderer/groups-wire.js index 0dbe17a..ff0f192 100644 --- a/renderer/groups-wire.js +++ b/renderer/groups-wire.js @@ -22,6 +22,7 @@ import { showAppToast } from './toasts.js'; import { sounds } from './audio.js'; import { openConfirmDialog } from './confirm-dialog.js'; import { createMessageId } from './message-id.js'; +import { setGroupPinnedMessageId } from './chat-pins.js'; import { joinVoiceChannel, leaveVoiceChannel, @@ -258,6 +259,28 @@ export async function sendGroupChatMessage(api, config, groupId, msg) { id: msg.id, timestamp: msg.timestamp, attachment: msg.attachment, + replyTo: msg.replyTo, + forwardFrom: msg.forwardFrom, + }); + } + return { ok: true }; +} + +export async function sendGroupPin(api, config, groupId, payload) { + const group = getGroup(groupId); + if (!group) return { ok: false }; + const myId = Number(config.blipId); + + for (const m of group.members) { + if (Number(m) === myId) continue; + await safeSendTcp(api, { + type: 'group-pin', + to: m, + groupId, + host: group.hostId, + messageId: payload.messageId, + pinned: payload.pinned !== false, + author: myId, }); } return { ok: true }; @@ -336,6 +359,8 @@ export async function handleGroupTcpMessage(msg, ctx) { text: msg.text, timestamp: msg.timestamp || Date.now(), attachment: msg.attachment, + replyTo: msg.replyTo, + forwardFrom: msg.forwardFrom, }; deliverGroupMessage(msg.groupId, incoming, ctx, { bumpUnread: true }); @@ -346,6 +371,18 @@ export async function handleGroupTcpMessage(msg, ctx) { return true; } + if (type === 'group-pin') { + const group = getGroup(msg.groupId); + if (!group || !isGroupMember(group, myId)) return true; + if (msg.pinned === false || !msg.messageId) { + setGroupPinnedMessageId(msg.groupId, null); + } else { + setGroupPinnedMessageId(msg.groupId, String(msg.messageId)); + } + ctx.getGroupChatView?.(msg.groupId)?.handlePin?.(msg); + return true; + } + if (type === 'group-leave') { const group = getGroup(msg.groupId); if (!group) return true; diff --git a/renderer/groups.js b/renderer/groups.js index 4251647..bc9895f 100644 --- a/renderer/groups.js +++ b/renderer/groups.js @@ -250,6 +250,11 @@ export function getGroupMessages(groupId) { return g.messages; } +export function findGroupMessage(groupId, messageId) { + if (!messageId) return null; + return getGroupMessages(groupId).find((m) => m.id === messageId) || null; +} + /** @returns {boolean} true if message was stored */ export function addGroupMessage(groupId, msg) { const g = getGroup(groupId); diff --git a/renderer/i18n.js b/renderer/i18n.js index 48d5f5e..9286a96 100644 --- a/renderer/i18n.js +++ b/renderer/i18n.js @@ -96,6 +96,9 @@ const locales = { 'chat.forward_from_unknown': 'from unknown chat', 'chat.forward_sent': 'Message forwarded', 'chat.forward_no_peers': 'No online peers to forward to.', + 'chat.forward_seed_unavail': 'File unavailable in mesh.', + 'chat.forward_seed_request': 'Open in BEACON', + 'chat.forward_seed_link': 'Send link in chat', 'media.share': 'Share', 'media.download': 'Download', 'media.close': 'Close', @@ -522,6 +525,12 @@ const locales = { 'settings.notifications_calls': 'Incoming call alert', 'settings.notifications_dnd': 'Do not disturb', 'settings.notifications_dnd_hint': 'Mutes UI sounds and blocks message and call notifications.', + 'settings.toast_duration': 'Toast duration', + 'settings.bandwidth_title': 'Mesh bandwidth', + 'settings.bandwidth_hint': 'Live LAN throughput (BEACON + file transfer), last 60 seconds.', + 'settings.bandwidth_chart': 'Bandwidth chart', + 'settings.bandwidth_down': 'Download', + 'settings.bandwidth_up': 'Upload', 'settings.copy_id': 'Copy ID', 'settings.changelog': 'Changelog', 'settings.shortcuts_main_scope': 'Main window', @@ -533,6 +542,11 @@ const locales = { 'settings.shortcuts_chat_search': 'Focus chat search', 'settings.section_sound': 'Sound', 'settings.sound_enable': 'UI sounds', + 'settings.typing_sound': 'Typing sound (local)', + 'settings.ui_compact': 'Compact mode', + 'settings.ui_font_scale': 'UI font size', + 'settings.chat_font_scale': 'Chat font size', + 'settings.idle_away_minutes': 'Auto Away after (minutes, 0 = off)', 'settings.sound_volume': 'Volume', 'settings.sound_pack': 'Sound pack', 'settings.sound_mesh_plus_hint': 'WIRE and STATIC FX packs, BEACON and CHIME melodies — MESH+ only.', @@ -659,6 +673,10 @@ const locales = { 'transfer.hub_cancel': 'Cancel', 'transfer.hub_dismiss': 'Dismiss', 'transfer.hub_clear': 'Clear all transfers', + 'transfer.hub_active': 'Active', + 'transfer.hub_completed': 'Completed', + 'transfer.hub_done': 'Done', + 'transfer.hub_open_folder': 'Open folder', 'chat.file_cancelled': 'Transfer cancelled', 'clipboard.mode': 'LAN clipboard', 'clipboard.mode_off': 'Off', @@ -757,6 +775,11 @@ const locales = { 'beacon.published': 'Published to mesh', 'beacon.saved': 'File saved', 'beacon.failed': 'BEACON error', + 'beacon.err_not_readable': + 'Could not read the file from disk. Close programs using it, then use «Publish file» (not drag from inside a ZIP).', + 'beacon.err_no_path': + 'File is too large for browser-style upload. Use «Publish file» to pick it from disk.', + 'beacon.err_too_large': 'File exceeds the transfer limit in Settings → Network.', 'beacon.mine': 'My seed', 'beacon.tab_mesh': 'In mesh', 'beacon.tab_mine': 'My seeds', @@ -768,7 +791,10 @@ const locales = { 'beacon.pause_all': 'Pause all', 'beacon.clear_done': 'Clear completed', 'beacon.search_ph': 'Search…', - 'beacon.drop_hint': 'Drop a file here to publish (coming soon)', + 'beacon.drop_hint': 'Drop file to publish to mesh', + 'beacon.empty_downloads': 'No active transfers.', + 'beacon.hero_tag': 'LAN mesh library', + 'beacon.hero_lead': 'Publish once — peers download in parallel. Rare chunks first.', 'beacon.col_name': 'Name', 'beacon.col_size': 'Size', 'beacon.col_progress': 'Progress', @@ -806,6 +832,19 @@ const locales = { 'beacon.refresh': 'Refresh mesh', 'beacon.add_folder': 'Add folder', 'beacon.copy_link': 'Copy link', + 'beacon.link_copied': 'Seed link copied', + 'beacon.share_chat': 'Share in chat', + 'beacon.delete': 'Delete local copy', + 'beacon.delete_confirm': 'Delete local data for “{name}”? Chunks on disk will be removed.', + 'beacon.deleted': 'Local seed removed', + 'beacon.copy_failed': 'Could not copy to clipboard', + 'beacon.save_blip_file': 'Save .blip file', + 'beacon.open_blip_file': 'Open .blip file', + 'beacon.blip_saved': '.blip file saved', + 'beacon.blip_opened': 'Opening in BEACON', + 'beacon.blip_invalid': 'Invalid .blip file', + 'beacon.actions_menu': 'Actions', + 'beacon.col_menu': '', 'beacon.sort_name': 'Sort by name', 'beacon.status_paused': 'Paused', 'settings.dev_export': 'Export diagnostics', @@ -824,6 +863,8 @@ const locales = { 'settings.updates_releases': 'Releases', 'settings.release_pre': 'pre', 'settings.updates_dev_only': 'Not available in this build.', + 'settings.updates_portable_only': + 'Portable build: download a new Setup from Releases and install over this folder.', 'settings.updates_status_idle': '—', 'settings.updates_status_checking': 'Checking…', 'settings.updates_status_latest': 'Up to date', @@ -1042,6 +1083,9 @@ const locales = { 'chat.forward_from_unknown': 'из неизвестного чата', 'chat.forward_sent': 'Сообщение переслано', 'chat.forward_no_peers': 'Нет онлайн-пиров для пересылки.', + 'chat.forward_seed_unavail': 'Файл недоступен в меше.', + 'chat.forward_seed_request': 'Открыть в МАЯК', + 'chat.forward_seed_link': 'Отправить ссылку', 'media.share': 'Поделиться', 'media.download': 'Скачать', 'media.close': 'Закрыть', @@ -1467,6 +1511,12 @@ const locales = { 'settings.notifications_calls': 'Входящий звонок', 'settings.notifications_dnd': 'Не беспокоить', 'settings.notifications_dnd_hint': 'Отключает звуки интерфейса и уведомления о сообщениях и звонках.', + 'settings.toast_duration': 'Длительность тостов', + 'settings.bandwidth_title': 'Пропускная способность', + 'settings.bandwidth_hint': 'Скорость LAN (МАЯК + файлы), последние 60 секунд.', + 'settings.bandwidth_chart': 'График скорости', + 'settings.bandwidth_down': 'Загрузка', + 'settings.bandwidth_up': 'Отдача', 'settings.copy_id': 'Копировать АЙДИ', 'settings.changelog': 'ЧТО НОВОГО', 'settings.shortcuts_main_scope': 'Главное окно', @@ -1478,6 +1528,11 @@ const locales = { 'settings.shortcuts_chat_search': 'Поиск в чате', 'settings.section_sound': 'Звук', 'settings.sound_enable': 'Звуки интерфейса', + 'settings.typing_sound': 'Звук печати (локально)', + 'settings.ui_compact': 'Компактный режим', + 'settings.ui_font_scale': 'Размер шрифта UI', + 'settings.chat_font_scale': 'Размер шрифта чата', + 'settings.idle_away_minutes': 'Авто «Отошёл» через (мин, 0 = выкл)', 'settings.sound_volume': 'Громкость', 'settings.sound_pack': 'Набор звуков', 'settings.sound_mesh_plus_hint': 'ПРОВОД и СТАТИК, мелодии МАЯК и ПЕРЕЗВОН — только с МЭШ ПЛЮС.', @@ -1604,6 +1659,10 @@ const locales = { 'transfer.hub_cancel': 'Отмена', 'transfer.hub_dismiss': 'Закрыть', 'transfer.hub_clear': 'Закрыть все передачи', + 'transfer.hub_active': 'Активные', + 'transfer.hub_completed': 'Завершённые', + 'transfer.hub_done': 'Готово', + 'transfer.hub_open_folder': 'Открыть папку', 'chat.file_cancelled': 'Передача отменена', 'clipboard.mode': 'Буфер ЛАН', 'clipboard.mode_off': 'Выкл', @@ -1701,6 +1760,11 @@ const locales = { 'beacon.published': 'Опубликовано в меш', 'beacon.saved': 'Файл сохранён', 'beacon.failed': 'Ошибка МАЯК', + 'beacon.err_not_readable': + 'Не удалось прочитать файл с диска. Закройте программы, которые его используют, и выберите файл через «Опубликовать» (не перетаскивайте из открытого ZIP).', + 'beacon.err_no_path': + 'Файл слишком большой для загрузки через браузерный API. Нажмите «Опубликовать файл» и укажите путь на диске.', + 'beacon.err_too_large': 'Файл больше лимита в Настройки → Сеть.', 'beacon.mine': 'Мой сид', 'beacon.tab_mesh': 'В сети', 'beacon.tab_mine': 'Мои сиды', @@ -1712,7 +1776,10 @@ const locales = { 'beacon.pause_all': 'Пауза всем', 'beacon.clear_done': 'Очистить готовые', 'beacon.search_ph': 'Поиск…', - 'beacon.drop_hint': 'Перетащи файл для публикации (скоро)', + 'beacon.drop_hint': 'Перетащи файл в меш', + 'beacon.empty_downloads': 'Нет активных передач.', + 'beacon.hero_tag': 'Файловая библиотека LAN', + 'beacon.hero_lead': 'Опубликуй — пиры качают параллельно. Редкие чанки первыми.', 'beacon.col_name': 'Имя', 'beacon.col_size': 'Размер', 'beacon.col_progress': 'Прогресс', @@ -1750,6 +1817,19 @@ const locales = { 'beacon.refresh': 'Обновить меш', 'beacon.add_folder': 'Добавить папку', 'beacon.copy_link': 'Копировать ссылку', + 'beacon.link_copied': 'Ссылка скопирована', + 'beacon.share_chat': 'В чат', + 'beacon.delete': 'Удалить локально', + 'beacon.delete_confirm': 'Удалить локальные данные «{name}»? Файлы на диске будут удалены.', + 'beacon.deleted': 'Локальный сид удалён', + 'beacon.copy_failed': 'Не удалось скопировать', + 'beacon.save_blip_file': 'Сохранить .blip', + 'beacon.open_blip_file': 'Открыть .blip', + 'beacon.blip_saved': 'Файл .blip сохранён', + 'beacon.blip_opened': 'Открытие в МАЯК', + 'beacon.blip_invalid': 'Неверный файл .blip', + 'beacon.actions_menu': 'Действия', + 'beacon.col_menu': '', 'beacon.sort_name': 'По имени', 'beacon.status_paused': 'Пауза', 'settings.dev_export': 'Экспорт диагностики', @@ -1768,6 +1848,8 @@ const locales = { 'settings.updates_releases': 'Релизы', 'settings.release_pre': 'pre', 'settings.updates_dev_only': 'В этой сборке недоступно.', + 'settings.updates_portable_only': + 'Portable-сборка: скачайте новый Setup в Releases и установите поверх этой папки.', 'settings.updates_status_idle': '—', 'settings.updates_status_checking': 'Проверка…', 'settings.updates_status_latest': 'Актуальная версия', diff --git a/renderer/idle-away.js b/renderer/idle-away.js new file mode 100644 index 0000000..f18d8ed --- /dev/null +++ b/renderer/idle-away.js @@ -0,0 +1,61 @@ +/** Auto away after idle (#29) — manual busy/away wins over idle timer. */ + +let lastActivity = Date.now(); +let pollTimer = null; +let getConfigRef = () => ({}); +let saveConfigRef = async () => ({}); + +function bumpActivity() { + lastActivity = Date.now(); + const cfg = getConfigRef(); + if (cfg?.idleAwayActive) { + void saveConfigRef({ idleAwayActive: false }); + } +} + +function bindActivity() { + const opts = { passive: true }; + for (const ev of ['mousemove', 'mousedown', 'keydown', 'wheel', 'touchstart', 'focusin']) { + window.addEventListener(ev, bumpActivity, opts); + } + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') bumpActivity(); + }); +} + +async function pollIdle() { + const cfg = getConfigRef(); + const mins = Number(cfg?.idleAwayMinutes); + if (!Number.isFinite(mins) || mins <= 0) { + if (cfg?.idleAwayActive) await saveConfigRef({ idleAwayActive: false }); + return; + } + const manual = cfg?.presenceStatus || 'online'; + if (manual !== 'online') { + if (cfg?.idleAwayActive) await saveConfigRef({ idleAwayActive: false }); + return; + } + const idleMs = mins * 60_000; + if (Date.now() - lastActivity >= idleMs) { + if (!cfg?.idleAwayActive) await saveConfigRef({ idleAwayActive: true }); + } +} + +/** + * @param {{ getConfig: () => object, saveConfig: (patch: object) => Promise }} opts + */ +export function initIdleAway(opts) { + getConfigRef = opts.getConfig; + saveConfigRef = opts.saveConfig; + lastActivity = Date.now(); + bindActivity(); + if (pollTimer) clearInterval(pollTimer); + pollTimer = setInterval(() => void pollIdle(), 30_000); +} + +export function notePresenceManualChange(status) { + lastActivity = Date.now(); + if (status !== 'online') { + void saveConfigRef({ idleAwayActive: false }); + } +} diff --git a/renderer/linkify.js b/renderer/linkify.js index 2a317d5..e25398a 100644 --- a/renderer/linkify.js +++ b/renderer/linkify.js @@ -1,10 +1,13 @@ const URL_RE = - /(?:https?:\/\/|www\.)[\w\-._~:/?#[\]@!$&'()*+,;=%]+/gi; + /(?:https?:\/\/|www\.)[\w\-._~:/?#[\]@!$&'()*+,;=%]+|blip:\/\/seed\/[a-f0-9]{8,64}/gi; function normalizeHref(raw) { const trimmed = raw.replace(/[.,;:!?)]+$/, ''); + if (/^blip:\/\/seed\//i.test(trimmed)) { + return { href: trimmed, label: trimmed, blipSeed: trimmed.replace(/^blip:\/\/seed\//i, '').slice(0, 64) }; + } const href = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`; - return { href, label: trimmed }; + return { href, label: trimmed, blipSeed: null }; } /** @@ -25,7 +28,7 @@ export function appendLinkifiedText(parent, text, onOpen) { if (start > last) { parent.appendChild(document.createTextNode(src.slice(last, start))); } - const { href, label } = normalizeHref(match[0]); + const { href, label, blipSeed } = normalizeHref(match[0]); const a = document.createElement('a'); a.className = 'chat-link'; a.href = href; @@ -33,6 +36,12 @@ export function appendLinkifiedText(parent, text, onOpen) { a.rel = 'noopener noreferrer'; a.addEventListener('click', (e) => { e.preventDefault(); + if (blipSeed) { + window.dispatchEvent( + new CustomEvent('blip-open-beacon-seed', { detail: { seedId: blipSeed } }) + ); + return; + } onOpen?.(href); }); parent.appendChild(a); diff --git a/renderer/settings-profile-panel.js b/renderer/settings-profile-panel.js index 5aafa76..e461bb7 100644 --- a/renderer/settings-profile-panel.js +++ b/renderer/settings-profile-panel.js @@ -16,6 +16,7 @@ import { MESH_PLUS_FEATURES, } from '../shared/mesh-plus-gates.js'; import { showAppToast } from './toasts.js'; +import { notePresenceManualChange } from './idle-away.js'; import { buildSettingsField, buildThemedSelect, @@ -115,7 +116,8 @@ export function buildSettingsProfilePanel(state, api, deps = {}) { state.config.presenceStatus || 'online', async (id) => { state.config.presenceStatus = id; - await api.saveConfig({ presenceStatus: id }); + notePresenceManualChange(id); + await api.saveConfig({ presenceStatus: id, idleAwayActive: false }); refreshPreview(); } ); @@ -124,6 +126,23 @@ export function buildSettingsProfilePanel(state, api, deps = {}) { namePresenceRow.appendChild(buildSettingsField('settings.presence', presenceSelect)); editorCard.appendChild(namePresenceRow); + const idleRow = document.createElement('div'); + idleRow.className = 'settings-profile-field--full'; + const idleInput = document.createElement('input'); + idleInput.type = 'number'; + idleInput.min = '0'; + idleInput.max = '120'; + idleInput.step = '1'; + idleInput.className = 'input'; + idleInput.value = String(Number(state.config.idleAwayMinutes ?? 5)); + idleInput.addEventListener('change', async () => { + const n = Math.max(0, Math.min(120, Number(idleInput.value) || 0)); + idleInput.value = String(n); + state.config = await api.saveConfig({ idleAwayMinutes: n }); + }); + idleRow.appendChild(buildSettingsField('settings.idle_away_minutes', idleInput)); + editorCard.appendChild(idleRow); + const statusInput = document.createElement('input'); statusInput.type = 'text'; statusInput.className = 'input settings-status-input'; diff --git a/renderer/styles.css b/renderer/styles.css index 9fc64b7..93d2bcf 100644 --- a/renderer/styles.css +++ b/renderer/styles.css @@ -44,7 +44,7 @@ body { background: var(--blip-bg); color: var(--blip-text); font-family: 'BlipPixel', 'Minecraft', monospace; - font-size: 15px; + font-size: calc(15px * var(--ui-font-scale, 1)); line-height: 1.35; -webkit-font-smoothing: none; font-smooth: never; @@ -52,6 +52,39 @@ body { user-select: none; } +html[data-ui-density='compact'] .nav-btn { + padding: 6px 10px; + font-size: 10px; + min-height: 34px; +} + +html[data-ui-density='compact'] .side-nav { + min-width: 132px; + padding: 10px 8px; +} + +html[data-ui-density='compact'] .chat-block { + padding: 6px 8px; + margin-bottom: 4px; +} + +html[data-ui-density='compact'] .chat-input-row { + padding: 6px 8px; + gap: 6px; +} + +html[data-ui-density='compact'] .peer-row { + padding: 5px 8px; +} + +html[data-ui-density='compact'] .settings-panel { + padding: 10px 12px; +} + +html[data-ui-density='compact'] .beacon-table-row { + padding: 8px 10px; +} + input, textarea, select, @@ -230,20 +263,28 @@ textarea { } .app-layout { - display: flex; + display: grid; + grid-template-columns: minmax(0, max-content) minmax(0, 1fr); height: 100%; + min-height: 0; } .side-nav { - width: 120px; + width: max-content; + min-width: 148px; + max-width: min(220px, 34vw); + flex-shrink: 0; display: flex; flex-direction: column; gap: 8px; - padding: 12px 8px; + padding: 12px 10px; border-right: 2px solid var(--blip-accent); border-top: none; border-bottom: none; border-left: none; + box-sizing: border-box; + overflow-x: visible; + overflow-y: auto; } .main-content { @@ -329,20 +370,33 @@ textarea { .nav-btn { position: relative; width: 100%; - padding: 10px 4px; + min-width: 7.25rem; + max-width: 100%; + padding: 10px 12px; border: 2px solid transparent; background: transparent; color: var(--blip-text); font-family: inherit; - font-size: 11px; - text-transform: uppercase; + font-size: 10px; + letter-spacing: 0.08em; + line-height: 1.35; + text-align: center; + white-space: normal; + overflow-wrap: anywhere; + word-break: break-word; + hyphens: auto; cursor: pointer; + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + min-height: 40px; } .nav-unread-badge { position: absolute; - top: 4px; - right: 4px; + top: 2px; + right: 2px; min-width: 16px; height: 16px; padding: 0 4px; @@ -352,6 +406,23 @@ textarea { background: var(--blip-danger); color: #fff; border: 1px solid var(--blip-bg); + pointer-events: none; +} + +@media (max-width: 720px) { + .side-nav { + min-width: 124px; + max-width: 40vw; + padding: 10px 6px; + } + + .nav-btn { + min-width: 6.5rem; + padding: 8px 8px; + font-size: 9px; + letter-spacing: 0.06em; + min-height: 36px; + } } .nav-btn:hover, @@ -1696,6 +1767,7 @@ select.blip-select:focus { display: flex; flex-direction: column; gap: 8px; + font-size: calc(1em * var(--chat-font-scale, 1)); } .chat-messages.flash { @@ -2980,6 +3052,56 @@ select.blip-select:focus { letter-spacing: 0.08em; } +.transfer-hub-section-head { + font-size: 10px; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--blip-muted); + margin: 6px 0 4px; +} + +.transfer-hub-section-head--done { + color: var(--blip-accent); +} + +.transfer-hub-row--done .transfer-hub-fill { + background: color-mix(in srgb, var(--blip-accent) 70%, #2a2a2a); +} + +.transfer-hub-open { + align-self: flex-start; + margin-top: 4px; + padding: 4px 10px; + font-size: 10px; +} + +.beacon-drop-compact.beacon-drop--active { + border-color: var(--blip-accent); + background: color-mix(in srgb, var(--blip-accent) 12%, transparent); +} + +.chat-forward-seed-unavail { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin-top: 6px; + padding: 6px 8px; + font-size: 0.72rem; + background: rgba(255, 80, 80, 0.12); + border: 1px solid rgba(255, 80, 80, 0.35); +} + +.chat-forward-seed-text { + flex: 1 1 120px; + opacity: 0.9; +} + +.chat-forward-seed-btn { + padding: 4px 8px; + font-size: 10px; +} + .chat-file-pending { font-size: 10px; color: var(--blip-muted); @@ -3196,70 +3318,431 @@ select.blip-select:focus { gap: 8px; } -.beacon-row-top { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 12px; +.beacon-row-top { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.beacon-row-info { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.beacon-row-actions { + flex-shrink: 0; +} + +.beacon-toolbar { + display: flex; + gap: 10px; + margin-bottom: 4px; +} + +.beacon-file-input.hidden { + display: none; +} + +.beacon-progress-track { + height: 6px; + border: 1px solid var(--blip-accent); + background: rgba(0, 0, 0, 0.4); +} + +.beacon-progress-fill { + height: 100%; + background: var(--blip-accent); + transition: width 0.12s steps(6); +} + +.beacon-progress-label { + font-size: 10px; + color: var(--blip-muted); + letter-spacing: 0.06em; +} + +.beacon-sub { + font-size: 10px; + color: var(--blip-muted); +} + +.beacon-empty { + font-size: 11px; + color: var(--blip-muted); + margin: 0; +} + +.beacon-view { + padding: 16px 20px 24px; + display: flex; + flex-direction: column; + gap: 14px; + max-height: 100%; + overflow: auto; + background: + linear-gradient( + 180deg, + color-mix(in srgb, var(--blip-accent) 6%, transparent) 0%, + transparent 28% + ), + transparent; +} + +.beacon-title-row { + align-items: center; + gap: 10px; +} + +.beacon-title-beta { + font-size: 9px; + letter-spacing: 0.14em; + padding: 2px 6px; + border: 2px solid var(--blip-accent); + color: var(--blip-accent); +} + +.beacon-hero { + display: grid; + grid-template-columns: 1fr minmax(140px, 220px); + gap: 14px; + padding: 14px 16px; + border: 2px solid var(--blip-accent); + align-items: center; +} + +.beacon-hero-left { + display: flex; + gap: 14px; + align-items: center; + min-width: 0; +} + +.beacon-hero-copy { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; +} + +.beacon-hero-tag { + font-size: 9px; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--blip-accent); +} + +.beacon-hero-lead { + margin: 0; + font-size: 11px; + line-height: 1.45; + color: var(--blip-muted); + max-width: 42ch; +} + +.beacon-hero-tower { + position: relative; + flex: 0 0 auto; +} + +.beacon-hero-beam { + position: absolute; + left: 50%; + top: 2px; + width: 28px; + height: 28px; + margin-left: -14px; + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--blip-accent) 45%, transparent) 0%, + transparent 100% + ); + clip-path: polygon(50% 0%, 0% 100%, 100% 100%); + animation: beacon-beam-pulse 2.4s steps(4) infinite; + pointer-events: none; +} + +@keyframes beacon-beam-pulse { + 0%, + 100% { + opacity: 0.35; + } + 50% { + opacity: 0.9; + } +} + +.beacon-pixel-icon { + display: block; + width: 16px; + height: 20px; + image-rendering: pixelated; + background: var(--blip-accent); + clip-path: polygon( + 50% 0%, + 62% 28%, + 88% 32%, + 68% 52%, + 74% 100%, + 50% 82%, + 26% 100%, + 32% 52%, + 12% 32%, + 38% 28% + ); + box-shadow: + 0 0 0 2px var(--blip-bg), + 0 0 12px color-mix(in srgb, var(--blip-accent) 35%, transparent); +} + +.beacon-pixel-icon--hero { + width: 28px; + height: 36px; +} + +.beacon-hero-spark, +.beacon-bw-legend { + min-width: 0; +} + +.beacon-bw-canvas { + width: 100%; + max-width: 220px; + height: auto; + image-rendering: pixelated; + border: 2px solid var(--blip-border); +} + +.beacon-bw-legend { + display: flex; + gap: 10px; + font-size: 9px; + letter-spacing: 0.06em; + margin-top: 6px; + color: var(--blip-muted); +} + +.beacon-bw-legend__down { + color: var(--blip-accent); +} + +.beacon-stat-card--accent { + border-color: var(--blip-accent); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--blip-accent) 14%, transparent); +} + +.beacon-stat-card--accent strong { + color: var(--blip-accent); +} + +.beacon-drop-zone { + flex: 1; + min-width: 180px; + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + padding: 12px 14px; + font-size: 10px; + letter-spacing: 0.06em; + color: var(--blip-muted); + border: 2px dashed var(--blip-muted); + cursor: pointer; + transition: + border-color 0.12s steps(2), + color 0.12s steps(2); +} + +.beacon-drop-zone.beacon-drop--active { + border-color: var(--blip-accent); + color: var(--blip-accent); + background: color-mix(in srgb, var(--blip-accent) 8%, transparent); +} + +.beacon-table-row--active { + border-color: var(--blip-accent); + box-shadow: inset 3px 0 0 var(--blip-accent); +} + +.beacon-col-name { + display: flex; + flex-direction: row; + align-items: center; + gap: 10px; + min-width: 0; + padding-right: 6px; +} + +.beacon-col-name-text { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.beacon-preview-thumb { + flex: 0 0 40px; + width: 40px; + height: 40px; + border: 2px solid var(--blip-border); + background: rgba(0, 0, 0, 0.45); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} + +.beacon-preview-img { + width: 100%; + height: 100%; + object-fit: cover; + image-rendering: pixelated; +} + +.beacon-preview-glyph { + width: 18px; + height: 14px; + opacity: 0.7; +} + +.beacon-progress-track--pixel { + height: 8px; + border-width: 2px; +} + +.beacon-seed-pip { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 26px; + padding: 4px 7px; + font-size: 10px; + line-height: 1.2; + border: 2px solid var(--blip-border); +} + +.beacon-status-badge--active { + border-color: var(--blip-accent); + color: var(--blip-accent); + animation: beacon-badge-blink 1.2s steps(2) infinite; +} + +@keyframes beacon-badge-blink { + 50% { + opacity: 0.65; + } +} + +.pixel-glyph--file { + display: block; + width: 14px; + height: 16px; + border: 2px solid currentColor; + box-shadow: inset 0 -6px 0 currentColor; +} + +.pixel-glyph--upload { + display: block; + width: 0; + height: 0; + border-left: 7px solid transparent; + border-right: 7px solid transparent; + border-bottom: 10px solid currentColor; + box-shadow: 0 12px 0 -4px currentColor; +} + +.settings-range-row { + display: grid; + grid-template-columns: 1fr auto; + grid-template-rows: auto auto; + gap: 6px 12px; + align-items: center; + padding: 10px 0; + font-size: 11px; +} + +.settings-range-row > span:first-child { + grid-column: 1 / -1; + color: var(--blip-muted); + letter-spacing: 0.06em; + text-transform: uppercase; + font-size: 10px; +} + +.settings-range { + grid-column: 1; + width: 100%; + accent-color: var(--blip-accent); +} + +.settings-range-val { + font-size: 11px; + color: var(--blip-accent); + min-width: 2.5rem; + text-align: right; +} + +.settings-bandwidth-block { + margin-top: 16px; } -.beacon-row-info { +.bandwidth-graph-panel { + padding: 12px; display: flex; flex-direction: column; - gap: 4px; - min-width: 0; + gap: 12px; } -.beacon-row-actions { - flex-shrink: 0; +.bandwidth-graph-panel__spark { + width: 100%; } -.beacon-toolbar { +.bandwidth-graph-panel__bars { display: flex; - gap: 10px; - margin-bottom: 4px; + flex-direction: column; + gap: 8px; } -.beacon-file-input.hidden { - display: none; +.bandwidth-graph-panel__row { + display: grid; + grid-template-columns: 72px 1fr auto; + gap: 8px; + align-items: center; + font-size: 10px; } -.beacon-progress-track { - height: 6px; - border: 1px solid var(--blip-accent); +.bandwidth-graph-panel__track { + height: 8px; + border: 2px solid var(--blip-border); background: rgba(0, 0, 0, 0.4); } -.beacon-progress-fill { +.bandwidth-graph-panel__fill { height: 100%; background: var(--blip-accent); - transition: width 0.12s steps(6); -} - -.beacon-progress-label { - font-size: 10px; - color: var(--blip-muted); - letter-spacing: 0.06em; + transition: width 0.2s steps(4); } -.beacon-sub { - font-size: 10px; - color: var(--blip-muted); +.bandwidth-graph-panel__fill--up { + background: rgba(120, 200, 255, 0.9); } -.beacon-empty { - font-size: 11px; +.bandwidth-graph-panel__val { color: var(--blip-muted); - margin: 0; + font-size: 9px; + min-width: 4.5rem; + text-align: right; } -.beacon-view { - padding: 16px 20px 24px; - display: flex; - flex-direction: column; - gap: 14px; - max-height: 100%; - overflow: auto; +@media (max-width: 900px) { + .beacon-hero { + grid-template-columns: 1fr; + } + .beacon-hero-spark { + order: 3; + } } .beacon-stats-grid { @@ -3272,7 +3755,7 @@ select.blip-select:focus { display: flex; flex-direction: column; gap: 6px; - padding: 10px 12px; + padding: 12px 14px; border: 2px solid var(--blip-accent); } @@ -3481,15 +3964,20 @@ select.blip-select:focus { .beacon-table-head { display: grid; - grid-template-columns: 1.5fr 0.65fr 1fr 0.5fr 0.85fr 1.1fr; + grid-template-columns: 1.6fr 0.65fr 1fr 0.5fr 0.85fr 36px; gap: 8px; - padding: 0 12px; + padding: 4px 14px 6px; font-size: 9px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--blip-muted); } +.beacon-table-head-cell { + padding: 2px 4px; + line-height: 1.35; +} + .beacon-table-body { display: flex; flex-direction: column; @@ -3498,15 +3986,21 @@ select.blip-select:focus { .beacon-table-row { display: grid; - grid-template-columns: 1.5fr 0.65fr 1fr 0.5fr 0.85fr 1.1fr; - gap: 8px; + grid-template-columns: 1.6fr 0.65fr 1fr 0.5fr 0.85fr 36px; + gap: 10px; align-items: center; - padding: 12px; + padding: 14px 16px; font-size: 12px; + line-height: 1.4; border: 2px solid var(--blip-border); transition: border-color 0.12s steps(2); } +.beacon-col { + min-width: 0; + padding: 2px 4px; +} + .beacon-table-row:hover { border-color: var(--blip-accent); } @@ -3516,13 +4010,6 @@ select.blip-select:focus { border-color: var(--blip-muted); } -.beacon-col-name { - display: flex; - flex-direction: column; - gap: 2px; - min-width: 0; -} - .beacon-col-name strong { overflow: hidden; text-overflow: ellipsis; @@ -3549,11 +4036,12 @@ select.blip-select:focus { .beacon-status-badge { display: inline-block; - padding: 2px 6px; + padding: 4px 8px; font-size: 9px; letter-spacing: 0.08em; text-transform: uppercase; border: 1px solid var(--blip-muted); + line-height: 1.3; } .beacon-status-badge--seed { @@ -3570,6 +4058,88 @@ select.blip-select:focus { color: var(--blip-muted); } +.beacon-col-menu { + display: flex; + align-items: center; + justify-content: flex-end; +} + +.beacon-row-menu-btn { + width: 22px; + height: 22px; + padding: 0; + border: 2px solid var(--blip-border); + background: rgba(0, 0, 0, 0.35); + cursor: pointer; + position: relative; + flex-shrink: 0; +} + +.beacon-row-menu-btn:hover, +.beacon-row-menu-btn:focus-visible { + border-color: var(--blip-accent); + background: color-mix(in srgb, var(--blip-accent) 10%, transparent); +} + +.beacon-row-menu-btn::after { + content: ''; + position: absolute; + left: 5px; + top: 5px; + width: 4px; + height: 4px; + background: var(--blip-accent); + box-shadow: + 6px 0 0 var(--blip-accent), + 0 6px 0 var(--blip-accent), + 6px 6px 0 var(--blip-accent); +} + +.beacon-row-menu { + position: fixed; + z-index: 12000; + min-width: 200px; + max-width: min(280px, calc(100vw - 16px)); + padding: 6px; + display: flex; + flex-direction: column; + gap: 2px; + border: 2px solid var(--blip-accent); +} + +.beacon-row-menu-item { + width: 100%; + text-align: left; + padding: 8px 10px; + font-size: 10px; + letter-spacing: 0.06em; + text-transform: uppercase; + border: none; + background: transparent; + color: var(--blip-text); + cursor: pointer; + font-family: inherit; +} + +.beacon-row-menu-item:hover:not(:disabled) { + background: var(--blip-accent); + color: var(--blip-on-accent); +} + +.beacon-row-menu-item--danger { + color: var(--blip-danger); +} + +.beacon-row-menu-item--danger:hover:not(:disabled) { + background: var(--blip-danger); + color: #fff; +} + +.beacon-row-menu-item:disabled { + opacity: 0.45; + cursor: not-allowed; +} + .beacon-action-group { display: flex; flex-wrap: wrap; @@ -7009,7 +7579,6 @@ body.media-viewer-open { flex-shrink: 0; width: 32px; height: 32px; - cursor: pointer; vertical-align: middle; } @@ -7017,6 +7586,8 @@ body.media-viewer-open { display: block; width: 100%; height: 100%; + margin: 0; + padding: 0; box-sizing: border-box; border: 2px solid var(--blip-glass-border); background: var(--blip-input-bg, #141414); @@ -7024,32 +7595,168 @@ body.media-viewer-open { inset 0 0 0 1px color-mix(in srgb, var(--blip-accent) 18%, transparent), 3px 3px 0 rgba(0, 0, 0, 0.45); image-rendering: pixelated; - pointer-events: none; + cursor: pointer; + font: inherit; +} + +.blip-color-input__preview:disabled { + opacity: 0.45; + cursor: not-allowed; } .blip-color-input__native { position: absolute; - inset: 0; - width: 100%; - height: 100%; - margin: 0; - padding: 0; + width: 0; + height: 0; opacity: 0; - cursor: pointer; + pointer-events: none; border: none; - background: transparent; + padding: 0; + margin: 0; } -.blip-color-input:hover .blip-color-input__preview { +.blip-color-input:hover .blip-color-input__preview:not(:disabled) { border-color: color-mix(in srgb, var(--blip-accent) 55%, var(--blip-glass-border)); } -.blip-color-input:focus-within .blip-color-input__preview { +.blip-color-input--open .blip-color-input__preview, +.blip-color-input__preview:focus-visible { border-color: var(--blip-accent); box-shadow: inset 0 0 0 1px var(--blip-accent), 0 0 0 1px var(--blip-accent), 3px 3px 0 rgba(0, 0, 0, 0.45); + outline: none; +} + +.blip-color-popover { + position: fixed; + z-index: 15000; + width: min(248px, calc(100vw - 20px)); + padding: 12px; + display: flex; + flex-direction: column; + gap: 10px; + border: 2px solid var(--blip-accent); + box-shadow: 6px 6px 0 rgba(0, 0, 0, 0.55); +} + +.blip-color-popover__sv { + position: relative; + width: 100%; + height: 140px; + border: 2px solid var(--blip-border); + cursor: crosshair; + touch-action: none; + background-image: + linear-gradient(to right, #fff, transparent), + linear-gradient(to top, #000, transparent); +} + +.blip-color-popover__sv-cursor { + position: absolute; + width: 12px; + height: 12px; + margin: -7px 0 0 -7px; + border: 2px solid #fff; + box-shadow: + 0 0 0 1px #000, + 2px 2px 0 rgba(0, 0, 0, 0.5); + pointer-events: none; +} + +.blip-color-popover__hue { + position: relative; + width: 100%; + height: 14px; + border: 2px solid var(--blip-border); + cursor: ew-resize; + touch-action: none; + background: linear-gradient( + 90deg, + #f00 0%, + #ff0 17%, + #0f0 33%, + #0ff 50%, + #00f 67%, + #f0f 83%, + #f00 100% + ); +} + +.blip-color-popover__hue-cursor { + position: absolute; + top: -3px; + width: 6px; + height: 20px; + margin-left: -4px; + border: 2px solid #fff; + box-shadow: + 0 0 0 1px #000, + 2px 2px 0 rgba(0, 0, 0, 0.45); + pointer-events: none; +} + +.blip-color-popover__fields { + display: grid; + grid-template-columns: 32px 1fr; + gap: 8px; + align-items: center; +} + +.blip-color-popover__current { + width: 32px; + height: 32px; + border: 2px solid var(--blip-border); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--blip-accent) 25%, transparent); +} + +.blip-color-popover__hex { + width: 100%; + font-family: 'BlipPixel', 'Minecraft', monospace; + letter-spacing: 0.05em; +} + +.blip-color-popover__rgb { + grid-column: 1 / -1; + display: grid; + grid-template-columns: auto 1fr auto 1fr auto 1fr; + gap: 6px; + align-items: center; +} + +.blip-color-popover__rgb-label { + font-size: 9px; + letter-spacing: 0.1em; + color: var(--blip-muted); +} + +.blip-color-popover__r, +.blip-color-popover__g, +.blip-color-popover__b { + width: 100%; + min-width: 0; + padding: 6px 4px; + font-size: 11px; + text-align: center; +} + +.blip-color-popover__actions { + display: flex; + gap: 8px; + justify-content: flex-end; +} + +.blip-color-popover__eyedropper { + min-width: 36px; + padding: 6px 8px; + font-size: 14px; + line-height: 1; +} + +.blip-color-popover__done { + flex: 1; + max-width: 120px; } .settings-theme-editor-color.blip-color-input { diff --git a/renderer/theme-editor.js b/renderer/theme-editor.js index a3e2a5c..1c68f15 100644 --- a/renderer/theme-editor.js +++ b/renderer/theme-editor.js @@ -55,14 +55,14 @@ export function appendThemeEditorSection(block, getConfig, saveConfig) { function setDisabled(locked) { hexInput.disabled = locked; - colorPick.disabled = locked; + colorPickUi.disabled = locked; applyBtn.disabled = locked; clearBtn.disabled = locked; row.classList.toggle('settings-theme-editor--locked', locked); } colorPick.addEventListener('input', () => { - hexInput.value = colorPick.value; + hexInput.value = colorPickUi.value; }); applyBtn.addEventListener('click', async () => { diff --git a/renderer/toast-config.js b/renderer/toast-config.js new file mode 100644 index 0000000..3fdfcde --- /dev/null +++ b/renderer/toast-config.js @@ -0,0 +1,17 @@ +/** Global default toast duration from user config (ms). */ + +let defaultToastMs = 9000; + +export function setDefaultToastDurationMs(ms) { + const n = Number(ms); + defaultToastMs = Number.isFinite(n) ? Math.max(2000, Math.min(60000, Math.round(n))) : 9000; +} + +/** @param {number | undefined} overrideMs explicit per-toast duration */ +export function resolveToastDurationMs(overrideMs) { + if (overrideMs != null && Number.isFinite(Number(overrideMs))) { + const n = Number(overrideMs); + return n <= 0 ? 0 : Math.max(2000, Math.min(60000, Math.round(n))); + } + return defaultToastMs; +} diff --git a/renderer/toasts.js b/renderer/toasts.js index f98a3ad..f11b8c8 100644 --- a/renderer/toasts.js +++ b/renderer/toasts.js @@ -2,6 +2,8 @@ * Bottom-right in-app toast stack (messages, updates, hints). */ +import { resolveToastDurationMs } from './toast-config.js'; + export const DEFAULT_TOAST_MS = 9000; const FADE_MS = 300; const SWIPE_DISMISS_PX = 72; @@ -85,10 +87,11 @@ export function showAppToast(opts) { title, body = '', variant = 'accent', - durationMs = DEFAULT_TOAST_MS, + durationMs: durationOverride, dismissible = true, actions = [], } = opts; + const durationMs = resolveToastDurationMs(durationOverride); if (!title) return null; const stack = ensureStack(); diff --git a/renderer/typing-sound.js b/renderer/typing-sound.js new file mode 100644 index 0000000..c769118 --- /dev/null +++ b/renderer/typing-sound.js @@ -0,0 +1,17 @@ +import { playTypingClick } from './audio.js'; + +/** + * Subtle local key click on chat composers (#30). + * @param {HTMLElement} input + * @param {() => object} getConfig + */ +export function attachTypingSound(input, getConfig) { + if (!input) return; + input.addEventListener('keydown', (e) => { + if (e.isComposing || e.ctrlKey || e.metaKey || e.altKey) return; + if (e.key === 'Enter' || e.key === 'Tab' || e.key.startsWith('Arrow')) return; + const cfg = getConfig?.(); + if (!cfg?.typingSoundEnabled || cfg?.doNotDisturb) return; + playTypingClick(); + }); +} diff --git a/renderer/ui.js b/renderer/ui.js index 55526e5..8b78957 100644 --- a/renderer/ui.js +++ b/renderer/ui.js @@ -1,6 +1,6 @@ import { t, setLang, getLang, applyLangChange, onLangChange, applyI18n } from './i18n.js'; import { createIdGrid } from './grid.js'; -import { createChatView, getMessages } from './chat.js'; +import { createChatView, getMessages, addMessage } from './chat.js'; import { isFavorite, toggleFavorite, comparePeersFavoriteFirst } from './peer-favorites.js'; import { getGroup, @@ -26,6 +26,7 @@ import { handleGroupTcpMessage, migrateGroupsHostOnPeerOffline, sendGroupChatMessage, + sendGroupPin, leaveGroup, acceptGroupInvite, declineGroupInviteFlow, @@ -134,7 +135,10 @@ import { formatPeerDisplayName } from './peer-labels.js'; import { openMeshLabelDialog } from './mesh-label-dialog.js'; import { showAppToast } from './toasts.js'; import { initBeaconMesh, refreshBeaconMesh, handleBeaconTcp } from './beacon-mesh.js'; +import { initIdleAway } from './idle-away.js'; import { renderBeaconView } from './beacon-ui.js'; +import { setDefaultToastDurationMs } from './toast-config.js'; +import { appendBandwidthGraphSection } from './beacon-bandwidth-graph.js'; import { openConfirmDialog } from './confirm-dialog.js'; import { clearRendererLocalStorage, @@ -170,6 +174,7 @@ import { normalizeThemeMode, normalizeAccentId, normalizeBgId, + applyUiPreferences, } from './appearance.js'; import { initReactiveWallpaper, applyReactiveWallpaperConfig } from './reactive-wallpaper.js'; @@ -443,7 +448,39 @@ function ensureGroupChatView(groupId) { }, }); }, - api + api, + { + onPin: (gid, payload) => sendGroupPin(api, state.config, gid, payload), + getForwardTargets: () => + state.peers + .filter((p) => p.online && !isBlocked(p.blipId)) + .map((p) => ({ + id: p.blipId, + label: formatPeerDisplayName(p, p.blipId), + })), + onForwardToPeer: async (targetId, { forwardFrom }) => { + ensureChatView(targetId); + const outMsg = { + id: createMessageId(), + from: state.config.blipId, + to: targetId, + text: '', + timestamp: Date.now(), + outgoing: true, + forwardFrom, + }; + addMessage(targetId, outMsg); + await api.sendTcpMessage({ + to: targetId, + type: 'message', + text: outMsg.text, + id: outMsg.id, + timestamp: outMsg.timestamp, + forwardFrom: outMsg.forwardFrom, + }); + state.chatViews.get(targetId)?.renderMessages?.(); + }, + } ); state.groupChatViews.set(groupId, view); } @@ -1762,6 +1799,68 @@ function buildAppearanceSection() { motionRow.appendChild(createPixelHintIcon('settings.motion_hint')); block.appendChild(motionRow); + const compactToggle = createPixelToggle({ + checked: state.config.uiDensity === 'compact', + labelKey: 'settings.ui_compact', + onChange: async (checked) => { + state.config = await api.saveConfig({ uiDensity: checked ? 'compact' : 'comfortable' }); + applyAppearance(state.config); + }, + }); + block.appendChild(compactToggle.el); + + block.appendChild(buildSectionSubtitleRow('settings.ui_font_scale')); + const uiFontRow = document.createElement('div'); + uiFontRow.className = 'settings-sound-volume-row'; + const uiFontRange = document.createElement('input'); + uiFontRange.type = 'range'; + uiFontRange.min = '85'; + uiFontRange.max = '125'; + uiFontRange.step = '5'; + uiFontRange.className = 'settings-sound-range'; + const uiFontPct = Math.round((Number(state.config.uiFontScale) || 1) * 100); + uiFontRange.value = String(uiFontPct); + const uiFontVal = document.createElement('span'); + uiFontVal.className = 'settings-sound-volume-val'; + uiFontVal.textContent = `${uiFontPct}%`; + uiFontRange.addEventListener('input', () => { + uiFontVal.textContent = `${uiFontRange.value}%`; + applyUiPreferences({ ...state.config, uiFontScale: Number(uiFontRange.value) / 100 }); + }); + uiFontRange.addEventListener('change', async () => { + state.config = await api.saveConfig({ uiFontScale: Number(uiFontRange.value) / 100 }); + applyAppearance(state.config); + }); + uiFontRow.appendChild(uiFontRange); + uiFontRow.appendChild(uiFontVal); + appendAppearanceControl(block, uiFontRow); + + block.appendChild(buildSectionSubtitleRow('settings.chat_font_scale')); + const chatFontRow = document.createElement('div'); + chatFontRow.className = 'settings-sound-volume-row'; + const chatFontRange = document.createElement('input'); + chatFontRange.type = 'range'; + chatFontRange.min = '85'; + chatFontRange.max = '135'; + chatFontRange.step = '5'; + chatFontRange.className = 'settings-sound-range'; + const chatFontPct = Math.round((Number(state.config.chatFontScale) || 1) * 100); + chatFontRange.value = String(chatFontPct); + const chatFontVal = document.createElement('span'); + chatFontVal.className = 'settings-sound-volume-val'; + chatFontVal.textContent = `${chatFontPct}%`; + chatFontRange.addEventListener('input', () => { + chatFontVal.textContent = `${chatFontRange.value}%`; + applyUiPreferences({ ...state.config, chatFontScale: Number(chatFontRange.value) / 100 }); + }); + chatFontRange.addEventListener('change', async () => { + state.config = await api.saveConfig({ chatFontScale: Number(chatFontRange.value) / 100 }); + applyAppearance(state.config); + }); + chatFontRow.appendChild(chatFontRange); + chatFontRow.appendChild(chatFontVal); + appendAppearanceControl(block, chatFontRow); + appendAppIconPickerSections(block, state, (patch) => api.saveConfig(patch)); return block; @@ -1897,6 +1996,10 @@ async function runStartupUpdateCheck() { showUpdateStatusToast({ state: 'checking' }); if (meta?.isPackaged && window.blip.checkForUpdates) { + if (meta?.isPortable) { + await checkUpdatesViaGithub(current); + return; + } const r = await window.blip.checkForUpdates(); if (r?.skipped) { await checkUpdatesViaGithub(current); @@ -2088,6 +2191,35 @@ function buildSettingsNotificationsPanel() { dndRow.appendChild(createPixelHintIcon('settings.notifications_dnd_hint')); frag.appendChild(dndRow); + const toastSec = Math.max(2, Math.min(60, Number(state.config.toastDurationSec) || 9)); + const toastRow = document.createElement('label'); + toastRow.className = 'settings-range-row'; + const toastLab = document.createElement('span'); + toastLab.dataset.i18n = 'settings.toast_duration'; + toastLab.textContent = t('settings.toast_duration'); + const toastRange = document.createElement('input'); + toastRange.type = 'range'; + toastRange.min = '2'; + toastRange.max = '60'; + toastRange.step = '1'; + toastRange.value = String(toastSec); + toastRange.className = 'settings-range'; + const toastVal = document.createElement('span'); + toastVal.className = 'settings-range-val'; + toastVal.textContent = `${toastSec}s`; + toastRange.addEventListener('input', () => { + toastVal.textContent = `${toastRange.value}s`; + }); + toastRange.addEventListener('change', async () => { + const sec = Number(toastRange.value) || 9; + state.config = await api.saveConfig({ toastDurationSec: sec }); + setDefaultToastDurationMs(sec * 1000); + }); + toastRow.appendChild(toastLab); + toastRow.appendChild(toastRange); + toastRow.appendChild(toastVal); + frag.appendChild(toastRow); + return frag; } @@ -2319,6 +2451,16 @@ function buildSettingsSoundPanel() { ); frag.appendChild(enableToggle.el); + + const typingToggle = createPixelToggle({ + checked: !!state.config.typingSoundEnabled, + labelKey: 'settings.typing_sound', + onChange: async (checked) => { + state.config = await api.saveConfig({ typingSoundEnabled: checked }); + }, + }); + frag.appendChild(typingToggle.el); + frag.appendChild(volLabel); frag.appendChild(volRow); frag.appendChild( @@ -2650,6 +2792,7 @@ function buildSettingsNetworkPanel() { frag.appendChild(projClipRow); const statsUi = appendSessionStatsSection(frag); + appendBandwidthGraphSection(frag); const actions = document.createElement('div'); actions.className = 'settings-network-actions'; @@ -3378,8 +3521,15 @@ function buildSettingsUpdatesPanel() { const r = await window.blip.checkForUpdates(); if (r?.skipped) { lastUpdateStatus = null; - statusLine.dataset.i18n = 'settings.updates_dev_only'; - statusLine.textContent = t('settings.updates_dev_only'); + if (r.reason === 'portable') { + statusLine.dataset.i18n = 'settings.updates_portable_only'; + statusLine.textContent = t('settings.updates_portable_only'); + const meta = await window.blip.getAppMetadata?.().catch(() => null); + if (meta?.version) void checkUpdatesViaGithub(meta.version); + } else { + statusLine.dataset.i18n = 'settings.updates_dev_only'; + statusLine.textContent = t('settings.updates_dev_only'); + } } else { statusLine.textContent = formatUpdateStatusText(); } @@ -3510,6 +3660,11 @@ function buildSettingsUpdatesPanel() { statusLine.textContent = t('settings.updates_dev_only'); checkBtn.disabled = true; installBtn.disabled = true; + } else if (meta?.isPortable) { + statusLine.dataset.i18n = 'settings.updates_portable_only'; + statusLine.textContent = t('settings.updates_portable_only'); + checkBtn.disabled = false; + installBtn.disabled = true; } else { checkBtn.disabled = false; delete statusLine.dataset.i18n; @@ -4154,7 +4309,7 @@ function renderView(viewName, options = {}) { view = renderProjectsView(); break; case 'beacon': - view = renderBeaconView(); + view = renderBeaconView(state.config); break; case 'profile': { view = renderPeerProfileView(); @@ -4280,6 +4435,7 @@ export function initUI(config, blipApi) { initPeerTrust(config, blipApi); purgeGroupsFor(config.blipId); setLang(config.language || localStorage.getItem('blip_lang') || 'en'); + setDefaultToastDurationMs((Number(config.toastDurationSec) || 9) * 1000); applySoundPrefsFromConfig(config); applyAppearance(state.config); applyReactiveWallpaperConfig(state.config); @@ -4308,18 +4464,110 @@ export function initUI(config, blipApi) { beaconSendUdp: (payload) => window.blip?.beaconSendUdp?.(payload), beaconWriteMeta: (payload) => window.blip?.beaconWriteMeta?.(payload), beaconReadMeta: (payload) => window.blip?.beaconReadMeta?.(payload), + beaconReadPreview: (payload) => window.blip?.beaconReadPreview?.(payload), + beaconWritePreview: (payload) => window.blip?.beaconWritePreview?.(payload), + setTrayTransferProgress: (info) => window.blip?.setTrayTransferProgress?.(info), beaconWriteChunk: (payload) => window.blip?.beaconWriteChunk?.(payload), + beaconWriteChunksBatch: (payload) => window.blip?.beaconWriteChunksBatch?.(payload), beaconReadChunk: (payload) => window.blip?.beaconReadChunk?.(payload), + beaconReadChunksBatch: (payload) => window.blip?.beaconReadChunksBatch?.(payload), + beaconHaveBitmap: (payload) => window.blip?.beaconHaveBitmap?.(payload), beaconChunkExists: (payload) => window.blip?.beaconChunkExists?.(payload), beaconCountChunks: (payload) => window.blip?.beaconCountChunks?.(payload), beaconListLocal: () => window.blip?.beaconListLocal?.(), beaconSaveAssembled: (payload) => window.blip?.beaconSaveAssembled?.(payload), + beaconDeleteSeed: (payload) => window.blip?.beaconDeleteSeed?.(payload), }, getConfig: () => state.config, getPeers: () => state.peers, getPeerLatency: (id) => peerLatencyMs.get(id) ?? 9999, }); + initIdleAway({ + getConfig: () => state.config, + saveConfig: (patch) => api.saveConfig(patch), + }); + + window.addEventListener('blip-open-beacon-seed', (ev) => { + const seedId = String(ev.detail?.seedId || '').trim(); + if (!seedId) return; + state.view = 'beacon'; + renderView('beacon'); + void import('./beacon-mesh.js').then(({ downloadBeaconSeed }) => + downloadBeaconSeed(seedId).catch(() => {}) + ); + }); + + window.blip?.onBeaconOpenSeed?.((data) => { + const seedId = String(data?.seedId || '').trim(); + if (!seedId) return; + window.dispatchEvent( + new CustomEvent('blip-open-beacon-seed', { detail: { seedId } }) + ); + }); + + window.blip?.onBeaconOpenBlipFile?.((data) => { + const doc = data?.doc; + const seedId = String(doc?.seedId || '').trim(); + if (!seedId) { + showAppToast({ + title: t('beacon.blip_invalid'), + variant: 'danger', + durationMs: 5000, + }); + return; + } + void import('./beacon-mesh.js').then(({ registerBlipSeedDescriptor }) => { + registerBlipSeedDescriptor(doc); + state.view = 'beacon'; + renderView('beacon'); + window.dispatchEvent( + new CustomEvent('blip-open-beacon-seed', { detail: { seedId } }) + ); + }); + }); + + window.addEventListener('blip-beacon-share-chat', (ev) => { + const seedId = String(ev.detail?.seedId || '').trim(); + if (!seedId) return; + const targets = state.peers + .filter((p) => p.online && !isBlocked(p.blipId)) + .map((p) => ({ id: p.blipId, label: formatPeerDisplayName(p, p.blipId) })); + void import('./chat-forward-picker.js').then(async ({ openForwardPeerPicker }) => { + const peerId = await openForwardPeerPicker(targets); + if (peerId == null) return; + const { buildBeaconSeedLink, buildBeaconAttachment } = await import('./beacon-mesh.js'); + const { addMessage } = await import('./chat.js'); + const { createMessageId } = await import('./message-id.js'); + const link = buildBeaconSeedLink(seedId); + const outMsg = { + id: createMessageId(), + from: state.config.blipId, + to: peerId, + text: link, + timestamp: Date.now(), + outgoing: true, + attachment: buildBeaconAttachment({ + seedId, + filename: ev.detail?.filename, + size: ev.detail?.size, + }), + }; + ensureChatView(peerId); + addMessage(peerId, outMsg); + state.chatViews.get(peerId)?.renderMessages?.(); + await api.sendTcpMessage({ + to: peerId, + type: 'message', + text: outMsg.text, + id: outMsg.id, + timestamp: outMsg.timestamp, + attachment: outMsg.attachment, + }); + openChat(peerId); + }); + }); + const titleBar = createTitleBar(); rootEl.appendChild(titleBar); @@ -4618,7 +4866,6 @@ export function handleTcpMessage(msg) { config: state.config, onProgress: (peerId, transferId, pct) => { trackTransferProgress(peerId, transferId, pct, { direction: 'in' }); - if (pct >= 100) trackTransferEnd(peerId, transferId); }, onComplete: (peerId, payload) => { if (payload?.transferId) trackTransferEnd(peerId, payload.transferId); diff --git a/scripts/publish-release-notes.mjs b/scripts/publish-release-notes.mjs new file mode 100644 index 0000000..64b90f1 --- /dev/null +++ b/scripts/publish-release-notes.mjs @@ -0,0 +1,25 @@ +/** + * Prints files to attach when publishing a release manually (no GH_TOKEN). + */ +import { existsSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; + +const outDir = join(process.cwd(), 'dist-electron'); +if (!existsSync(outDir)) { + console.error('[publish-release-notes] Run npm run electron:build:all first.'); + process.exit(1); +} + +const required = ['latest.yml']; +const optional = (name) => + name.endsWith('.exe') && (name.includes('Setup') || name.includes('Portable')); + +const files = readdirSync(outDir).filter( + (f) => required.includes(f) || optional(f) +); + +console.log('\nAttach to GitHub Release (same tag as package.json version):\n'); +for (const f of files.sort()) { + console.log(` dist-electron/${f}`); +} +console.log('\nRequired for in-app auto-update: latest.yml + BLIP-Setup-.exe\n'); From 89471d04842ccef34651c39a789dbabc0989d740 Mon Sep 17 00:00:00 2001 From: krwg Date: Mon, 25 May 2026 01:49:33 +0300 Subject: [PATCH 19/25] fix: LAN throughput, stream quality, and BEACON publish UX (hold 1.1.1) - Wire beaconPublishFromPath/beaconServeChunksTcp into mesh API; fallback to preload - Send DM files from main process (send-file-from-path); serialize TCP writes per socket - Serve BEACON chunks from main; single-pass ingest with parallel disk writes - Publish overlay + Downloads row while large files ingest on BEACON - Raise WebRTC bitrates, maintain-resolution for camera/screen; tune voice-channel screen share - Fix electron-updater signature verify for unsigned NSIS installers --- CHANGELOG.md | 8 +- build/build-info.json | 4 +- main/beacon-ingest.js | 112 +++++++++++++--------- main/beacon-store.js | 16 +++- main/beacon-tcp-serve.js | 60 ++++++++++++ main/file-tcp-send.js | 96 +++++++++++++++++++ main/index.js | 67 ++++++++++++++ main/tcp-client.js | 13 +-- main/tcp-write-queue.js | 31 +++++++ main/updater.js | 4 +- preload.cjs | 21 ++++- renderer/beacon-mesh.js | 113 +++++++++++++++++++++-- renderer/beacon-ui.js | 159 +++++++++++++++++++++++++++++++- renderer/call-media.js | 52 ++++++----- renderer/file-transfer-speed.js | 6 +- renderer/file-transfer.js | 150 ++++++++++++++++++++++++------ renderer/styles.css | 75 +++++++++++++++ renderer/ui.js | 5 + renderer/voice-channel.js | 5 + 19 files changed, 863 insertions(+), 134 deletions(-) create mode 100644 main/beacon-tcp-serve.js create mode 100644 main/file-tcp-send.js create mode 100644 main/tcp-write-queue.js diff --git a/CHANGELOG.md b/CHANGELOG.md index a0f72cf..cca05e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,13 @@ Release **version numbers** track [`app-metadata.json`](app-metadata.json) (sync ## [Unreleased] -_No changes yet._ +### Fixed + +- **BEACON publish** — `beaconPublishFromPath` was missing from `initBeaconMesh` API (always failed with «browser file» for large ZIP); native file dialog + main-process ingest. +- **DM file transfer** — main-process send from disk path (`send-file-from-path`), 1 MiB chunks, serialized TCP write queue (fixes interleaved JSON lines under parallel IPC). +- **BEACON throughput** — serve chunks from main (`beacon-serve-chunks-tcp`), single-pass ingest with parallel disk writes; publish overlay + row in Downloads tab. +- **Call/stream quality (LAN)** — higher RTP bitrates, `maintain-resolution` on camera and screen; voice-channel screen share now calls `tuneVideoSender`. +- **Windows auto-update** — `verifyUpdateCodeSignature = false` was ignored by electron-updater; use async verifier that skips unsigned NSIS installers so download + install complete. ## [1.1.1] — Beacon — 2026-05-24 diff --git a/build/build-info.json b/build/build-info.json index d0ee680..5510dca 100644 --- a/build/build-info.json +++ b/build/build-info.json @@ -1,7 +1,7 @@ { "issuer": "krwg-official", "version": "1.1.1", - "buildTimestamp": 1779632926033, - "buildSignature": "nw5pDEsvyfsvJBfBpNbLK8nfcT81bGox1E3g1NuMIp0cQbFqqz8OLfIRTWl0URb4GUZqtwm1Onfq1i8NOi6bAA==", + "buildTimestamp": 1779656042339, + "buildSignature": "cJXNEzz92AxCZXuaCDKRfk2QjeVD1dCMwLHYMMS6KNgBL+MRJJbXdK3VGngmcuH9U9yVG3lBBsHuHlIfBUolBg==", "meshPlusPublicKeyHash": "b91e80f821881373" } diff --git a/main/beacon-ingest.js b/main/beacon-ingest.js index ba3a03c..db4d59d 100644 --- a/main/beacon-ingest.js +++ b/main/beacon-ingest.js @@ -1,10 +1,11 @@ -import { createHash } from 'crypto'; -import { createReadStream } from 'fs'; +import { createHash, randomBytes } from 'crypto'; import { stat, open, readFile } from 'fs/promises'; -import { basename, extname } from 'path'; -import { getSeedDir, writeSeedMeta } from './beacon-store.js'; +import { rename, rm } from 'fs/promises'; +import { basename, extname, join } from 'path'; +import { getSeedDir, writeSeedMeta, seedDirExists } from './beacon-store.js'; import { mkdir, writeFile } from 'fs/promises'; -import { join } from 'path'; + +const WRITE_POOL = 8; function chunkFilePath(seedDir, chunkIndex) { return join(seedDir, `chunk-${String(chunkIndex).padStart(5, '0')}`); @@ -16,19 +17,31 @@ async function writeRawChunk(seedId, chunkIndex, buf) { await writeFile(chunkFilePath(dir, chunkIndex), buf); } -/** - * Streaming SHA-256 (full hex). - * @param {string} filePath - * @param {number} chunkSize - */ -export function hashFilePath(filePath, chunkSize) { - return new Promise((resolve, reject) => { - const hash = createHash('sha256'); - const rs = createReadStream(filePath, { highWaterMark: chunkSize }); - rs.on('data', (chunk) => hash.update(chunk)); - rs.on('end', () => resolve(hash.digest('hex'))); - rs.on('error', reject); - }); +async function finalizeStagingSeed(stagingId, seedId, meta) { + const stagingDir = getSeedDir(stagingId); + const targetDir = getSeedDir(seedId); + if (stagingId === seedId) { + await writeSeedMeta(seedId, meta); + return; + } + if (await seedDirExists(seedId)) { + try { + await rm(stagingDir, { recursive: true, force: true }); + } catch { + /* ignore */ + } + } else { + try { + await rename(stagingDir, targetDir); + } catch { + try { + await rm(stagingDir, { recursive: true, force: true }); + } catch { + /* ignore */ + } + } + } + await writeSeedMeta(seedId, meta); } function guessMime(filename) { @@ -52,7 +65,7 @@ function guessMime(filename) { } /** - * Ingest a file from disk into seed storage (no renderer File API). + * Ingest a file from disk into seed storage (single pass: hash + write). * @param {string} filePath * @param {{ chunkSize?: number, maxBytes?: number, onProgress?: (p: { phase: string, percent?: number }) => void }} [opts] */ @@ -63,48 +76,59 @@ export async function ingestPublishFromPath(filePath, opts = {}) { let st; try { st = await stat(filePath); - } catch { - throw new Error('not_found'); + } catch (e) { + const code = e && typeof e === 'object' && 'code' in e ? e.code : ''; + if (code === 'ENOENT') throw new Error('not_found'); + if (code === 'EACCES' || code === 'EPERM') throw new Error('not_readable'); + throw new Error('not_readable'); } if (!st.isFile()) throw new Error('not_file'); if (st.size <= 0) throw new Error('empty'); if (opts.maxBytes && st.size > opts.maxBytes) throw new Error('too_large'); - onProgress?.({ phase: 'hashing', percent: 2 }); - const fullHash = await hashFilePath(filePath, chunkSize); - const seedId = fullHash.slice(0, 16); - const filename = basename(filePath); const totalChunks = Math.ceil(st.size / chunkSize); - const meta = { - seedId, - filename, - size: st.size, - chunkSize, - totalChunks, - mime: guessMime(filename), - publishedAt: Date.now(), - }; + const stagingId = `ing_${randomBytes(8).toString('hex')}`; + const hash = createHash('sha256'); - onProgress?.({ phase: 'publishing', percent: 8 }); + onProgress?.({ phase: 'hashing', percent: 4 }); const fh = await open(filePath, 'r'); + try { - for (let i = 0; i < totalChunks; i++) { - const offset = i * chunkSize; - const len = Math.min(chunkSize, st.size - offset); - const buf = Buffer.allocUnsafe(len); - const { bytesRead } = await fh.read(buf, 0, len, offset); - if (bytesRead <= 0) throw new Error('read'); - await writeRawChunk(seedId, i, buf.subarray(0, bytesRead)); - const pct = Math.round(8 + ((i + 1) / totalChunks) * 82); + for (let batchStart = 0; batchStart < totalChunks; batchStart += WRITE_POOL) { + const writes = []; + const batchEnd = Math.min(batchStart + WRITE_POOL, totalChunks); + for (let i = batchStart; i < batchEnd; i++) { + const offset = i * chunkSize; + const len = Math.min(chunkSize, st.size - offset); + const buf = Buffer.allocUnsafe(len); + const { bytesRead } = await fh.read(buf, 0, len, offset); + if (bytesRead <= 0) throw new Error('read'); + const slice = buf.subarray(0, bytesRead); + hash.update(slice); + writes.push(writeRawChunk(stagingId, i, slice)); + } + await Promise.all(writes); + const pct = Math.round(4 + (batchEnd / totalChunks) * 88); onProgress?.({ phase: 'publishing', percent: pct }); } } finally { await fh.close(); } - await writeSeedMeta(seedId, meta); + const seedId = hash.digest('hex').slice(0, 16); + const meta = { + seedId, + filename, + size: st.size, + chunkSize, + totalChunks, + mime: guessMime(filename), + publishedAt: Date.now(), + }; + + await finalizeStagingSeed(stagingId, seedId, meta); onProgress?.({ phase: 'ready', percent: 100 }); return meta; } diff --git a/main/beacon-store.js b/main/beacon-store.js index 7df08e3..55c2623 100644 --- a/main/beacon-store.js +++ b/main/beacon-store.js @@ -16,12 +16,22 @@ export function getBeaconSeedsRoot() { } export function getSeedDir(seedId) { - const safe = String(seedId || '') - .replace(/[^a-f0-9]/gi, '') - .slice(0, 64); + const raw = String(seedId || '').trim(); + const safe = raw.startsWith('ing_') + ? raw.replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 64) + : raw.replace(/[^a-f0-9]/gi, '').slice(0, 64); return join(getBeaconSeedsRoot(), safe || 'unknown'); } +export async function seedDirExists(seedId) { + try { + await access(getSeedDir(seedId), constants.F_OK); + return true; + } catch { + return false; + } +} + function chunkPath(seedDir, chunkIndex) { return join(seedDir, `chunk-${String(chunkIndex).padStart(5, '0')}`); } diff --git a/main/beacon-tcp-serve.js b/main/beacon-tcp-serve.js new file mode 100644 index 0000000..1b1182d --- /dev/null +++ b/main/beacon-tcp-serve.js @@ -0,0 +1,60 @@ +import { readSeedChunksBatch } from './beacon-store.js'; +import { sendOnSocketQueued } from './tcp-write-queue.js'; +import { MAX_TCP_LINE_BYTES } from './tcp-framing.js'; + +/** 1 MiB raw chunks → ~2× base64 per TCP line (under 4 MiB cap). */ +const CHUNKS_PER_LINE = 2; + +/** + * Read seed chunks from disk and send to peer in main (no renderer round-trip). + * @param {import('net').Socket} socket + * @param {number} fromBlipId + * @param {{ to: number, seedId: string, chunkIndices: number[] }} opts + */ +export async function serveSeedChunksOnSocket(socket, fromBlipId, { to, seedId, chunkIndices }) { + const indices = (chunkIndices || []).map(Number).filter(Number.isFinite); + if (!seedId || !indices.length || !socket) return { ok: false, sent: 0 }; + + const payloads = (await readSeedChunksBatch(seedId, indices)).filter((c) => c?.ok && c.data); + let sent = 0; + + for (let i = 0; i < payloads.length; i += CHUNKS_PER_LINE) { + const batch = payloads.slice(i, i + CHUNKS_PER_LINE); + const packet = + batch.length === 1 + ? { + type: 'seed-chunk', + from: fromBlipId, + to, + seedId, + chunkIndex: batch[0].chunkIndex, + data: batch[0].data, + } + : { + type: 'seed-chunks-batch', + from: fromBlipId, + to, + seedId, + chunks: batch.map((c) => ({ chunkIndex: c.chunkIndex, data: c.data })), + }; + const lineBytes = Buffer.byteLength(JSON.stringify(packet) + '\n', 'utf8'); + if (lineBytes > MAX_TCP_LINE_BYTES) { + for (const one of batch) { + await sendOnSocketQueued(socket, { + type: 'seed-chunk', + from: fromBlipId, + to, + seedId, + chunkIndex: one.chunkIndex, + data: one.data, + }); + sent += 1; + } + continue; + } + await sendOnSocketQueued(socket, packet); + sent += batch.length; + } + + return { ok: true, sent }; +} diff --git a/main/file-tcp-send.js b/main/file-tcp-send.js new file mode 100644 index 0000000..f3fe40f --- /dev/null +++ b/main/file-tcp-send.js @@ -0,0 +1,96 @@ +import { open, stat } from 'fs/promises'; +import { sendOnSocketQueued } from './tcp-write-queue.js'; +import { MAX_TCP_LINE_BYTES } from './tcp-framing.js'; + +const CHUNK_RAW_BYTES = 1024 * 1024; +const CHUNKS_PER_BATCH = 2; + +/** + * Stream a file from disk to a peer over TCP (main process — no renderer FileReader). + * @param {import('net').Socket} socket + * @param {number} fromBlipId + * @param {object} opts + */ +export async function sendFileFromPathOnSocket(socket, fromBlipId, opts) { + const { + filePath, + to, + transferId, + name, + mime, + size: sizeHint, + groupId, + msgId, + onProgress, + isCancelled, + } = opts; + + if (!filePath || !socket || !transferId) throw new Error('invalid_args'); + + const st = await stat(filePath); + if (!st.isFile() || st.size <= 0) throw new Error('not_file'); + + const size = Number(sizeHint) > 0 ? Number(sizeHint) : st.size; + const chunkCount = Math.ceil(size / CHUNK_RAW_BYTES); + const base = { from: fromBlipId, to, transferId }; + if (groupId) base.groupId = groupId; + if (msgId) base.msgId = msgId; + + await sendOnSocketQueued(socket, { + type: 'file-offer', + ...base, + name: name || 'file', + mime: mime || 'application/octet-stream', + size, + chunkCount, + }); + + const fh = await open(filePath, 'r'); + let bytesSent = 0; + const startedAt = Date.now(); + + try { + for (let i = 0; i < chunkCount; ) { + if (isCancelled?.()) throw new Error('cancelled'); + + const batch = []; + for (let b = 0; b < CHUNKS_PER_BATCH && i < chunkCount; b++, i++) { + const start = i * CHUNK_RAW_BYTES; + const len = Math.min(CHUNK_RAW_BYTES, size - start); + const buf = Buffer.allocUnsafe(len); + const { bytesRead } = await fh.read(buf, 0, len, start); + if (bytesRead <= 0) throw new Error('read'); + batch.push({ index: i, data: buf.subarray(0, bytesRead).toString('base64') }); + bytesSent = start + bytesRead; + } + + const packet = { type: 'file-chunks-batch', ...base, chunks: batch }; + let lineBytes = Buffer.byteLength(JSON.stringify(packet) + '\n', 'utf8'); + if (lineBytes > MAX_TCP_LINE_BYTES && batch.length > 1) { + for (const one of batch) { + await sendOnSocketQueued(socket, { + type: 'file-chunks-batch', + ...base, + chunks: [one], + }); + } + } else { + await sendOnSocketQueued(socket, packet); + } + + const elapsed = Math.max(0.001, (Date.now() - startedAt) / 1000); + onProgress?.({ + percent: Math.min(100, Math.round((bytesSent / size) * 100)), + bytesSent, + speedBps: bytesSent / elapsed, + }); + } + } finally { + await fh.close(); + } + + if (isCancelled?.()) throw new Error('cancelled'); + + await sendOnSocketQueued(socket, { type: 'file-done', ...base }); + return { ok: true, bytesSent: size }; +} diff --git a/main/index.js b/main/index.js index 4e81202..d9a7866 100644 --- a/main/index.js +++ b/main/index.js @@ -27,6 +27,8 @@ import { ingestPublishFromPath, tryReadImagePreviewB64, } from './beacon-ingest.js'; +import { serveSeedChunksOnSocket } from './beacon-tcp-serve.js'; +import { sendFileFromPathOnSocket } from './file-tcp-send.js'; import { createTcpServer } from './tcp-server.js'; import { connectToPeer, sendOnSocket, pingPeer } from './tcp-client.js'; import { createTcpLineReader } from './tcp-framing.js'; @@ -778,6 +780,7 @@ function handleTcpPayload(msg, fromBlipId) { case 'seed-have-request': case 'file-offer': case 'file-chunk': + case 'file-chunks-batch': case 'file-done': case 'file-abort': case 'clipboard-push': @@ -1118,6 +1121,17 @@ function setupIpc() { return true; }); + ipcMain.handle('beacon-pick-publish-file', async () => { + const { canceled, filePaths } = await dialog.showOpenDialog({ + properties: ['openFile'], + title: 'BEACON — publish file', + }); + if (canceled || !filePaths?.[0]) { + return { ok: false, cancelled: true }; + } + return { ok: true, filePath: filePaths[0] }; + }); + ipcMain.handle('beacon-publish-from-path', async (event, { filePath, maxBytes, chunkSize }) => { if (typeof filePath !== 'string' || !filePath.trim()) { return { ok: false, error: 'no_path' }; @@ -1203,6 +1217,59 @@ function setupIpc() { return { ok: true, chunks }; }); + ipcMain.handle('beacon-serve-chunks-tcp', async (_, payload) => { + try { + const to = Number(payload?.to); + const seedId = String(payload?.seedId || ''); + const chunkIndices = Array.isArray(payload?.chunkIndices) ? payload.chunkIndices : []; + if (!Number.isFinite(to) || !seedId || !chunkIndices.length) { + return { ok: false, error: 'invalid' }; + } + const socket = await ensurePeerSocket(to); + return await serveSeedChunksOnSocket(socket, config.blipId, { + to, + seedId, + chunkIndices, + }); + } catch (err) { + return { ok: false, error: err?.message || 'serve_failed' }; + } + }); + + ipcMain.handle('send-file-from-path', async (event, payload) => { + const wc = event.sender; + try { + const to = Number(payload?.to); + const filePath = String(payload?.filePath || '').trim(); + const transferId = String(payload?.transferId || ''); + if (!Number.isFinite(to) || !filePath || !transferId) { + return { ok: false, error: 'invalid' }; + } + const socket = await ensurePeerSocket(to); + await sendFileFromPathOnSocket(socket, config.blipId, { + filePath, + to, + transferId, + name: payload.name, + mime: payload.mime, + size: payload.size, + groupId: payload.groupId, + msgId: payload.msgId, + onProgress: (p) => { + try { + wc.send('file-send-progress', { transferId, to, ...p }); + } catch { + /* gone */ + } + }, + }); + return { ok: true }; + } catch (err) { + const msg = err?.message || 'send_failed'; + return { ok: false, error: msg }; + } + }); + ipcMain.handle('beacon-have-bitmap', async (_, { seedId, totalChunks }) => { if (!seedId) return { ok: false, bitmap: '' }; const bitmap = await buildSeedHaveBitmap(seedId, Number(totalChunks) || 0); diff --git a/main/tcp-client.js b/main/tcp-client.js index 74d4419..3089e25 100644 --- a/main/tcp-client.js +++ b/main/tcp-client.js @@ -32,17 +32,10 @@ export function connectToPeer(ip, blipId, tcpPort = DEFAULT_TCP_PORT) { return promise; } +import { sendOnSocketQueued } from './tcp-write-queue.js'; + export function sendOnSocket(socket, payload) { - return new Promise((resolve, reject) => { - if (!socket || socket.destroyed) { - reject(new Error('Socket not available')); - return; - } - socket.write(JSON.stringify(payload) + '\n', (err) => { - if (err) reject(err); - else resolve(); - }); - }); + return sendOnSocketQueued(socket, payload); } /** @returns {Promise<{ ok: boolean, ms: number | null }>} */ diff --git a/main/tcp-write-queue.js b/main/tcp-write-queue.js new file mode 100644 index 0000000..09194e7 --- /dev/null +++ b/main/tcp-write-queue.js @@ -0,0 +1,31 @@ +/** Per-socket serialized writes so parallel IPC handlers cannot interleave JSON lines. */ + +/** @type {WeakMap }>} */ +const queues = new WeakMap(); + +/** + * @param {import('net').Socket} socket + * @param {object} payload + */ +export function sendOnSocketQueued(socket, payload) { + if (!socket || socket.destroyed) { + return Promise.reject(new Error('Socket not available')); + } + let q = queues.get(socket); + if (!q) { + q = { tail: Promise.resolve() }; + queues.set(socket, q); + } + const line = JSON.stringify(payload) + '\n'; + const job = q.tail.then( + () => + new Promise((resolve, reject) => { + socket.write(line, (err) => { + if (err) reject(err); + else resolve(); + }); + }) + ); + q.tail = job.catch(() => {}); + return job; +} diff --git a/main/updater.js b/main/updater.js index 3386bbd..d5685a0 100644 --- a/main/updater.js +++ b/main/updater.js @@ -94,8 +94,10 @@ async function applyUpdateFeed(config) { autoUpdater.autoInstallOnAppQuit = config?.autoDownloadUpdates !== false; autoUpdater.disableDifferentialDownload = true; + // electron-updater ignores `false` — setter only applies truthy values (NsisUpdater.js). + // Unsigned GitHub installers must skip Authenticode verification explicitly. if (process.platform === 'win32') { - autoUpdater.verifyUpdateCodeSignature = false; + autoUpdater.verifyUpdateCodeSignature = async () => null; } const feed = await resolveUpdateFeedUrl(config); diff --git a/preload.cjs b/preload.cjs index b68afe8..cabfa08 100644 --- a/preload.cjs +++ b/preload.cjs @@ -1,4 +1,4 @@ -const { contextBridge, ipcRenderer } = require('electron'); +const { contextBridge, ipcRenderer, webUtils } = require('electron'); let groupCallActive = false; let groupCallGroupId = null; @@ -96,7 +96,26 @@ contextBridge.exposeInMainWorld('blip', { }, beaconSendUdp: (payload) => ipcRenderer.invoke('beacon-udp-send', payload), getBeaconPaths: () => ipcRenderer.invoke('beacon-paths'), + beaconPickPublishFile: () => ipcRenderer.invoke('beacon-pick-publish-file'), + getPathForFile: (file) => { + try { + if (file && typeof webUtils?.getPathForFile === 'function') { + return webUtils.getPathForFile(file) || ''; + } + } catch { + /* ignore */ + } + if (file && typeof file.path === 'string') return file.path; + return ''; + }, beaconPublishFromPath: (payload) => ipcRenderer.invoke('beacon-publish-from-path', payload), + beaconServeChunksTcp: (payload) => ipcRenderer.invoke('beacon-serve-chunks-tcp', payload), + sendFileFromPath: (payload) => ipcRenderer.invoke('send-file-from-path', payload), + onFileSendProgress: (cb) => { + const handler = (_, data) => cb(data); + ipcRenderer.on('file-send-progress', handler); + return () => ipcRenderer.removeListener('file-send-progress', handler); + }, onBeaconIngestProgress: (cb) => { const handler = (_, data) => cb(data); ipcRenderer.on('beacon-ingest-progress', handler); diff --git a/renderer/beacon-mesh.js b/renderer/beacon-mesh.js index 7279960..7158c28 100644 --- a/renderer/beacon-mesh.js +++ b/renderer/beacon-mesh.js @@ -4,11 +4,12 @@ import { getMaxFileBytes } from './file-transfer-limits.js'; import { parseBlipSeedFile } from './beacon-seed-file.js'; +import { recordBandwidthSample } from './bandwidth-monitor.js'; export const BEACON_CHUNK_SIZE = 1048576; const PULSE_INTERVAL_MS = 30_000; const CHUNK_REQUEST_BATCH = 16; -const MAX_PARALLEL_PEERS = 6; +const MAX_PARALLEL_PEERS = 8; const PIPELINED_BATCHES_PER_PEER = 3; /** Max chunks per TCP JSON line (stay under 4 MiB framing limit). */ const SEED_TCP_BATCH = 2; @@ -35,12 +36,24 @@ const stoppedSeeding = new Set(); /** @type {Map} */ const jobProgress = new Map(); +/** In-flight publish before catalog entry exists. */ +let pendingIngest = null; + /** @type {Map void, reject: (err: Error) => void, timer: ReturnType }>} */ const pendingChunks = new Map(); let pulseTimer = null; let meshApi = null; let getConfig = () => ({}); + +/** IPC surface (initBeaconMesh may omit newer methods — always fall back to preload). */ +function beaconIpc() { + if (meshApi?.beaconPublishFromPath) return meshApi; + if (typeof window !== 'undefined' && window.blip?.beaconPublishFromPath) { + return window.blip; + } + return meshApi; +} let getPeers = () => []; let getPeerLatency = () => 9999; @@ -99,6 +112,25 @@ function emitCatalog() { window.dispatchEvent(new CustomEvent('blip-beacon-catalog')); } +function beginPendingIngest(seedId, { filename, size }) { + pendingIngest = { + seedId, + filename: String(filename || 'file'), + size: Number(size) || 0, + }; + setJobProgress(seedId, 0, 'hashing'); + emitCatalog(); +} + +function endPendingIngest() { + pendingIngest = null; + emitCatalog(); +} + +export function getPendingBeaconIngest() { + return pendingIngest; +} + function emitProgress(seedId) { window.dispatchEvent( new CustomEvent('blip-beacon-progress', { detail: { seedId, ...(jobProgress.get(seedId) || {}) } }) @@ -307,8 +339,12 @@ function readFileSliceAsBase64(file, start, end) { /** @param {File} file */ function resolvePublishFilePath(file) { if (!file) return ''; - const p = file.path; - if (typeof p === 'string' && p.trim()) return p.trim(); + if (typeof file.path === 'string' && file.path.trim()) return file.path.trim(); + const api = beaconIpc(); + if (typeof api?.getPathForFile === 'function') { + const p = api.getPathForFile(file); + if (typeof p === 'string' && p.trim()) return p.trim(); + } return ''; } @@ -326,9 +362,14 @@ async function computeSeedId(file) { async function publishBeaconFileFromPath(filePath, file) { const cfg = getConfig(); const maxBytes = getMaxFileBytes(cfg); + const displayName = + file?.name || String(filePath || '').replace(/^.*[/\\]/, '') || 'file'; + const displaySize = Number(file?.size) || 0; + beginPendingIngest('path-ingest', { filename: displayName, size: displaySize }); let unsub = () => {}; - if (typeof meshApi?.onBeaconIngestProgress === 'function') { - unsub = meshApi.onBeaconIngestProgress((p) => { + const api = beaconIpc(); + if (typeof api?.onBeaconIngestProgress === 'function') { + unsub = api.onBeaconIngestProgress((p) => { if (p?.phase === 'hashing') setJobProgress('path-ingest', 5, 'hashing'); else if (p?.phase === 'publishing' && p.percent != null) { setJobProgress('path-ingest', p.percent, 'publishing'); @@ -336,7 +377,7 @@ async function publishBeaconFileFromPath(filePath, file) { }); } try { - const res = await meshApi.beaconPublishFromPath({ + const res = await api.beaconPublishFromPath({ filePath, maxBytes, chunkSize: BEACON_CHUNK_SIZE, @@ -362,6 +403,7 @@ async function publishBeaconFileFromPath(filePath, file) { } finally { unsub(); clearJobProgress('path-ingest'); + endPendingIngest(); } } @@ -608,6 +650,16 @@ async function serveSeedRequest(msg, api, config) { await acquireServeSlot(); try { + const ipc = beaconIpc(); + if (typeof ipc?.beaconServeChunksTcp === 'function') { + const res = await ipc.beaconServeChunksTcp({ to: from, seedId, chunkIndices: chunks }); + const sent = Number(res?.sent) || 0; + if (sent > 0) { + recordBandwidthSample({ upBps: sent * BEACON_CHUNK_SIZE * 8 }); + } + return; + } + let payloads = []; if (meshApi?.beaconReadChunksBatch) { const res = await meshApi.beaconReadChunksBatch({ seedId, chunkIndices: chunks }); @@ -906,7 +958,7 @@ export function isSeedLocalComplete(seedId) { export function getBeaconCatalog() { const cfg = getConfig(); - return [...catalog.values()].map((e) => { + const rows = [...catalog.values()].map((e) => { const local = localComplete.has(e.seedId); const job = jobProgress.get(e.seedId); const paused = pausedSeeds.has(e.seedId); @@ -938,6 +990,34 @@ export function getBeaconCatalog() { : job?.phase || (activelySeeding ? 'seeding' : local ? 'local' : 'available'), }; }); + + if (pendingIngest) { + const job = jobProgress.get(pendingIngest.seedId); + rows.unshift({ + seedId: pendingIngest.seedId, + filename: pendingIngest.filename, + size: pendingIngest.size, + chunkSize: BEACON_CHUNK_SIZE, + totalChunks: 0, + seederCount: 0, + leechers: 0, + updatedAt: Date.now(), + local: false, + mine: true, + canSave: false, + paused: false, + stopped: false, + progress: job?.progress ?? 0, + phase: job?.phase || 'hashing', + previewB64: '', + previewUrl: null, + hasLocalData: false, + status: job?.phase || 'hashing', + pendingIngest: true, + }); + } + + return rows; } /** @@ -989,8 +1069,21 @@ export async function announceSeed(meta) { * Publish a file into the mesh library. * @param {File} file */ -/** Renderer-only publish for small files without a disk path (e.g. some browser-like picks). */ -const RENDERER_PUBLISH_MAX = 48 * 1024 * 1024; +/** Renderer-only publish for tiny files without a disk path. */ +const RENDERER_PUBLISH_MAX = 4 * 1024 * 1024; + +/** + * Publish from absolute path (main-process read). Preferred for all sizes. + * @param {string} filePath + */ +export async function publishBeaconFilePath(filePath) { + const cfg = getConfig(); + if (!cfg?.devBeaconEnabled) throw new Error('disabled'); + const p = String(filePath || '').trim(); + if (!p) throw new Error('no_path'); + if (!beaconIpc()?.beaconPublishFromPath) throw new Error('no_path'); + return publishBeaconFileFromPath(p, null); +} export async function publishBeaconFile(file) { const cfg = getConfig(); @@ -1000,7 +1093,7 @@ export async function publishBeaconFile(file) { if (file.size > maxBytes) throw new Error('too_large'); const diskPath = resolvePublishFilePath(file); - if (diskPath && meshApi?.beaconPublishFromPath) { + if (diskPath && beaconIpc()?.beaconPublishFromPath) { return publishBeaconFileFromPath(diskPath, file); } if (!diskPath && file.size > RENDERER_PUBLISH_MAX) { diff --git a/renderer/beacon-ui.js b/renderer/beacon-ui.js index 2b49833..7f13000 100644 --- a/renderer/beacon-ui.js +++ b/renderer/beacon-ui.js @@ -2,6 +2,7 @@ import { t } from './i18n.js'; import { getBeaconCatalog, publishBeaconFile, + publishBeaconFilePath, downloadBeaconSeed, refreshBeaconLocalState, setSeedPaused, @@ -33,9 +34,80 @@ let statsRootEl = null; let bwUnsub = null; let searchQuery = ''; let publishBusy = false; +/** @type {HTMLElement | null} */ +let publishOverlayEl = null; /** @type {() => object} */ let getUiConfig = () => ({}); +function ensurePublishOverlay(wrap) { + if (publishOverlayEl?.isConnected) return publishOverlayEl; + const host = wrap.querySelector('.beacon-view') || wrap; + const overlay = document.createElement('div'); + overlay.className = 'beacon-publish-overlay hidden'; + overlay.setAttribute('role', 'status'); + overlay.setAttribute('aria-live', 'polite'); + overlay.innerHTML = ` +
      + + + +
      +
      +
      + 0% + +
      + `; + host.appendChild(overlay); + publishOverlayEl = overlay; + return overlay; +} + +function showPublishOverlay(wrap, filename) { + const overlay = ensurePublishOverlay(wrap); + overlay.classList.remove('hidden'); + const title = overlay.querySelector('.beacon-publish-overlay-title'); + if (title) title.textContent = t('beacon.publishing'); + const nameEl = overlay.querySelector('.beacon-publish-overlay-name'); + if (nameEl) nameEl.textContent = filename || ''; + const fill = overlay.querySelector('.beacon-publish-overlay-fill'); + if (fill) fill.style.width = '0%'; + const pct = overlay.querySelector('.beacon-publish-overlay-pct'); + if (pct) pct.textContent = '0%'; + const phase = overlay.querySelector('.beacon-publish-overlay-phase'); + if (phase) { + phase.dataset.i18n = 'beacon.status_hashing'; + phase.textContent = t('beacon.status_hashing'); + } + activeTab = 'downloads'; + setActiveTab('downloads', wrap); + renderTableRows(); +} + +function updatePublishOverlay(progress, phase) { + if (!publishOverlayEl || publishOverlayEl.classList.contains('hidden')) return; + const pctVal = Math.max(0, Math.min(100, Math.round(Number(progress) || 0))); + const fill = publishOverlayEl.querySelector('.beacon-publish-overlay-fill'); + if (fill) fill.style.width = `${pctVal}%`; + const pct = publishOverlayEl.querySelector('.beacon-publish-overlay-pct'); + if (pct) pct.textContent = `${pctVal}%`; + const phaseEl = publishOverlayEl.querySelector('.beacon-publish-overlay-phase'); + if (phaseEl) { + const key = + phase === 'publishing' + ? 'beacon.status_publishing' + : phase === 'hashing' + ? 'beacon.status_hashing' + : 'beacon.publishing'; + phaseEl.dataset.i18n = key; + phaseEl.textContent = t(key); + } +} + +function hidePublishOverlay() { + publishOverlayEl?.classList.add('hidden'); +} + async function copySeedLink(seedId) { const link = buildBeaconSeedLink(seedId); const ok = await copyTextToClipboard(link); @@ -371,7 +443,7 @@ function renderTableRows() { for (const item of items) { const row = document.createElement('div'); - row.className = `beacon-table-row glass${item.paused ? ' beacon-table-row--paused' : ''}${item.phase === 'downloading' || item.phase === 'publishing' ? ' beacon-table-row--active' : ''}`; + row.className = `beacon-table-row glass${item.paused ? ' beacon-table-row--paused' : ''}${item.phase === 'downloading' || item.phase === 'publishing' || item.phase === 'hashing' ? ' beacon-table-row--active' : ''}${item.pendingIngest ? ' beacon-table-row--ingest' : ''}`; const nameCell = document.createElement('div'); nameCell.className = 'beacon-col beacon-col-name'; @@ -476,12 +548,60 @@ function handleBlipFile(file) { return true; } +async function runPublishPath(filePath, wrap) { + const btn = wrap.querySelector('.beacon-publish-btn'); + if (!filePath || publishBusy) return; + const displayName = filePath.replace(/^.*[/\\]/, ''); + publishBusy = true; + if (btn) btn.disabled = true; + showPublishOverlay(wrap, displayName); + void publishBeaconFilePath(filePath) + .then(() => { + const name = filePath.replace(/^.*[/\\]/, ''); + showAppToast({ title: t('beacon.published'), body: name, durationMs: 4500 }); + activeTab = 'mine'; + setActiveTab('mine', wrap); + }) + .catch((err) => { + const code = err?.message || ''; + let body = code; + if (code === 'not_readable' || /could not be read/i.test(code)) { + body = t('beacon.err_not_readable'); + } else if (code === 'no_path') { + body = t('beacon.err_no_path'); + } else if (code === 'too_large') { + body = t('beacon.err_too_large'); + } + showAppToast({ + title: t('beacon.failed'), + body, + variant: 'danger', + durationMs: 7000, + }); + }) + .finally(() => { + publishBusy = false; + if (btn) btn.disabled = false; + hidePublishOverlay(); + renderTableRows(); + }); +} + function runPublish(file, wrap) { if (handleBlipFile(file)) return; const btn = wrap.querySelector('.beacon-publish-btn'); if (!file || publishBusy) return; + const diskPath = + typeof window.blip?.getPathForFile === 'function' + ? window.blip.getPathForFile(file) + : file.path; + if (diskPath && window.blip?.beaconPublishFromPath) { + void runPublishPath(diskPath, wrap); + return; + } publishBusy = true; if (btn) btn.disabled = true; + showPublishOverlay(wrap, file.name); void publishBeaconFile(file) .then(() => { showAppToast({ title: t('beacon.published'), body: file.name, durationMs: 4500 }); @@ -508,6 +628,7 @@ function runPublish(file, wrap) { .finally(() => { publishBusy = false; if (btn) btn.disabled = false; + hidePublishOverlay(); renderTableRows(); }); } @@ -515,14 +636,34 @@ function runPublish(file, wrap) { function wirePublishInput(wrap) { const input = wrap.querySelector('.beacon-file-input'); const btn = wrap.querySelector('.beacon-publish-btn'); - if (!input || !btn) return; + if (!btn) return; - btn.addEventListener('click', () => { + btn.addEventListener('click', async () => { if (publishBusy) return; - input.click(); + if (!window.blip?.beaconPickPublishFile) { + showAppToast({ + title: t('beacon.failed'), + body: t('beacon.err_no_path'), + variant: 'danger', + durationMs: 6000, + }); + return; + } + const pick = await window.blip.beaconPickPublishFile(); + if (pick?.cancelled) return; + if (pick?.ok && pick.filePath) { + void runPublishPath(pick.filePath, wrap); + return; + } + showAppToast({ + title: t('beacon.failed'), + body: t('beacon.err_no_path'), + variant: 'danger', + durationMs: 6000, + }); }); - input.addEventListener('change', () => { + input?.addEventListener('change', () => { const file = input.files?.[0]; input.value = ''; runPublish(file, wrap); @@ -806,6 +947,13 @@ function onCatalogUpdate() { renderTableRows(); } +function onBeaconProgress(ev) { + const seedId = ev?.detail?.seedId; + if (seedId !== 'path-ingest') return; + updatePublishOverlay(ev.detail?.progress, ev.detail?.phase); + renderTableRows(); +} + function teardownBeaconView() { bwUnsub?.(); bwUnsub = null; @@ -814,6 +962,7 @@ function teardownBeaconView() { if (typeof window !== 'undefined') { window.addEventListener('blip-beacon-catalog', onCatalogUpdate); window.addEventListener('blip-beacon-progress', onCatalogUpdate); + window.addEventListener('blip-beacon-progress', onBeaconProgress); } export function renderBeaconView(config) { diff --git a/renderer/call-media.js b/renderer/call-media.js index 8fcf849..ce3c6d5 100644 --- a/renderer/call-media.js +++ b/renderer/call-media.js @@ -6,48 +6,52 @@ const PRESETS = { low: { camera: { width: { ideal: 640, min: 480 }, height: { ideal: 480, min: 360 }, frameRate: { ideal: 24, max: 24 } }, screen: { width: { ideal: 1280, max: 1280 }, height: { ideal: 720, max: 720 }, frameRate: { ideal: 24, max: 24 } }, - camBitrate: 900_000, - screenBitrate: 2_500_000, + camBitrate: 2_000_000, + screenBitrate: 6_000_000, screenMax: { w: 1280, h: 720 }, screenFps: 24, }, hd: { - camera: { width: { ideal: 1280, min: 640 }, height: { ideal: 720, min: 480 }, frameRate: { ideal: 24, max: 30 } }, - screen: { width: { ideal: 1280, max: 1920 }, height: { ideal: 720, max: 1080 }, frameRate: { ideal: 30, max: 30 } }, - camBitrate: 1_800_000, - screenBitrate: 5_000_000, - screenMax: { w: 1280, h: 720 }, + camera: { width: { ideal: 1280, min: 960 }, height: { ideal: 720, min: 540 }, frameRate: { ideal: 30, max: 30 } }, + screen: { width: { ideal: 1920, max: 1920 }, height: { ideal: 1080, max: 1080 }, frameRate: { ideal: 30, max: 30 } }, + camBitrate: 5_000_000, + screenBitrate: 12_000_000, + screenMax: { w: 1920, h: 1080 }, screenFps: 30, }, fhd: { camera: { width: { ideal: 1920, min: 1280 }, height: { ideal: 1080, min: 720 }, frameRate: { ideal: 30, max: 30 } }, screen: { width: { ideal: 1920, max: 1920 }, height: { ideal: 1080, max: 1080 }, frameRate: { ideal: 30, max: 30 } }, - camBitrate: 3_000_000, - screenBitrate: 8_000_000, + camBitrate: 8_000_000, + screenBitrate: 18_000_000, screenMax: { w: 1920, h: 1080 }, screenFps: 30, }, max: { camera: { width: { ideal: 1920, min: 1280 }, height: { ideal: 1080, min: 720 }, frameRate: { ideal: 30, max: 60 } }, screen: { width: { ideal: 1920, max: 1920 }, height: { ideal: 1080, max: 1080 }, frameRate: { ideal: 30, max: 60 } }, - camBitrate: 4_500_000, - screenBitrate: 12_000_000, + camBitrate: 12_000_000, + screenBitrate: 28_000_000, screenMax: { w: 1920, h: 1080 }, screenFps: 60, }, qhd: { camera: { width: { ideal: 1920, min: 1280 }, height: { ideal: 1080, min: 720 }, frameRate: { ideal: 30, max: 60 } }, screen: { width: { ideal: 2560, max: 2560 }, height: { ideal: 1440, max: 1440 }, frameRate: { ideal: 30, max: 60 } }, - camBitrate: 4_500_000, - screenBitrate: 16_000_000, + camBitrate: 14_000_000, + screenBitrate: 35_000_000, screenMax: { w: 2560, h: 1440 }, screenFps: 60, }, uhd: { - camera: { width: { ideal: 1920, min: 1280 }, height: { ideal: 1080, min: 720 }, frameRate: { ideal: 30, max: 60 } }, + camera: { + width: { ideal: 3840, min: 1920 }, + height: { ideal: 2160, min: 1080 }, + frameRate: { ideal: 30, max: 60 }, + }, screen: { width: { ideal: 3840, max: 3840 }, height: { ideal: 2160, max: 2160 }, frameRate: { ideal: 30, max: 60 } }, - camBitrate: 5_000_000, - screenBitrate: 25_000_000, + camBitrate: 16_000_000, + screenBitrate: 45_000_000, screenMax: { w: 3840, h: 2160 }, screenFps: 60, }, @@ -173,15 +177,13 @@ export async function tuneVideoSender(sender, { screenShare = false, config } = enc.maxFramerate = screenShare ? screenFps : p.camera.frameRate?.max || 30; - if (screenShare) { - enc.scaleResolutionDownBy = 1; - try { - enc.degradationPreference = 'maintain-resolution'; - enc.priority = 'high'; - enc.networkPriority = 'high'; - } catch { - /* optional RTP fields */ - } + enc.scaleResolutionDownBy = 1; + try { + enc.degradationPreference = 'maintain-resolution'; + enc.priority = 'high'; + enc.networkPriority = 'high'; + } catch { + /* optional RTP fields */ } await sender.setParameters(params); } catch (err) { diff --git a/renderer/file-transfer-speed.js b/renderer/file-transfer-speed.js index 1682733..55b4db6 100644 --- a/renderer/file-transfer-speed.js +++ b/renderer/file-transfer-speed.js @@ -11,11 +11,11 @@ export function normalizeFileTransferSpeed(value) { * @param {boolean} [callActive] */ export function getChunkDelayMs(config, callActive = false) { - if (callActive) return 36; + if (callActive) return 12; const speed = normalizeFileTransferSpeed(config?.fileTransferSpeed); if (speed === 'fast') return 0; - if (speed === 'slow') return 28; - return 10; + if (speed === 'slow') return 8; + return 0; } /** @param {number} bytesPerSec */ diff --git a/renderer/file-transfer.js b/renderer/file-transfer.js index 7f9ba19..efc7cca 100644 --- a/renderer/file-transfer.js +++ b/renderer/file-transfer.js @@ -8,7 +8,38 @@ import { import { getChunkDelayMs } from './file-transfer-speed.js'; import { recordBandwidthSample } from './bandwidth-monitor.js'; -const CHUNK_RAW_BYTES = 48 * 1024; +/** Raw bytes per chunk (~1 MiB on wire after base64, under 4 MiB TCP line cap). */ +const CHUNK_RAW_BYTES = 1024 * 1024; +/** Multiple chunks per TCP line (fewer IPC round-trips). */ +const CHUNKS_PER_TCP_BATCH = 2; +/** In-flight TCP batch sends without waiting for each to finish. */ +const SEND_PIPELINE_DEPTH = 12; + +function resolveFileDiskPath(file) { + if (!file) return ''; + if (typeof window !== 'undefined' && window.blip?.getPathForFile) { + const p = window.blip.getPathForFile(file); + if (typeof p === 'string' && p.trim()) return p.trim(); + } + if (typeof file.path === 'string' && file.path.trim()) return file.path.trim(); + return ''; +} + +function buildChunkedAttachment(file, transferId, opts) { + return { + chunked: true, + transferId, + attachment: { + kind: 'file', + name: file.name || 'file', + mime: file.type || 'application/octet-stream', + size: file.size, + transferId, + groupId: opts.groupId, + msgId: opts.msgId, + }, + }; +} function delay(ms) { return ms > 0 ? new Promise((r) => setTimeout(r, ms)) : Promise.resolve(); @@ -105,6 +136,40 @@ function assembleIncoming(entry) { * @param {{ transferId?: string, groupId?: string, msgId?: string }} [opts] * @returns {{ attachment, messageText } | { chunked: true, transferId }} */ +async function sendChatFileFromDisk(api, config, peerId, file, diskPath, onProgress, opts) { + const transferId = opts.transferId || createMessageId(); + const key = transferKey(peerId, transferId); + clearTransferCancel(peerId, transferId); + + let unsub = () => {}; + if (typeof window !== 'undefined' && window.blip?.onFileSendProgress) { + unsub = window.blip.onFileSendProgress((p) => { + if (p?.transferId !== transferId) return; + if (p.speedBps) recordBandwidthSample({ upBps: p.speedBps }); + onProgress?.(p.percent ?? 0, { speedBps: p.speedBps, bytesSent: p.bytesSent }); + }); + } + + try { + const res = await window.blip.sendFileFromPath({ + filePath: diskPath, + to: peerId, + transferId, + name: file.name || 'file', + mime: file.type || 'application/octet-stream', + size: file.size, + groupId: opts.groupId, + msgId: opts.msgId, + }); + if (!res?.ok) throw new Error(res?.error || 'send_failed'); + if (cancelRequested.has(key)) throw new Error('cancelled'); + clearTransferCancel(peerId, transferId); + return buildChunkedAttachment(file, transferId, opts); + } finally { + unsub(); + } +} + export async function sendChatFile(api, config, peerId, file, onProgress, opts = {}) { validateChatFile(file); @@ -113,6 +178,11 @@ export async function sendChatFile(api, config, peerId, file, onProgress, opts = return { inline: true, attachment }; } + const diskPath = resolveFileDiskPath(file); + if (diskPath && typeof window !== 'undefined' && window.blip?.sendFileFromPath) { + return sendChatFileFromDisk(api, config, peerId, file, diskPath, onProgress, opts); + } + const transferId = opts.transferId || createMessageId(); const chunkCount = Math.ceil(file.size / CHUNK_RAW_BYTES); const key = transferKey(peerId, transferId); @@ -139,29 +209,44 @@ export async function sendChatFile(api, config, peerId, file, onProgress, opts = const chunkDelay = getChunkDelayMs(config, callActive); const startedAt = Date.now(); let bytesSent = 0; + const inFlight = []; - for (let i = 0; i < chunkCount; i++) { + for (let i = 0; i < chunkCount; ) { if (cancelRequested.has(key)) { await abortFileTransfer(api, config, peerId, transferId); throw new Error('cancelled'); } - const start = i * CHUNK_RAW_BYTES; - const end = Math.min(file.size, start + CHUNK_RAW_BYTES); - const data = await readFileSliceAsBase64(file, start, end); - await api.sendTcpMessage({ - type: 'file-chunk', + + const batch = []; + for (let b = 0; b < CHUNKS_PER_TCP_BATCH && i < chunkCount; b++, i++) { + const start = i * CHUNK_RAW_BYTES; + const end = Math.min(file.size, start + CHUNK_RAW_BYTES); + const data = await readFileSliceAsBase64(file, start, end); + batch.push({ index: i, data }); + bytesSent = end; + } + + const sendPromise = api.sendTcpMessage({ + type: 'file-chunks-batch', ...base, - index: i, - data, + chunks: batch, }); - bytesSent = end; + inFlight.push(sendPromise); + if (inFlight.length >= SEND_PIPELINE_DEPTH) { + await inFlight.shift(); + } + const elapsed = Math.max(0.001, (Date.now() - startedAt) / 1000); const speedBps = bytesSent / elapsed; recordBandwidthSample({ upBps: speedBps }); - onProgress?.(Math.round(((i + 1) / chunkCount) * 100), { speedBps, bytesSent }); + onProgress?.(Math.round((bytesSent / file.size) * 100), { speedBps, bytesSent }); await delay(chunkDelay); } + while (inFlight.length) { + await inFlight.shift(); + } + if (cancelRequested.has(key)) { await abortFileTransfer(api, config, peerId, transferId); throw new Error('cancelled'); @@ -174,19 +259,7 @@ export async function sendChatFile(api, config, peerId, file, onProgress, opts = clearTransferCancel(peerId, transferId); - return { - chunked: true, - transferId, - attachment: { - kind: 'file', - name: file.name || 'file', - mime: file.type || 'application/octet-stream', - size: file.size, - transferId, - groupId: opts.groupId, - msgId: opts.msgId, - }, - }; + return buildChunkedAttachment(file, transferId, opts); } /** Build a local data URL for UI after send/receive. */ @@ -199,6 +272,16 @@ export async function fileToDataUrl(file) { }); } +function applyIncomingChunks(entry, chunks) { + if (!Array.isArray(chunks)) return; + for (const part of chunks) { + const idx = Number(part?.index); + if (!Number.isFinite(idx) || idx < 0) continue; + entry.chunks[idx] = String(part.data || ''); + entry.received += 1; + } +} + export function handleFileTransferTcp(msg, { config, onComplete, onProgress, onAbort }) { const type = msg.type; const peerId = Number(msg.from === config.blipId ? msg.to : msg.from); @@ -224,16 +307,25 @@ export function handleFileTransferTcp(msg, { config, onComplete, onProgress, onA return true; } + if (type === 'file-chunks-batch') { + const key = transferKey(peerId, msg.transferId); + const entry = incoming.get(key); + if (!entry) return true; + applyIncomingChunks(entry, msg.chunks); + const pct = entry.meta.chunkCount + ? Math.min(100, Math.round((entry.received / entry.meta.chunkCount) * 100)) + : 0; + onProgress?.(peerId, msg.transferId, pct); + return true; + } + if (type === 'file-chunk') { const key = transferKey(peerId, msg.transferId); const entry = incoming.get(key); if (!entry) return true; - const idx = Number(msg.index); - if (!Number.isFinite(idx) || idx < 0) return true; - entry.chunks[idx] = String(msg.data || ''); - entry.received += 1; + applyIncomingChunks(entry, [{ index: msg.index, data: msg.data }]); const pct = entry.meta.chunkCount - ? Math.round((entry.received / entry.meta.chunkCount) * 100) + ? Math.min(100, Math.round((entry.received / entry.meta.chunkCount) * 100)) : 0; onProgress?.(peerId, msg.transferId, pct); return true; diff --git a/renderer/styles.css b/renderer/styles.css index 93d2bcf..8fe8d5c 100644 --- a/renderer/styles.css +++ b/renderer/styles.css @@ -3364,6 +3364,80 @@ select.blip-select:focus { letter-spacing: 0.06em; } +.beacon-publish-overlay { + position: absolute; + inset: 0; + z-index: 40; + display: flex; + align-items: center; + justify-content: center; + background: color-mix(in srgb, var(--blip-bg, #0a0a12) 72%, transparent); + backdrop-filter: blur(4px); +} + +.beacon-publish-overlay.hidden { + display: none; +} + +.beacon-publish-overlay-card { + min-width: min(420px, 92vw); + padding: 20px 22px; + display: flex; + flex-direction: column; + gap: 10px; + align-items: stretch; + border: 2px solid var(--blip-accent); + animation: beacon-publish-pulse 1.2s steps(4) infinite; +} + +.beacon-publish-overlay-spinner { + width: 28px; + height: 28px; + margin: 0 auto 4px; + border: 3px solid color-mix(in srgb, var(--blip-accent) 35%, transparent); + border-top-color: var(--blip-accent); + border-radius: 50%; + animation: beacon-spin 0.9s linear infinite; +} + +.beacon-publish-overlay-title { + text-align: center; + letter-spacing: 0.08em; + font-size: 12px; +} + +.beacon-publish-overlay-name { + text-align: center; + font-size: 11px; + color: var(--blip-muted); + word-break: break-all; +} + +.beacon-publish-overlay-track { + margin-top: 4px; +} + +.beacon-publish-overlay-pct, +.beacon-publish-overlay-phase { + text-align: center; +} + +.beacon-table-row--ingest { + outline: 1px dashed var(--blip-accent); +} + +@keyframes beacon-spin { + to { + transform: rotate(360deg); + } +} + +@keyframes beacon-publish-pulse { + 50% { + border-color: color-mix(in srgb, var(--blip-accent) 55%, #fff); + } +} + .beacon-sub { font-size: 10px; color: var(--blip-muted); @@ -3376,6 +3450,7 @@ select.blip-select:focus { } .beacon-view { + position: relative; padding: 16px 20px 24px; display: flex; flex-direction: column; diff --git a/renderer/ui.js b/renderer/ui.js index 8b78957..8807842 100644 --- a/renderer/ui.js +++ b/renderer/ui.js @@ -4477,6 +4477,11 @@ export function initUI(config, blipApi) { beaconListLocal: () => window.blip?.beaconListLocal?.(), beaconSaveAssembled: (payload) => window.blip?.beaconSaveAssembled?.(payload), beaconDeleteSeed: (payload) => window.blip?.beaconDeleteSeed?.(payload), + beaconPickPublishFile: () => window.blip?.beaconPickPublishFile?.(), + getPathForFile: (file) => window.blip?.getPathForFile?.(file) || '', + beaconPublishFromPath: (payload) => window.blip?.beaconPublishFromPath?.(payload), + onBeaconIngestProgress: (cb) => window.blip?.onBeaconIngestProgress?.(cb), + beaconServeChunksTcp: (payload) => window.blip?.beaconServeChunksTcp?.(payload), }, getConfig: () => state.config, getPeers: () => state.peers, diff --git a/renderer/voice-channel.js b/renderer/voice-channel.js index 29eb227..d6c20e3 100644 --- a/renderer/voice-channel.js +++ b/renderer/voice-channel.js @@ -517,13 +517,18 @@ async function syncScreenVideoOut() { if (!track) return; const group = getGroup(activeGroupId); if (!group) return; + const cfg = configRef || {}; if (isHost(group)) { for (const [rid, pc] of hostPeers) { await setVideoOnPc(pc, track, screenStream); + const sender = pc.getSenders().find((s) => s.track?.kind === 'video'); + if (sender) void tuneVideoSender(sender, { screenShare: true, config: cfg }); if (pc.signalingState === 'stable') await sendRenegotiateOffer(pc, activeGroupId, rid); } } else if (clientPc) { await setVideoOnPc(clientPc, track, screenStream); + const sender = clientPc.getSenders().find((s) => s.track?.kind === 'video'); + if (sender) void tuneVideoSender(sender, { screenShare: true, config: cfg }); if (clientPc.signalingState === 'stable') { await sendRenegotiateOffer(clientPc, activeGroupId, peerNum(group.hostId)); } From 7f23750b4ff0aa58f457d32a09029fb885cdc812 Mon Sep 17 00:00:00 2001 From: RKN Date: Mon, 25 May 2026 02:39:54 +0300 Subject: [PATCH 20/25] update README.md --- README.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 0f2fb11..3724df9 100644 --- a/README.md +++ b/README.md @@ -7,15 +7,15 @@ **P2P messenger for local networks — no cloud, no servers, no internet.** -[![Electron](https://img.shields.io/badge/Electron-35-47848F?style=flat-square&logo=electron&logoColor=white)](https://www.electronjs.org/) -[![Vite](https://img.shields.io/badge/Vite-6-646CFF?style=flat-square&logo=vite&logoColor=white)](https://vitejs.dev/) -[![License](https://img.shields.io/badge/License-GPLv3-00ffc8?style=flat-square)](LICENSE) -[![Platform](https://img.shields.io/badge/Platform-Windows-0078D6?style=flat-square&logo=windows&logoColor=white)]() -[![P2P](https://img.shields.io/badge/Network-P2P%20LAN-00ffc8?style=flat-square)]() -[![Lang](https://img.shields.io/badge/i18n-EN%20%7C%20RU-e0e0e0?style=flat-square)]() - +[![Version](https://img.shields.io/badge/version-1.1.1_Beacon-00ffc8?style=for-the-badge&labelColor=0a0a0a)](https://github.com/krwg/BLIP/releases) +[![Electron](https://img.shields.io/badge/Electron-35-47848F?style=for-the-badge&logo=electron&logoColor=white&labelColor=0a0a0a)](https://www.electronjs.org/) +[![License](https://img.shields.io/badge/License-GPLv3-00ffc8?style=for-the-badge&labelColor=0a0a0a)](LICENSE) +[![Platform](https://img.shields.io/badge/Windows-10%2F11-0078D6?style=for-the-badge&logo=windows&logoColor=white&labelColor=0a0a0a)]() +[![i18n](https://img.shields.io/badge/i18n-EN%20%7C%20RU-e0e0e0?style=for-the-badge&labelColor=0a0a0a)]() + *You're on the grid. You're the signal.* - +[**English**](#english) · [**Русский**](#russian) · [**Сайт**](https://krwg.github.io/BLIP/) + ## Navigation From 714db7525dee76cb54fc6b0d5df5003c8e79d986 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=A2=E3=82=A4=E3=83=80=20=E3=83=9F=E3=83=BC=E3=83=AB?= Date: Mon, 25 May 2026 03:00:01 +0300 Subject: [PATCH 21/25] update README.md header Co-Authored-By: RKN <287542913+roskommnadzor@users.noreply.github.com> --- README.md | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3724df9..ba7c01b 100644 --- a/README.md +++ b/README.md @@ -5,18 +5,28 @@ # BLIP -**P2P messenger for local networks — no cloud, no servers, no internet.** +Tagline + +
      [![Version](https://img.shields.io/badge/version-1.1.1_Beacon-00ffc8?style=for-the-badge&labelColor=0a0a0a)](https://github.com/krwg/BLIP/releases) [![Electron](https://img.shields.io/badge/Electron-35-47848F?style=for-the-badge&logo=electron&logoColor=white&labelColor=0a0a0a)](https://www.electronjs.org/) [![License](https://img.shields.io/badge/License-GPLv3-00ffc8?style=for-the-badge&labelColor=0a0a0a)](LICENSE) [![Platform](https://img.shields.io/badge/Windows-10%2F11-0078D6?style=for-the-badge&logo=windows&logoColor=white&labelColor=0a0a0a)]() [![i18n](https://img.shields.io/badge/i18n-EN%20%7C%20RU-e0e0e0?style=for-the-badge&labelColor=0a0a0a)]() + + +[![Stars](https://img.shields.io/github/stars/krwg/BLIP?style=for-the-badge&labelColor=0a0a0a&color=00ffc8&logo=github)](https://github.com/krwg/BLIP/stargazers) +[![Downloads](https://img.shields.io/github/downloads/krwg/BLIP/total?style=for-the-badge&labelColor=0a0a0a&color=00ffc8&logo=github)](https://github.com/krwg/BLIP/releases) +[![Last Commit](https://img.shields.io/github/last-commit/krwg/BLIP?style=for-the-badge&labelColor=0a0a0a&color=00ffc8&logo=github)](https://github.com/krwg/BLIP/commits/main) +[![Issues](https://img.shields.io/github/issues/krwg/BLIP?style=for-the-badge&labelColor=0a0a0a&color=ff3366&logo=github)](https://github.com/krwg/BLIP/issues) + +
      *You're on the grid. You're the signal.* [**English**](#english) · [**Русский**](#russian) · [**Сайт**](https://krwg.github.io/BLIP/) - + with cyberpunk aesthetic --> ## Navigation From 2860382298bfc1122ec0363786a9d0eec56b6066 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=A2=E3=82=A4=E3=83=80=20=E3=83=9F=E3=83=BC=E3=83=AB?= Date: Mon, 25 May 2026 03:01:30 +0300 Subject: [PATCH 22/25] fix HTML tags in README.md (again) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ba7c01b..e2e7b9f 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ *You're on the grid. You're the signal.* [**English**](#english) · [**Русский**](#russian) · [**Сайт**](https://krwg.github.io/BLIP/) - with cyberpunk aesthetic --> + ## Navigation From b2346423135a37ac984f4f87f4dab7a065b3de0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=A2=E3=82=A4=E3=83=80=20=E3=83=9F=E3=83=BC=E3=83=AB?= Date: Tue, 26 May 2026 23:56:38 +0300 Subject: [PATCH 23/25] fix HTML entities in README.md --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index e2e7b9f..7393594 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ icon-main + # BLIP Tagline From 9d4ca766784c4a318402231ee530312f5da656f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=A2=E3=82=A4=E3=83=80=20=E3=83=9F=E3=83=BC=E3=83=AB?= Date: Wed, 27 May 2026 03:42:02 +0300 Subject: [PATCH 24/25] remove Language section from README navigation Removed the Language section from the navigation table. --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 7393594..1c922f3 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,6 @@ | 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) | From 64f1dd7b97d596f1c30e4d2a1500f6aa04ae2045 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 28 May 2026 12:51:27 +0000 Subject: [PATCH 25/25] chore(deps): bump jspdf from 2.5.2 to 4.2.1 Bumps [jspdf](https://github.com/parallax/jsPDF) from 2.5.2 to 4.2.1. - [Release notes](https://github.com/parallax/jsPDF/releases) - [Changelog](https://github.com/parallax/jsPDF/blob/master/RELEASE.md) - [Commits](https://github.com/parallax/jsPDF/compare/v2.5.2...v4.2.1) --- updated-dependencies: - dependency-name: jspdf dependency-version: 4.2.1 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- package-lock.json | 92 +++++++++++++++++++++++++++-------------------- package.json | 2 +- 2 files changed, 54 insertions(+), 40 deletions(-) diff --git a/package-lock.json b/package-lock.json index b36b873..eb37a99 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,16 @@ { "name": "blip", - "version": "1.0.1", + "version": "1.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "blip", - "version": "1.0.1", + "version": "1.1.1", "hasInstallScript": true, "dependencies": { "electron-updater": "^6.8.3", - "jspdf": "^2.5.2", + "jspdf": "^4.2.1", "multicast-dns": "^7.2.5" }, "devDependencies": { @@ -2181,6 +2181,12 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/pako": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", + "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", + "license": "MIT" + }, "node_modules/@types/plist": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@types/plist/-/plist-3.0.5.tgz", @@ -2210,6 +2216,13 @@ "@types/node": "*" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/validator": { "version": "13.15.10", "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz", @@ -2684,18 +2697,6 @@ "node": ">= 4.0.0" } }, - "node_modules/atob": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz", - "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==", - "license": "(MIT OR Apache-2.0)", - "bin": { - "atob": "bin/atob.js" - }, - "engines": { - "node": ">= 4.5.0" - } - }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -2888,18 +2889,6 @@ "node": "18 || 20 || >=22" } }, - "node_modules/btoa": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/btoa/-/btoa-1.2.1.tgz", - "integrity": "sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g==", - "license": "(MIT OR Apache-2.0)", - "bin": { - "btoa": "bin/btoa.js" - }, - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/buffer": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", @@ -4048,11 +4037,14 @@ "dev": true }, "node_modules/dompurify": { - "version": "2.5.9", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.5.9.tgz", - "integrity": "sha512-i6mvVmWN4xo9LrhCOZrDgSs9noW6nOahbrmzjRbPF36YPyj5Ue5lgok0MHDWkG7xzpWFO2OYttXdzM7rJxHvNA==", + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.7.tgz", + "integrity": "sha512-2jBxDJY4RR06tQNy4w5FlFH7kfxsQZlufd0sbv+chfHCxeJwrFw2baUDsSwvBISD4K4RDbd0PTfy3uNXsR6siA==", "license": "(MPL-2.0 OR Apache-2.0)", - "optional": true + "optional": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } }, "node_modules/dotenv": { "version": "16.6.1", @@ -4726,6 +4718,17 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-png": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz", + "integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==", + "license": "MIT", + "dependencies": { + "@types/pako": "^2.0.3", + "iobuffer": "^5.3.2", + "pako": "^2.1.0" + } + }, "node_modules/fd-slicer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", @@ -5555,6 +5558,12 @@ "dev": true, "license": "MIT" }, + "node_modules/iobuffer": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz", + "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==", + "license": "MIT" + }, "node_modules/ip-address": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", @@ -6076,20 +6085,19 @@ } }, "node_modules/jspdf": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-2.5.2.tgz", - "integrity": "sha512-myeX9c+p7znDWPk0eTrujCzNjT+CXdXyk7YmJq5nD5V7uLLKmSXnlQ/Jn/kuo3X09Op70Apm0rQSnFWyGK8uEQ==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz", + "integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.23.2", - "atob": "^2.1.2", - "btoa": "^1.2.1", + "@babel/runtime": "^7.28.6", + "fast-png": "^6.2.0", "fflate": "^0.8.1" }, "optionalDependencies": { - "canvg": "^3.0.6", + "canvg": "^3.0.11", "core-js": "^3.6.0", - "dompurify": "^2.5.4", + "dompurify": "^3.3.1", "html2canvas": "^1.0.0-rc.5" } }, @@ -7105,6 +7113,12 @@ "dev": true, "license": "BlueOak-1.0.0" }, + "node_modules/pako": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", + "license": "(MIT AND Zlib)" + }, "node_modules/parse-bmfont-ascii": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/parse-bmfont-ascii/-/parse-bmfont-ascii-1.0.6.tgz", diff --git a/package.json b/package.json index 9c2fadf..7387620 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ }, "dependencies": { "electron-updater": "^6.8.3", - "jspdf": "^2.5.2", + "jspdf": "^4.2.1", "multicast-dns": "^7.2.5" } }