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:
2025-04-07 19:14:39 +08:00
parent 277497420c
commit 0195e7b01a
14 changed files with 536 additions and 328 deletions

BIN
bun.lockb

Binary file not shown.

View File

@@ -15,6 +15,7 @@
"@microsoft/signalr": "^8.0.7", "@microsoft/signalr": "^8.0.7",
"@microsoft/signalr-protocol-msgpack": "^8.0.7", "@microsoft/signalr-protocol-msgpack": "^8.0.7",
"@mixer/postmessage-rpc": "^1.1.4", "@mixer/postmessage-rpc": "^1.1.4",
"@oneidentity/zstd-js": "^1.0.3",
"@tauri-apps/api": "^2.4.0", "@tauri-apps/api": "^2.4.0",
"@tauri-apps/plugin-autostart": "^2.3.0", "@tauri-apps/plugin-autostart": "^2.3.0",
"@tauri-apps/plugin-http": "^2.4.2", "@tauri-apps/plugin-http": "^2.4.2",

View File

@@ -1,4 +1,4 @@
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core';
import { import {
ConfigProviderProps, ConfigProviderProps,
NButton, NButton,
@@ -10,114 +10,114 @@ import {
dateZhCN, dateZhCN,
useOsTheme, useOsTheme,
zhCN zhCN
} from 'naive-ui' } from 'naive-ui';
import { SongFrom, SongsInfo, ThemeType } from './api/api-models' import { SongFrom, SongsInfo, ThemeType } from './api/api-models';
import { computed } from 'vue' import { computed } from 'vue';
import { VTSURU_API_URL } from './data/constants' import { VTSURU_API_URL } from './data/constants';
import { DiscreteApiType } from 'naive-ui/es/discrete/src/interface' import { DiscreteApiType } from 'naive-ui/es/discrete/src/interface';
import { SquareArrowForward24Filled } from '@vicons/fluent'; import { SquareArrowForward24Filled } from '@vicons/fluent';
import FiveSingIcon from '@/svgs/fivesing.svg' import FiveSingIcon from '@/svgs/fivesing.svg';
import NeteaseIcon from '@/svgs/netease.svg' import NeteaseIcon from '@/svgs/netease.svg';
const { message } = createDiscreteApi(['message']) const { message } = createDiscreteApi(['message']);
const osThemeRef = useOsTheme() //获取当前系统主题 const osThemeRef = useOsTheme(); //获取当前系统主题
const themeType = useStorage('Settings.Theme', ThemeType.Auto) const themeType = useStorage('Settings.Theme', ThemeType.Auto);
export const theme = computed(() => { export const theme = computed(() => {
if (themeType.value == ThemeType.Auto) { if (themeType.value == ThemeType.Auto) {
var osThemeRef = useOsTheme() //获取当前系统主题 var osThemeRef = useOsTheme(); //获取当前系统主题
return osThemeRef.value === 'dark' ? darkTheme : null return osThemeRef.value === 'dark' ? darkTheme : null;
} else { } else {
return themeType.value == ThemeType.Dark ? darkTheme : null return themeType.value == ThemeType.Dark ? darkTheme : null;
} }
}) });
export const configProviderPropsRef = computed<ConfigProviderProps>(() => ({ export const configProviderPropsRef = computed<ConfigProviderProps>(() => ({
theme: theme.value, theme: theme.value,
locale: zhCN, locale: zhCN,
dateLocale: dateZhCN, dateLocale: dateZhCN,
})) }));
export function createNaiveUIApi(types: DiscreteApiType[]) { export function createNaiveUIApi(types: DiscreteApiType[]) {
return createDiscreteApi(types, { return createDiscreteApi(types, {
configProviderProps: configProviderPropsRef configProviderProps: configProviderPropsRef
}) });
} }
export function NavigateToNewTab(url: string) { export function NavigateToNewTab(url: string) {
window.open(url, '_blank') window.open(url, '_blank');
} }
export const isDarkMode = computed(() => { export const isDarkMode = computed(() => {
if (themeType.value == ThemeType.Auto) return osThemeRef.value === 'dark' if (themeType.value == ThemeType.Auto) return osThemeRef.value === 'dark';
else return themeType.value == ThemeType.Dark else return themeType.value == ThemeType.Dark;
}) });
export function copyToClipboard(text: string) { export function copyToClipboard(text: string) {
if (navigator.clipboard) { if (navigator.clipboard) {
navigator.clipboard.writeText(text) navigator.clipboard.writeText(text);
message.success('已复制到剪切板') message.success('已复制到剪切板');
} else { } else {
message.warning('当前环境不支持自动复制, 请手动选择并复制') message.warning('当前环境不支持自动复制, 请手动选择并复制');
} }
} }
export function objectsToCSV(arr: any[]) { export function objectsToCSV(arr: any[]) {
const array = [Object.keys(arr[0])].concat(arr) const array = [Object.keys(arr[0])].concat(arr);
return array return array
.map((row) => { .map((row) => {
return Object.values(row) return Object.values(row)
.map((value) => { .map((value) => {
return typeof value === 'string' ? JSON.stringify(value) : value return typeof value === 'string' ? JSON.stringify(value) : value;
}) })
.toString() .toString();
}) })
.join('\n') .join('\n');
} }
export function GetGuardColor(level: number | null | undefined): string { export function GetGuardColor(level: number | null | undefined): string {
if (level) { if (level) {
switch (level) { switch (level) {
case 1: { case 1: {
return 'rgb(122, 4, 35)' return 'rgb(122, 4, 35)';
} }
case 2: { case 2: {
return 'rgb(157, 155, 255)' return 'rgb(157, 155, 255)';
} }
case 3: { case 3: {
return 'rgb(104, 136, 241)' return 'rgb(104, 136, 241)';
} }
} }
} }
return '' return '';
} }
export function downloadImage(imageSrc: string, filename: string) { export function downloadImage(imageSrc: string, filename: string) {
const image = new Image() const image = new Image();
image.crossOrigin = 'Anonymous' // This might be needed depending on the image's server image.crossOrigin = 'Anonymous'; // This might be needed depending on the image's server
image.onload = () => { image.onload = () => {
const canvas = document.createElement('canvas') const canvas = document.createElement('canvas');
canvas.width = image.width canvas.width = image.width;
canvas.height = image.height canvas.height = image.height;
const ctx = canvas.getContext('2d') const ctx = canvas.getContext('2d');
ctx!.drawImage(image, 0, 0) ctx!.drawImage(image, 0, 0);
canvas.toBlob((blob) => { canvas.toBlob((blob) => {
if (blob) { if (blob) {
const link = document.createElement('a') const link = document.createElement('a');
link.href = URL.createObjectURL(blob) link.href = URL.createObjectURL(blob);
link.download = filename link.download = filename;
document.body.appendChild(link) document.body.appendChild(link);
link.click() link.click();
document.body.removeChild(link) document.body.removeChild(link);
} }
}) // Omitted the 'image/jpeg' to use the original image format }); // Omitted the 'image/jpeg' to use the original image format
} };
image.src = imageSrc image.src = imageSrc;
} }
export function getBase64( export function getBase64(
file: File | undefined | null file: File | undefined | null
): Promise<string | undefined> { ): Promise<string | undefined> {
if (!file) return new Promise((resolve) => resolve(undefined)) if (!file) return new Promise((resolve) => resolve(undefined));
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const reader = new FileReader() const reader = new FileReader();
reader.readAsDataURL(file) reader.readAsDataURL(file);
reader.onload = () => reader.onload = () =>
resolve(reader.result?.toString().split(',')[1] || undefined) resolve(reader.result?.toString().split(',')[1] || undefined);
reader.onerror = (error) => reject(error) reader.onerror = (error) => reject(error);
}) });
} }
export async function getImageUploadModel( export async function getImageUploadModel(
files: UploadFileInfo[] | undefined | null, files: UploadFileInfo[] | undefined | null,
@@ -126,83 +126,83 @@ export async function getImageUploadModel(
const result = { const result = {
existImages: [], existImages: [],
newImagesBase64: [] newImagesBase64: []
} as { existImages: string[]; newImagesBase64: string[] } } as { existImages: string[]; newImagesBase64: string[]; };
if (!files) return result if (!files) return result;
for (let i = 0; i < files.length; i++) { for (let i = 0; i < files.length; i++) {
const file = files[i] const file = files[i];
if ((file.file?.size ?? 0) > maxSize) { if ((file.file?.size ?? 0) > maxSize) {
message.error('文件大小不能超过 ' + maxSize / 1024 / 1024 + 'MB') message.error('文件大小不能超过 ' + maxSize / 1024 / 1024 + 'MB');
return result return result;
} }
if (!file.file) { if (!file.file) {
result.existImages.push(file.id) //用id绝对路径当的文件名 result.existImages.push(file.id); //用id绝对路径当的文件名
} else { } else {
const base64 = await getBase64(file.file) const base64 = await getBase64(file.file);
if (base64) { if (base64) {
result.newImagesBase64.push(base64) result.newImagesBase64.push(base64);
} }
} }
} }
return result return result;
} }
export function getUserAvatarUrl(userId: number | undefined | null) { export function getUserAvatarUrl(userId: number | undefined | null) {
if (!userId) return '' if (!userId) return '';
return VTSURU_API_URL + 'user-face/' + userId return VTSURU_API_URL + 'user-face/' + userId;
} }
export function getOUIdAvatarUrl(ouid: string) { export function getOUIdAvatarUrl(ouid: string) {
return VTSURU_API_URL + 'face/' + ouid return VTSURU_API_URL + 'face/' + ouid;
} }
export class GuidUtils { export class GuidUtils {
// 将数字转换为GUID // 将数字转换为GUID
public static numToGuid(value: number): string { public static numToGuid(value: number): string {
const buffer = new ArrayBuffer(16) const buffer = new ArrayBuffer(16);
const view = new DataView(buffer) const view = new DataView(buffer);
view.setBigUint64(8, BigInt(value)) // 将数字写入后8个字节 view.setBigUint64(8, BigInt(value)); // 将数字写入后8个字节
return GuidUtils.bufferToGuid(buffer) return GuidUtils.bufferToGuid(buffer);
} }
// 检查GUID是否由数字生成 // 检查GUID是否由数字生成
public static isGuidFromUserId(guid: string): boolean { public static isGuidFromUserId(guid: string): boolean {
const buffer = GuidUtils.guidToBuffer(guid) const buffer = GuidUtils.guidToBuffer(guid);
const view = new DataView(buffer) const view = new DataView(buffer);
for (let i = 0; i < 8; i++) { for (let i = 0; i < 8; i++) {
if (view.getUint8(i) !== 0) return false // 检查前8个字节是否为0 if (view.getUint8(i) !== 0) return false; // 检查前8个字节是否为0
} }
return true return true;
} }
// 将GUID转换为数字 // 将GUID转换为数字
public static guidToLong(guid: string): number { public static guidToLong(guid: string): number {
const buffer = GuidUtils.guidToBuffer(guid) const buffer = GuidUtils.guidToBuffer(guid);
const view = new DataView(buffer) const view = new DataView(buffer);
return Number(view.getBigUint64(8)) return Number(view.getBigUint64(8));
} }
// 辅助方法将ArrayBuffer转换为GUID字符串 // 辅助方法将ArrayBuffer转换为GUID字符串
private static bufferToGuid(buffer: ArrayBuffer): string { private static bufferToGuid(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer) const bytes = new Uint8Array(buffer);
const guid = bytes.reduce((str, byte, idx) => { const guid = bytes.reduce((str, byte, idx) => {
const pair = byte.toString(16).padStart(2, '0') const pair = byte.toString(16).padStart(2, '0');
return ( return (
str + str +
pair + pair +
(idx === 3 || idx === 5 || idx === 7 || idx === 9 ? '-' : '') (idx === 3 || idx === 5 || idx === 7 || idx === 9 ? '-' : '')
) );
}, '') }, '');
return guid return guid;
} }
// 辅助方法将GUID字符串转换为ArrayBuffer // 辅助方法将GUID字符串转换为ArrayBuffer
private static guidToBuffer(guid: string): ArrayBuffer { private static guidToBuffer(guid: string): ArrayBuffer {
const hex = guid.replace(/-/g, '') const hex = guid.replace(/-/g, '');
if (hex.length !== 32) throw new Error('Invalid GUID format.') if (hex.length !== 32) throw new Error('Invalid GUID format.');
const buffer = new ArrayBuffer(16) const buffer = new ArrayBuffer(16);
const view = new DataView(buffer) const view = new DataView(buffer);
for (let i = 0; i < 16; i++) { for (let i = 0; i < 16; i++) {
view.setUint8(i, parseInt(hex.substr(i * 2, 2), 16)) view.setUint8(i, parseInt(hex.substr(i * 2, 2), 16));
} }
return buffer return buffer;
} }
} }
export function GetPlayButton(song: SongsInfo) { export function GetPlayButton(song: SongsInfo) {
@@ -218,7 +218,7 @@ export function GetPlayButton(song: SongsInfo) {
color: '#00BBB3', color: '#00BBB3',
ghost: true, ghost: true,
onClick: () => { onClick: () => {
window.open(`http://5sing.kugou.com/bz/${song.id}.html`) window.open(`http://5sing.kugou.com/bz/${song.id}.html`);
}, },
}, },
{ {
@@ -227,7 +227,7 @@ export function GetPlayButton(song: SongsInfo) {
), ),
), ),
default: () => '在5sing打开', default: () => '在5sing打开',
}) });
} }
case SongFrom.Netease: case SongFrom.Netease:
return h(NTooltip, null, { return h(NTooltip, null, {
@@ -239,7 +239,7 @@ export function GetPlayButton(song: SongsInfo) {
color: '#C20C0C', color: '#C20C0C',
ghost: true, ghost: true,
onClick: () => { onClick: () => {
window.open(`https://music.163.com/#/song?id=${song.id}`) window.open(`https://music.163.com/#/song?id=${song.id}`);
}, },
}, },
{ {
@@ -247,7 +247,7 @@ export function GetPlayButton(song: SongsInfo) {
}, },
), ),
default: () => '在网易云打开', default: () => '在网易云打开',
}) });
case SongFrom.Custom: case SongFrom.Custom:
return song.url return song.url
? h(NTooltip, null, { ? h(NTooltip, null, {
@@ -259,7 +259,7 @@ export function GetPlayButton(song: SongsInfo) {
color: '#6b95bd', color: '#6b95bd',
ghost: true, ghost: true,
onClick: () => { onClick: () => {
window.open(song.url) window.open(song.url);
}, },
}, },
{ {
@@ -268,6 +268,33 @@ export function GetPlayButton(song: SongsInfo) {
), ),
default: () => '打开链接', default: () => '打开链接',
}) })
: null : null;
}
}
export function getBrowserName() {
var userAgent = navigator.userAgent;
if (userAgent.indexOf("Opera") > -1 || userAgent.indexOf("OPR") > -1) {
return 'Opera';
}
else if (userAgent.indexOf("compatible") > -1 && userAgent.indexOf("MSIE") > -1) {
return 'IE';
}
else if (userAgent.indexOf("Edge") > -1) {
return 'Edge';
}
else if (userAgent.indexOf("Firefox") > -1) {
return 'Firefox';
}
else if (userAgent.indexOf("Safari") > -1 && userAgent.indexOf("Chrome") == -1) {
return 'Safari';
}
else if (userAgent.indexOf("Chrome") > -1 && userAgent.indexOf("Safari") > -1) {
return 'Chrome';
}
else if (!!window.ActiveXObject || "ActiveXObject" in window) {
return 'IE>=11';
}
else {
return 'Unkonwn';
} }
} }

View File

@@ -54,6 +54,7 @@ export interface EventFetcherStateModel {
export enum EventFetcherType { export enum EventFetcherType {
Application, Application,
OBS, OBS,
Tauri,
Server Server
} }
export interface AccountInfo extends UserInfo { export interface AccountInfo extends UserInfo {

View File

@@ -357,8 +357,8 @@
const minutes = duration.minutes || 0; const seconds = duration.seconds || 0; const minutes = duration.minutes || 0; const seconds = duration.seconds || 0;
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
} }
async function onSwitchDanmakuClientMode(type: 'openlive' | 'direct') { async function onSwitchDanmakuClientMode(type: 'openlive' | 'direct', force: boolean = false) {
if (webfetcher.webfetcherType === type) { if (webfetcher.webfetcherType === type && !force) {
message.info('当前已是该模式'); return; message.info('当前已是该模式'); return;
} }
const noticeRef = window.$notification.info({ const noticeRef = window.$notification.info({
@@ -494,8 +494,7 @@
embedded embedded
style="width: 100%; max-width: 800px;" style="width: 100%; max-width: 800px;"
> >
<NFlex align="center"> <NFlex vertical>
<NText>模式:</NText>
<NRadioGroup <NRadioGroup
v-model:value="settings.settings.useDanmakuClientType" v-model:value="settings.settings.useDanmakuClientType"
:disabled="webfetcher.state === 'connecting'" :disabled="webfetcher.state === 'connecting'"
@@ -519,6 +518,27 @@
</NText> </NText>
</NRadioButton> </NRadioButton>
</NRadioGroup> </NRadioGroup>
<NPopconfirm
type="info"
:disabled="webfetcher.state === 'connecting'"
@positive-click="async () => {
await onSwitchDanmakuClientMode(settings.settings.useDanmakuClientType, true);
message.success('已重启弹幕服务器');
}"
>
<template #trigger>
<NButton
type="error"
style="max-width: 150px;"
:disabled="webfetcher.state === 'connecting'"
>
强制重启弹幕客户端
</NButton>
</template>
<template #default>
确定要强制重启弹幕服务器吗?
</template>
</NPopconfirm>
</NFlex> </NFlex>
</NCard> </NCard>
@@ -789,6 +809,7 @@
bordered bordered
:columns="2" :columns="2"
size="small" size="small"
style="overflow-x: auto;"
> >
<NDescriptionsItem label="启动时间"> <NDescriptionsItem label="启动时间">
<NIcon :component="TimeOutline" /> {{ formattedStartedAt }} <NIcon :component="TimeOutline" /> {{ formattedStartedAt }}
@@ -800,6 +821,7 @@
<NFlex <NFlex
align="center" align="center"
size="small" size="small"
:wrap="false"
> >
<NTag <NTag
:type="signalRStateType" :type="signalRStateType"
@@ -807,8 +829,8 @@
> >
{{ signalRStateText }} {{ signalRStateText }}
</NTag> </NTag>
<NEllipsis style="max-width: 200px;"> <NEllipsis style="max-width: 150px;">
{{ webfetcher.signalRClient?.connectionId ?? 'N/A' }} {{ webfetcher.signalRId ?? 'N/A' }}
</NEllipsis> </NEllipsis>
</NFlex> </NFlex>
</NDescriptionsItem> </NDescriptionsItem>
@@ -816,6 +838,7 @@
<NFlex <NFlex
align="center" align="center"
size="small" size="small"
:wrap="false"
> >
<NTag <NTag
:type="danmakuClientStateType" :type="danmakuClientStateType"
@@ -823,7 +846,7 @@
> >
{{ danmakuClientStateText }} {{ danmakuClientStateText }}
</NTag> </NTag>
<NEllipsis style="max-width: 200px;"> <NEllipsis style="max-width: 150px;">
{{ webfetcher.danmakuServerUrl ?? 'N/A' }} {{ webfetcher.danmakuServerUrl ?? 'N/A' }}
</NEllipsis> <!-- Assuming this is exposed --> </NEllipsis> <!-- Assuming this is exposed -->
</NFlex> </NFlex>
@@ -968,10 +991,6 @@
<NStatistic label="会话事件总数"> <NStatistic label="会话事件总数">
<NIcon :component="BarChartOutline" /> {{ webfetcher.sessionEventCount?.toLocaleString() ?? 0 }} <NIcon :component="BarChartOutline" /> {{ webfetcher.sessionEventCount?.toLocaleString() ?? 0 }}
</NStatistic> </NStatistic>
<NStatistic label="数据上传">
<NIcon :component="DownloadOutline" /> 成功: {{ webfetcher.successfulUploads ?? 0 }} / 失败:
{{ webfetcher.failedUploads ?? 0 }} <!-- Assuming exposed -->
</NStatistic>
<NStatistic label="已发送"> <NStatistic label="已发送">
{{ ((webfetcher.bytesSentSession ?? 0) / 1024).toFixed(2) }} KB <!-- Assuming exposed --> {{ ((webfetcher.bytesSentSession ?? 0) / 1024).toFixed(2) }} KB <!-- Assuming exposed -->
</NStatistic> </NStatistic>

View File

@@ -17,6 +17,7 @@
import WindowBar from './WindowBar.vue'; import WindowBar from './WindowBar.vue';
import { initAll, OnClientUnmounted } from './data/initialize'; import { initAll, OnClientUnmounted } from './data/initialize';
import { CloudArchive24Filled, Settings24Filled } from '@vicons/fluent'; import { CloudArchive24Filled, Settings24Filled } from '@vicons/fluent';
import { isTauri } from '@/data/constants';
// --- 响应式状态 --- // --- 响应式状态 ---
@@ -68,7 +69,7 @@ import { CloudArchive24Filled, Settings24Filled } from '@vicons/fluent';
window.$message.success('登陆成功'); window.$message.success('登陆成功');
ACCOUNT.value = result; // 更新全局账户信息 ACCOUNT.value = result; // 更新全局账户信息
// isLoadingAccount.value = false; // 状态在 finally 中统一处理 // isLoadingAccount.value = false; // 状态在 finally 中统一处理
initAll(); // 初始化 WebFetcher //initAll(false); // 初始化 WebFetcher
} }
} }
} catch (error) { } catch (error) {
@@ -116,6 +117,14 @@ import { CloudArchive24Filled, Settings24Filled } from '@vicons/fluent';
</script> </script>
<template> <template>
<NAlert
v-if="!isTauri"
type="error"
title="错误"
>
此应用在 Tauri 环境外运行无法使用
</NAlert>
<template v-else>
<WindowBar /> <WindowBar />
<div <div
@@ -189,7 +198,7 @@ import { CloudArchive24Filled, Settings24Filled } from '@vicons/fluent';
v-else v-else
has-sider has-sider
class="main-layout" class="main-layout"
@vue:mounted="initAll()" @vue:mounted="initAll(true)"
> >
<NLayoutSider <NLayoutSider
width="200" width="200"
@@ -270,6 +279,7 @@ import { CloudArchive24Filled, Settings24Filled } from '@vicons/fluent';
</div> </div>
</NLayoutContent> </NLayoutContent>
</NLayout> </NLayout>
</template>
</template> </template>
<style scoped> <style scoped>

View File

@@ -15,11 +15,13 @@ import {
NFormItem, // Added NFormItem NFormItem, // Added NFormItem
NAlert, NAlert,
NCheckboxGroup, NCheckboxGroup,
NCheckbox, // Added NAlert for error messages NCheckbox,
NDivider, // Added NAlert for error messages
} from 'naive-ui'; } from 'naive-ui';
import type { MenuOption } from 'naive-ui'; // Import MenuOption type import type { MenuOption } from 'naive-ui'; // Import MenuOption type
import { ThemeType } from '@/api/api-models'; import { ThemeType } from '@/api/api-models';
import { NotificationType, useSettings } from './store/useSettings'; import { NotificationType, useSettings } from './store/useSettings';
import { getVersion } from '@tauri-apps/api/app';
// --- State --- // --- State ---
@@ -28,6 +30,7 @@ const isLoading = ref(true); // Loading state for initial fetch
const errorMsg = ref<string | null>(null); // Error message state const errorMsg = ref<string | null>(null); // Error message state
const setting = useSettings(); const setting = useSettings();
const currentVersion = await getVersion(); // Fetch current version on mount
// Navigation // Navigation
const navOptions: MenuOption[] = [ // Explicitly typed const navOptions: MenuOption[] = [ // Explicitly typed
@@ -73,10 +76,10 @@ watch(isStartOnBoot, async (newValue, oldValue) => {
try { try {
if (newValue) { if (newValue) {
await enable(); await enable();
window.$message.success('已启用开机启动'); //window.$message.success('已启用开机启动');
} else { } else {
await disable(); await disable();
window.$message.success('已禁用开机启动'); // Provide feedback for disabling too //window.$message.success('已禁用开机启动'); // Provide feedback for disabling too
} }
} catch (err) { } catch (err) {
console.error("Failed to update autostart status:", err); console.error("Failed to update autostart status:", err);
@@ -170,14 +173,16 @@ watch(minimizeOnStart, (newValue) => {
<template v-if="currentTab === 'general'"> <template v-if="currentTab === 'general'">
<NSpace <NSpace
vertical vertical
size="large"
> >
<NCard <NCard
title="启动" title="启动"
:bordered="false" :bordered="false"
> >
<NSpace vertical> <NFlex
<NFormItem vertical
align="start"
>
<LabelItem
label="开机时启动应用" label="开机时启动应用"
label-placement="left" label-placement="left"
> >
@@ -185,15 +190,19 @@ watch(minimizeOnStart, (newValue) => {
v-model:value="isStartOnBoot" v-model:value="isStartOnBoot"
:disabled="isLoading" :disabled="isLoading"
/> />
</NFormItem> </LabelItem>
<NFormItem <LabelItem
v-if="isStartOnBoot"
label="启动后最小化到托盘" label="启动后最小化到托盘"
label-placement="left" label-placement="left"
> >
<NSwitch v-model:value="minimizeOnStart" /> <NSwitch
v-model:value="setting.settings.bootAsMinimized"
@update:value="setting.save()"
/>
<!-- Add appropriate logic/state for this --> <!-- Add appropriate logic/state for this -->
</NFormItem> </LabelItem>
</NSpace> </NFlex>
</NCard> </NCard>
<NCard <NCard
@@ -230,6 +239,10 @@ watch(minimizeOnStart, (newValue) => {
title="通知设置" title="通知设置"
:bordered="false" :bordered="false"
> >
<NAlert type="warning">
暂未完成
</NAlert>
<NDivider />
<NSpace vertical> <NSpace vertical>
<NCheckbox <NCheckbox
v-model:checked="setting.settings.enableNotification" v-model:checked="setting.settings.enableNotification"
@@ -284,8 +297,42 @@ watch(minimizeOnStart, (newValue) => {
@click="$router.push({name: 'client-test'})" @click="$router.push({name: 'client-test'})"
/> />
</template> </template>
<p>应用名称: Your App Name</p> <p>VTsuruEventFetcher Tauri</p>
<p>版本: 1.0.0</p> <p>版本: {{ currentVersion }}</p>
<p>
作者:
<NButton
tag="a"
href="https://space.bilibili.com/10021741"
target="_blank"
type="info"
text
>
Megghy
</NButton>
</p>
<p>
储存库:
<NButton
tag="a"
href="https://github.com/Megghy/vtsuru.live/tree/master/src/client"
target="_blank"
type="info"
text
>
界面/逻辑
</NButton>
<NDivider vertical />
<NButton
tag="a"
href="https://github.com/Megghy/vtsuru-fetcher-client"
target="_blank"
type="info"
text
>
Tauri 客户端
</NButton>
</p>
<!-- Add more about info --> <!-- Add more about info -->
</NCard> </NCard>
</template> </template>
@@ -312,7 +359,9 @@ watch(minimizeOnStart, (newValue) => {
.fade-leave-to { .fade-leave-to {
opacity: 0; opacity: 0;
} }
.label-item {
height: 20px;
}
/* Optional: Adjust NFormItem label alignment if needed */ /* Optional: Adjust NFormItem label alignment if needed */
/* :deep(.n-form-item-label) { */ /* :deep(.n-form-item-label) { */
/* Add custom styles */ /* Add custom styles */

View File

@@ -7,7 +7,7 @@ import { getBuvid, getRoomKey } from "./utils";
import { initInfo } from "./info"; import { initInfo } from "./info";
import { TrayIcon, TrayIconOptions } from '@tauri-apps/api/tray'; import { TrayIcon, TrayIconOptions } from '@tauri-apps/api/tray';
import { Menu } from "@tauri-apps/api/menu"; import { Menu } from "@tauri-apps/api/menu";
import { getCurrentWindow } from "@tauri-apps/api/window"; import { getCurrentWindow, PhysicalSize } from "@tauri-apps/api/window";
import { import {
isPermissionGranted, isPermissionGranted,
onAction, onAction,
@@ -15,7 +15,7 @@ import {
sendNotification, sendNotification,
} from '@tauri-apps/plugin-notification'; } from '@tauri-apps/plugin-notification';
import { openUrl } from "@tauri-apps/plugin-opener"; import { openUrl } from "@tauri-apps/plugin-opener";
import { CN_HOST } from "@/data/constants"; import { CN_HOST, isDev } from "@/data/constants";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { check } from '@tauri-apps/plugin-updater'; import { check } from '@tauri-apps/plugin-updater';
import { relaunch } from '@tauri-apps/plugin-process'; import { relaunch } from '@tauri-apps/plugin-process';
@@ -24,10 +24,17 @@ const accountInfo = useAccount();
export const clientInited = ref(false); export const clientInited = ref(false);
let tray: TrayIcon; let tray: TrayIcon;
export async function initAll() { export async function initAll(isOnBoot: boolean) {
const setting = useSettings();
if (clientInited.value) { if (clientInited.value) {
return; return;
} }
if (isOnBoot) {
if (setting.settings.bootAsMinimized && !isDev) {
const appWindow = getCurrentWindow();
appWindow.hide();
}
}
let permissionGranted = await isPermissionGranted(); let permissionGranted = await isPermissionGranted();
checkUpdate(); checkUpdate();
@@ -102,10 +109,10 @@ export async function initAll() {
} }
} }
}; };
tray = await TrayIcon.new(options); tray = await TrayIcon.new(options);
appWindow.setMinSize(new PhysicalSize(720, 480));
clientInited.value = true; clientInited.value = true;
} }
export function OnClientUnmounted() { export function OnClientUnmounted() {
@@ -209,7 +216,7 @@ export async function initOpenLive() {
} }
return reuslt; return reuslt;
} }
function initNotificationHandler(){ function initNotificationHandler() {
onAction((event) => { onAction((event) => {
if (event.extra?.type === 'question-box') { if (event.extra?.type === 'question-box') {
openUrl(CN_HOST + '/manage/question-box'); openUrl(CN_HOST + '/manage/question-box');

View File

@@ -4,7 +4,7 @@ import { QueryPostAPI } from '@/api/query';
import { OPEN_LIVE_API_URL } from '@/data/constants'; import { OPEN_LIVE_API_URL } from '@/data/constants';
import { error } from '@tauri-apps/plugin-log'; import { error } from '@tauri-apps/plugin-log';
export async function QueryBiliAPI(url: string, method: string = 'GET', cookie: string = '') { export async function QueryBiliAPI(url: string, method: string = 'GET', cookie: string = '', useCookie: boolean = true) {
const u = new URL(url); const u = new URL(url);
return fetch(url, { return fetch(url, {
method: method, method: method,
@@ -12,8 +12,7 @@ export async function QueryBiliAPI(url: string, method: string = 'GET', cookie:
'User-Agent': 'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36',
Origin: '', Origin: '',
Cookie: cookie || (await useBiliCookie().getBiliCookie()) || '', Cookie: useCookie ? (cookie || (await useBiliCookie().getBiliCookie()) || '') : ''
'Upgrade-Insecure-Requests': '1',
}, },
}); });
} }

View File

@@ -7,6 +7,7 @@ export type NotificationSettings = {
export type VTsuruClientSettings = { export type VTsuruClientSettings = {
useDanmakuClientType: 'openlive' | 'direct'; useDanmakuClientType: 'openlive' | 'direct';
fallbackToOpenLive: boolean; fallbackToOpenLive: boolean;
bootAsMinimized: boolean;
danmakuHistorySize: number; danmakuHistorySize: number;
loginType: 'qrcode' | 'cookiecloud' loginType: 'qrcode' | 'cookiecloud'
@@ -20,6 +21,7 @@ export const useSettings = defineStore('settings', () => {
const defaultSettings: VTsuruClientSettings = { const defaultSettings: VTsuruClientSettings = {
useDanmakuClientType: 'openlive', useDanmakuClientType: 'openlive',
fallbackToOpenLive: true, fallbackToOpenLive: true,
bootAsMinimized: true,
danmakuHistorySize: 100, danmakuHistorySize: 100,
loginType: 'qrcode', loginType: 'qrcode',

1
src/components.d.ts vendored
View File

@@ -15,6 +15,7 @@ declare module 'vue' {
EventFetcherAlert: typeof import('./components/EventFetcherAlert.vue')['default'] EventFetcherAlert: typeof import('./components/EventFetcherAlert.vue')['default']
EventFetcherStatusCard: typeof import('./components/EventFetcherStatusCard.vue')['default'] EventFetcherStatusCard: typeof import('./components/EventFetcherStatusCard.vue')['default']
FeedbackItem: typeof import('./components/FeedbackItem.vue')['default'] FeedbackItem: typeof import('./components/FeedbackItem.vue')['default']
LabelItem: typeof import('./components/LabelItem.vue')['default']
LiveInfoContainer: typeof import('./components/LiveInfoContainer.vue')['default'] LiveInfoContainer: typeof import('./components/LiveInfoContainer.vue')['default']
MonacoEditorComponent: typeof import('./components/MonacoEditorComponent.vue')['default'] MonacoEditorComponent: typeof import('./components/MonacoEditorComponent.vue')['default']
NAlert: typeof import('naive-ui')['NAlert'] NAlert: typeof import('naive-ui')['NAlert']

View File

@@ -6,7 +6,21 @@ import { NAlert, NButton, NDivider, NIcon, NTag, NText, NTooltip } from 'naive-u
import { computed } from 'vue' import { computed } from 'vue'
const accountInfo = useAccount() const accountInfo = useAccount()
const state = accountInfo.value?.eventFetcherState const state = accountInfo.value?.eventFetcherState
const eventFetcherVersionName = computed(() => {
if (state?.type == EventFetcherType.OBS) {
return 'OBS/网页端'
} else if (state?.type == EventFetcherType.Application) {
return '控制台应用'
} else if (state?.type == EventFetcherType.Server) {
return '本站监听 (已删除)'
} else if (state?.type == EventFetcherType.Tauri) {
return 'Tauri 应用'
} else {
return state?.version ?? '未知'
}
})
const status = computed(() => { const status = computed(() => {
if (state.online == true) { if (state.online == true) {
@@ -57,7 +71,7 @@ const status = computed(() => {
:color="{ borderColor: 'white', textColor: 'white', color: '#4b6159' }" :color="{ borderColor: 'white', textColor: 'white', color: '#4b6159' }"
> >
<NIcon :component="FlashCheckmark16Filled" /> <NIcon :component="FlashCheckmark16Filled" />
{{ state.type == EventFetcherType.OBS ? 'OBS/网页端' : state.version ?? '未知' }} {{ eventFetcherVersionName }}
</NTag> </NTag>
</template> </template>
你所使用的版本 你所使用的版本

View File

@@ -0,0 +1,24 @@
<template>
<span class="label-item">
<p>
{{ label }}
</p>
<slot />
</span>
</template>
<script lang="ts" setup>
defineProps<{
label: string;
}>();
</script>
<style scoped>
.label-item {
display: flex;
align-items: center;
justify-content: center;
padding: 0.5rem;
gap: 0.5rem;
}
</style>

View File

@@ -1,19 +1,21 @@
import { defineStore } from 'pinia'; import { cookie, useAccount } from '@/api/account';
import { ref, computed, shallowRef } from 'vue'; // shallowRef 用于非深度响应对象 import { getEventType, recordEvent, streamingInfo } from '@/client/data/info';
import { useLocalStorage } from '@vueuse/core'; import { BASE_HUB_URL, isDev, isTauri } from '@/data/constants';
import { useRoute } from 'vue-router'; import BaseDanmakuClient from '@/data/DanmakuClients/BaseDanmakuClient';
import { compress } from 'brotli-compress'; import DirectClient, { DirectClientAuthInfo } from '@/data/DanmakuClients/DirectClient';
import { format } from 'date-fns'; import OpenLiveClient from '@/data/DanmakuClients/OpenLiveClient';
import * as signalR from '@microsoft/signalr'; import * as signalR from '@microsoft/signalr';
import * as msgpack from '@microsoft/signalr-protocol-msgpack'; import * as msgpack from '@microsoft/signalr-protocol-msgpack';
import { cookie, useAccount } from '@/api/account'; // 假设账户信息路径 import { defineStore } from 'pinia';
import { BASE_HUB_URL, isDev } from '@/data/constants'; // 假设常量路径 import { computed, ref, shallowRef } from 'vue'; // shallowRef 用于非深度响应对象
import BaseDanmakuClient from '@/data/DanmakuClients/BaseDanmakuClient'; // 假设弹幕客户端基类路径 import { useRoute } from 'vue-router';
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 { useWebRTC } from './useRTC'; 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', () => { export const useWebFetcher = defineStore('WebFetcher', () => {
const route = useRoute(); const route = useRoute();
@@ -24,11 +26,14 @@ export const useWebFetcher = defineStore('WebFetcher', () => {
const state = ref<'disconnected' | 'connecting' | 'connected'>('disconnected'); // SignalR 连接状态 const state = ref<'disconnected' | 'connecting' | 'connected'>('disconnected'); // SignalR 连接状态
const startedAt = ref<Date>(); // 本次启动时间 const startedAt = ref<Date>(); // 本次启动时间
const signalRClient = shallowRef<signalR.HubConnection>(); // SignalR 客户端实例 (浅响应) const signalRClient = shallowRef<signalR.HubConnection>(); // SignalR 客户端实例 (浅响应)
const signalRId = ref<string>(); // SignalR 连接 ID
const client = shallowRef<BaseDanmakuClient>(); // 弹幕客户端实例 (浅响应) const client = shallowRef<BaseDanmakuClient>(); // 弹幕客户端实例 (浅响应)
let timer: any; // 事件发送定时器 let timer: any; // 事件发送定时器
let disconnectedByServer = false; let disconnectedByServer = false;
let isFromClient = false; // 是否由Tauri客户端启动 let isFromClient = false; // 是否由Tauri客户端启动
// --- 新增: 详细状态与信息 --- // --- 新增: 详细状态与信息 ---
/** 弹幕客户端内部状态 */ /** 弹幕客户端内部状态 */
const danmakuClientState = ref<'stopped' | 'connecting' | 'connected'>('stopped'); // 更详细的弹幕客户端状态 const danmakuClientState = ref<'stopped' | 'connecting' | 'connected'>('stopped'); // 更详细的弹幕客户端状态
@@ -52,6 +57,7 @@ export const useWebFetcher = defineStore('WebFetcher', () => {
const failedUploads = ref(0); const failedUploads = ref(0);
/** 本次会话发送的总字节数 (压缩后) */ /** 本次会话发送的总字节数 (压缩后) */
const bytesSentSession = ref(0); const bytesSentSession = ref(0);
let zstd: ZstdCodec | undefined = undefined; // Zstd 编码器实例 (如果需要压缩)
const prefix = computed(() => isFromClient ? '[web-fetcher-iframe] ' : '[web-fetcher] '); const prefix = computed(() => isFromClient ? '[web-fetcher-iframe] ' : '[web-fetcher] ');
@@ -64,9 +70,15 @@ export const useWebFetcher = defineStore('WebFetcher', () => {
_isFromClient: boolean = false _isFromClient: boolean = false
): Promise<{ success: boolean; message: string; }> { ): Promise<{ success: boolean; message: string; }> {
if (state.value === 'connected' || state.value === 'connecting') { if (state.value === 'connected' || state.value === 'connecting') {
logInfo(prefix.value + '已经启动,无需重复启动'); console.log(prefix.value + '已经启动,无需重复启动');
return { success: true, message: '已启动' }; return { success: true, message: '已启动' };
} }
try {
zstd ??= await ZstdInit();
} catch (error) {
console.error(prefix.value + '当前浏览器不支持zstd压缩, 回退到原始数据传输');
}
webfetcherType.value = type; // 设置弹幕客户端类型 webfetcherType.value = type; // 设置弹幕客户端类型
// 重置会话统计数据 // 重置会话统计数据
resetSessionStats(); resetSessionStats();
@@ -76,9 +88,9 @@ export const useWebFetcher = defineStore('WebFetcher', () => {
// 使用 navigator.locks 确保同一时间只有一个 Start 操作执行 // 使用 navigator.locks 确保同一时间只有一个 Start 操作执行
const result = await navigator.locks.request('webFetcherStartLock', async () => { const result = await navigator.locks.request('webFetcherStartLock', async () => {
logInfo(prefix.value + '开始启动...'); console.log(prefix.value + '开始启动...');
while (!(await connectSignalR())) { while (!(await connectSignalR())) {
logInfo(prefix.value + '连接 SignalR 失败, 5秒后重试'); console.log(prefix.value + '连接 SignalR 失败, 5秒后重试');
await new Promise((resolve) => setTimeout(resolve, 5000)); await new Promise((resolve) => setTimeout(resolve, 5000));
// 如果用户手动停止,则退出重试循环 // 如果用户手动停止,则退出重试循环
if (state.value === 'disconnected') return { success: false, message: '用户手动停止' }; if (state.value === 'disconnected') return { success: false, message: '用户手动停止' };
@@ -86,7 +98,7 @@ export const useWebFetcher = defineStore('WebFetcher', () => {
let danmakuResult = await connectDanmakuClient(type, directAuthInfo); let danmakuResult = await connectDanmakuClient(type, directAuthInfo);
while (!danmakuResult?.success) { while (!danmakuResult?.success) {
logInfo(prefix.value + '弹幕客户端启动失败, 5秒后重试'); console.log(prefix.value + '弹幕客户端启动失败, 5秒后重试');
await new Promise((resolve) => setTimeout(resolve, 5000)); await new Promise((resolve) => setTimeout(resolve, 5000));
// 如果用户手动停止,则退出重试循环 // 如果用户手动停止,则退出重试循环
if (state.value === 'disconnected') return { success: false, message: '用户手动停止' }; if (state.value === 'disconnected') return { success: false, message: '用户手动停止' };
@@ -96,7 +108,7 @@ export const useWebFetcher = defineStore('WebFetcher', () => {
// 只有在两个连接都成功后才设置为 connected // 只有在两个连接都成功后才设置为 connected
state.value = 'connected'; state.value = 'connected';
disconnectedByServer = false; disconnectedByServer = false;
logInfo(prefix.value + '启动成功'); console.log(prefix.value + '启动成功');
return { success: true, message: '启动成功' }; return { success: true, message: '启动成功' };
}); });
@@ -115,7 +127,7 @@ export const useWebFetcher = defineStore('WebFetcher', () => {
function Stop() { function Stop() {
if (state.value === 'disconnected') return; if (state.value === 'disconnected') return;
logInfo(prefix.value + '正在停止...'); console.log(prefix.value + '正在停止...');
state.value = 'disconnected'; // 立即设置状态,防止重连逻辑触发 state.value = 'disconnected'; // 立即设置状态,防止重连逻辑触发
// 清理定时器 // 清理定时器
@@ -137,7 +149,7 @@ export const useWebFetcher = defineStore('WebFetcher', () => {
events.length = 0; // 清空事件队列 events.length = 0; // 清空事件队列
// resetSessionStats(); // 会话统计在下次 Start 时重置 // resetSessionStats(); // 会话统计在下次 Start 时重置
logInfo(prefix.value + '已停止'); console.log(prefix.value + '已停止');
} }
/** 重置会话统计数据 */ /** 重置会话统计数据 */
@@ -157,11 +169,11 @@ export const useWebFetcher = defineStore('WebFetcher', () => {
directConnectInfo?: DirectClientAuthInfo directConnectInfo?: DirectClientAuthInfo
) { ) {
if (client.value?.state === 'connected' || client.value?.state === 'connecting') { if (client.value?.state === 'connected' || client.value?.state === 'connecting') {
logInfo(prefix.value + '弹幕客户端已连接或正在连接'); console.log(prefix.value + '弹幕客户端已连接或正在连接');
return { success: true, message: '弹幕客户端已启动' }; return { success: true, message: '弹幕客户端已启动' };
} }
logInfo(prefix.value + '正在连接弹幕客户端...'); console.log(prefix.value + '正在连接弹幕客户端...');
danmakuClientState.value = 'connecting'; danmakuClientState.value = 'connecting';
// 如果实例存在但已停止,先清理 // 如果实例存在但已停止,先清理
@@ -176,7 +188,7 @@ export const useWebFetcher = defineStore('WebFetcher', () => {
} else { } else {
if (!directConnectInfo) { if (!directConnectInfo) {
danmakuClientState.value = 'stopped'; danmakuClientState.value = 'stopped';
logError(prefix.value + '未提供直连弹幕客户端认证信息'); console.error(prefix.value + '未提供直连弹幕客户端认证信息');
return { success: false, message: '未提供弹幕客户端认证信息' }; return { success: false, message: '未提供弹幕客户端认证信息' };
} }
client.value = new DirectClient(directConnectInfo); client.value = new DirectClient(directConnectInfo);
@@ -193,13 +205,13 @@ export const useWebFetcher = defineStore('WebFetcher', () => {
const result = await client.value?.Start(); const result = await client.value?.Start();
if (result?.success) { if (result?.success) {
logInfo(prefix.value + '弹幕客户端连接成功, 开始监听弹幕'); console.log(prefix.value + '弹幕客户端连接成功, 开始监听弹幕');
danmakuClientState.value = 'connected'; // 明确设置状态 danmakuClientState.value = 'connected'; // 明确设置状态
danmakuServerUrl.value = client.value.serverUrl; // 获取服务器地址 danmakuServerUrl.value = client.value.serverUrl; // 获取服务器地址
// 启动事件发送定时器 (如果之前没有启动) // 启动事件发送定时器 (如果之前没有启动)
timer ??= setInterval(sendEvents, 1500); // 每 1.5 秒尝试发送一次事件 timer ??= setInterval(sendEvents, 1500); // 每 1.5 秒尝试发送一次事件
} else { } else {
logError(prefix.value + '弹幕客户端启动失败: ' + result?.message); console.error(prefix.value + '弹幕客户端启动失败: ' + result?.message);
danmakuClientState.value = 'stopped'; danmakuClientState.value = 'stopped';
danmakuServerUrl.value = undefined; danmakuServerUrl.value = undefined;
client.value = undefined; // 启动失败,清理实例,下次会重建 client.value = undefined; // 启动失败,清理实例,下次会重建
@@ -212,11 +224,11 @@ export const useWebFetcher = defineStore('WebFetcher', () => {
*/ */
async function connectSignalR() { async function connectSignalR() {
if (signalRClient.value && signalRClient.value.state !== signalR.HubConnectionState.Disconnected) { if (signalRClient.value && signalRClient.value.state !== signalR.HubConnectionState.Disconnected) {
logInfo(prefix.value + "SignalR 已连接或正在连接"); console.log(prefix.value + "SignalR 已连接或正在连接");
return true; return true;
} }
logInfo(prefix.value + '正在连接到 vtsuru 服务器...'); console.log(prefix.value + '正在连接到 vtsuru 服务器...');
const connection = new signalR.HubConnectionBuilder() const connection = new signalR.HubConnectionBuilder()
.withUrl(BASE_HUB_URL + 'web-fetcher?token=' + (route.query.token ?? account.value.token), { // 使用 account.token .withUrl(BASE_HUB_URL + 'web-fetcher?token=' + (route.query.token ?? account.value.token), { // 使用 account.token
headers: { Authorization: `Bearer ${cookie.value?.cookie}` }, headers: { Authorization: `Bearer ${cookie.value?.cookie}` },
@@ -229,71 +241,98 @@ export const useWebFetcher = defineStore('WebFetcher', () => {
// --- SignalR 事件监听 --- // --- SignalR 事件监听 ---
connection.onreconnecting(error => { connection.onreconnecting(error => {
logInfo(prefix.value + `与服务器断开,正在尝试重连... ${error?.message || ''}`); console.log(prefix.value + `与服务器断开,正在尝试重连... ${error?.message || ''}`);
state.value = 'connecting'; // 更新状态为连接中 state.value = 'connecting'; // 更新状态为连接中
signalRConnectionId.value = undefined; // 连接断开ID失效 signalRConnectionId.value = undefined; // 连接断开ID失效
}); });
connection.onreconnected(connectionId => { connection.onreconnected(async connectionId => {
logInfo(prefix.value + `与服务器重新连接成功! ConnectionId: ${connectionId}`); console.log(prefix.value + `与服务器重新连接成功! ConnectionId: ${connectionId}`);
signalRConnectionId.value = connectionId ?? undefined; signalRConnectionId.value = connectionId ?? undefined;
state.value = 'connected'; // 更新状态为已连接 state.value = 'connected'; // 更新状态为已连接
// 重连成功后可能需要重新发送标识 signalRId.value = connectionId ?? await sendSelfInfo(connection); // 更新连接ID
if (isFromClient) { connection.send('Reconnected').catch(err => console.error(prefix.value + "Send Reconnected failed: " + err));
connection.send('SetAsVTsuruClient').catch(err => logError(prefix.value + "Send SetAsVTsuruClient failed: " + err));
}
connection.send('Reconnected').catch(err => logError(prefix.value + "Send Reconnected failed: " + err));
}); });
connection.onclose(async (error) => { connection.onclose(async (error) => {
// 只有在不是由 Stop() 或服务器明确要求断开时才记录错误并尝试独立重连(虽然 withAutomaticReconnect 应该处理) // 只有在不是由 Stop() 或服务器明确要求断开时才记录错误并尝试独立重连(虽然 withAutomaticReconnect 应该处理)
if (state.value !== 'disconnected' && !disconnectedByServer) { if (state.value !== 'disconnected' && !disconnectedByServer) {
logError(prefix.value + `与服务器连接关闭: ${error?.message || '未知原因'}. 自动重连将处理.`); console.error(prefix.value + `与服务器连接关闭: ${error?.message || '未知原因'}. 自动重连将处理.`);
state.value = 'connecting'; // 标记为连接中,等待自动重连 state.value = 'connecting'; // 标记为连接中,等待自动重连
signalRConnectionId.value = undefined; signalRConnectionId.value = undefined;
// withAutomaticReconnect 会处理重连,这里不需要手动调用 reconnect // withAutomaticReconnect 会处理重连,这里不需要手动调用 reconnect
} else if (disconnectedByServer) { } else if (disconnectedByServer) {
logInfo(prefix.value + `连接已被服务器关闭.`); console.log(prefix.value + `连接已被服务器关闭.`);
Stop(); // 服务器要求断开,则彻底停止 Stop(); // 服务器要求断开,则彻底停止
} else { } else {
logInfo(prefix.value + `连接已手动关闭.`); console.log(prefix.value + `连接已手动关闭.`);
} }
}); });
connection.on('Disconnect', (reason: unknown) => { connection.on('Disconnect', (reason: unknown) => {
logInfo(prefix.value + '被服务器断开连接: ' + reason); console.log(prefix.value + '被服务器断开连接: ' + reason);
disconnectedByServer = true; // 标记是服务器主动断开 disconnectedByServer = true; // 标记是服务器主动断开
Stop(); // 服务器要求断开,调用 Stop 清理所有资源 Stop(); // 服务器要求断开,调用 Stop 清理所有资源
}); });
connection.on('Request', async (url: string, method: string, body: string, useCookie: boolean) => onRequest(url, method, body, useCookie));
// --- 尝试启动连接 --- // --- 尝试启动连接 ---
try { try {
await connection.start(); await connection.start();
logInfo(prefix.value + '已连接到 vtsuru 服务器, ConnectionId: ' + connection.connectionId);
console.log(prefix.value + '已连接到 vtsuru 服务器, ConnectionId: ' + connection.connectionId); // 调试输出连接状态 console.log(prefix.value + '已连接到 vtsuru 服务器, ConnectionId: ' + connection.connectionId); // 调试输出连接状态
signalRConnectionId.value = connection.connectionId ?? undefined; // 保存连接ID signalRConnectionId.value = connection.connectionId ?? undefined; // 保存连接ID
signalRId.value = await sendSelfInfo(connection); // 发送客户端信息
await connection.send('Finished'); // 通知服务器已准备好 await connection.send('Finished'); // 通知服务器已准备好
if (isFromClient) {
await connection.send('SetAsVTsuruClient'); // 如果是客户端,发送标识
}
signalRClient.value = connection; // 保存实例 signalRClient.value = connection; // 保存实例
// state.value = 'connected'; // 状态将在 Start 函数末尾统一设置 // state.value = 'connected'; // 状态将在 Start 函数末尾统一设置
return true; return true;
} catch (e) { } catch (e) {
logError(prefix.value + '无法连接到 vtsuru 服务器: ' + e); console.error(prefix.value + '无法连接到 vtsuru 服务器: ' + e);
signalRConnectionId.value = undefined; signalRConnectionId.value = undefined;
signalRClient.value = undefined; signalRClient.value = undefined;
// state.value = 'disconnected'; // 保持 connecting 或由 Start 控制 // state.value = 'disconnected'; // 保持 connecting 或由 Start 控制
return false; 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 存在时,此函数通常不需要手动调用 // async function reconnect() { // withAutomaticReconnect 存在时,此函数通常不需要手动调用
// if (disconnectedByServer || state.value === 'disconnected') return; // if (disconnectedByServer || state.value === 'disconnected') return;
// logInfo(prefix.value + '尝试手动重连...'); // console.log(prefix.value + '尝试手动重连...');
// try { // try {
// await signalRClient.value?.start(); // await signalRClient.value?.start();
// logInfo(prefix.value + '手动重连成功'); // console.log(prefix.value + '手动重连成功');
// signalRConnectionId.value = signalRClient.value?.connectionId ?? null; // signalRConnectionId.value = signalRClient.value?.connectionId ?? null;
// state.value = 'connected'; // state.value = 'connected';
// if (isFromClient) { // if (isFromClient) {
@@ -301,7 +340,7 @@ export const useWebFetcher = defineStore('WebFetcher', () => {
// } // }
// await signalRClient.value?.send('Reconnected'); // await signalRClient.value?.send('Reconnected');
// } catch (err) { // } catch (err) {
// logError(prefix.value + '手动重连失败: ' + err); // console.error(prefix.value + '手动重连失败: ' + err);
// setTimeout(reconnect, 10000); // 失败后10秒再次尝试 // setTimeout(reconnect, 10000); // 失败后10秒再次尝试
// } // }
// } // }
@@ -351,20 +390,34 @@ export const useWebFetcher = defineStore('WebFetcher', () => {
try { try {
const result = await signalRClient.value.invoke<{ Success: boolean; Message: string; }>( let result: { Success: boolean; Message: string; } = { Success: false, Message: '' };
'UploadEvents', batch, webfetcherType.value === 'direct'? true : false 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) { if (result?.Success) {
events.splice(0, batch.length); // 从队列中移除已成功发送的事件 events.splice(0, batch.length); // 从队列中移除已成功发送的事件
successfulUploads.value++; successfulUploads.value++;
bytesSentSession.value += new TextEncoder().encode(batch.join()).length; bytesSentSession.value += length;
} else { } else {
failedUploads.value++; failedUploads.value++;
logError(prefix.value + '上传弹幕失败: ' + result?.Message); console.error(prefix.value + '上传弹幕失败: ' + result?.Message);
} }
} catch (err) { } catch (err) {
failedUploads.value++; failedUploads.value++;
logError(prefix.value + '发送事件时出错: ' + err); console.error(prefix.value + '发送事件时出错: ' + err);
} }
} }
@@ -379,6 +432,7 @@ export const useWebFetcher = defineStore('WebFetcher', () => {
startedAt, startedAt,
isStreaming: computed(() => streamingInfo.value?.status === 'streaming'), // 从 statistics 模块获取 isStreaming: computed(() => streamingInfo.value?.status === 'streaming'), // 从 statistics 模块获取
webfetcherType, webfetcherType,
signalRId,
// 连接详情 // 连接详情
danmakuClientState, danmakuClientState,