mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-24 23:06:51 +00:00
3
.github/workflows/release.yml
vendored
3
.github/workflows/release.yml
vendored
@@ -190,7 +190,6 @@ jobs:
|
||||
fi
|
||||
WINDOWS_ARM64_ASSET="$(echo "$ASSETS_JSON" | jq -r '[.assets[].name | select(test("arm64.*\\.exe$"))][0] // ""')"
|
||||
MAC_ASSET="$(pick_asset "\\.dmg$")"
|
||||
LINUX_DEB_ASSET="$(pick_asset "\\.deb$")"
|
||||
LINUX_TAR_ASSET="$(pick_asset "\\.tar\\.gz$")"
|
||||
LINUX_APPIMAGE_ASSET="$(pick_asset "\\.AppImage$")"
|
||||
|
||||
@@ -204,7 +203,6 @@ jobs:
|
||||
WINDOWS_URL="$(build_link "$WINDOWS_ASSET")"
|
||||
WINDOWS_ARM64_URL="$(build_link "$WINDOWS_ARM64_ASSET")"
|
||||
MAC_URL="$(build_link "$MAC_ASSET")"
|
||||
LINUX_DEB_URL="$(build_link "$LINUX_DEB_ASSET")"
|
||||
LINUX_TAR_URL="$(build_link "$LINUX_TAR_ASSET")"
|
||||
LINUX_APPIMAGE_URL="$(build_link "$LINUX_APPIMAGE_ASSET")"
|
||||
|
||||
@@ -219,7 +217,6 @@ jobs:
|
||||
- Windows x64(Win10+): ${WINDOWS_URL:-$RELEASE_PAGE}
|
||||
- Windows arm64: ${WINDOWS_ARM64_URL:-$RELEASE_PAGE}
|
||||
- macOS(M系列芯片): ${MAC_URL:-$RELEASE_PAGE}
|
||||
- Linux (.deb) (即将废弃): ${LINUX_DEB_URL:-$RELEASE_PAGE}
|
||||
- Linux (.tar.gz): ${LINUX_TAR_URL:-$RELEASE_PAGE}
|
||||
- linux (.AppImage): ${LINUX_APPIMAGE_URL:-$RELEASE_PAGE}
|
||||
|
||||
|
||||
@@ -50,13 +50,15 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
|
||||
|------|----------|--------|
|
||||
| Windows | Windows10+、x64(amd64) | `.exe` |
|
||||
| macOS | Apple Silicon(M 系列,arm64) | `.dmg` |
|
||||
| Linux | x64 设备(amd64) | `.deb`、`.tar.gz` |
|
||||
| Linux | x64 设备(amd64) | `.AppImage`、`.tar.gz` |
|
||||
|
||||
|
||||
## 快速开始
|
||||
|
||||
若你只想使用成品版本,可前往 [Releases](https://github.com/hicccc77/WeFlow/releases) 下载并安装。
|
||||
|
||||
> ArchLinux 用户可以选择 `yay -S weflow` 快速安装
|
||||
|
||||
## 详细功能清单
|
||||
|
||||
当前版本已支持以下能力:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# WeFlow HTTP API / Push 文档
|
||||
|
||||
WeFlow 提供本地 HTTP API,便于外部脚本或工具读取聊天记录、会话、联系人、群成员和导出的媒体文件;也支持在检测到新消息后通过固定 SSE 地址主动推送消息事件。
|
||||
WeFlow 提供本地 HTTP API(已支持GET 和 POST请求),便于外部脚本或工具读取聊天记录、会话、联系人、群成员和导出的媒体文件;也支持在检测到新消息后通过固定 SSE 地址主动推送消息事件。
|
||||
|
||||
## 启用方式
|
||||
|
||||
@@ -11,17 +11,27 @@ WeFlow 提供本地 HTTP API,便于外部脚本或工具读取聊天记录、
|
||||
- 基础地址:`http://127.0.0.1:5031`
|
||||
- 可选开启 `主动推送`,检测到新收到的消息后会通过 `GET /api/v1/push/messages` 推送给 SSE 订阅端
|
||||
|
||||
**状态记忆**:API 服务和主动推送的状态及端口会自动保存,重启 WeFlow 后会自动恢复运行。
|
||||
|
||||
## 鉴权规范
|
||||
|
||||
**鉴权规范 (Access Token)** 除健康检查接口外,所有 `/api/v1/*` 接口均受 Token 保护。支持三种传参方式(任选其一):
|
||||
|
||||
1. **HTTP Header (推荐)**: `Authorization: Bearer <您的Token>`
|
||||
2. **Query 参数**: `?access_token=<您的Token>`(SSE 长连接推荐此方式)
|
||||
3. **JSON Body**: `{"access_token": "<您的Token>"}`(仅限 POST 请求)
|
||||
|
||||
## 接口列表
|
||||
|
||||
- `GET /health`
|
||||
- `GET /api/v1/health`
|
||||
- `GET /api/v1/push/messages`
|
||||
- `GET /api/v1/messages`
|
||||
- `GET /api/v1/messages/new`
|
||||
- `GET /api/v1/sessions`
|
||||
- `GET /api/v1/contacts`
|
||||
- `GET /api/v1/group-members`
|
||||
- `GET /api/v1/media/*`
|
||||
- `GET|POST /health`
|
||||
- `GET|POST /api/v1/health`
|
||||
- `GET|POST /api/v1/push/messages`
|
||||
- `GET|POST /api/v1/messages`
|
||||
- `GET|POST /api/v1/messages/new`
|
||||
- `GET|POST /api/v1/sessions`
|
||||
- `GET|POST /api/v1/contacts`
|
||||
- `GET|POST /api/v1/group-members`
|
||||
- `GET|POST /api/v1/media/*`
|
||||
|
||||
---
|
||||
|
||||
@@ -80,7 +90,7 @@ GET /api/v1/push/messages
|
||||
### 示例
|
||||
|
||||
```bash
|
||||
curl -N "http://127.0.0.1:5031/api/v1/push/messages"
|
||||
curl -N "http://127.0.0.1:5031/api/v1/push/messages?access_token=YOUR_TOKEN
|
||||
```
|
||||
|
||||
示例事件:
|
||||
@@ -94,6 +104,8 @@ data: {"event":"message.new","sessionId":"xxx@chatroom","messageKey":"server:123
|
||||
|
||||
## 3. 获取消息
|
||||
|
||||
> 当使用 POST 时,请将参数放在 JSON Body 中(Content-Type: application/json)
|
||||
|
||||
读取指定会话的消息,支持原始 JSON 和 ChatLab 格式。
|
||||
|
||||
**请求**
|
||||
@@ -231,6 +243,8 @@ curl "http://127.0.0.1:5031/api/v1/messages?talker=xxx@chatroom&media=1&image=1&
|
||||
|
||||
## 4. 获取会话列表
|
||||
|
||||
> 当使用 POST 时,请将参数放在 JSON Body 中(Content-Type: application/json)
|
||||
|
||||
**请求**
|
||||
|
||||
```http
|
||||
@@ -276,6 +290,8 @@ GET /api/v1/sessions
|
||||
|
||||
## 5. 获取联系人列表
|
||||
|
||||
> 当使用 POST 时,请将参数放在 JSON Body 中(Content-Type: application/json)
|
||||
|
||||
**请求**
|
||||
|
||||
```http
|
||||
@@ -325,6 +341,8 @@ GET /api/v1/contacts
|
||||
|
||||
## 6. 获取群成员列表
|
||||
|
||||
> 当使用 POST 时,请将参数放在 JSON Body 中(Content-Type: application/json)
|
||||
|
||||
返回群成员的 `wxid`、群昵称、备注、微信号等信息。
|
||||
|
||||
**请求**
|
||||
@@ -417,6 +435,8 @@ curl "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom&include
|
||||
|
||||
## 7. 访问导出媒体
|
||||
|
||||
> 当使用 POST 时,请将参数放在 JSON Body 中(Content-Type: application/json)
|
||||
|
||||
通过消息接口启用 `media=1` 后,接口会先把图片、语音、视频、表情导出到本地缓存目录,再返回可访问的 HTTP 地址。
|
||||
|
||||
**请求**
|
||||
@@ -461,19 +481,23 @@ curl "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/emojis/emoji_300.gif"
|
||||
### PowerShell
|
||||
|
||||
```powershell
|
||||
Invoke-RestMethod http://127.0.0.1:5031/health
|
||||
Invoke-RestMethod http://127.0.0.1:5031/api/v1/sessions
|
||||
Invoke-RestMethod "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&limit=10"
|
||||
Invoke-RestMethod "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom&includeMessageCounts=1"
|
||||
$headers = @{ "Authorization" = "Bearer YOUR_TOKEN" }
|
||||
$body = @{ talker = "wxid_xxx"; limit = 10 } | ConvertTo-Json
|
||||
|
||||
Invoke-RestMethod -Uri "http://127.0.0.1:5031/api/v1/messages" -Method POST -Headers $headers -Body $body -ContentType "application/json"
|
||||
```
|
||||
|
||||
### cURL
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:5031/health
|
||||
curl "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&chatlab=1"
|
||||
curl "http://127.0.0.1:5031/api/v1/contacts?keyword=张三"
|
||||
curl "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom"
|
||||
# GET 带 Token Header
|
||||
curl -H "Authorization: Bearer YOUR_TOKEN" "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx"
|
||||
|
||||
# POST 带 JSON Body
|
||||
curl -X POST http://127.0.0.1:5031/api/v1/messages \
|
||||
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"talker": "xxx@chatroom", "chatlab": true}'
|
||||
```
|
||||
|
||||
### Python
|
||||
@@ -482,19 +506,21 @@ curl "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom"
|
||||
import requests
|
||||
|
||||
BASE_URL = "http://127.0.0.1:5031"
|
||||
headers = {"Authorization": "Bearer YOUR_TOKEN", "Content-Type": "application/json"}
|
||||
|
||||
messages = requests.get(
|
||||
# POST 方式获取消息
|
||||
messages = requests.post(
|
||||
f"{BASE_URL}/api/v1/messages",
|
||||
params={"talker": "xxx@chatroom", "limit": 50}
|
||||
json={"talker": "xxx@chatroom", "limit": 50},
|
||||
headers=headers
|
||||
).json()
|
||||
|
||||
# GET 方式获取群成员
|
||||
members = requests.get(
|
||||
f"{BASE_URL}/api/v1/group-members",
|
||||
params={"chatroomId": "xxx@chatroom", "includeMessageCounts": 1}
|
||||
params={"chatroomId": "xxx@chatroom", "includeMessageCounts": 1},
|
||||
headers=headers
|
||||
).json()
|
||||
|
||||
print(messages)
|
||||
print(members)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -2825,6 +2825,8 @@ app.whenReady().then(async () => {
|
||||
// 启动时检测更新(不阻塞启动)
|
||||
checkForUpdatesOnStartup()
|
||||
|
||||
await httpService.autoStart()
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
mainWindow = createWindow()
|
||||
|
||||
@@ -52,13 +52,16 @@ interface ConfigSchema {
|
||||
notificationFilterMode: 'all' | 'whitelist' | 'blacklist'
|
||||
notificationFilterList: string[]
|
||||
messagePushEnabled: boolean
|
||||
httpApiEnabled: boolean
|
||||
httpApiPort: number
|
||||
httpApiToken: string
|
||||
windowCloseBehavior: 'ask' | 'tray' | 'quit'
|
||||
quoteLayout: 'quote-top' | 'quote-bottom'
|
||||
wordCloudExcludeWords: string[]
|
||||
}
|
||||
|
||||
// 需要 safeStorage 加密的字段(普通模式)
|
||||
const ENCRYPTED_STRING_KEYS: Set<string> = new Set(['decryptKey', 'imageAesKey', 'authPassword'])
|
||||
const ENCRYPTED_STRING_KEYS: Set<string> = new Set(['decryptKey', 'imageAesKey', 'authPassword', 'httpApiToken'])
|
||||
const ENCRYPTED_BOOL_KEYS: Set<string> = new Set(['authEnabled', 'authUseHello'])
|
||||
const ENCRYPTED_NUMBER_KEYS: Set<string> = new Set(['imageXorKey'])
|
||||
|
||||
@@ -119,6 +122,9 @@ export class ConfigService {
|
||||
notificationPosition: 'top-right',
|
||||
notificationFilterMode: 'all',
|
||||
notificationFilterList: [],
|
||||
httpApiToken: '',
|
||||
httpApiEnabled: false,
|
||||
httpApiPort: 5031,
|
||||
messagePushEnabled: false,
|
||||
windowCloseBehavior: 'ask',
|
||||
quoteLayout: 'quote-top',
|
||||
@@ -662,11 +668,9 @@ export class ConfigService {
|
||||
|
||||
// 即使 authEnabled 被删除/篡改,如果密钥是 lock: 格式,说明曾开启过应用锁
|
||||
const rawDecryptKey: any = this.store.get('decryptKey')
|
||||
if (typeof rawDecryptKey === 'string' && rawDecryptKey.startsWith(LOCK_PREFIX)) {
|
||||
return true
|
||||
}
|
||||
return typeof rawDecryptKey === 'string' && rawDecryptKey.startsWith(LOCK_PREFIX);
|
||||
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// === 工具方法 ===
|
||||
|
||||
@@ -246,14 +246,67 @@ class HttpService {
|
||||
}
|
||||
}
|
||||
|
||||
async autoStart(): Promise<void> {
|
||||
const enabled = this.configService.get('httpApiEnabled')
|
||||
if (enabled) {
|
||||
const port = Number(this.configService.get('httpApiPort')) || 5031
|
||||
try {
|
||||
await this.start(port)
|
||||
console.log(`[HttpService] Auto-started on port ${port}`)
|
||||
} catch (err) {
|
||||
console.error('[HttpService] Auto-start failed:', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 HTTP 请求
|
||||
* 解析 POST 请求的 JSON Body
|
||||
*/
|
||||
private async parseBody(req: http.IncomingMessage): Promise<Record<string, any>> {
|
||||
if (req.method !== 'POST') return {}
|
||||
return new Promise((resolve) => {
|
||||
let body = ''
|
||||
req.on('data', chunk => { body += chunk.toString() })
|
||||
req.on('end', () => {
|
||||
try {
|
||||
resolve(JSON.parse(body))
|
||||
} catch {
|
||||
resolve({})
|
||||
}
|
||||
})
|
||||
req.on('error', () => resolve({}))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 鉴权拦截器
|
||||
*/
|
||||
private verifyToken(req: http.IncomingMessage, url: URL, body: Record<string, any>): boolean {
|
||||
const expectedToken = String(this.configService.get('httpApiToken') || '').trim()
|
||||
if (!expectedToken) return true
|
||||
|
||||
const authHeader = req.headers.authorization
|
||||
if (authHeader && authHeader.toLowerCase().startsWith('bearer ')) {
|
||||
const token = authHeader.substring(7).trim()
|
||||
if (token === expectedToken) return true
|
||||
}
|
||||
|
||||
const queryToken = url.searchParams.get('access_token')
|
||||
if (queryToken && queryToken.trim() === expectedToken) return true
|
||||
|
||||
const bodyToken = body['access_token']
|
||||
return !!(bodyToken && String(bodyToken).trim() === expectedToken);
|
||||
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 HTTP 请求 (重构后)
|
||||
*/
|
||||
private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
||||
// 设置 CORS 头
|
||||
res.setHeader('Access-Control-Allow-Origin', '*')
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS')
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(204)
|
||||
@@ -265,7 +318,21 @@ class HttpService {
|
||||
const pathname = url.pathname
|
||||
|
||||
try {
|
||||
// 路由处理
|
||||
const bodyParams = await this.parseBody(req)
|
||||
|
||||
for (const [key, value] of Object.entries(bodyParams)) {
|
||||
if (!url.searchParams.has(key)) {
|
||||
url.searchParams.set(key, String(value))
|
||||
}
|
||||
}
|
||||
|
||||
if (pathname !== '/health' && pathname !== '/api/v1/health') {
|
||||
if (!this.verifyToken(req, url, bodyParams)) {
|
||||
this.sendError(res, 401, 'Unauthorized: Invalid or missing access_token')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (pathname === '/health' || pathname === '/api/v1/health') {
|
||||
this.sendJson(res, { status: 'ok' })
|
||||
} else if (pathname === '/api/v1/push/messages') {
|
||||
@@ -288,7 +355,6 @@ class HttpService {
|
||||
this.sendError(res, 500, String(error))
|
||||
}
|
||||
}
|
||||
|
||||
private startMessagePushHeartbeat(): void {
|
||||
if (this.messagePushHeartbeatTimer) return
|
||||
this.messagePushHeartbeatTimer = setInterval(() => {
|
||||
|
||||
@@ -96,12 +96,17 @@
|
||||
"icon": "public/icon.png",
|
||||
"target": [
|
||||
"appimage",
|
||||
"deb",
|
||||
"tar.gz"
|
||||
],
|
||||
"category": "Utility",
|
||||
"executableName": "weflow",
|
||||
"synopsis": "WeFlow for Linux"
|
||||
"synopsis": "WeFlow for Linux",
|
||||
"extraFiles": [
|
||||
{
|
||||
"from": "resources/linux/install.sh",
|
||||
"to": "install.sh"
|
||||
}
|
||||
]
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
|
||||
59
resources/linux/install.sh
Normal file
59
resources/linux/install.sh
Normal file
@@ -0,0 +1,59 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
APP_NAME="weflow"
|
||||
APP_EXEC="weflow"
|
||||
OPT_DIR="/opt/$APP_NAME"
|
||||
BIN_LINK="/usr/bin/$APP_NAME"
|
||||
DESKTOP_DIR="/usr/share/applications"
|
||||
ICON_DIR="/usr/share/pixmaps"
|
||||
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo "❌ 请使用 root 权限运行此脚本 (例如: sudo ./install.sh)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "🚀 开始安装 $APP_NAME..."
|
||||
|
||||
echo "📦 正在复制文件到 $OPT_DIR..."
|
||||
rm -rf "$OPT_DIR"
|
||||
mkdir -p "$OPT_DIR"
|
||||
cp -r ./* "$OPT_DIR/"
|
||||
chmod -R 755 "$OPT_DIR"
|
||||
chmod +x "$OPT_DIR/$APP_EXEC"
|
||||
|
||||
echo "🔗 正在创建软链接 $BIN_LINK..."
|
||||
ln -sf "$OPT_DIR/$APP_EXEC" "$BIN_LINK"
|
||||
|
||||
echo "📝 正在创建桌面快捷方式..."
|
||||
cat <<EOF >"$DESKTOP_DIR/${APP_NAME}.desktop"
|
||||
[Desktop Entry]
|
||||
Name=WeFlow
|
||||
Exec=$OPT_DIR/$APP_EXEC %U
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Icon=$APP_NAME
|
||||
StartupWMClass=WeFlow
|
||||
Comment=A local WeChat database decryption and analysis tool
|
||||
Categories=Utility;
|
||||
EOF
|
||||
chmod 644 "$DESKTOP_DIR/${APP_NAME}.desktop"
|
||||
|
||||
echo "🖼️ 正在安装图标..."
|
||||
if [ -f "$OPT_DIR/resources/icon.png" ]; then
|
||||
cp "$OPT_DIR/resources/icon.png" "$ICON_DIR/${APP_NAME}.png"
|
||||
chmod 644 "$ICON_DIR/${APP_NAME}.png"
|
||||
elif [ -f "$OPT_DIR/icon.png" ]; then
|
||||
cp "$OPT_DIR/icon.png" "$ICON_DIR/${APP_NAME}.png"
|
||||
chmod 644 "$ICON_DIR/${APP_NAME}.png"
|
||||
else
|
||||
echo "⚠️ 警告: 未找到图标文件,跳过图标安装。"
|
||||
fi
|
||||
|
||||
if command -v update-desktop-database >/dev/null 2>&1; then
|
||||
echo "🔄 更新桌面数据库..."
|
||||
update-desktop-database "$DESKTOP_DIR"
|
||||
fi
|
||||
|
||||
echo "✅ 安装完成!你现在可以在应用菜单中找到 WeFlow,或者在终端输入 'weflow' 启动。"
|
||||
Binary file not shown.
@@ -103,6 +103,8 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
const [whisperProgressData, setWhisperProgressData] = useState<{ downloaded: number; total: number; speed: number }>({ downloaded: 0, total: 0, speed: 0 })
|
||||
const [whisperModelStatus, setWhisperModelStatus] = useState<{ exists: boolean; modelPath?: string; tokensPath?: string } | null>(null)
|
||||
|
||||
const [httpApiToken, setHttpApiToken] = useState('')
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
@@ -111,6 +113,25 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
const generateRandomToken = async () => {
|
||||
// 生成 32 字符的十六进制随机字符串 (16 bytes)
|
||||
const array = new Uint8Array(16)
|
||||
crypto.getRandomValues(array)
|
||||
const token = Array.from(array).map(b => b.toString(16).padStart(2, '0')).join('')
|
||||
|
||||
setHttpApiToken(token)
|
||||
await configService.setHttpApiToken(token)
|
||||
showMessage('已生成并保存新的 Access Token', true)
|
||||
}
|
||||
|
||||
const clearApiToken = async () => {
|
||||
setHttpApiToken('')
|
||||
await configService.setHttpApiToken('')
|
||||
showMessage('已清除 Access Token,API 将允许无鉴权访问', true)
|
||||
}
|
||||
|
||||
|
||||
|
||||
const [autoTranscribeVoice, setAutoTranscribeVoice] = useState(false)
|
||||
const [transcribeLanguages, setTranscribeLanguages] = useState<string[]>(['zh'])
|
||||
|
||||
@@ -192,6 +213,8 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
checkWaylandStatus()
|
||||
}, [])
|
||||
|
||||
|
||||
|
||||
// 检查 Hello 可用性
|
||||
useEffect(() => {
|
||||
setHelloAvailable(isWindows)
|
||||
@@ -319,6 +342,13 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
const savedAuthEnabled = await window.electronAPI.auth.verifyEnabled()
|
||||
const savedAuthUseHello = await configService.getAuthUseHello()
|
||||
const savedIsLockMode = await window.electronAPI.auth.isLockMode()
|
||||
|
||||
const savedHttpApiToken = await configService.getHttpApiToken()
|
||||
if (savedHttpApiToken) setHttpApiToken(savedHttpApiToken)
|
||||
|
||||
const savedApiPort = await configService.getHttpApiPort()
|
||||
if (savedApiPort) setHttpApiPort(savedApiPort)
|
||||
|
||||
setAuthEnabled(savedAuthEnabled)
|
||||
setAuthUseHello(savedAuthUseHello)
|
||||
setIsLockMode(savedIsLockMode)
|
||||
@@ -361,6 +391,8 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
const savedAnalyticsConsent = await configService.getAnalyticsConsent()
|
||||
setAnalyticsConsent(savedAnalyticsConsent ?? false)
|
||||
|
||||
|
||||
|
||||
// 如果语言列表为空,保存默认值
|
||||
if (!savedTranscribeLanguages || savedTranscribeLanguages.length === 0) {
|
||||
const defaultLanguages = ['zh']
|
||||
@@ -1825,6 +1857,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
try {
|
||||
await window.electronAPI.http.stop()
|
||||
setHttpApiRunning(false)
|
||||
await configService.setHttpApiEnabled(false)
|
||||
showMessage('API 服务已停止', true)
|
||||
} catch (e: any) {
|
||||
showMessage(`操作失败: ${e}`, false)
|
||||
@@ -1842,6 +1875,10 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
if (result.success) {
|
||||
setHttpApiRunning(true)
|
||||
if (result.port) setHttpApiPort(result.port)
|
||||
|
||||
await configService.setHttpApiEnabled(true)
|
||||
await configService.setHttpApiPort(result.port || httpApiPort)
|
||||
|
||||
showMessage(`API 服务已启动,端口 ${result.port}`, true)
|
||||
} else {
|
||||
showMessage(`启动失败: ${result.error}`, false)
|
||||
@@ -1893,7 +1930,11 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
type="number"
|
||||
className="field-input"
|
||||
value={httpApiPort}
|
||||
onChange={(e) => setHttpApiPort(parseInt(e.target.value, 10) || 5031)}
|
||||
onChange={(e) => {
|
||||
const port = parseInt(e.target.value, 10) || 5031
|
||||
setHttpApiPort(port)
|
||||
scheduleConfigSave('httpApiPort', () => configService.setHttpApiPort(port))
|
||||
}}
|
||||
disabled={httpApiRunning}
|
||||
style={{ width: 120 }}
|
||||
min={1024}
|
||||
@@ -1901,6 +1942,36 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Access Token (鉴权凭证)</label>
|
||||
<span className="form-hint">
|
||||
设置后,请求头需携带 <code>Authorization: Bearer <token></code>,
|
||||
或者参数中携带 <code>?access_token=<token></code>
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: '8px', marginTop: '8px' }}>
|
||||
<input
|
||||
type="text"
|
||||
className="field-input"
|
||||
value={httpApiToken}
|
||||
placeholder="留空表示不验证 Token"
|
||||
onChange={(e) => {
|
||||
const val = e.target.value
|
||||
setHttpApiToken(val)
|
||||
scheduleConfigSave('httpApiToken', () => configService.setHttpApiToken(val))
|
||||
}}
|
||||
style={{ flex: 1, fontFamily: 'monospace' }}
|
||||
/>
|
||||
<button className="btn btn-secondary" onClick={generateRandomToken}>
|
||||
<RefreshCw size={14} style={{ marginRight: 4 }} /> 随机生成
|
||||
</button>
|
||||
{httpApiToken && (
|
||||
<button className="btn btn-danger" onClick={clearApiToken} title="清除 Token">
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{httpApiRunning && (
|
||||
<div className="form-group">
|
||||
<label>API 地址</label>
|
||||
@@ -1958,13 +2029,13 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
<input
|
||||
type="text"
|
||||
className="field-input"
|
||||
value={`http://127.0.0.1:${httpApiPort}/api/v1/push/messages`}
|
||||
value={`http://127.0.0.1:${httpApiPort}/api/v1/push/messages${httpApiToken ? `?access_token=${httpApiToken}` : ''}`}
|
||||
readOnly
|
||||
/>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(`http://127.0.0.1:${httpApiPort}/api/v1/push/messages`)
|
||||
navigator.clipboard.writeText(`http://127.0.0.1:${httpApiPort}/api/v1/push/messages${httpApiToken ? `?access_token=${httpApiToken}` : ''}`)
|
||||
showMessage('已复制推送地址', true)
|
||||
}}
|
||||
title="复制"
|
||||
|
||||
@@ -64,6 +64,9 @@ export const CONFIG_KEYS = {
|
||||
NOTIFICATION_POSITION: 'notificationPosition',
|
||||
NOTIFICATION_FILTER_MODE: 'notificationFilterMode',
|
||||
NOTIFICATION_FILTER_LIST: 'notificationFilterList',
|
||||
HTTP_API_TOKEN: 'httpApiToken',
|
||||
HTTP_API_ENABLED: 'httpApiEnabled',
|
||||
HTTP_API_PORT: 'httpApiPort',
|
||||
MESSAGE_PUSH_ENABLED: 'messagePushEnabled',
|
||||
WINDOW_CLOSE_BEHAVIOR: 'windowCloseBehavior',
|
||||
QUOTE_LAYOUT: 'quoteLayout',
|
||||
@@ -117,6 +120,17 @@ export async function getDbPath(): Promise<string | null> {
|
||||
return value as string | null
|
||||
}
|
||||
|
||||
// 获取api access_token
|
||||
export async function getHttpApiToken(): Promise<string> {
|
||||
const value = await config.get(CONFIG_KEYS.HTTP_API_TOKEN)
|
||||
return (value as string) || ''
|
||||
}
|
||||
|
||||
// 设置access_token
|
||||
export async function setHttpApiToken(token: string): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.HTTP_API_TOKEN, token)
|
||||
}
|
||||
|
||||
// 设置数据库路径
|
||||
export async function setDbPath(path: string): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.DB_PATH, path)
|
||||
@@ -1472,3 +1486,26 @@ export async function getAnalyticsDenyCount(): Promise<number> {
|
||||
export async function setAnalyticsDenyCount(count: number): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.ANALYTICS_DENY_COUNT, count)
|
||||
}
|
||||
|
||||
|
||||
// 获取 HTTP API 自动启动状态
|
||||
export async function getHttpApiEnabled(): Promise<boolean> {
|
||||
const value = await config.get(CONFIG_KEYS.HTTP_API_ENABLED)
|
||||
return value === true
|
||||
}
|
||||
|
||||
// 设置 HTTP API 自动启动状态
|
||||
export async function setHttpApiEnabled(enabled: boolean): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.HTTP_API_ENABLED, enabled)
|
||||
}
|
||||
|
||||
// 获取 HTTP API 端口
|
||||
export async function getHttpApiPort(): Promise<number> {
|
||||
const value = await config.get(CONFIG_KEYS.HTTP_API_PORT)
|
||||
return typeof value === 'number' ? value : 5031
|
||||
}
|
||||
|
||||
// 设置 HTTP API 端口
|
||||
export async function setHttpApiPort(port: number): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.HTTP_API_PORT, port)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user