mirror of
https://github.com/Megghy/vtsuru.live.git
synced 2025-12-08 11:26:56 +08:00
feat: Add Tauri support and enhance client functionality
- Introduced Tauri as a new EventFetcherType in api-models. - Enhanced ClientFetcher.vue to support forced mode switching for Danmaku client. - Updated ClientLayout.vue to restrict usage outside Tauri environment with appropriate alerts. - Improved ClientSettings.vue to fetch and display the current version of the application. - Modified initialization logic in initialize.ts to handle minimized startup for Tauri. - Updated QueryBiliAPI function to conditionally use cookies based on a new parameter. - Added bootAsMinimized setting to useSettings store for better user experience. - Refactored logging in useWebFetcher to use console instead of logError/logInfo for clarity. - Created a new LabelItem component for better label handling in forms. - Enhanced EventFetcherStatusCard.vue to display version information based on EventFetcherType.
This commit is contained in:
@@ -1,19 +1,21 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, computed, shallowRef } from 'vue'; // shallowRef 用于非深度响应对象
|
||||
import { useLocalStorage } from '@vueuse/core';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { compress } from 'brotli-compress';
|
||||
import { format } from 'date-fns';
|
||||
import { cookie, useAccount } from '@/api/account';
|
||||
import { getEventType, recordEvent, streamingInfo } from '@/client/data/info';
|
||||
import { BASE_HUB_URL, isDev, isTauri } from '@/data/constants';
|
||||
import BaseDanmakuClient from '@/data/DanmakuClients/BaseDanmakuClient';
|
||||
import DirectClient, { DirectClientAuthInfo } from '@/data/DanmakuClients/DirectClient';
|
||||
import OpenLiveClient from '@/data/DanmakuClients/OpenLiveClient';
|
||||
import * as signalR from '@microsoft/signalr';
|
||||
import * as msgpack from '@microsoft/signalr-protocol-msgpack';
|
||||
import { cookie, useAccount } from '@/api/account'; // 假设账户信息路径
|
||||
import { BASE_HUB_URL, isDev } from '@/data/constants'; // 假设常量路径
|
||||
import BaseDanmakuClient from '@/data/DanmakuClients/BaseDanmakuClient'; // 假设弹幕客户端基类路径
|
||||
import DirectClient, { DirectClientAuthInfo } from '@/data/DanmakuClients/DirectClient'; // 假设直连客户端路径
|
||||
import OpenLiveClient from '@/data/DanmakuClients/OpenLiveClient'; // 假设开放平台客户端路径
|
||||
import { error as logError, info as logInfo } from '@tauri-apps/plugin-log'; // 使用日志插件
|
||||
import { getEventType, recordEvent, streamingInfo } from '@/client/data/info';
|
||||
import { defineStore } from 'pinia';
|
||||
import { computed, ref, shallowRef } from 'vue'; // shallowRef 用于非深度响应对象
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useWebRTC } from './useRTC';
|
||||
import { QueryBiliAPI } from '@/client/data/utils';
|
||||
import { platform, type, version } from '@tauri-apps/plugin-os';
|
||||
import { ZstdCodec, ZstdInit } from '@oneidentity/zstd-js/wasm';
|
||||
|
||||
import { encode } from "@msgpack/msgpack";
|
||||
import { getVersion } from '@tauri-apps/api/app';
|
||||
|
||||
export const useWebFetcher = defineStore('WebFetcher', () => {
|
||||
const route = useRoute();
|
||||
@@ -24,11 +26,14 @@ export const useWebFetcher = defineStore('WebFetcher', () => {
|
||||
const state = ref<'disconnected' | 'connecting' | 'connected'>('disconnected'); // SignalR 连接状态
|
||||
const startedAt = ref<Date>(); // 本次启动时间
|
||||
const signalRClient = shallowRef<signalR.HubConnection>(); // SignalR 客户端实例 (浅响应)
|
||||
const signalRId = ref<string>(); // SignalR 连接 ID
|
||||
const client = shallowRef<BaseDanmakuClient>(); // 弹幕客户端实例 (浅响应)
|
||||
let timer: any; // 事件发送定时器
|
||||
let disconnectedByServer = false;
|
||||
let isFromClient = false; // 是否由Tauri客户端启动
|
||||
|
||||
|
||||
|
||||
// --- 新增: 详细状态与信息 ---
|
||||
/** 弹幕客户端内部状态 */
|
||||
const danmakuClientState = ref<'stopped' | 'connecting' | 'connected'>('stopped'); // 更详细的弹幕客户端状态
|
||||
@@ -52,6 +57,7 @@ export const useWebFetcher = defineStore('WebFetcher', () => {
|
||||
const failedUploads = ref(0);
|
||||
/** 本次会话发送的总字节数 (压缩后) */
|
||||
const bytesSentSession = ref(0);
|
||||
let zstd: ZstdCodec | undefined = undefined; // Zstd 编码器实例 (如果需要压缩)
|
||||
|
||||
const prefix = computed(() => isFromClient ? '[web-fetcher-iframe] ' : '[web-fetcher] ');
|
||||
|
||||
@@ -64,9 +70,15 @@ export const useWebFetcher = defineStore('WebFetcher', () => {
|
||||
_isFromClient: boolean = false
|
||||
): Promise<{ success: boolean; message: string; }> {
|
||||
if (state.value === 'connected' || state.value === 'connecting') {
|
||||
logInfo(prefix.value + '已经启动,无需重复启动');
|
||||
console.log(prefix.value + '已经启动,无需重复启动');
|
||||
return { success: true, message: '已启动' };
|
||||
}
|
||||
try {
|
||||
zstd ??= await ZstdInit();
|
||||
|
||||
} catch (error) {
|
||||
console.error(prefix.value + '当前浏览器不支持zstd压缩, 回退到原始数据传输');
|
||||
}
|
||||
webfetcherType.value = type; // 设置弹幕客户端类型
|
||||
// 重置会话统计数据
|
||||
resetSessionStats();
|
||||
@@ -76,9 +88,9 @@ export const useWebFetcher = defineStore('WebFetcher', () => {
|
||||
|
||||
// 使用 navigator.locks 确保同一时间只有一个 Start 操作执行
|
||||
const result = await navigator.locks.request('webFetcherStartLock', async () => {
|
||||
logInfo(prefix.value + '开始启动...');
|
||||
console.log(prefix.value + '开始启动...');
|
||||
while (!(await connectSignalR())) {
|
||||
logInfo(prefix.value + '连接 SignalR 失败, 5秒后重试');
|
||||
console.log(prefix.value + '连接 SignalR 失败, 5秒后重试');
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
// 如果用户手动停止,则退出重试循环
|
||||
if (state.value === 'disconnected') return { success: false, message: '用户手动停止' };
|
||||
@@ -86,7 +98,7 @@ export const useWebFetcher = defineStore('WebFetcher', () => {
|
||||
|
||||
let danmakuResult = await connectDanmakuClient(type, directAuthInfo);
|
||||
while (!danmakuResult?.success) {
|
||||
logInfo(prefix.value + '弹幕客户端启动失败, 5秒后重试');
|
||||
console.log(prefix.value + '弹幕客户端启动失败, 5秒后重试');
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
// 如果用户手动停止,则退出重试循环
|
||||
if (state.value === 'disconnected') return { success: false, message: '用户手动停止' };
|
||||
@@ -96,7 +108,7 @@ export const useWebFetcher = defineStore('WebFetcher', () => {
|
||||
// 只有在两个连接都成功后才设置为 connected
|
||||
state.value = 'connected';
|
||||
disconnectedByServer = false;
|
||||
logInfo(prefix.value + '启动成功');
|
||||
console.log(prefix.value + '启动成功');
|
||||
return { success: true, message: '启动成功' };
|
||||
});
|
||||
|
||||
@@ -115,7 +127,7 @@ export const useWebFetcher = defineStore('WebFetcher', () => {
|
||||
function Stop() {
|
||||
if (state.value === 'disconnected') return;
|
||||
|
||||
logInfo(prefix.value + '正在停止...');
|
||||
console.log(prefix.value + '正在停止...');
|
||||
state.value = 'disconnected'; // 立即设置状态,防止重连逻辑触发
|
||||
|
||||
// 清理定时器
|
||||
@@ -137,7 +149,7 @@ export const useWebFetcher = defineStore('WebFetcher', () => {
|
||||
events.length = 0; // 清空事件队列
|
||||
// resetSessionStats(); // 会话统计在下次 Start 时重置
|
||||
|
||||
logInfo(prefix.value + '已停止');
|
||||
console.log(prefix.value + '已停止');
|
||||
}
|
||||
|
||||
/** 重置会话统计数据 */
|
||||
@@ -157,11 +169,11 @@ export const useWebFetcher = defineStore('WebFetcher', () => {
|
||||
directConnectInfo?: DirectClientAuthInfo
|
||||
) {
|
||||
if (client.value?.state === 'connected' || client.value?.state === 'connecting') {
|
||||
logInfo(prefix.value + '弹幕客户端已连接或正在连接');
|
||||
console.log(prefix.value + '弹幕客户端已连接或正在连接');
|
||||
return { success: true, message: '弹幕客户端已启动' };
|
||||
}
|
||||
|
||||
logInfo(prefix.value + '正在连接弹幕客户端...');
|
||||
console.log(prefix.value + '正在连接弹幕客户端...');
|
||||
danmakuClientState.value = 'connecting';
|
||||
|
||||
// 如果实例存在但已停止,先清理
|
||||
@@ -176,7 +188,7 @@ export const useWebFetcher = defineStore('WebFetcher', () => {
|
||||
} else {
|
||||
if (!directConnectInfo) {
|
||||
danmakuClientState.value = 'stopped';
|
||||
logError(prefix.value + '未提供直连弹幕客户端认证信息');
|
||||
console.error(prefix.value + '未提供直连弹幕客户端认证信息');
|
||||
return { success: false, message: '未提供弹幕客户端认证信息' };
|
||||
}
|
||||
client.value = new DirectClient(directConnectInfo);
|
||||
@@ -193,13 +205,13 @@ export const useWebFetcher = defineStore('WebFetcher', () => {
|
||||
const result = await client.value?.Start();
|
||||
|
||||
if (result?.success) {
|
||||
logInfo(prefix.value + '弹幕客户端连接成功, 开始监听弹幕');
|
||||
console.log(prefix.value + '弹幕客户端连接成功, 开始监听弹幕');
|
||||
danmakuClientState.value = 'connected'; // 明确设置状态
|
||||
danmakuServerUrl.value = client.value.serverUrl; // 获取服务器地址
|
||||
// 启动事件发送定时器 (如果之前没有启动)
|
||||
timer ??= setInterval(sendEvents, 1500); // 每 1.5 秒尝试发送一次事件
|
||||
} else {
|
||||
logError(prefix.value + '弹幕客户端启动失败: ' + result?.message);
|
||||
console.error(prefix.value + '弹幕客户端启动失败: ' + result?.message);
|
||||
danmakuClientState.value = 'stopped';
|
||||
danmakuServerUrl.value = undefined;
|
||||
client.value = undefined; // 启动失败,清理实例,下次会重建
|
||||
@@ -212,11 +224,11 @@ export const useWebFetcher = defineStore('WebFetcher', () => {
|
||||
*/
|
||||
async function connectSignalR() {
|
||||
if (signalRClient.value && signalRClient.value.state !== signalR.HubConnectionState.Disconnected) {
|
||||
logInfo(prefix.value + "SignalR 已连接或正在连接");
|
||||
console.log(prefix.value + "SignalR 已连接或正在连接");
|
||||
return true;
|
||||
}
|
||||
|
||||
logInfo(prefix.value + '正在连接到 vtsuru 服务器...');
|
||||
console.log(prefix.value + '正在连接到 vtsuru 服务器...');
|
||||
const connection = new signalR.HubConnectionBuilder()
|
||||
.withUrl(BASE_HUB_URL + 'web-fetcher?token=' + (route.query.token ?? account.value.token), { // 使用 account.token
|
||||
headers: { Authorization: `Bearer ${cookie.value?.cookie}` },
|
||||
@@ -229,71 +241,98 @@ export const useWebFetcher = defineStore('WebFetcher', () => {
|
||||
|
||||
// --- SignalR 事件监听 ---
|
||||
connection.onreconnecting(error => {
|
||||
logInfo(prefix.value + `与服务器断开,正在尝试重连... ${error?.message || ''}`);
|
||||
console.log(prefix.value + `与服务器断开,正在尝试重连... ${error?.message || ''}`);
|
||||
state.value = 'connecting'; // 更新状态为连接中
|
||||
signalRConnectionId.value = undefined; // 连接断开,ID失效
|
||||
});
|
||||
|
||||
connection.onreconnected(connectionId => {
|
||||
logInfo(prefix.value + `与服务器重新连接成功! ConnectionId: ${connectionId}`);
|
||||
connection.onreconnected(async connectionId => {
|
||||
console.log(prefix.value + `与服务器重新连接成功! ConnectionId: ${connectionId}`);
|
||||
signalRConnectionId.value = connectionId ?? undefined;
|
||||
state.value = 'connected'; // 更新状态为已连接
|
||||
// 重连成功后可能需要重新发送标识
|
||||
if (isFromClient) {
|
||||
connection.send('SetAsVTsuruClient').catch(err => logError(prefix.value + "Send SetAsVTsuruClient failed: " + err));
|
||||
}
|
||||
connection.send('Reconnected').catch(err => logError(prefix.value + "Send Reconnected failed: " + err));
|
||||
signalRId.value = connectionId ?? await sendSelfInfo(connection); // 更新连接ID
|
||||
connection.send('Reconnected').catch(err => console.error(prefix.value + "Send Reconnected failed: " + err));
|
||||
});
|
||||
|
||||
connection.onclose(async (error) => {
|
||||
// 只有在不是由 Stop() 或服务器明确要求断开时才记录错误并尝试独立重连(虽然 withAutomaticReconnect 应该处理)
|
||||
if (state.value !== 'disconnected' && !disconnectedByServer) {
|
||||
logError(prefix.value + `与服务器连接关闭: ${error?.message || '未知原因'}. 自动重连将处理.`);
|
||||
console.error(prefix.value + `与服务器连接关闭: ${error?.message || '未知原因'}. 自动重连将处理.`);
|
||||
state.value = 'connecting'; // 标记为连接中,等待自动重连
|
||||
signalRConnectionId.value = undefined;
|
||||
// withAutomaticReconnect 会处理重连,这里不需要手动调用 reconnect
|
||||
} else if (disconnectedByServer) {
|
||||
logInfo(prefix.value + `连接已被服务器关闭.`);
|
||||
console.log(prefix.value + `连接已被服务器关闭.`);
|
||||
Stop(); // 服务器要求断开,则彻底停止
|
||||
} else {
|
||||
logInfo(prefix.value + `连接已手动关闭.`);
|
||||
console.log(prefix.value + `连接已手动关闭.`);
|
||||
}
|
||||
});
|
||||
|
||||
connection.on('Disconnect', (reason: unknown) => {
|
||||
logInfo(prefix.value + '被服务器断开连接: ' + reason);
|
||||
console.log(prefix.value + '被服务器断开连接: ' + reason);
|
||||
disconnectedByServer = true; // 标记是服务器主动断开
|
||||
Stop(); // 服务器要求断开,调用 Stop 清理所有资源
|
||||
});
|
||||
connection.on('Request', async (url: string, method: string, body: string, useCookie: boolean) => onRequest(url, method, body, useCookie));
|
||||
|
||||
// --- 尝试启动连接 ---
|
||||
try {
|
||||
await connection.start();
|
||||
logInfo(prefix.value + '已连接到 vtsuru 服务器, ConnectionId: ' + connection.connectionId);
|
||||
console.log(prefix.value + '已连接到 vtsuru 服务器, ConnectionId: ' + connection.connectionId); // 调试输出连接状态
|
||||
signalRConnectionId.value = connection.connectionId ?? undefined; // 保存连接ID
|
||||
signalRId.value = await sendSelfInfo(connection); // 发送客户端信息
|
||||
await connection.send('Finished'); // 通知服务器已准备好
|
||||
if (isFromClient) {
|
||||
await connection.send('SetAsVTsuruClient'); // 如果是客户端,发送标识
|
||||
}
|
||||
signalRClient.value = connection; // 保存实例
|
||||
// state.value = 'connected'; // 状态将在 Start 函数末尾统一设置
|
||||
return true;
|
||||
} catch (e) {
|
||||
logError(prefix.value + '无法连接到 vtsuru 服务器: ' + e);
|
||||
console.error(prefix.value + '无法连接到 vtsuru 服务器: ' + e);
|
||||
signalRConnectionId.value = undefined;
|
||||
signalRClient.value = undefined;
|
||||
// state.value = 'disconnected'; // 保持 connecting 或由 Start 控制
|
||||
return false;
|
||||
}
|
||||
}
|
||||
async function sendSelfInfo(client: signalR.HubConnection) {
|
||||
return client.invoke('SetSelfInfo',
|
||||
isTauri ? `tauri ${platform()} ${version()}` : navigator.userAgent,
|
||||
isTauri ? 'tauri' : 'web',
|
||||
isTauri ? await getVersion() : '1.0.0',
|
||||
webfetcherType.value === 'direct');
|
||||
}
|
||||
type ResponseFetchRequestData = {
|
||||
Message: string;
|
||||
Success: boolean;
|
||||
Data: string;
|
||||
};
|
||||
async function onRequest(url: string, method: string, body: string, useCookie: boolean) {
|
||||
if (!isTauri) {
|
||||
console.error(prefix.value + '非Tauri环境下无法处理请求: ' + url);
|
||||
return {
|
||||
Message: '非Tauri环境',
|
||||
Success: false,
|
||||
Data: ''
|
||||
};
|
||||
}
|
||||
const result = await QueryBiliAPI(url, method, body, useCookie);
|
||||
console.log(`${prefix.value}请求 (${method})${url}: `, result.statusText);
|
||||
if (result.ok) {
|
||||
const data = await result.text();
|
||||
return {
|
||||
Message: '请求成功',
|
||||
Success: true,
|
||||
Data: data
|
||||
} as ResponseFetchRequestData;
|
||||
}
|
||||
}
|
||||
|
||||
// async function reconnect() { // withAutomaticReconnect 存在时,此函数通常不需要手动调用
|
||||
// if (disconnectedByServer || state.value === 'disconnected') return;
|
||||
// logInfo(prefix.value + '尝试手动重连...');
|
||||
// console.log(prefix.value + '尝试手动重连...');
|
||||
// try {
|
||||
// await signalRClient.value?.start();
|
||||
// logInfo(prefix.value + '手动重连成功');
|
||||
// console.log(prefix.value + '手动重连成功');
|
||||
// signalRConnectionId.value = signalRClient.value?.connectionId ?? null;
|
||||
// state.value = 'connected';
|
||||
// if (isFromClient) {
|
||||
@@ -301,7 +340,7 @@ export const useWebFetcher = defineStore('WebFetcher', () => {
|
||||
// }
|
||||
// await signalRClient.value?.send('Reconnected');
|
||||
// } catch (err) {
|
||||
// logError(prefix.value + '手动重连失败: ' + err);
|
||||
// console.error(prefix.value + '手动重连失败: ' + err);
|
||||
// setTimeout(reconnect, 10000); // 失败后10秒再次尝试
|
||||
// }
|
||||
// }
|
||||
@@ -351,20 +390,34 @@ export const useWebFetcher = defineStore('WebFetcher', () => {
|
||||
|
||||
try {
|
||||
|
||||
const result = await signalRClient.value.invoke<{ Success: boolean; Message: string; }>(
|
||||
'UploadEvents', batch, webfetcherType.value === 'direct'? true : false
|
||||
);
|
||||
let result: { Success: boolean; Message: string; } = { Success: false, Message: '' };
|
||||
let length = 0;
|
||||
let eventCharLength = batch.map(event => event.length).reduce((a, b) => a + b, 0); // 计算字符长度
|
||||
if (zstd && eventCharLength > 100) {
|
||||
const data = zstd.ZstdSimple.compress(encode(batch), 11);
|
||||
length = data.length;
|
||||
result = await signalRClient.value.invoke<{ Success: boolean; Message: string; }>(
|
||||
'UploadEventsCompressedV2', data
|
||||
);
|
||||
}
|
||||
else {
|
||||
length = new TextEncoder().encode(batch.join()).length;
|
||||
result = await signalRClient.value.invoke<{ Success: boolean; Message: string; }>(
|
||||
'UploadEvents', batch, webfetcherType.value === 'direct' ? true : false
|
||||
);
|
||||
}
|
||||
|
||||
if (result?.Success) {
|
||||
events.splice(0, batch.length); // 从队列中移除已成功发送的事件
|
||||
successfulUploads.value++;
|
||||
bytesSentSession.value += new TextEncoder().encode(batch.join()).length;
|
||||
bytesSentSession.value += length;
|
||||
} else {
|
||||
failedUploads.value++;
|
||||
logError(prefix.value + '上传弹幕失败: ' + result?.Message);
|
||||
console.error(prefix.value + '上传弹幕失败: ' + result?.Message);
|
||||
}
|
||||
} catch (err) {
|
||||
failedUploads.value++;
|
||||
logError(prefix.value + '发送事件时出错: ' + err);
|
||||
console.error(prefix.value + '发送事件时出错: ' + err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -379,6 +432,7 @@ export const useWebFetcher = defineStore('WebFetcher', () => {
|
||||
startedAt,
|
||||
isStreaming: computed(() => streamingInfo.value?.status === 'streaming'), // 从 statistics 模块获取
|
||||
webfetcherType,
|
||||
signalRId,
|
||||
|
||||
// 连接详情
|
||||
danmakuClientState,
|
||||
|
||||
Reference in New Issue
Block a user