Skip to content

Commit d4680db

Browse files
authored
Merge pull request #90 from xconnio/detect-disconnect
Detect and display desktop disconnects
2 parents 79331b0 + 631c116 commit d4680db

12 files changed

Lines changed: 182 additions & 50 deletions

package-lock.json

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
"vue": "^3.5.25",
3131
"vue-router": "^4.6.3",
3232
"vue3-otp-input": "^0.5.40",
33-
"xconn": "https://github.com/xconnio/xconn-js.git#1ac1bf928da9a910e6c8f5632e3980ce55d5a037",
33+
"xconn": "https://github.com/xconnio/xconn-js.git#1db3ea93e1f181537599ad8456fb3f70fe0fbbe5",
3434
"xconn-webrtc-js": "https://github.com/xconnio/xconn-webrtc-js.git#c18817dcd767d4718055a0d65267018ed969c554"
3535
},
3636
"devDependencies": {

src/components/DesktopSessionHost.vue

Lines changed: 113 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ const {
3737
focusedId,
3838
openWindow,
3939
closeWindow,
40+
closeAll,
4041
focusWindow,
4142
minimizeWindow,
4243
restoreWindow,
@@ -93,31 +94,65 @@ async function fetchWallpaper() {
9394
// gate this.
9495
const isConnecting = ref(true)
9596
96-
// A WAMP session can connect fine even when the machine itself is offline;
97-
// pinging deskconnd is what actually confirms it's reachable.
98-
const isOffline = ref(false)
97+
// Set whenever there's no live session — the initial connect failed (e.g.
98+
// the machine is offline) or a previously-live session dropped mid-use
99+
// (session.onDisconnect() already fires for that today). Both cases get the
100+
// same full-screen treatment.
101+
const isDisconnected = ref(false)
102+
103+
// Apps stay open (blurred) behind the overlay while disconnected — only
104+
// cleared once the user actually navigates away (see the active-prop watch
105+
// below), so returning to a still-active view doesn't lose anything.
106+
function markDisconnected() {
107+
isDisconnected.value = true
108+
}
99109
100-
async function ensureConnected() {
110+
// Acquires a session and confirms deskconnd is actually reachable (a WAMP
111+
// session can connect fine even when the machine itself is offline), then
112+
// arms the disconnect listener for the rest of its lifetime.
113+
async function connectSession(): Promise<boolean> {
101114
try {
102115
const session = await sessionCacheStore.acquire(props.realm)
103-
if (!session) {
104-
isOffline.value = true
105-
return
106-
}
116+
if (!session) return false
107117
await session.call('io.xconn.deskconn.deskconnd.ping')
118+
session.onDisconnect(async () => {
119+
markDisconnected()
120+
})
121+
return true
108122
} catch {
109-
isOffline.value = true
110-
} finally {
111-
isConnecting.value = false
123+
return false
112124
}
125+
}
126+
127+
// Used for both the initial connect and the "Reconnect" button — a failure
128+
// always lands back in the same disconnected overlay.
129+
async function connect() {
130+
isDisconnected.value = false
131+
isConnecting.value = true
132+
sessionCacheStore.clearUnreachable(props.realm)
133+
sessionCacheStore.invalidate(props.realm)
134+
135+
const connected = await connectSession()
136+
isConnecting.value = false
137+
if (!connected) markDisconnected()
113138
114139
// Otherwise the stale session gets reused next time, stuck on whatever
115140
// fallback transport it originally connected with.
116-
if (isOffline.value) {
141+
if (!connected) {
117142
sessionCacheStore.invalidate(props.realm)
118143
}
119144
}
120145
146+
// Other panels (terminal, files, ...) notice a dead desktop the moment their
147+
// own next call fails, well before this session's onDisconnect would — so
148+
// mirror that signal here too instead of leaving the blur to lag behind.
149+
watch(
150+
() => sessionCacheStore.unreachableRealms.has(props.realm),
151+
(unreachable) => {
152+
if (unreachable) markDisconnected()
153+
},
154+
)
155+
121156
const isMobile = ref(window.innerWidth < 768)
122157
function updateIsMobile() {
123158
isMobile.value = window.innerWidth < 768
@@ -411,13 +446,27 @@ function onActivateWindow(id: string) {
411446
}
412447
413448
function handleLaunch(appId: string) {
414-
if (isOffline.value) return
449+
if (isDisconnected.value) return
415450
const app = apps.find((a) => a.id === appId)
416451
if (app) launchApp(app)
417452
}
418453
454+
// App.vue keeps one DesktopSessionHost per realm mounted forever (v-show, not
455+
// v-if) so navigating to the dashboard and back never remounts this component
456+
// — onMounted's connect() never reruns on its own. Without this, a desktop
457+
// left disconnected just keeps showing the same stale blur/overlay every time
458+
// you come back to it instead of trying again.
419459
watch(() => props.active, (active) => {
420-
if (active) syncMaximizedBounds(maximizedContainerSize())
460+
if (active) {
461+
syncMaximizedBounds(maximizedContainerSize())
462+
if (isDisconnected.value && !isConnecting.value) connect()
463+
return
464+
}
465+
466+
// Leaving a disconnected desktop (Dashboard button, browser back, closing
467+
// the tab) discards its stale windows so the next visit starts fresh —
468+
// while still connected, navigating away/back is expected to keep them.
469+
if (isDisconnected.value) closeAll()
421470
})
422471
423472
watch(dockPosition, async () => {
@@ -430,7 +479,7 @@ let launcherBodyResizeObserver: ResizeObserver | null = null
430479
431480
onMounted(() => {
432481
window.addEventListener('resize', updateIsMobile)
433-
ensureConnected()
482+
connect()
434483
fetchWallpaper()
435484
measureDockThickness()
436485
@@ -451,15 +500,10 @@ onUnmounted(() => {
451500

452501
<template>
453502
<div class="launcher-wrapper fade-in-up" @contextmenu.prevent>
454-
<div v-if="isOffline" class="offline-banner">
455-
<i class="bi bi-wifi-off"></i>
456-
<span>{{ desktopName }} is offline — apps aren't available right now.</span>
457-
</div>
458-
459503
<div
460504
ref="launcherBodyRef"
461505
class="launcher-body"
462-
:class="{ 'has-wallpaper': !!wallpaperUrl, 'is-connecting': isConnecting }"
506+
:class="{ 'has-wallpaper': !!wallpaperUrl, 'is-connecting': isConnecting, 'is-disconnected': isDisconnected }"
463507
:style="wallpaperUrl ? { backgroundImage: `url(${wallpaperUrl})`, backgroundSize: 'cover', backgroundPosition: 'center' } : {}"
464508
>
465509
<div class="windows-layer">
@@ -508,7 +552,7 @@ onUnmounted(() => {
508552
:windows="windows"
509553
:focused-id="focusedId"
510554
:position="dockPosition"
511-
:offline="isOffline"
555+
:offline="isDisconnected"
512556
@launch="handleLaunch"
513557
@activate="onActivateWindow"
514558
@close="onCloseWindow"
@@ -522,6 +566,12 @@ onUnmounted(() => {
522566
<div class="connecting-spinner"></div>
523567
<p>Connecting to {{ desktopName }}…</p>
524568
</div>
569+
570+
<div v-if="isDisconnected" class="disconnected-overlay">
571+
<i class="bi bi-wifi-off"></i>
572+
<p>Can't connect to {{ desktopName }}.</p>
573+
<button type="button" class="overlay-btn" @click="close">Dashboard</button>
574+
</div>
525575
</div>
526576
</template>
527577

@@ -534,20 +584,6 @@ onUnmounted(() => {
534584
min-height: 0;
535585
}
536586
537-
.offline-banner {
538-
display: flex;
539-
align-items: center;
540-
gap: 0.5rem;
541-
padding: 0.45rem 1rem;
542-
background: #fef3c7;
543-
border-bottom: 1px solid #fbbf24;
544-
color: #92400e;
545-
font-size: 0.8rem;
546-
font-weight: 500;
547-
flex-shrink: 0;
548-
z-index: 60;
549-
}
550-
551587
.launcher-body {
552588
position: relative;
553589
flex: 1;
@@ -561,6 +597,11 @@ onUnmounted(() => {
561597
pointer-events: none;
562598
}
563599
600+
.launcher-body.is-disconnected {
601+
filter: grayscale(1) brightness(0.65);
602+
pointer-events: none;
603+
}
604+
564605
.windows-layer {
565606
position: absolute;
566607
inset: 0;
@@ -600,4 +641,40 @@ onUnmounted(() => {
600641
transform: rotate(360deg);
601642
}
602643
}
644+
645+
.disconnected-overlay {
646+
position: absolute;
647+
inset: 0;
648+
z-index: 50;
649+
display: flex;
650+
flex-direction: column;
651+
align-items: center;
652+
justify-content: center;
653+
gap: 0.75rem;
654+
color: #f1f5f9;
655+
font-weight: 600;
656+
text-align: center;
657+
padding: 1rem;
658+
}
659+
660+
.disconnected-overlay i {
661+
font-size: 1.75rem;
662+
}
663+
664+
.overlay-btn {
665+
margin-top: 0.25rem;
666+
padding: 0.4rem 1.1rem;
667+
border: 1px solid rgba(241, 245, 249, 0.4);
668+
border-radius: 0.4rem;
669+
background: rgba(15, 23, 42, 0.5);
670+
color: #f1f5f9;
671+
font-weight: 600;
672+
font-size: 0.85rem;
673+
cursor: pointer;
674+
pointer-events: auto;
675+
}
676+
677+
.overlay-btn:hover {
678+
background: rgba(15, 23, 42, 0.75);
679+
}
603680
</style>

src/components/EmbeddedDesktopFiles.vue

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import {
1818
import { uploadFileToPath } from '@/utils/fileUpload'
1919
import { downloadUrl } from '@/utils/download'
2020
import { formatSize, getFilePreviewType, isFirefoxBrowser } from '@/utils/fileTypes'
21-
import { formatDesktopError, isNoSuchProcedureException } from '@/utils/desktopError'
21+
import { formatDesktopError, isDesktopOfflineError, isNoSuchProcedureException } from '@/utils/desktopError'
2222
2323
const procedureFileBrowse = 'io.xconn.deskconn.deskconnd.file.browse'
2424
const procedureFileRename = 'io.xconn.deskconn.deskconnd.file.rename'
@@ -279,6 +279,8 @@ function parseBrowseResult(raw: unknown): FileBrowseResult {
279279
}
280280
281281
function formatError(error: unknown) {
282+
if (isDesktopOfflineError(error)) sessionCacheStore.reportUnreachable(props.realm)
283+
282284
const invalidPathMessage = 'Invalid path.'
283285
284286
if (error instanceof ApplicationError || error instanceof Error) {

src/components/EmbeddedIndexedFiles.vue

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
decryptPayload,
1212
type EncryptionKeys,
1313
} from '@/utils/encryption'
14-
import { DESKTOP_OFFLINE_MESSAGE, formatDesktopError } from '@/utils/desktopError'
14+
import { DESKTOP_OFFLINE_MESSAGE, formatDesktopError, isDesktopOfflineError } from '@/utils/desktopError'
1515
1616
const procedureIndexQuery = 'io.xconn.deskconn.deskconnd.index.query'
1717
@@ -222,6 +222,11 @@ function filterToCategories(list: IndexEntry[], categories: string[]): IndexEntr
222222
return list.filter((e) => categories.includes(e.category))
223223
}
224224
225+
function reportError(err: unknown, fallback: string) {
226+
if (isDesktopOfflineError(err)) sessionCacheStore.reportUnreachable(props.realm)
227+
error.value = formatDesktopError(err, fallback)
228+
}
229+
225230
async function loadMore() {
226231
if (!hasMore.value || isLoadingMore.value) return
227232
if (!activeSession.value || !activeKeys) return
@@ -234,7 +239,7 @@ async function loadMore() {
234239
nextCursor.value = result.next_cursor
235240
hasMore.value = result.has_more ?? false
236241
} catch (err) {
237-
error.value = formatDesktopError(err, 'Failed to load more files')
242+
reportError(err, 'Failed to load more files')
238243
} finally {
239244
isLoadingMore.value = false
240245
}
@@ -276,7 +281,7 @@ async function load() {
276281
try {
277282
sess = await sessionCacheStore.acquire(props.realm)
278283
} catch (err) {
279-
error.value = formatDesktopError(err, 'Could not connect to desktop')
284+
reportError(err, 'Could not connect to desktop')
280285
isConnecting.value = false
281286
return
282287
}
@@ -312,7 +317,7 @@ async function load() {
312317
activeCategories = categories
313318
}
314319
} catch (err) {
315-
error.value = formatDesktopError(err, 'Failed to load index')
320+
reportError(err, 'Failed to load index')
316321
} finally {
317322
isLoading.value = false
318323
}

src/components/ResourceMonitor.vue

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
33
import { useSessionCacheStore } from '../stores/sessionCache'
44
import { useSettingsStore } from '../stores/settings'
5-
import { formatDesktopError } from '../utils/desktopError'
5+
import { formatDesktopError, isDesktopOfflineError } from '../utils/desktopError'
66
77
const props = defineProps<{
88
realm: string
@@ -67,6 +67,7 @@ async function fetchInfo() {
6767
info.value = JSON.parse(new TextDecoder().decode(bytes)) as DeviceInfo
6868
error.value = null
6969
} catch (e) {
70+
if (isDesktopOfflineError(e)) sessionCacheStore.reportUnreachable(props.realm)
7071
error.value = formatDesktopError(e)
7172
} finally {
7273
loading.value = false

src/components/ScreenshotPanel.vue

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { ref, computed, onMounted, onUnmounted } from 'vue'
33
import { ApplicationError } from 'xconn'
44
import { useSessionCacheStore } from '@/stores/sessionCache'
55
import { downloadUrl } from '@/utils/download'
6-
import { formatDesktopError } from '@/utils/desktopError'
6+
import { formatDesktopError, isDesktopOfflineError } from '@/utils/desktopError'
77
88
const props = defineProps<{ realm: string }>()
99
const emit = defineEmits<{ close: [] }>()
@@ -98,6 +98,7 @@ async function capture() {
9898
if (String(detail).includes('screenshot not enabled')) {
9999
disabled.value = true
100100
} else {
101+
if (isDesktopOfflineError(e)) sessionCacheStore.reportUnreachable(props.realm)
101102
error.value = formatDesktopError(e)
102103
}
103104
} finally {

src/components/TerminalPanel.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,7 @@ async function startShell(tab: TabState) {
358358
if (tab.closed) return
359359
if (isNoSuchProcedureException(err) || isDataChannelClosedError(err)) {
360360
tab.term?.write(DESKTOP_OFFLINE_MESSAGE)
361+
sessionCacheStore.reportUnreachable(props.realm)
361362
} else {
362363
tab.term?.write(`Shell error: ${err}`)
363364
}

0 commit comments

Comments
 (0)