初始化web服务功能

This commit is contained in:
xintaofei
2026-03-25 14:26:26 +08:00
parent ae70f17d2e
commit ac09d3db9e
99 changed files with 3253 additions and 304 deletions

1151
src/lib/api.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,22 @@
import { invoke } from "@tauri-apps/api/core"
import { getTransport } from "./transport"
import { isDesktop } from "./transport"
export async function notifyTurnComplete(
title: string,
body: string
): Promise<void> {
if (!document.hidden) return
await invoke("send_notification", { title, body })
if (isDesktop()) {
await getTransport().call("send_notification", { title, body })
} else {
// Web fallback: Browser Notification API
if (Notification.permission === "granted") {
new Notification(title, { body })
} else if (Notification.permission !== "denied") {
const permission = await Notification.requestPermission()
if (permission === "granted") {
new Notification(title, { body })
}
}
}
}

123
src/lib/platform.ts Normal file
View File

@@ -0,0 +1,123 @@
import { isDesktop, getTransport } from "./transport"
import type { UnsubscribeFn } from "./transport"
/**
* Platform-aware API wrappers for features that differ between
* Tauri desktop and web browser environments.
*/
export { isDesktop }
/**
* Subscribe to backend events.
* Uses Tauri listen() in desktop mode, WebSocket in web mode.
*/
export async function subscribe<T>(
event: string,
handler: (payload: T) => void
): Promise<UnsubscribeFn> {
return getTransport().subscribe(event, handler)
}
/**
* Open a URL in the default browser (desktop) or new tab (web).
*/
export async function openUrl(url: string): Promise<void> {
if (isDesktop()) {
const { openUrl: tauriOpenUrl } = await import(
"@tauri-apps/plugin-opener"
)
await tauriOpenUrl(url)
} else {
window.open(url, "_blank")
}
}
/**
* Open a path in the system file manager (desktop only).
* No-op in web mode.
*/
export async function openPath(path: string): Promise<void> {
if (isDesktop()) {
const { openPath: tauriOpenPath } = await import(
"@tauri-apps/plugin-opener"
)
await tauriOpenPath(path)
}
}
/**
* Reveal a file/directory in the system file manager (desktop only).
* No-op in web mode.
*/
export async function revealItemInDir(path: string): Promise<void> {
if (isDesktop()) {
const { revealItemInDir: tauriReveal } = await import(
"@tauri-apps/plugin-opener"
)
await tauriReveal(path)
}
}
/**
* Open a native file/directory dialog (desktop) or fallback (web).
*/
export async function openFileDialog(options?: {
directory?: boolean
multiple?: boolean
title?: string
}): Promise<string | string[] | null> {
if (isDesktop()) {
const { open } = await import("@tauri-apps/plugin-dialog")
return open(options ?? {})
}
// Web fallback: for directory selection, prompt for server-side path.
// For file selection, use a hidden file input.
if (options?.directory) {
const path = window.prompt(
options?.title ?? "输入服务端目录路径 (Enter server directory path)"
)
return path || null
}
return new Promise((resolve) => {
const input = document.createElement("input")
input.type = "file"
if (options?.multiple) input.multiple = true
input.onchange = () => {
if (!input.files?.length) {
resolve(null)
return
}
const paths = Array.from(input.files).map((f) => f.name)
resolve(options?.multiple ? paths : paths[0])
}
input.click()
})
}
/**
* Get the current Tauri window (desktop only).
* Returns null in web mode.
*/
export async function getCurrentWindow() {
if (isDesktop()) {
const { getCurrentWindow: tauriGetCurrentWindow } = await import(
"@tauri-apps/api/window"
)
return tauriGetCurrentWindow()
}
return null
}
/**
* Close the current window.
* Desktop: closes Tauri window. Web: navigates back or closes tab.
*/
export async function closeCurrentWindow(): Promise<void> {
if (isDesktop()) {
const win = await getCurrentWindow()
await win?.close()
} else {
window.history.back()
}
}

View File

@@ -0,0 +1,11 @@
export type TransportEnvironment = "tauri" | "web"
export function detectEnvironment(): TransportEnvironment {
if (
typeof window !== "undefined" &&
"__TAURI_INTERNALS__" in window
) {
return "tauri"
}
return "web"
}

View File

@@ -0,0 +1,33 @@
import { detectEnvironment } from "./detect"
import type { Transport } from "./types"
export type { Transport, UnsubscribeFn } from "./types"
let _transport: Transport | null = null
export function getTransport(): Transport {
if (!_transport) {
const env = detectEnvironment()
if (env === "tauri") {
// Use dynamic require to avoid bundling tauri deps in web mode.
// TauriTransport uses dynamic imports internally.
const { TauriTransport } = require("./tauri-transport") as {
TauriTransport: new () => Transport
}
_transport = new TauriTransport()
} else {
const { WebTransport } = require("./web-transport") as {
WebTransport: new (baseUrl: string) => Transport
}
// In web mode, the API is served from the same origin.
// Token is read from localStorage on each request.
const baseUrl = window.location.origin
_transport = new WebTransport(baseUrl)
}
}
return _transport
}
export function isDesktop(): boolean {
return getTransport().isDesktop()
}

View File

@@ -0,0 +1,23 @@
import type { Transport, UnsubscribeFn } from "./types"
export class TauriTransport implements Transport {
async call<T>(
command: string,
args?: Record<string, unknown>
): Promise<T> {
const { invoke } = await import("@tauri-apps/api/core")
return invoke(command, args)
}
async subscribe<T>(
event: string,
handler: (payload: T) => void
): Promise<UnsubscribeFn> {
const { listen } = await import("@tauri-apps/api/event")
return listen<T>(event, (e) => handler(e.payload))
}
isDesktop(): boolean {
return true
}
}

View File

@@ -0,0 +1,22 @@
export type UnsubscribeFn = () => void
export interface Transport {
/**
* Invoke a backend command (replaces Tauri's invoke()).
*/
call<T>(command: string, args?: Record<string, unknown>): Promise<T>
/**
* Subscribe to a backend event stream (replaces Tauri's listen()).
* Returns an unsubscribe function.
*/
subscribe<T>(
event: string,
handler: (payload: T) => void
): Promise<UnsubscribeFn>
/**
* Whether the app is running in a desktop Tauri environment.
*/
isDesktop(): boolean
}

View File

@@ -0,0 +1,130 @@
import type { Transport, UnsubscribeFn } from "./types"
interface WebEvent {
channel: string
payload: unknown
}
function getToken(): string {
return localStorage.getItem("codeg_token") ?? ""
}
export class WebTransport implements Transport {
private ws: WebSocket | null = null
private handlers = new Map<string, Set<(payload: unknown) => void>>()
private baseUrl: string
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
private wsFailCount = 0
constructor(baseUrl: string) {
this.baseUrl = baseUrl
}
async call<T>(
command: string,
args?: Record<string, unknown>
): Promise<T> {
const token = getToken()
const res = await fetch(`${this.baseUrl}/api/${command}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(args ?? {}),
})
if (res.status === 401) {
WebTransport.redirectToLogin()
throw new Error("Unauthorized")
}
if (!res.ok) {
const error = await res.json().catch(() => ({
code: "network_error",
message: `HTTP ${res.status}`,
}))
throw error
}
return res.json()
}
async subscribe<T>(
event: string,
handler: (payload: T) => void
): Promise<UnsubscribeFn> {
if (!this.handlers.has(event)) {
this.handlers.set(event, new Set())
}
const wrappedHandler = handler as (payload: unknown) => void
this.handlers.get(event)!.add(wrappedHandler)
// If WS is not connected but we now have a token, connect
if (!this.ws && getToken()) {
this.connectWs()
}
return () => {
this.handlers.get(event)?.delete(wrappedHandler)
}
}
isDesktop(): boolean {
return false
}
private static redirectToLogin() {
if (window.location.pathname.startsWith("/login")) return
localStorage.removeItem("codeg_token")
window.location.href = "/login"
}
private connectWs() {
const token = getToken()
if (!token) return
const wsUrl =
this.baseUrl.replace(/^http/, "ws") +
`/ws/events?token=${encodeURIComponent(token)}`
this.ws = new WebSocket(wsUrl)
this.ws.onopen = () => {
this.wsFailCount = 0
}
this.ws.onmessage = (msg) => {
try {
const event = JSON.parse(msg.data) as WebEvent
const handlers = this.handlers.get(event.channel)
if (handlers) {
for (const h of handlers) {
h(event.payload)
}
}
} catch {
// ignore malformed messages
}
}
this.ws.onclose = () => {
this.ws = null
this.wsFailCount++
if (this.wsFailCount >= 3) {
WebTransport.redirectToLogin()
return
}
this.reconnectTimer = setTimeout(() => this.connectWs(), 3000)
}
this.ws.onerror = () => {
this.ws?.close()
}
}
destroy() {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
}
this.ws?.close()
this.ws = null
this.handlers.clear()
}
}

View File

@@ -1,6 +1,6 @@
import { getVersion } from "@tauri-apps/api/app"
import { relaunch } from "@tauri-apps/plugin-process"
import { check, type Update } from "@tauri-apps/plugin-updater"
// All updater imports are dynamic to avoid crashing in non-Tauri browsers.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Update = any
export interface AppUpdateCheckResult {
currentVersion: string
@@ -20,23 +20,31 @@ export interface AppUpdateErrorInfo {
}
export async function getCurrentAppVersion(): Promise<string> {
return getVersion()
try {
const { getVersion } = await import("@tauri-apps/api/app")
return await getVersion()
} catch {
return "web"
}
}
export async function checkAppUpdate(): Promise<AppUpdateCheckResult> {
const { getVersion } = await import("@tauri-apps/api/app")
const { check } = await import("@tauri-apps/plugin-updater")
const [currentVersion, update] = await Promise.all([getVersion(), check()])
return { currentVersion, update }
}
export async function installAppUpdate(update: Update): Promise<void> {
export async function installAppUpdate(update: NonNullable<Update>): Promise<void> {
await update.downloadAndInstall()
}
export async function relaunchApp(): Promise<void> {
const { relaunch } = await import("@tauri-apps/plugin-process")
await relaunch()
}
export async function closeAppUpdate(update: Update): Promise<void> {
export async function closeAppUpdate(update: NonNullable<Update>): Promise<void> {
await update.close()
}