Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified build/dsh-loader.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 8 additions & 2 deletions patches/@deepseek-ai+dsh-client-ui-sidebar+0.1.0-rc.7.patch
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
diff --git a/node_modules/@deepseek-ai/dsh-client-ui-sidebar/lib/client.js b/node_modules/@deepseek-ai/dsh-client-ui-sidebar/lib/client.js
index 9ca3639..16d58b0 100644
index 9ca3639..9709649 100644
--- a/node_modules/@deepseek-ai/dsh-client-ui-sidebar/lib/client.js
+++ b/node_modules/@deepseek-ai/dsh-client-ui-sidebar/lib/client.js
@@ -23,16 +23,20 @@ window.__ModuleLoader__.load({
Expand Down Expand Up @@ -107,11 +107,17 @@ index 9ca3639..16d58b0 100644
}), (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconPanelLeftOutline16, {
className: SidebarRoot_module_css_default.panelIcon,
size: wide ? 16 : 18
@@ -205,6 +256,7 @@ window.__ModuleLoader__.load({
@@ -205,11 +256,13 @@ window.__ModuleLoader__.load({
})
}),
(0, react_jsx_runtime.jsxs)("div", {
+ "data-dsh-sidebar-footer": "",
className: SidebarRoot_module_css_default.footArea,
children: [(0, react_jsx_runtime.jsx)("div", {
className: SidebarRoot_module_css_default.footerActions,
children: renderSlot("sidebar.footer.action", { wide })
}), (0, react_jsx_runtime.jsx)("div", {
+ "data-dsh-sidebar-settings": "",
className: SidebarRoot_module_css_default.settingsArea,
children: renderSlot("sidebar.settings", { wide })
})]
15 changes: 12 additions & 3 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,11 @@ import {
resolveProfileRecoveryPlugins,
uninstallPluginFromProfile
} from './state/plugin-recovery'
import { isAbortedNavigationError, shouldLoadHarnessUrl } from './window-navigation'
import {
desktopHarnessUrl,
isAbortedNavigationError,
shouldLoadHarnessUrl
} from './window-navigation'
import {
checkForUpdates,
registerUpdateHandlers,
Expand Down Expand Up @@ -368,12 +372,13 @@ function createWindow(): BrowserWindow {

async function openHarness(url: string): Promise<void> {
const window = mainWindow && !mainWindow.isDestroyed() ? mainWindow : createWindow()
const rendererUrl = desktopHarnessUrl(url, process.platform)
if (shouldLoadHarnessUrl(window.webContents.getURL(), url)) {
const navigationVersion = ++mainWindowNavigationVersion
rendererPluginFailureLogs = []
window.webContents.stop()
try {
await window.loadURL(url)
await window.loadURL(rendererUrl)
} catch (error) {
if (navigationVersion !== mainWindowNavigationVersion) return
if (isAbortedNavigationError(error)) return
Expand Down Expand Up @@ -859,8 +864,12 @@ async function bootstrap(): Promise<void> {
dark: dshBrandLogoPath('dark')
},
appIconPath: desktopIconPath(),
port: developmentBuild ? 43128 : 43127
port: developmentBuild ? 43128 : 43127,
onReconnectRequested: () => {
void showMobilePairing().catch(showUnexpectedError)
}
})
void mobileBridge.start().catch(showUnexpectedError)
ipcMain.handle('directory-picker:open', async (event) => {
if (
!mainWindow ||
Expand Down
101 changes: 90 additions & 11 deletions src/main/mobile/lan-mobile-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@ import { networkInterfaces } from 'node:os'
import type { AddressInfo } from 'node:net'
import { readFile } from 'node:fs/promises'
import QRCode from 'qrcode'
import { renderDesktopPairingPage, renderMobilePage, renderPairingWaitPage } from './lan-mobile-pages'
import {
renderDesktopPairingPage,
renderMobilePage,
renderMobileReconnectPage,
renderPairingWaitPage
} from './lan-mobile-pages'

const MAX_BODY_BYTES = 64 * 1024
const PAIRING_TTL_MS = 5 * 60 * 1000
Expand All @@ -25,6 +30,7 @@ export interface LanMobileBridgeOptions {
appIconPath?: string
port?: number
now?: () => number
onReconnectRequested?: () => void
}

export interface LanMobileBridgeSnapshot {
Expand All @@ -38,6 +44,7 @@ export interface LanMobileBridgeSnapshot {

interface MobileSession {
token: string
remoteAddress: string
}

interface PendingPairing {
Expand All @@ -53,6 +60,7 @@ export class LanMobileBridge {
private pairingToken?: string
private pairingExpiresAt?: number
private readonly sessions = new Map<string, MobileSession>()
private readonly suspendedSessions = new Map<string, MobileSession>()
private readonly pendingPairings = new Map<string, PendingPairing>()
private readonly now: () => number

Expand All @@ -62,8 +70,9 @@ export class LanMobileBridge {

async start(): Promise<LanMobileBridgeSnapshot> {
if (this.server) {
this.rotatePairingToken()
this.pendingPairings.clear()
if (!this.pairingToken || !this.pairingExpiresAt || this.pairingExpiresAt < this.now()) {
this.rotatePairingToken()
}
return this.snapshot()
}
this.rotatePairingToken()
Expand All @@ -88,6 +97,7 @@ export class LanMobileBridge {
this.pairingToken = undefined
this.pairingExpiresAt = undefined
this.sessions.clear()
this.suspendedSessions.clear()
this.pendingPairings.clear()
if (!server) return
await new Promise<void>((resolve) => server.close(() => resolve()))
Expand Down Expand Up @@ -191,6 +201,7 @@ export class LanMobileBridge {

if (request.method === 'POST' && url.pathname === '/desktop/disconnect') {
if (!isLoopbackAddress(remoteAddress)) return this.text(response, 403, 'Desktop only.')
for (const [token, session] of this.sessions) this.suspendedSessions.set(token, session)
this.sessions.clear()
this.pendingPairings.clear()
this.rotatePairingToken()
Expand All @@ -206,8 +217,25 @@ export class LanMobileBridge {
return this.json(response, 200, { ok: true })
}

if (request.method === 'GET' && url.pathname === '/disconnected') {
return this.html(response, renderMobileReconnectPage(this.locale()))
}

if (request.method === 'GET' && url.pathname === '/reconnect') {
const pending = this.reconnectPairing(remoteAddress)
this.options.onReconnectRequested?.()
return this.html(response, renderPairingWaitPage(pending.id, this.locale()))
}

if (request.method === 'POST' && url.pathname === '/pair/retry') {
this.verifySameOrigin(request)
const pending = this.reconnectPairing(remoteAddress)
this.options.onReconnectRequested?.()
return this.json(response, 200, { id: pending.id, expiresAt: pending.expiresAt })
}

if (request.method === 'GET' && url.pathname === '/pair') {
if (this.authorized(request)) {
if (this.authorized(request, remoteAddress)) {
response.statusCode = 302
response.setHeader('location', '/')
response.end()
Expand Down Expand Up @@ -239,16 +267,28 @@ export class LanMobileBridge {
}
if (pending.decision !== true) return this.json(response, 200, { pending: true })
const token = randomBytes(32).toString('base64url')
this.sessions.clear()
this.sessions.set(token, { token })
for (const [savedToken, session] of this.suspendedSessions) {
if (session.remoteAddress !== pending.remoteAddress) continue
this.sessions.set(savedToken, session)
this.suspendedSessions.delete(savedToken)
}
this.sessions.set(token, { token, remoteAddress: pending.remoteAddress })
this.pendingPairings.delete(pending.id)
this.pairingToken = undefined
this.pairingExpiresAt = undefined
response.setHeader('set-cookie', `dsh_mobile=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=31536000`)
return this.json(response, 200, { approved: true })
}

if (!this.authorized(request)) return this.text(response, 401, 'Pair your phone again.')
if (!this.authorized(request, remoteAddress)) {
this.rememberMobileContext(request, remoteAddress)
if (!this.authorized(request, remoteAddress)) {
if (request.method === 'GET' && url.pathname === '/') {
return this.html(response, renderMobileReconnectPage(this.locale()))
}
return this.text(response, 401, 'Pair your phone again.')
}
}
if (request.method === 'GET' && url.pathname === '/api/status') {
return this.json(response, 200, { connected: true })
}
Expand Down Expand Up @@ -280,11 +320,50 @@ export class LanMobileBridge {
return left.length === right.length && timingSafeEqual(left, right)
}

private authorized(request: IncomingMessage): boolean {
private reconnectPairing(remoteAddress: string): PendingPairing {
const current = [...this.pendingPairings.values()].find(
(item) =>
item.remoteAddress === remoteAddress &&
item.decision === undefined &&
item.expiresAt >= this.now()
)
if (current) return current
const pending = {
id: randomUUID(),
remoteAddress,
expiresAt: this.now() + PAIRING_TTL_MS
}
this.pendingPairings.set(pending.id, pending)
return pending
}

private authorized(request: IncomingMessage, remoteAddress: string): boolean {
const token = this.mobileToken(request)
if (token && this.sessions.has(token)) return true
return [...this.sessions.values()].some((session) => session.remoteAddress === remoteAddress)
}

private mobileToken(request: IncomingMessage): string | undefined {
const cookie = request.headers.cookie ?? ''
const match = /(?:^|;\s*)dsh_mobile=([^;]+)/.exec(cookie)
if (!match) return false
return this.sessions.has(match[1]!)
return /(?:^|;\s*)dsh_mobile=([^;]+)/.exec(cookie)?.[1]
}

private rememberMobileContext(request: IncomingMessage, remoteAddress: string): void {
const token = this.mobileToken(request)
if (!token || !/^[A-Za-z0-9_-]{43}$/.test(token)) return
const sameDeviceIsActive = [...this.sessions.values()].some(
(session) => session.remoteAddress === remoteAddress
)
if (sameDeviceIsActive) {
this.sessions.set(token, { token, remoteAddress })
this.suspendedSessions.delete(token)
return
}
if (!this.suspendedSessions.has(token) && this.suspendedSessions.size >= 16) {
const oldest = this.suspendedSessions.keys().next().value
if (oldest) this.suspendedSessions.delete(oldest)
}
this.suspendedSessions.set(token, { token, remoteAddress })
}

private verifySameOrigin(request: IncomingMessage): void {
Expand Down
Loading
Loading