11<script setup lang="ts">
22import { computed , inject , nextTick , onMounted , onUnmounted , reactive , ref , shallowRef , watch } from ' vue'
3- import { ApplicationError , Progress , type Result , type Session } from ' xconn'
3+ import { ApplicationError , type Session } from ' xconn'
44
55import { useSessionCacheStore } from ' @/stores/sessionCache'
6+ import { useSessionEncryptionStore } from ' @/stores/sessionEncryption'
67import { useSettingsStore } from ' @/stores/settings'
78import { useEntryNavigation } from ' @/composables/useEntryNavigation'
89import { floatingWindowToolbarKey } from ' @/composables/floatingWindowToolbar'
@@ -14,18 +15,16 @@ import {
1415 decryptPayload ,
1516 type EncryptionKeys ,
1617} from ' @/utils/encryption'
18+ import { uploadFileToPath } from ' @/utils/fileUpload'
1719import { formatSize , getFilePreviewType , isFirefoxBrowser } from ' @/utils/fileTypes'
1820import { formatDesktopError , isNoSuchProcedureException } from ' @/utils/desktopError'
1921
20- const procedureKeyExchange = ' io.xconn.deskconn.deskconnd.key.exchange'
2122const procedureFileBrowse = ' io.xconn.deskconn.deskconnd.file.browse'
2223const procedureFileRename = ' io.xconn.deskconn.deskconnd.file.rename'
2324const procedureFileDelete = ' io.xconn.deskconn.deskconnd.file.delete'
2425const procedureFileCopy = ' io.xconn.deskconn.deskconnd.file.copy'
2526const procedureFileDownload = ' io.xconn.deskconn.deskconnd.file.download'
2627const procedureFileSearch = ' io.xconn.deskconn.deskconnd.file.search'
27- const procedureFileUpload = ' io.xconn.deskconn.deskconnd.file.upload'
28- const UPLOAD_CHUNK_SIZE = 1024 * 1024
2928const outsideHomeMessage = ' Access denied. You can only browse files inside the home directory.'
3029
3130const props = defineProps <{
@@ -41,6 +40,7 @@ const emit = defineEmits<{
4140}>()
4241
4342const sessionCacheStore = useSessionCacheStore ()
43+ const sessionEncryptionStore = useSessionEncryptionStore ()
4444const settingsStore = useSettingsStore ()
4545
4646// Absent when there's no FloatingWindow ancestor (e.g. the standalone /files
@@ -398,25 +398,18 @@ function resetExplorerState() {
398398async function performKeyExchange(): Promise <boolean > {
399399 if (! session .value ) return false
400400
401- const { publicKey, privateKey } = createX25519KeyPair ()
402-
403- let result
401+ // Shared across every window on this realm's session (see
402+ // sessionEncryption.ts) — the backend keeps only one key per session, so
403+ // Files/Pictures/an Image Viewer save all have to reuse the same one.
404404 try {
405- result = await session .value .call (procedureKeyExchange , [publicKey ])
405+ encryptionKeys .value = await sessionEncryptionStore .getOrExchange (session .value , props .realm )
406+ return true
406407 } catch (error ) {
407408 if (isNoSuchProcedureException (error )) {
408409 return false
409410 }
410411 throw error
411412 }
412-
413- const serverKeyBytes = result .args ?.[0 ] as Uint8Array
414- if (! serverKeyBytes ?.length ) {
415- throw new Error (' Invalid server public key received during key exchange' )
416- }
417-
418- encryptionKeys .value = await deriveSessionKeys (privateKey , serverKeyBytes )
419- return true
420413}
421414
422415async function probeFileOperation(procedure : string ): Promise <boolean > {
@@ -1058,7 +1051,6 @@ async function uploadFileToRemote(file: File) {
10581051 if (! session .value || ! currentBrowse .value ) return
10591052
10601053 const destPath = currentBrowse .value .path
1061- const startTime = Date .now ()
10621054 const controller = new AbortController ()
10631055
10641056 uploadProgress .value = {
@@ -1069,79 +1061,19 @@ async function uploadFileToRemote(file: File) {
10691061 cancel : () => controller .abort (),
10701062 }
10711063
1072- let sendKey: Uint8Array | null = encryptionKeys .value ?.encryptKey ?? null
1073- const needsKeyExchange = sendKey === null
1074- const { publicKey, privateKey } = createX25519KeyPair ()
1075-
1076- let seq = 0
1077- let fileOffset = 0
1078- let ackResolve: ((r : Result ) => void ) | null = null
1079-
1080- const progressHandler = async (result : Result ) => {
1081- ackResolve ?.(result )
1082- ackResolve = null
1083- }
1084-
1085- function waitForAck(): Promise <Result > {
1086- return new Promise ((resolve , reject ) => {
1087- ackResolve = resolve
1088- controller .signal .addEventListener (' abort' , () => reject (new Error (' cancelled' )), { once: true })
1089- })
1090- }
1091-
1092- function enc(obj : object ): Uint8Array {
1093- return encryptPayload (new TextEncoder ().encode (JSON .stringify (obj )), sendKey ! )
1094- }
1095-
1096- async function * frames(): AsyncGenerator <Progress > {
1097- if (needsKeyExchange ) {
1098- yield new Progress ([' K' , publicKey ], {}, { progress: true })
1099- const ack = await waitForAck ()
1100- const raw = ack .args ?.[0 ] as Uint8Array
1101- if (! raw ?.length ) throw new Error (' Empty key exchange response from server' )
1102- const keys = await deriveSessionKeys (privateKey , raw .slice (4 ))
1103- sendKey = keys .encryptKey
1104- }
1105-
1106- yield new Progress ([' I' , seq ++ , enc ({ remote_path: destPath , source_is_dir: false , target_is_dir_hint: true })], {}, { progress: true })
1107- await waitForAck ()
1108-
1109- yield new Progress ([' H' , seq ++ , enc ({ name: file .name , rel_path: file .name , size: file .size , mode: 0 , is_dir: false })], {}, { progress: true })
1110-
1111- while (fileOffset < file .size ) {
1112- await waitForAck ()
1113- const chunkBytes = new Uint8Array (await file .slice (fileOffset , fileOffset + UPLOAD_CHUNK_SIZE ).arrayBuffer ())
1114- fileOffset += chunkBytes .length
1115- if (uploadProgress .value ) {
1116- uploadProgress .value .sent = fileOffset
1117- const elapsed = (Date .now () - startTime ) / 1000
1118- uploadProgress .value .speed = elapsed > 0 ? fileOffset / elapsed : 0
1119- }
1120- yield new Progress ([' D' , seq ++ , encryptPayload (chunkBytes , sendKey ! )], {}, { progress: true })
1121- }
1122-
1123- await waitForAck ()
1124- yield new Progress ([' E' , seq ], {}, {})
1125- }
1126-
1127- const gen = frames ()
1128- const progressFunc = async () => (await gen .next ()).value as Progress
1129-
11301064 try {
1131- await session .value .callProgressiveProgress (procedureFileUpload , progressFunc , progressHandler )
1065+ await uploadFileToPath (session .value , props .realm , destPath , file .name , file , (p ) => {
1066+ if (! uploadProgress .value ) return
1067+ uploadProgress .value .sent = p .sent
1068+ uploadProgress .value .total = p .total
1069+ uploadProgress .value .speed = p .speed
1070+ }, controller .signal )
11321071 await refreshCurrentPath ()
11331072 } catch (err ) {
1073+ // On abort, uploadFileToPath already tells the backend to discard its
1074+ // per-session upload state before rethrowing — nothing left to surface.
11341075 if (! controller .signal .aborted ) {
11351076 operationError .value = err instanceof Error ? err .message : ' Upload failed'
1136- } else {
1137- // Aborting mid-stream never tells the backend the call ended, so it keeps
1138- // its per-session upload state around — the next upload would then get
1139- // stuck waiting on a sequence number that will never arrive. A plain
1140- // (non-progressive) call with any message type other than 'E' makes the
1141- // backend discard that stale state.
1142- try {
1143- await session .value ?.call (procedureFileUpload , [' C' ])
1144- } catch { /* best-effort cleanup, ignore failures */ }
11451077 }
11461078 } finally {
11471079 uploadProgress .value = null
0 commit comments