This commit is contained in:
eric
2026-03-26 00:48:03 -05:00
parent b003f3507c
commit 029b46db1a
11 changed files with 406 additions and 15 deletions

View File

@@ -4,9 +4,19 @@ import { resolve } from 'path'
export default defineConfig({
main: {
resolve: {
alias: {
'@shared': resolve('src/shared'),
},
},
plugins: [externalizeDepsPlugin()],
},
preload: {
resolve: {
alias: {
'@shared': resolve('src/shared'),
},
},
plugins: [externalizeDepsPlugin()],
build: {
rollupOptions: {
@@ -21,6 +31,7 @@ export default defineConfig({
resolve: {
alias: {
'@renderer': resolve('src/renderer/src'),
'@shared': resolve('src/shared'),
},
},
plugins: [react()],

View File

@@ -8,9 +8,6 @@ importers:
.:
dependencies:
adm-zip:
specifier: ^0.5.16
version: 0.5.16
d3:
specifier: ^7.9.0
version: 7.9.0
@@ -709,10 +706,6 @@ packages:
abbrev@1.1.1:
resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==}
adm-zip@0.5.16:
resolution: {integrity: sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==}
engines: {node: '>=12.0'}
agent-base@6.0.2:
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
engines: {node: '>= 6.0.0'}
@@ -2950,8 +2943,6 @@ snapshots:
abbrev@1.1.1: {}
adm-zip@0.5.16: {}
agent-base@6.0.2:
dependencies:
debug: 4.4.3

View File

@@ -30,6 +30,7 @@ import {
setAppUserModelIdEarly,
} from './appHost'
import { setMainBrowserWindow } from './mainWindowRef'
import { ensureUserAgreementAccepted } from './userAgreementGate'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
@@ -645,6 +646,12 @@ if (!gotLock) {
appendClientLog('info', 'main', '本地服务已连接,准备打开界面')
try {
await ensureUserAgreementAccepted(resolvePreload)
} catch {
return
}
await createWindow()
app.on('activate', () => {

View File

@@ -0,0 +1,83 @@
import { app, BrowserWindow, ipcMain } from 'electron'
import fs from 'fs'
import path from 'path'
import { agreementHtmlDocument } from '../shared/userAgreement'
const AGREEMENT_FILE = 'user-agreement-v1.json'
export function userAgreementAcceptedPath(): string {
return path.join(app.getPath('userData'), AGREEMENT_FILE)
}
export function hasUserAgreementAccepted(): boolean {
try {
return fs.existsSync(userAgreementAcceptedPath())
} catch {
return false
}
}
function markUserAgreementAccepted(): void {
fs.writeFileSync(
userAgreementAcceptedPath(),
JSON.stringify({ v: 1, acceptedAt: Date.now() }, null, 2),
'utf-8',
)
}
/**
* 首次启动:在打开主窗口前展示协议;拒绝或关闭未同意则退出应用。
*/
export function ensureUserAgreementAccepted(resolvePreload: () => string): Promise<void> {
if (hasUserAgreementAccepted()) return Promise.resolve()
return new Promise((resolve, reject) => {
const win = new BrowserWindow({
width: 520,
height: 680,
show: false,
resizable: true,
minimizable: false,
maximizable: false,
fullscreenable: false,
autoHideMenuBar: true,
title: '用户使用协议',
webPreferences: {
preload: resolvePreload(),
contextIsolation: true,
nodeIntegration: false,
},
})
const html = agreementHtmlDocument()
win.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`)
const onAccept = (): void => {
ipcMain.removeListener('user-agreement:refuse', onRefuse)
markUserAgreementAccepted()
win.close()
resolve()
}
const onRefuse = (): void => {
ipcMain.removeListener('user-agreement:accept', onAccept)
app.quit()
reject(new Error('user refused agreement'))
}
ipcMain.once('user-agreement:accept', onAccept)
ipcMain.once('user-agreement:refuse', onRefuse)
win.once('ready-to-show', () => win.show())
win.on('closed', () => {
ipcMain.removeListener('user-agreement:accept', onAccept)
ipcMain.removeListener('user-agreement:refuse', onRefuse)
if (!hasUserAgreementAccepted()) {
app.quit()
reject(new Error('agreement window closed'))
}
})
})
}

View File

@@ -36,6 +36,12 @@ contextBridge.exposeInMainWorld('appTheme', {
set: (value: string) => ipcRenderer.invoke('app:set-setting', 'theme', value),
})
/** 首次启动协议窗(主进程内嵌页) */
contextBridge.exposeInMainWorld('userAgreement', {
accept: () => ipcRenderer.send('user-agreement:accept'),
refuse: () => ipcRenderer.send('user-agreement:refuse'),
})
contextBridge.exposeInMainWorld('relayConfig', {
syncConfig: () => ipcRenderer.invoke('relay:sync-config'),
importFromConfig: () => ipcRenderer.invoke('relay:import-from-config'),

View File

@@ -2939,3 +2939,91 @@ button.net-badge {
.app-host-metrics {
font-size: 11px;
}
.user-agreement-inline-link {
display: inline-block;
margin-top: 0.65rem;
padding: 0;
border: none;
background: none;
font: inherit;
font-size: 12px;
color: var(--accent, #7c5cff);
text-decoration: underline;
cursor: pointer;
}
.user-agreement-inline-link:hover {
opacity: 0.9;
}
.user-agreement-backdrop {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background: rgba(0, 0, 0, 0.55);
}
.user-agreement-sheet {
max-width: 520px;
width: 100%;
max-height: min(88vh, 720px);
display: flex;
flex-direction: column;
border-radius: var(--radius-lg, 12px);
border: 1px solid var(--border-card);
background: var(--bg-card);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35);
}
.user-agreement-sheet__title {
margin: 0;
padding: 0.85rem 1rem 0.5rem;
font-size: 1.05rem;
font-weight: 700;
text-align: center;
color: var(--text);
}
.user-agreement-sheet__scroll {
flex: 1;
overflow: auto;
padding: 0 1rem 0.5rem;
font-size: 13px;
line-height: 1.55;
color: var(--text-muted);
}
.user-agreement-dialog__h2 {
margin: 0.75rem 0 0.4rem;
font-size: 0.95rem;
font-weight: 600;
color: var(--text);
}
.user-agreement-dialog__p {
margin: 0 0 0.35rem;
}
.user-agreement-dialog__li {
margin: 0 0 0.35rem;
padding-left: 0.25rem;
}
.user-agreement-dialog__gap {
height: 0.35rem;
}
.user-agreement-sheet__footer {
flex-shrink: 0;
padding: 0.65rem 1rem 1rem;
border-top: 1px solid var(--border-subtle);
}
.user-agreement-sheet__footer .btn {
width: 100%;
}

View File

@@ -1,5 +1,7 @@
import { useCallback, useEffect, useState } from 'react'
import UserAgreementDialog from './UserAgreementDialog'
type LoginItem = {
openAtLogin?: boolean
executableWillLaunchAtLogin?: boolean
@@ -10,6 +12,7 @@ export default function AppHostControls() {
const [login, setLogin] = useState<LoginItem | null>(null)
const [loading, setLoading] = useState(false)
const [err, setErr] = useState<string | null>(null)
const [agreementOpen, setAgreementOpen] = useState(false)
const load = useCallback(async () => {
if (!host?.getLoginItemSettings) return
@@ -30,12 +33,18 @@ export default function AppHostControls() {
if (!host?.getPaths) {
return (
<section className="card app-host-card" aria-labelledby="app-host-title">
<h2 className="card__title" id="app-host-title">
</h2>
<p className="card__desc"> Electron </p>
</section>
<>
<section className="card app-host-card" aria-labelledby="app-host-title">
<h2 className="card__title" id="app-host-title">
</h2>
<p className="card__desc"> Electron </p>
<button type="button" className="user-agreement-inline-link" onClick={() => setAgreementOpen(true)}>
</button>
</section>
<UserAgreementDialog open={agreementOpen} onClose={() => setAgreementOpen(false)} />
</>
)
}
@@ -82,6 +91,10 @@ export default function AppHostControls() {
</label>
</div>
</div>
<button type="button" className="user-agreement-inline-link" onClick={() => setAgreementOpen(true)}>
</button>
<UserAgreementDialog open={agreementOpen} onClose={() => setAgreementOpen(false)} />
</section>
)
}

View File

@@ -0,0 +1,87 @@
import type { ReactNode } from 'react'
import { useCallback, useEffect } from 'react'
import { USER_AGREEMENT_MARKDOWN } from '@shared/userAgreement'
function renderAgreementBody(md: string) {
const lines = md.replace(/^# 用户使用协议\n+/, '').split('\n')
const out: ReactNode[] = []
lines.forEach((line, i) => {
if (line.startsWith('## ')) {
out.push(
<h3 key={i} className="user-agreement-dialog__h2">
{line.slice(3)}
</h3>,
)
return
}
if (line.startsWith('* ')) {
out.push(
<p key={i} className="user-agreement-dialog__li">
{line.slice(2)}
</p>,
)
return
}
if (line.trim() === '') {
out.push(<div key={i} className="user-agreement-dialog__gap" />)
return
}
out.push(
<p key={i} className="user-agreement-dialog__p">
{line}
</p>,
)
})
return out
}
type Props = {
open: boolean
onClose: () => void
}
export default function UserAgreementDialog({ open, onClose }: Props) {
const onKey = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
},
[onClose],
)
useEffect(() => {
if (!open) return
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [open, onKey])
if (!open) return null
return (
<div
className="user-agreement-backdrop"
role="presentation"
onClick={(e) => {
if (e.target === e.currentTarget) onClose()
}}
>
<div
className="user-agreement-sheet"
role="dialog"
aria-modal="true"
aria-labelledby="user-agreement-title"
onClick={(e) => e.stopPropagation()}
>
<h2 id="user-agreement-title" className="user-agreement-sheet__title">
使
</h2>
<div className="user-agreement-sheet__scroll">{renderAgreementBody(USER_AGREEMENT_MARKDOWN)}</div>
<div className="user-agreement-sheet__footer">
<button type="button" className="btn btn-primary" onClick={onClose}>
</button>
</div>
</div>
</div>
)
}

View File

@@ -49,6 +49,10 @@ interface Window {
get?: () => Promise<string | null>
set?: (value: string) => Promise<void>
}
userAgreement?: {
accept: () => void
refuse: () => void
}
relayConfig?: {
syncConfig?: () => Promise<{ ok: true }>
importFromConfig?: () => Promise<{ ok: true }>

View File

@@ -0,0 +1,100 @@
/** 用户使用协议(桌面端首次启动弹窗与系统页「用户协议」共用) */
export const USER_AGREEMENT_MARKDOWN = `# 用户使用协议
欢迎使用本软件(以下简称「本软件」)。在使用本软件前,请您仔细阅读并理解本协议。
## 1. 软件性质说明
本软件为通用的视频处理与推流工具,支持用户对自有或合法授权的视频源进行处理与分发。本软件不提供任何第三方平台内容的解析、抓取或分发服务。
## 2. 用户责任
用户在使用本软件过程中,应确保:
* 所使用的视频源具有合法使用权或授权;
* 不侵犯任何第三方的著作权、邻接权或其他合法权益;
* 不违反相关法律法规及平台规则。
由用户上传、处理、推流的所有内容,均由用户自行承担全部法律责任。
## 3. 禁止行为
用户不得利用本软件从事以下行为:
* 未经授权抓取、转播、传播第三方平台内容;
* 侵犯他人版权、肖像权、隐私权;
* 从事违法违规内容传播。
## 4. 责任限制
本软件仅提供技术工具服务,不参与用户内容的获取、编辑或分发过程。
因用户行为导致的任何法律责任或纠纷,均由用户自行承担,与本软件无关。
## 5. 权限与服务
部分功能属于付费服务(如 Pro/VIP用户购买后仅获得使用权限不涉及软件所有权转移。
## 6. 协议变更
本软件有权根据业务发展对本协议进行调整,并通过软件或网站进行公告。
如您不同意本协议,请立即停止使用本软件。
`
/** 供主进程 data: URL 内嵌窗口使用(依赖 preload 暴露 window.userAgreement */
export function agreementHtmlDocument(): string {
const esc = (s: string) =>
s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
const lines = USER_AGREEMENT_MARKDOWN.split('\n')
const body: string[] = []
for (const line of lines) {
if (line.startsWith('# ')) {
body.push(`<h1>${esc(line.slice(2))}</h1>`)
} else if (line.startsWith('## ')) {
body.push(`<h2>${esc(line.slice(3))}</h2>`)
} else if (line.startsWith('* ')) {
body.push(`<p class="li">• ${esc(line.slice(2))}</p>`)
} else if (line.trim() === '') {
body.push('<div class="sp"></div>')
} else {
body.push(`<p>${esc(line)}</p>`)
}
}
return `<!DOCTYPE html>
<html lang="zh-CN"><head>
<meta charset="UTF-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>用户使用协议</title>
<style>
*{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,"Segoe UI","Microsoft YaHei",sans-serif;font-size:13px;line-height:1.55;color:#e8e6f2;background:#14121a;display:flex;flex-direction:column;height:100vh}
h1{font-size:17px;font-weight:700;margin:0 0 12px;color:#f2f0fa}
h2{font-size:14px;font-weight:600;margin:14px 0 8px;color:#e8e6f2}
p{margin:0 0 8px;color:#a39bb8}
p.li{padding-left:4px}
.sp{height:6px}
.scroll{flex:1;overflow:auto;padding:16px 18px 12px}
.footer{flex-shrink:0;display:flex;gap:10px;padding:12px 16px 16px;border-top:1px solid rgba(124,92,255,.25);background:#1a1722}
button{flex:1;padding:11px 14px;border-radius:8px;border:1px solid rgba(124,92,255,.35);font-size:14px;font-weight:600;cursor:pointer}
.primary{background:linear-gradient(135deg,#5c4dff,#3d7eff);color:#fff;border:none}
.ghost{background:transparent;color:#c4bdd9}
</style></head><body>
<div class="scroll">${body.join('')}</div>
<div class="footer">
<button type="button" class="ghost" id="refuse">不同意并退出</button>
<button type="button" class="primary" id="accept">同意并继续</button>
</div>
<script>
document.getElementById('accept').addEventListener('click',function(){
if(window.userAgreement) window.userAgreement.accept();
});
document.getElementById('refuse').addEventListener('click',function(){
if(window.userAgreement) window.userAgreement.refuse();
});
</script>
</body></html>`
}

View File

@@ -12,6 +12,7 @@
},
"include": [
"electron.vite.config.ts",
"src/shared/**/*.ts",
"src/main/**/*.ts",
"src/preload/**/*.ts",
"src/renderer/**/*.ts",