mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-24 18:50:56 +08:00
* refactor: change qrcode landrop to lantransfer * chore: update docs and tests * fix: pr review * fix: pr review * chore: remove qrcode dependency * fix: pr review * fix: format * fix: test
208 lines
6.0 KiB
TypeScript
208 lines
6.0 KiB
TypeScript
import { loggerService } from '@logger'
|
|
import type { LocalTransferPeer, LocalTransferState } from '@shared/config/types'
|
|
import { IpcChannel } from '@shared/IpcChannel'
|
|
import type { Browser, Service } from 'bonjour-service'
|
|
import Bonjour from 'bonjour-service'
|
|
|
|
import { windowService } from './WindowService'
|
|
|
|
const SERVICE_TYPE = 'cherrystudio'
|
|
const SERVICE_PROTOCOL = 'tcp' as const
|
|
|
|
const logger = loggerService.withContext('LocalTransferService')
|
|
|
|
type StartDiscoveryOptions = {
|
|
resetList?: boolean
|
|
}
|
|
|
|
class LocalTransferService {
|
|
private static instance: LocalTransferService
|
|
private bonjour: Bonjour | null = null
|
|
private browser: Browser | null = null
|
|
private services = new Map<string, LocalTransferPeer>()
|
|
private isScanning = false
|
|
private lastScanStartedAt?: number
|
|
private lastUpdatedAt = Date.now()
|
|
private lastError?: string
|
|
|
|
private constructor() {}
|
|
|
|
public static getInstance(): LocalTransferService {
|
|
if (!LocalTransferService.instance) {
|
|
LocalTransferService.instance = new LocalTransferService()
|
|
}
|
|
return LocalTransferService.instance
|
|
}
|
|
|
|
public startDiscovery(options?: StartDiscoveryOptions): LocalTransferState {
|
|
if (options?.resetList) {
|
|
this.services.clear()
|
|
}
|
|
|
|
this.isScanning = true
|
|
this.lastScanStartedAt = Date.now()
|
|
this.lastUpdatedAt = Date.now()
|
|
this.lastError = undefined
|
|
this.restartBrowser()
|
|
this.broadcastState()
|
|
return this.getState()
|
|
}
|
|
|
|
public stopDiscovery(): LocalTransferState {
|
|
if (this.browser) {
|
|
try {
|
|
this.browser.stop()
|
|
} catch (error) {
|
|
logger.warn('Failed to stop local transfer browser', error as Error)
|
|
}
|
|
}
|
|
this.isScanning = false
|
|
this.lastUpdatedAt = Date.now()
|
|
this.broadcastState()
|
|
return this.getState()
|
|
}
|
|
|
|
public getState(): LocalTransferState {
|
|
const services = Array.from(this.services.values()).sort((a, b) => a.name.localeCompare(b.name))
|
|
return {
|
|
services,
|
|
isScanning: this.isScanning,
|
|
lastScanStartedAt: this.lastScanStartedAt,
|
|
lastUpdatedAt: this.lastUpdatedAt,
|
|
lastError: this.lastError
|
|
}
|
|
}
|
|
|
|
public getPeerById(id: string): LocalTransferPeer | undefined {
|
|
return this.services.get(id)
|
|
}
|
|
|
|
public dispose(): void {
|
|
this.stopDiscovery()
|
|
this.services.clear()
|
|
this.browser?.removeAllListeners()
|
|
this.browser = null
|
|
if (this.bonjour) {
|
|
try {
|
|
this.bonjour.destroy()
|
|
} catch (error) {
|
|
logger.warn('Failed to destroy Bonjour instance', error as Error)
|
|
}
|
|
this.bonjour = null
|
|
}
|
|
}
|
|
|
|
private getBonjour(): Bonjour {
|
|
if (!this.bonjour) {
|
|
this.bonjour = new Bonjour()
|
|
}
|
|
return this.bonjour
|
|
}
|
|
|
|
private restartBrowser(): void {
|
|
// Clean up existing browser
|
|
if (this.browser) {
|
|
this.browser.removeAllListeners()
|
|
try {
|
|
this.browser.stop()
|
|
} catch (error) {
|
|
logger.warn('Error while stopping Bonjour browser', error as Error)
|
|
}
|
|
this.browser = null
|
|
}
|
|
|
|
// Destroy and recreate Bonjour instance to prevent socket leaks
|
|
if (this.bonjour) {
|
|
try {
|
|
this.bonjour.destroy()
|
|
} catch (error) {
|
|
logger.warn('Error while destroying Bonjour instance', error as Error)
|
|
}
|
|
this.bonjour = null
|
|
}
|
|
|
|
const browser = this.getBonjour().find({ type: SERVICE_TYPE, protocol: SERVICE_PROTOCOL })
|
|
this.browser = browser
|
|
this.bindBrowserEvents(browser)
|
|
|
|
try {
|
|
browser.start()
|
|
logger.info('Local transfer discovery started')
|
|
} catch (error) {
|
|
const err = error instanceof Error ? error : new Error(String(error))
|
|
this.lastError = err.message
|
|
logger.error('Failed to start local transfer discovery', err)
|
|
}
|
|
}
|
|
|
|
private bindBrowserEvents(browser: Browser) {
|
|
browser.on('up', (service) => {
|
|
const peer = this.normalizeService(service)
|
|
logger.info(`LAN peer detected: ${peer.name} (${peer.addresses.join(', ')})`)
|
|
this.services.set(peer.id, peer)
|
|
this.lastUpdatedAt = Date.now()
|
|
this.broadcastState()
|
|
})
|
|
|
|
browser.on('down', (service) => {
|
|
const key = this.buildServiceKey(service.fqdn || service.name, service.host, service.port)
|
|
if (this.services.delete(key)) {
|
|
logger.info(`LAN peer removed: ${service.name}`)
|
|
this.lastUpdatedAt = Date.now()
|
|
this.broadcastState()
|
|
}
|
|
})
|
|
|
|
browser.on('error', (error) => {
|
|
const err = error instanceof Error ? error : new Error(String(error))
|
|
logger.error('Local transfer discovery error', err)
|
|
this.lastError = err.message
|
|
this.broadcastState()
|
|
})
|
|
}
|
|
|
|
private normalizeService(service: Service): LocalTransferPeer {
|
|
const addressCandidates = [...(service.addresses || []), service.referer?.address].filter(
|
|
(value): value is string => typeof value === 'string' && value.length > 0
|
|
)
|
|
const addresses = Array.from(new Set(addressCandidates))
|
|
const txtEntries = Object.entries(service.txt || {})
|
|
const txt =
|
|
txtEntries.length > 0
|
|
? Object.fromEntries(
|
|
txtEntries.map(([key, value]) => [key, value === undefined || value === null ? '' : String(value)])
|
|
)
|
|
: undefined
|
|
|
|
const peer: LocalTransferPeer = {
|
|
id: this.buildServiceKey(service.fqdn || service.name, service.host, service.port),
|
|
name: service.name,
|
|
host: service.host,
|
|
fqdn: service.fqdn,
|
|
port: service.port,
|
|
type: service.type,
|
|
protocol: service.protocol,
|
|
addresses,
|
|
txt,
|
|
updatedAt: Date.now()
|
|
}
|
|
|
|
return peer
|
|
}
|
|
|
|
private buildServiceKey(name?: string, host?: string, port?: number): string {
|
|
const raw = [name, host, port?.toString()].filter(Boolean).join('-')
|
|
return raw || `service-${Date.now()}`
|
|
}
|
|
|
|
private broadcastState() {
|
|
const mainWindow = windowService.getMainWindow()
|
|
if (!mainWindow || mainWindow.isDestroyed()) {
|
|
return
|
|
}
|
|
mainWindow.webContents.send(IpcChannel.LocalTransfer_ServicesUpdated, this.getState())
|
|
}
|
|
}
|
|
|
|
export const localTransferService = LocalTransferService.getInstance()
|