From 6fe179ff10234094c37498846c9376ba66a513a2 Mon Sep 17 00:00:00 2001 From: Modole <104966315+Modole@users.noreply.github.com> Date: Tue, 11 Aug 2026 11:49:47 +0800 Subject: [PATCH] Add release updates and immersive receiver controls --- README.md | 6 ++- THIRD_PARTY_NOTICES.md | 2 +- docs/acceptance.md | 6 ++- docs/development.md | 4 +- docs/release.md | 18 +++++-- docs/security.md | 8 +-- docs/user-guide.md | 14 ++++-- package-lock.json | 4 +- package.json | 6 +-- src/core/updates.js | 86 +++++++++++++++++++++++++++++++ src/main.js | 66 ++++++++++++++++++++++++ src/preload.js | 2 + src/renderer/app.js | 100 +++++++++++++++++++++++++++++++++++-- src/renderer/index.html | 10 +++- src/renderer/styles.css | 66 ++++++++++++++++++++++-- tests/config-store.test.js | 4 +- tests/updates.test.js | 66 ++++++++++++++++++++++++ 17 files changed, 436 insertions(+), 32 deletions(-) create mode 100644 src/core/updates.js create mode 100644 tests/updates.test.js diff --git a/README.md b/README.md index a637d19..4d8806e 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,9 @@ macOS 虚拟显示依赖未公开的 `CGVirtualDisplay` CoreGraphics API。它 - Windows 子端通过 UDP 广播,Mac 主端监听并合并本机记忆列表。 - WebSocket 信令、协议版本/消息大小/字段校验、单主端占用保护。 - 主端主动连接、断开;子端可全屏并在会话期间阻止显示器休眠。 +- 子端画面信息条默认隐藏,鼠标移动、触摸或键盘聚焦时短暂显示,避免遮挡扩展桌面。 - JSON 设置持久化,最多记忆 32 台设备;离线设备仍可显示和删除。 +- 启动时及侧边栏手动检查 GitHub Releases;发现新版本后打开官方发布页,由用户下载并安装。 - Node 单元测试、源码语法检查,以及 macOS/Windows GitHub Actions 构建产物。 ## 快速开始 @@ -74,7 +76,7 @@ npm run dev:host 5. 如果私有虚拟显示不可用,GUI 会切到“已有显示器”兼容模式;此时才需要手动选择捕获源。选择已有屏会发送该屏全部内容,请先清除敏感信息。 6. 使用主端“断开扩展屏”结束投放;由本会话自动创建的虚拟显示器会随断开清理。 -完整操作和未签名产物说明见[用户指南](docs/user-guide.md)。 +完整操作和未签名产物说明见[用户指南](docs/user-guide.md)。公开安装包可从 [GitHub Releases](https://github.com/Modole/LanExtend/releases) 获取。 ## 架构概览 @@ -109,7 +111,7 @@ flowchart LR - [故障排除](docs/troubleshooting.md):发现、端口、权限、黑屏、连接和性能问题。 - [验收清单](docs/acceptance.md):自动化与双机手工验收,包含尚未验证项的记录方式。 - [开源方案调研](docs/open-source-research.md):OpenDisplay、VoidDisplay、DeskPad、Deskreen、Weylus、Sunshine 等方案与许可证矩阵。 -- [发布说明](docs/release.md):CI artifacts、签名/公证边界和正式发布门槛。 +- [发布说明](docs/release.md):GitHub Releases、CI artifacts、更新检查、签名/公证边界和正式发布门槛。 - [第三方声明](THIRD_PARTY_NOTICES.md)。 ## 开源与许可证边界 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index a67c1b5..5b78489 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -1,6 +1,6 @@ # Third-Party Notices -This file describes direct runtime/build dependencies and implementation references for LanExtend 0.1.0. The authoritative resolved dependency graph is `package-lock.json`. A release maintainer must re-run license review whenever the lockfile or packaging inputs change. +This file describes direct runtime/build dependencies and implementation references for LanExtend 0.2.0. The authoritative resolved dependency graph is `package-lock.json`. A release maintainer must re-run license review whenever the lockfile or packaging inputs change. LanExtend source code is licensed under the repository's MIT License. Third-party components remain under their respective licenses. diff --git a/docs/acceptance.md b/docs/acceptance.md index 5f4ba28..3779ad1 100644 --- a/docs/acceptance.md +++ b/docs/acceptance.md @@ -40,6 +40,7 @@ Windows 型号 / CPU / GPU / Windows build: | A08 | CI | `build.yml` 两个 job | 同一提交 Mac/Windows job 均绿且 artifacts 可下载 | 执行时填写 | | A09 | 许可证 | 人工检查 | LICENSE、第三方声明、锁文件与打包依赖一致 | 执行时填写 | | A10 | 文档范围 | 人工检查 | 明确 14+、Win10/11、MVP 限制、无认证和无签名 | 执行时填写 | +| A11 | 更新检查 | `tests/updates.test.js` + GUI | 语义版本比较正确,只接受本仓库 HTTPS Release 链接,失败可重试 | 执行时填写 | ## 4. 安装与首次启动(P0) @@ -90,6 +91,7 @@ Windows 型号 / CPU / GPU / Windows build: - [ ] R10 已连接时第二台 Mac 被子端以单会话策略拒绝,不影响现有会话。 - [ ] R11 两端使用不同协议版本时明确失败,不进入假连接状态。 - [ ] R12 让 Mac/Windows 分别睡眠/唤醒,记录实际行为;若不能恢复,UI 至少允许干净断开重连。 +- [ ] R13 子端视频出现后底部信息条默认隐藏;画面内移动鼠标/触摸/键盘聚焦后显示,无操作约 2.2 秒后隐藏;悬停按钮时不会在操作中消失。 ## 8. 画质、性能与稳定性(P0/P1) @@ -122,7 +124,7 @@ Windows 型号 / CPU / GPU / Windows build: ## 10. 当前明确不验收为“支持”的能力 -以下项目若意外“看似可用”也不应纳入 v0.1.0 支持声明: +以下项目若意外“看似可用”也不应纳入 v0.2.0 支持声明: - 音频播放/转发; - Windows 到 Mac 的键盘、鼠标、触控、剪贴板或文件回传; @@ -130,7 +132,7 @@ Windows 型号 / CPU / GPU / Windows build: - 一台 Mac 同时连接多个 Windows 子端或创建多块受管扩展屏; - IPv6、DNS 名称、跨公网、NAT、TURN、云中继; - 身份认证、配对、授权、受信设备安全列表; -- Mac App Store、自动更新、正式签名/公证; +- Mac App Store、后台下载/静默自动安装、正式签名/公证; - 无人值守服务和企业集中管理。 ## 11. 发布判定 diff --git a/docs/development.md b/docs/development.md index ef441dd..80d9b88 100644 --- a/docs/development.md +++ b/docs/development.md @@ -169,7 +169,7 @@ npm test npm run dist:win ``` -生成 portable EXE 和 ZIP。当前没有 MSI、系统服务、驱动、自动更新或 Authenticode 签名。Windows SmartScreen 可能提示未知发布者,这是正式发布前的阻塞项,不应通过文案声称“安全”来规避。 +生成 portable EXE 和 ZIP。当前没有 MSI、系统服务、驱动、静默自动安装或 Authenticode 签名。应用只检查 GitHub 最新 Release 并打开固定下载页;Windows SmartScreen 可能提示未知发布者,这是正式发布前的阻塞项,不应通过文案声称“安全”来规避。 ## 8. GitHub Actions @@ -184,7 +184,7 @@ npm run dist:win 1. `macos-build`:`npm ci` → `npm test` → `npm run dist:mac` → 上传 DMG/ZIP; 2. `windows-build`:`npm ci` → `npm test` → `npm run dist:win` → 上传 portable EXE/ZIP。 -workflow 使用最小 `contents: read` 权限,并上传 14 天 artifacts。它不创建 Release、不签名、不公证、不上传商店。 +workflow 使用最小 `contents: read` 权限,并上传 14 天 artifacts。它不自动创建 Release、不签名、不公证、不上传商店;维护者可在两个 job 均通过后,另外创建 GitHub Release 并上传同一版本的候选包与哈希。 ## 9. 发布前版本与合规检查 diff --git a/docs/release.md b/docs/release.md index 02d0f06..b7a21f0 100644 --- a/docs/release.md +++ b/docs/release.md @@ -2,14 +2,14 @@ ## 1. 当前交付级别 -`.github/workflows/build.yml` 生成的是**开发/验收 artifacts**,不是面向最终用户的正式 Release: +`.github/workflows/build.yml` 生成的是**开发/验收 artifacts**;维护者在同一提交的两个平台 job 通过后,可另行创建面向用户的 GitHub Release: - macOS job:测试、构建含 `arm64`/`x86_64` 的 universal2 私有 API helper,并以 `--universal` 打包 DMG/ZIP; - Windows job:测试,打包 portable EXE/ZIP; - 两端均未配置正式代码签名;Mac 未公证; -- workflow 不创建 GitHub Release、不上传商店、不自动更新。 +- workflow 不自动创建 GitHub Release、不上传商店,也不执行静默更新。 -Artifacts 保留 14 天,名称包含平台;实际文件名由 electron-builder 的 `LanExtend-${version}-${os}-${arch}.${ext}` 规则生成。 +Artifacts 保留 14 天,名称包含平台;实际文件名由 electron-builder 的 `LanExtend-${version}-${os}-${arch}.${ext}` 规则生成。发布到 Releases 时还应上传 SHA-256 校验文件,并在正文中写明签名状态与已知限制。 ## 2. 触发与权限 @@ -61,7 +61,7 @@ npm test npm run dist:win ``` -本地构建只能用于调试/验收。正式包应来自可审计、受保护且保存 provenance 的发布工作流。 +本地构建只能用于调试/验收。当前开发预览 Release 可由维护者上传经测试且附哈希的候选包;正式产品包仍应来自可审计、受保护且保存 provenance 的签名发布工作流。 ## 5. 正式发布前阻塞项 @@ -110,7 +110,15 @@ npm run dist:win `PROTOCOL_VERSION` 与应用 SemVer 独立。只要线协议有不兼容变化,就必须提升协议版本并在发布说明中列出主/子端兼容矩阵。 -由于无自动更新,主端和子端应从同一 Release 安装。协议不匹配时应失败关闭,不尝试静默降级。 +应用会在启动时或用户点击侧边栏更新卡片后查询 GitHub 的最新稳定 Release;发现新版只打开本仓库的固定 HTTPS 发布页,不会下载或静默安装。主端和子端应从同一 Release 安装。协议不匹配时应失败关闭,不尝试静默降级。 + +版本检查的发布契约: + +- tag 必须是稳定三段 SemVer,例如 `v0.2.0`;draft 和 prerelease 不会作为最新稳定版本; +- 公开元数据端点固定为 `api.github.com/repos/Modole/LanExtend/releases/latest`;遇到 GitHub API 速率限制时,只解析本仓库 `/releases/latest` 的 HTTPS 重定向作为降级; +- 下载页固定限制在 `https://github.com/Modole/LanExtend/releases/` 路径下; +- 更新失败不影响局域网扩展屏核心功能,用户可在侧边栏重试; +- 不把 Release 元数据视为安装包真实性证明,仍需核对哈希,并在正式产品阶段补签名、公证和安全升级机制。 ## 8. 发布页必须包含的限制 diff --git a/docs/security.md b/docs/security.md index 863594a..a0ebc79 100644 --- a/docs/security.md +++ b/docs/security.md @@ -2,7 +2,7 @@ ## 1. 安全结论 -LanExtend v0.1.0 是**无认证的可信内网 MVP**。只有在你控制网络、信任同网设备并能限制防火墙范围时才应运行。它不适合公网、端口转发、共享 VPN、访客 Wi‑Fi、酒店/机场网络、校园网或无法识别其他终端的办公网。 +LanExtend v0.2.0 是**无认证的可信内网 MVP**。只有在你控制网络、信任同网设备并能限制防火墙范围时才应运行。它不适合公网、端口转发、共享 VPN、访客 Wi‑Fi、酒店/机场网络、校园网或无法识别其他终端的办公网。 “Mac 主端权限最高”目前表示: @@ -107,13 +107,15 @@ macOS 主端通过运行时类名使用未公开的 `CGVirtualDisplay`、`CGVirt ## 7. 未签名构建与供应链 -当前 GitHub Actions artifacts 只用于开发验收: +当前 GitHub Actions artifacts 只用于开发验收;GitHub Releases 可分发维护者选定的候选包: - 没有 Apple Developer ID 签名/公证; - 没有 Windows Authenticode 签名; -- 没有发布证书固定、自动更新、SBOM 或独立可复现构建声明; +- 没有发布证书固定、静默自动安装、SBOM 或独立可复现构建声明; - workflow 中的 `npm ci` 会从 npm registry 获取锁定包,仍需供应链治理。 +应用的版本检查只向 `api.github.com/repos/Modole/LanExtend/releases/latest` 请求公开发布元数据;遇到 API 速率限制时,只解析本仓库 `/releases/latest` 的 HTTPS 重定向。应用只允许打开本仓库固定的 HTTPS Releases 页面。请求不会上传设备 UUID、局域网地址、设置或画面,但 GitHub 仍会看到普通 HTTPS 请求所包含的公网 IP 和网络元数据。安装包不会自动下载或执行,用户必须自行核对发布说明与 SHA-256 后安装。 + 不要指导普通用户长期关闭 Gatekeeper/SmartScreen。内部测试可以在确认提交、构建日志和哈希后使用系统提供的单次“仍要打开”流程。正式发布必须配置签名、证书保护、公证、哈希、依赖扫描和回滚。 直接依赖和研究参考见[第三方声明](../THIRD_PARTY_NOTICES.md)与[开源调研](open-source-research.md)。GPL/AGPL 项目只可作为概念参考;复制代码可能改变本项目分发义务。 diff --git a/docs/user-guide.md b/docs/user-guide.md index c05faf2..5049d26 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -14,16 +14,24 @@ ## 2. 获取和安装 -### CI 构建产物 +### GitHub Releases 与 CI 构建产物 + +面向用户发布的安装包位于项目的 [GitHub Releases](https://github.com/Modole/LanExtend/releases): + +- Mac:Universal DMG 和 ZIP;目前未配置 Developer ID 正式签名与 Apple 公证; +- Windows:portable EXE 和 ZIP;目前未配置 Authenticode 签名; +- 发布页同时提供 SHA-256 校验文件;下载后应先核对哈希和版本说明。 仓库的 `Build desktop artifacts` GitHub Actions 会分别生成 Mac 和 Windows artifacts。它们是开发/验收产物: - Mac:DMG 和 ZIP;未配置 Developer ID 正式签名与 Apple 公证; - Windows:portable EXE 和 ZIP;未配置 Authenticode 签名; -- artifacts 不是 GitHub Release,也没有自动更新能力。 +- artifacts 只保留有限时间,不等同于维护者发布的 Release。 只从你信任的仓库运行记录下载,并核对工作流对应的提交。正式分发要求见[发布说明](release.md)。 +应用启动后会向 GitHub 的公开 Release API 检查一次稳定版本,也可点击侧边栏底部的更新卡片手动复查。发现新版本时,卡片会打开本项目固定的 GitHub Releases 页面;LanExtend **不会后台下载、不会静默安装,也不会绕过系统安全提示**。升级前先断开会话,并让主端与子端安装相同版本。 + ### 从源码运行 两端都需要 Node.js 22+ 和 npm 10+。Mac 还需要 Xcode Command Line Tools 和 macOS SDK。 @@ -115,7 +123,7 @@ HiDPI 使同一逻辑尺寸的物理像素数变为四倍。发送端会尝试 ## 7. 投放与结束 1. 默认模式连接成功后,把要显示的 Mac 窗口拖过桌面边缘,移入自动创建的虚拟显示器。 -2. Windows 子端可用顶部按钮或画面悬浮按钮进入/退出全屏,按 Esc 也会退出全屏。 +2. Windows 子端可用顶部按钮或画面悬浮按钮进入/退出全屏,按 Esc 也会退出全屏。画面底部的主端名称、分辨率、FPS、码率与操作按钮默认隐藏;在画面内移动鼠标、触摸或用键盘聚焦后会出现,无操作约 2.2 秒后再次隐藏。 3. 需要切换分辨率时,先点击“断开扩展屏”,修改参数后重新点击“扩展到 …”;不需要单独创建/销毁按钮。 4. 由 Mac 主端点击断开结束会话。 5. 断开会清理本会话自动创建的虚拟显示器;直接退出主端也会尝试清理 helper 和显示器。兼容模式下不会删除已有物理/系统显示器。 diff --git a/package-lock.json b/package-lock.json index ec8e444..7344591 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "lanextend", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lanextend", - "version": "0.1.0", + "version": "0.2.0", "license": "MIT", "dependencies": { "ws": "8.21.3" diff --git a/package.json b/package.json index 0b0c3ac..dcbb1a5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lanextend", - "version": "0.1.0", + "version": "0.2.0", "description": "LAN virtual extended display: macOS host to Windows receiver", "main": "src/main.js", "author": "LanExtend contributors", @@ -18,8 +18,8 @@ "pretest": "node scripts/check-source.mjs", "test": "node --test --test-reporter=spec", "verify": "npm run build:native && npm test", - "dist:mac": "npm run build:native && electron-builder --mac dmg zip --universal", - "dist:win": "electron-builder --win portable zip --x64", + "dist:mac": "npm run build:native && electron-builder --mac dmg zip --universal --publish never", + "dist:win": "electron-builder --win portable zip --x64 --publish never", "dist": "npm run dist:mac" }, "dependencies": { diff --git a/src/core/updates.js b/src/core/updates.js new file mode 100644 index 0000000..3aa256b --- /dev/null +++ b/src/core/updates.js @@ -0,0 +1,86 @@ +'use strict'; + +const GITHUB_REPOSITORY = 'Modole/LanExtend'; +const RELEASE_API_URL = `https://api.github.com/repos/${GITHUB_REPOSITORY}/releases/latest`; +const RELEASE_PAGE_URL = `https://github.com/${GITHUB_REPOSITORY}/releases/latest`; +const MAX_RELEASE_RESPONSE_BYTES = 256 * 1024; + +function parseVersion(value) { + const match = String(value || '').trim().match(/^v?(\d+)\.(\d+)\.(\d+)$/); + if (!match) throw new Error('版本号格式无效'); + return match.slice(1).map(Number); +} + +function compareVersions(left, right) { + const a = parseVersion(left); + const b = parseVersion(right); + for (let index = 0; index < 3; index += 1) { + if (a[index] !== b[index]) return a[index] > b[index] ? 1 : -1; + } + return 0; +} + +function isAllowedReleaseUrl(value) { + try { + const url = new URL(value); + return url.protocol === 'https:' + && url.hostname === 'github.com' + && !url.port + && !url.username + && !url.password + && url.pathname.startsWith(`/${GITHUB_REPOSITORY}/releases/`); + } catch { + return false; + } +} + +function parseReleasePayload(payload, currentVersion) { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + throw new Error('GitHub 返回的版本信息无效'); + } + if (payload.draft === true || payload.prerelease === true) { + throw new Error('最新版本不是稳定公开版本'); + } + const latestVersion = String(payload.tag_name || '').replace(/^v/, ''); + parseVersion(currentVersion); + parseVersion(latestVersion); + const releaseUrl = isAllowedReleaseUrl(payload.html_url) + ? payload.html_url + : RELEASE_PAGE_URL; + return { + currentVersion, + latestVersion, + updateAvailable: compareVersions(latestVersion, currentVersion) > 0, + releaseUrl, + publishedAt: typeof payload.published_at === 'string' ? payload.published_at : null + }; +} + +function parseReleasePageUrl(value, currentVersion) { + if (!isAllowedReleaseUrl(value)) throw new Error('GitHub 发布页地址无效'); + const url = new URL(value); + const match = url.pathname.match(/^\/Modole\/LanExtend\/releases\/tag\/v?(\d+\.\d+\.\d+)\/?$/); + if (!match) throw new Error('GitHub 发布页不包含稳定版本号'); + const latestVersion = match[1]; + parseVersion(currentVersion); + parseVersion(latestVersion); + return { + currentVersion, + latestVersion, + updateAvailable: compareVersions(latestVersion, currentVersion) > 0, + releaseUrl: url.toString(), + publishedAt: null + }; +} + +module.exports = { + GITHUB_REPOSITORY, + MAX_RELEASE_RESPONSE_BYTES, + RELEASE_API_URL, + RELEASE_PAGE_URL, + compareVersions, + isAllowedReleaseUrl, + parseReleasePageUrl, + parseReleasePayload, + parseVersion +}; diff --git a/src/main.js b/src/main.js index 4b29f7f..ce84afe 100644 --- a/src/main.js +++ b/src/main.js @@ -8,6 +8,7 @@ const { desktopCapturer, ipcMain, Menu, + net, powerSaveBlocker, screen, session, @@ -18,6 +19,13 @@ const { ConfigStore } = require('./core/config-store'); const { PROTOCOL_VERSION } = require('./core/constants'); const { DiscoveryAdvertiser, DiscoveryListener, SignalServer } = require('./core/network'); const { isPrivateIPv4, isValidPort } = require('./core/protocol'); +const { + MAX_RELEASE_RESPONSE_BYTES, + RELEASE_API_URL, + RELEASE_PAGE_URL, + parseReleasePageUrl, + parseReleasePayload +} = require('./core/updates'); const { VirtualDisplayManager, helperPath } = require('./core/virtual-display'); const PROJECT_ROOT = path.resolve(__dirname, '..'); @@ -66,6 +74,59 @@ function mediaPermissionStatus() { } } +async function checkReleasePageFallback(signal) { + const response = await net.fetch(RELEASE_PAGE_URL, { + method: 'HEAD', + redirect: 'manual', + headers: { 'User-Agent': `LanExtend/${app.getVersion()}` }, + signal + }); + const location = response.headers.get('location'); + if (response.status < 300 || response.status >= 400 || !location) { + throw new Error(`GitHub 发布页返回 ${response.status}`); + } + const releaseUrl = new URL(location, RELEASE_PAGE_URL).toString(); + return parseReleasePageUrl(releaseUrl, app.getVersion()); +} + +async function checkForUpdates() { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 12_000); + try { + const response = await net.fetch(RELEASE_API_URL, { + method: 'GET', + headers: { + Accept: 'application/vnd.github+json', + 'User-Agent': `LanExtend/${app.getVersion()}`, + 'X-GitHub-Api-Version': '2022-11-28' + }, + signal: controller.signal + }); + if (response.status === 403 || response.status === 429) { + return await checkReleasePageFallback(controller.signal); + } + if (!response.ok) { + if (response.status === 404) throw new Error('项目暂未发布可下载版本'); + throw new Error(`GitHub 更新服务返回 ${response.status}`); + } + const declaredLength = Number(response.headers.get('content-length')); + if (Number.isFinite(declaredLength) && declaredLength > MAX_RELEASE_RESPONSE_BYTES) { + throw new Error('GitHub 版本信息超过大小限制'); + } + const raw = await response.text(); + if (Buffer.byteLength(raw, 'utf8') > MAX_RELEASE_RESPONSE_BYTES) { + throw new Error('GitHub 版本信息超过大小限制'); + } + return parseReleasePayload(JSON.parse(raw), app.getVersion()); + } catch (error) { + if (error?.name === 'AbortError') throw new Error('检查更新超时,请确认网络后重试'); + if (error instanceof SyntaxError) throw new Error('GitHub 返回了无法识别的版本信息'); + throw error; + } finally { + clearTimeout(timeout); + } +} + function mergedDevices() { const remembered = configStore.get().rememberedDevices; const discovered = discoveryListener?.list() || []; @@ -268,6 +329,11 @@ function registerIpc() { })) }; }); + ipcMain.handle('updates:check', () => checkForUpdates()); + ipcMain.handle('updates:open-release', async () => { + await shell.openExternal(RELEASE_PAGE_URL); + return true; + }); ipcMain.handle('settings:update', async (_event, patch) => { const previous = configStore.get(); diff --git a/src/preload.js b/src/preload.js index 543772c..85ef990 100644 --- a/src/preload.js +++ b/src/preload.js @@ -12,6 +12,8 @@ function subscribe(channel, callback) { contextBridge.exposeInMainWorld('lanextend', Object.freeze({ bootstrap: () => ipcRenderer.invoke('app:bootstrap'), rendererReady: () => ipcRenderer.invoke('app:renderer-ready'), + checkForUpdates: () => ipcRenderer.invoke('updates:check'), + openLatestRelease: () => ipcRenderer.invoke('updates:open-release'), updateSettings: (patch) => ipcRenderer.invoke('settings:update', patch), getDevices: () => ipcRenderer.invoke('devices:get'), rememberDevice: (device, connected = false) => ipcRenderer.invoke( diff --git a/src/renderer/app.js b/src/renderer/app.js index b163c88..2ae0ca9 100644 --- a/src/renderer/app.js +++ b/src/renderer/app.js @@ -22,11 +22,14 @@ receiverPendingIce: [], receiverStatsTimer: null, receiverStatsPrevious: null, + receiverOverlayTimer: null, receiverHost: null, receiverFullscreen: false, earlyReceiverConnection: null, earlyReceiverSignals: [], - listeningPort: null + listeningPort: null, + updateInfo: null, + updateChecking: false }; const byId = (id) => document.getElementById(id); @@ -228,6 +231,61 @@ setText('page-title', role === 'host' ? '扩展工作台' : '接收工作台'); } + function renderUpdateControl(kind, title, detail) { + const control = byId('update-control'); + if (!control) return; + control.classList.toggle('is-checking', kind === 'checking'); + control.classList.toggle('is-available', kind === 'available'); + control.classList.toggle('is-error', kind === 'error'); + control.disabled = kind === 'checking'; + setText('update-title', title); + setText('update-detail', detail); + } + + async function checkForUpdates({ quiet = false } = {}) { + if (state.updateChecking) return; + state.updateChecking = true; + renderUpdateControl('checking', '正在检查更新', '连接 GitHub Releases…'); + try { + const info = await api.checkForUpdates(); + if (!info + || typeof info.currentVersion !== 'string' + || typeof info.latestVersion !== 'string' + || typeof info.updateAvailable !== 'boolean') { + throw new Error('更新服务返回了无法识别的信息'); + } + state.updateInfo = info; + if (info.updateAvailable) { + renderUpdateControl('available', `发现 v${info.latestVersion}`, '点击前往 GitHub 下载'); + if (!quiet) toast('发现新版本', `LanExtend v${info.latestVersion} 已可下载`); + } else { + renderUpdateControl('current', '已是最新版本', `当前 v${info.currentVersion} · 点击复查`); + if (!quiet) toast('无需更新', `当前已是 LanExtend v${info.currentVersion}`); + } + } catch (error) { + state.updateInfo = null; + renderUpdateControl('error', '暂时无法检查更新', '点击重试'); + if (!quiet) toast('检查更新失败', errorText(error), 'warning', 6200); + } finally { + state.updateChecking = false; + } + } + + function bindUpdateUi() { + renderUpdateControl('idle', '检查更新', `当前 v${state.bootstrap.appVersion || '0.0.0'}`); + byId('update-control')?.addEventListener('click', async () => { + if (state.updateInfo?.updateAvailable) { + try { + await api.openLatestRelease(); + } catch (error) { + toast('无法打开下载页', errorText(error), 'error'); + } + return; + } + await checkForUpdates(); + }); + } + function bindCommonUi() { all('[data-scroll-to]').forEach((button) => { button.addEventListener('click', () => { @@ -1181,6 +1239,38 @@ const video = byId('remote-video'); video.addEventListener('loadedmetadata', renderReceiverVideoDimensions); video.addEventListener('resize', renderReceiverVideoDimensions); + const stage = byId('receiver-overview'); + const overlay = byId('receiver-video-overlay'); + stage.addEventListener('pointermove', () => revealReceiverOverlay()); + stage.addEventListener('pointerleave', hideReceiverOverlay); + stage.addEventListener('touchstart', () => revealReceiverOverlay(3200), { passive: true }); + stage.addEventListener('focusin', () => revealReceiverOverlay(3200)); + overlay.addEventListener('pointerenter', () => clearTimeout(state.receiverOverlayTimer)); + overlay.addEventListener('pointerleave', () => revealReceiverOverlay(900)); + } + + function hideReceiverOverlay() { + clearTimeout(state.receiverOverlayTimer); + state.receiverOverlayTimer = null; + const overlay = byId('receiver-video-overlay'); + if (!overlay) return; + overlay.classList.remove('is-visible'); + overlay.setAttribute('aria-hidden', 'true'); + } + + function revealReceiverOverlay(duration = 2200) { + const overlay = byId('receiver-video-overlay'); + if (!overlay || overlay.hidden || !byId('receiver-overview')?.classList.contains('has-video')) return; + clearTimeout(state.receiverOverlayTimer); + overlay.classList.add('is-visible'); + overlay.setAttribute('aria-hidden', 'false'); + state.receiverOverlayTimer = window.setTimeout(() => { + if (overlay.matches(':hover') || overlay.matches(':focus-within')) { + revealReceiverOverlay(900); + return; + } + hideReceiverOverlay(); + }, duration); } function renderListeningStatus() { @@ -1310,8 +1400,7 @@ video.srcObject = stream; byId('receiver-overview').classList.add('has-video'); byId('receiver-video-overlay').hidden = false; - byId('receiver-video-overlay').classList.add('is-visible'); - window.setTimeout(() => byId('receiver-video-overlay')?.classList.remove('is-visible'), 2600); + hideReceiverOverlay(); try { await video.play(); byId('play-video').hidden = true; @@ -1387,6 +1476,7 @@ } function cleanupReceiverPeer(clearSession = true) { + hideReceiverOverlay(); clearInterval(state.receiverStatsTimer); state.receiverStatsTimer = null; state.receiverStatsPrevious = null; @@ -1454,8 +1544,9 @@ state.devices = Array.isArray(bootstrap.devices) ? bootstrap.devices : []; state.virtualDisplayRunning = Boolean(bootstrap.virtualDisplay?.running); setRoleVisibility(state.role); - setText('app-version', `局域网扩展屏 · ${bootstrap.appVersion || '0.1.0'}`); + setText('app-version', `局域网扩展屏 · ${bootstrap.appVersion || '0.2.0'}`); setText('protocol-chip', `协议 v${bootstrap.protocolVersion}`); + bindUpdateUi(); if (state.role === 'host') { bindHostUi(); @@ -1497,6 +1588,7 @@ const boot = byId('boot-screen'); boot.classList.add('is-dismissing'); window.setTimeout(() => boot.remove(), 300); + window.setTimeout(() => checkForUpdates({ quiet: true }), 1800); } initialize().catch((error) => { diff --git a/src/renderer/index.html b/src/renderer/index.html index 6e96451..7774914 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -66,6 +66,14 @@
+