初始化web服务功能
This commit is contained in:
1151
src/lib/api.ts
Normal file
1151
src/lib/api.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
123
src/lib/platform.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
11
src/lib/transport/detect.ts
Normal file
11
src/lib/transport/detect.ts
Normal 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"
|
||||
}
|
||||
33
src/lib/transport/index.ts
Normal file
33
src/lib/transport/index.ts
Normal 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()
|
||||
}
|
||||
23
src/lib/transport/tauri-transport.ts
Normal file
23
src/lib/transport/tauri-transport.ts
Normal 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
|
||||
}
|
||||
}
|
||||
22
src/lib/transport/types.ts
Normal file
22
src/lib/transport/types.ts
Normal 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
|
||||
}
|
||||
130
src/lib/transport/web-transport.ts
Normal file
130
src/lib/transport/web-transport.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user