Organize uploader storage and restore transfer readiness

This commit is contained in:
2026-10-14 19:15:46 +09:00
parent 69e7449ba7
commit c3573b605c
24 changed files with 966 additions and 449 deletions

View File

@@ -450,6 +450,7 @@ async function logout() {
await api('/auth/logout', 'POST', {});
clearPrivate();
await refreshAccount();
emit('devices');
reconnect();
await refresh();
});

View File

@@ -66,6 +66,9 @@ import {
spaces,
error,
transferCooldown,
transferConnecting,
waitForConnection,
requestTransfer,
bootstrap,
api,
refresh,
@@ -168,7 +171,7 @@ const receiving = computed(() =>
r.transferStatus !== 'failed',
),
);
createNearbySearch({
const nearbySearch = createNearbySearch({
connected,
device,
spaces,
@@ -176,6 +179,24 @@ createNearbySearch({
publish: publishLocation,
clear: clearLocation,
});
const preparingTransfer = ref(false);
async function prepareTransfer(target) {
preparingTransfer.value = true;
try {
await waitForConnection();
if (
target.sources?.includes('nearby') &&
!target.sources.some((source) => ['network', 'space'].includes(source))
) {
if (!(await nearbySearch.ensureReady()))
throw Error(
'기기 연결을 복구하지 못했어요. 다시 시도하거나 다른 기기 연결을 이용해 주세요.',
);
}
} finally {
preparingTransfer.value = false;
}
}
const receiver = createDeliveryReceiver({ download, markRead, onError: showError });
const blockedLinks = receiver.blockedLinks;
const receivedTexts = receiver.receivedTexts;
@@ -509,12 +530,9 @@ async function forwardVault(item) {
}
const owner = account.value?.id;
await act(async () => {
await api(
`/vault/${item.accessId}/send`,
'POST',
{ targetDeviceId: selected.value.id },
crypto.randomUUID(),
);
const target = selected.value;
await prepareTransfer(target);
await requestTransfer(`/vault/${item.accessId}/send`, { targetDeviceId: target.id });
if (account.value?.id !== owner) return;
forwardedItem.value = item;
sendOpen.value = true;
@@ -629,10 +647,12 @@ function selectPeer(peer) {
async function submitClipboard(content) {
await act(async () => {
if (!selected.value) throw Error('받을 기기를 선택해 주세요.');
const target = selected.value;
await prepareTransfer(target);
forwardedItem.value = null;
itemToSend.value = null;
await send(
selected.value.id,
target.id,
content.kind,
content.kind === 'text' ? { text: content.text } : { urls: content.urls },
);
@@ -657,11 +677,15 @@ async function closeReceivedText(item) {
async function submitFiles(files) {
await act(async () => {
if (!selected.value) throw Error('받을 기기를 선택해 주세요.');
const target = selected.value;
forwardedItem.value = null;
itemToSend.value = null;
if (currentTransfer.value?.kind === 'file' && currentTransfer.value.status === 'accepted')
await retryFiles(currentTransfer.value.id, files);
else await sendFiles(selected.value.id, files);
else {
await prepareTransfer(target);
await sendFiles(target.id, files);
}
});
}
async function createSpace() {
@@ -1195,6 +1219,7 @@ async function spaceAction(space, action, memberId) {
:clipboard-key="selectionEpoch"
:read-clipboard-on-selection="true"
:busy="busy"
:preparing="preparingTransfer || transferConnecting"
:retry-seconds="retrySeconds"
:transfer="currentTransfer"
:progress="

View File

@@ -19,6 +19,7 @@ const props = defineProps({
targetKey: String,
clipboardKey: Number,
busy: Boolean,
preparing: Boolean,
retrySeconds: { type: Number, default: 0 },
disabled: Boolean,
transfer: Object,
@@ -521,15 +522,17 @@ function size(value) {
@click="submit"
>
{{
!transfer && retrySeconds > 0
? `${retrySeconds}초 후 전송 가능`
: retryable
? '다시 전송'
: transfer
? '전송 취소'
: saveMode
? '저장하기'
: '전송하기'
preparing
? '연결 확인 중'
: !transfer && retrySeconds > 0
? `${retrySeconds}초 후 전송 가능`
: retryable
? '다시 전송'
: transfer
? '전송 취소'
: saveMode
? '저장하기'
: '전송하기'
}}<X v-if="transfer && !retryable" :size="16" /><ArrowUpRight v-else :size="16" />
</button>
<div v-if="retryable" class="content-switches">

View File

@@ -1,7 +1,7 @@
import { readDeviceInfo } from './deviceInfo';
import { PASSWORD_MIN_LENGTH } from '@hop/shared';
import { createRefreshQueue } from './refreshQueue';
import { ref } from 'vue';
import { ref, watch } from 'vue';
import { io } from 'socket.io-client';
export const account = ref(null),
registrationOpen = ref(false),
@@ -27,6 +27,7 @@ export const device = ref(null),
error = ref('');
export const pendingPeers = ref([]);
export const transferCooldown = ref({ until: 0, targetDeviceId: null });
export const transferConnecting = ref(false);
let pendingTimer;
let deviceInfo = {};
export const nextCursor = ref(null);
@@ -46,6 +47,46 @@ export function reserveLocationAcceptance(requestId) {
}
let socket;
let reauthorizing;
export async function waitForConnection() {
if (socket?.connected) return;
if (!socket) throw Error('서버 연결을 확인해 주세요.');
await new Promise((resolve, reject) => {
const stop = watch(connected, (ready) => {
if (!ready) return;
clearTimeout(timer);
stop();
resolve();
});
const timer = setTimeout(() => {
stop();
reject(Error('서버에 다시 연결하지 못했어요. 잠시 후 다시 시도해 주세요.'));
}, 5000);
socket.connect();
});
}
export async function requestTransfer(path, input) {
const key = crypto.randomUUID(),
owner = device.value?.id;
try {
transferConnecting.value = !socket?.connected;
await waitForConnection();
const deadline = Date.now() + 5000;
while (true) {
if (device.value?.id !== owner)
throw Error('기기 세션이 변경되었습니다. 다시 선택해 주세요.');
try {
return await api(path, 'POST', input, key);
} catch (error) {
// Only a known, still-authorized peer in its reconnect grace period is retried.
if (error.code !== 'TARGET_RECONNECTING' || Date.now() >= deadline) throw error;
transferConnecting.value = true;
await new Promise((resolve) => setTimeout(resolve, 500));
}
}
} finally {
transferConnecting.value = false;
}
}
const drafts = new Map();
const completing = new Set();
export async function api(path, method = 'GET', body, key, signal) {
@@ -222,17 +263,12 @@ export const refresh = createRefreshQueue(async () => {
});
export async function send(targetDeviceId, kind, values) {
const content = kind === 'text' ? values.text : values.urls.join('\n');
const { requestId } = await api(
'/transfer-requests',
'POST',
{
targetDeviceId,
kind,
byteSize: new TextEncoder().encode(content).length,
count: kind === 'text' ? 1 : values.urls.length,
},
crypto.randomUUID(),
);
const { requestId } = await requestTransfer('/transfer-requests', {
targetDeviceId,
kind,
byteSize: new TextEncoder().encode(content).length,
count: kind === 'text' ? 1 : values.urls.length,
});
drafts.set(requestId, { ...values, key: crypto.randomUUID() });
await refresh();
return requestId;
@@ -317,7 +353,7 @@ export async function sendFiles(targetDeviceId, files) {
type: f.type || 'application/octet-stream',
})),
};
const { requestId } = await api('/transfer-requests', 'POST', input, crypto.randomUUID());
const { requestId } = await requestTransfer('/transfer-requests', input);
fileDrafts.set(requestId, files);
await refresh();
return requestId;

View File

@@ -9,6 +9,7 @@ export function createNearbySearch({ connected, device, spaces, settings, publis
timer,
permission,
permissionQuery,
locationRequest,
disposed = false,
blocked = false;
const ready = () =>
@@ -47,8 +48,13 @@ export function createNearbySearch({ connected, device, spaces, settings, publis
});
return permissionQuery;
}
async function locate() {
if (!ready() || blocked || locating.value || disposed) return;
function locate() {
if (!ready() || blocked || disposed) return;
if (locating.value) return locationRequest;
locationRequest = readLocation();
return locationRequest;
}
async function readLocation() {
const current = ++revision;
locating.value = true;
clearTimeout(timer);
@@ -84,7 +90,8 @@ export function createNearbySearch({ connected, device, spaces, settings, publis
}
}
async function resume() {
if (!ready() || blocked || active.value || locating.value || disposed) return;
if (!ready() || blocked || active.value || disposed) return;
if (locating.value) return locationRequest;
const current = revision;
const state = await readPermission();
if (disposed || current !== revision || !ready()) return;
@@ -92,7 +99,7 @@ export function createNearbySearch({ connected, device, spaces, settings, publis
blocked = true;
return;
}
void locate();
return locate();
}
function visibility() {
visible = document.visibilityState === 'visible';
@@ -119,5 +126,9 @@ export function createNearbySearch({ connected, device, spaces, settings, publis
document.removeEventListener('visibilitychange', visibility);
if (permission) permission.onchange = null;
});
return { active, locating };
async function ensureReady() {
await resume();
return ready() && active.value && !blocked && !disposed;
}
return { active, locating, ensureReady };
}