-
+
+
+

LocalSend
Web
+
+
+
+
+
+ {{ t(`index.connection.${store.connectionStatus}`) }}
+
+
+
{{ t("index.you") }}
- {{
+ {{
store.client.alias
}}
@@ -31,13 +53,14 @@
{{ t("index.pin.label") }}
-
+
{{ store.pin ?? t("index.pin.none") }}
+
{{
webCryptoSupported
- ? t("index.connecting")
+ ? store.connectionStatus === 'reconnecting'
+ ? t("index.connection.reconnecting")
+ : t("index.connecting")
: t("index.webCryptoNotSupported")
}}
+
{{ t("index.empty.lanHint") }}
+
+
+
+
+
+
+
+ {{ t("index.dragDrop.hint") }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -83,12 +161,16 @@ import {
startSendSession,
store,
updateAliasState,
+ showToast,
+ requestNotificationPermission,
} from "@/services/store";
import { getAgentInfoString } from "~/utils/userAgent";
import { protocolVersion } from "~/services/webrtc";
import { generateRandomAlias } from "~/utils/alias";
import { useFileDialog } from "@vueuse/core";
import SessionDialog from "~/components/dialog/SessionDialog.vue";
+import InputDialog from "~/components/dialog/InputDialog.vue";
+import Toast from "~/components/Toast.vue";
import {
cryptoKeyToPem,
generateClientTokenFromCurrentTimestamp,
@@ -108,68 +190,129 @@ const { t } = useI18n();
const { open: openFileDialog, onChange } = useFileDialog();
-onChange(async (files) => {
- if (!files) return;
+// --- Drag & Drop ---
+const isDragging = ref(false);
+let dragLeaveTimeout: ReturnType
| null = null;
- if (files.length === 0) return;
+const onDragOver = () => {
+ if (dragLeaveTimeout) clearTimeout(dragLeaveTimeout);
+ isDragging.value = true;
+};
- if (!store.signaling) return;
+const onDragLeave = () => {
+ dragLeaveTimeout = setTimeout(() => {
+ isDragging.value = false;
+ }, 100);
+};
- await startSendSession({
- files,
- targetId: targetId.value,
- onPin: async () => {
- return prompt(t("index.enterPin"));
- },
- });
-});
+const onDrop = async (e: DragEvent) => {
+ isDragging.value = false;
+ const files = e.dataTransfer?.files;
+ if (!files || files.length === 0) return;
-const minDelayFinished = ref(false);
-const webCryptoSupported = ref(true);
+ if (!store.signaling) {
+ showToast(t("index.dragDrop.noPeer"), "error");
+ return;
+ }
-const targetId = ref("");
+ if (store.peers.length === 0) {
+ showToast(t("index.dragDrop.noPeer"), "error");
+ return;
+ }
-const selectPeer = (id: string) => {
- targetId.value = id;
- openFileDialog();
+ // Send to the first available peer
+ const peerId = store.peers[0].id;
+ await startSendSession({
+ files,
+ targetId: peerId,
+ onPin: requestTransferPin,
+ });
};
-const updateAlias = async () => {
- if (!store.client) return;
+// --- Custom Input Dialogs ---
+const aliasDialogVisible = ref(false);
+const pinDialogVisible = ref(false);
+const transferPinDialogVisible = ref(false);
+let transferPinResolve: ((value: string | null) => void) | null = null;
- const current = store.client;
- if (!current) return;
+const showAliasDialog = () => {
+ aliasDialogVisible.value = true;
+};
- const alias = prompt(t("index.enterAlias"), current.alias);
- if (!alias || !store.signaling) return;
+const onAliasConfirm = (value: string) => {
+ aliasDialogVisible.value = false;
+ if (!value || !store.signaling || !store.client) return;
store.signaling.send({
type: "UPDATE",
info: {
- alias: alias,
- version: current.version,
- deviceModel: current.deviceModel,
- deviceType: current.deviceType,
- token: current.token,
+ alias: value,
+ version: store.client.version,
+ deviceModel: store.client.deviceModel,
+ deviceType: store.client.deviceType,
+ token: store.client.token,
},
});
- updateAliasState(alias);
+ updateAliasState(value);
};
-const updatePIN = async () => {
- const pin = prompt(t("index.enterPin"));
- if (typeof pin === "string") {
- store.pin = pin ? pin : null;
- }
+const showPinDialog = () => {
+ pinDialogVisible.value = true;
+};
+
+const onPinConfirm = (value: string) => {
+ pinDialogVisible.value = false;
+ store.pin = value ? value : null;
+};
+
+const requestTransferPin = (): Promise => {
+ return new Promise((resolve) => {
+ transferPinResolve = resolve;
+ transferPinDialogVisible.value = true;
+ });
+};
+
+const onTransferPinConfirm = (value: string) => {
+ transferPinDialogVisible.value = false;
+ transferPinResolve?.(value || null);
+ transferPinResolve = null;
+};
+
+const onTransferPinCancel = () => {
+ transferPinDialogVisible.value = false;
+ transferPinResolve?.(null);
+ transferPinResolve = null;
+};
+
+// --- File Selection via Click ---
+onChange(async (files) => {
+ if (!files) return;
+ if (files.length === 0) return;
+ if (!store.signaling) return;
+
+ await startSendSession({
+ files,
+ targetId: targetId.value,
+ onPin: requestTransferPin,
+ });
+});
+
+const minDelayFinished = ref(false);
+const webCryptoSupported = ref(true);
+const targetId = ref("");
+
+const selectPeer = (id: string) => {
+ targetId.value = id;
+ openFileDialog();
};
onMounted(async () => {
webCryptoSupported.value = isWebCryptoSupported();
+ requestNotificationPermission();
+
setTimeout(() => {
- // to prevent flickering during initial connection
- // i.e. show blank screen instead of "Connecting..."
minDelayFinished.value = true;
}, 1000);
@@ -198,9 +341,7 @@ onMounted(async () => {
await setupConnection({
url: runtimeConfig.public.signalingUrl,
info,
- onPin: async () => {
- return prompt(t("index.enterPin"));
- },
+ onPin: requestTransferPin,
});
});
@@ -214,4 +355,12 @@ onMounted(async () => {
transform: rotate(360deg);
}
}
+.fade-enter-active,
+.fade-leave-active {
+ transition: opacity 0.2s ease;
+}
+.fade-enter-from,
+.fade-leave-to {
+ opacity: 0;
+}
diff --git a/app/services/store.ts b/app/services/store.ts
index 68041d6..e2be5a9 100644
--- a/app/services/store.ts
+++ b/app/services/store.ts
@@ -29,6 +29,16 @@ export type FileState = {
error?: string;
};
+export type ToastMessage = {
+ id: number;
+ message: string;
+ type: "success" | "error" | "info";
+};
+
+export type ConnectionStatus = "connected" | "reconnecting" | "disconnected";
+
+let toastIdCounter = 0;
+
export const store = reactive({
// Whether the connection loop has started
_loopStarted: false,
@@ -53,6 +63,12 @@ export const store = reactive({
// List of peers connected to the same room
peers: [] as ClientInfo[],
+ // Connection status
+ connectionStatus: "disconnected" as ConnectionStatus,
+
+ // Toast notifications
+ toasts: [] as ToastMessage[],
+
// Current session information
session: {
state: SessionState.idle,
@@ -62,6 +78,32 @@ export const store = reactive({
},
});
+export function showToast(message: string, type: ToastMessage["type"] = "info") {
+ const id = ++toastIdCounter;
+ store.toasts.push({ id, message, type });
+ setTimeout(() => dismissToast(id), 4000);
+}
+
+export function dismissToast(id: number) {
+ store.toasts = store.toasts.filter((t) => t.id !== id);
+}
+
+function showBrowserNotification(title: string, body: string) {
+ if (typeof window === "undefined") return;
+ if (document.visibilityState === "visible") return;
+ if ("Notification" in window && Notification.permission === "granted") {
+ new Notification(title, { body, icon: "/apple-touch-icon.png" });
+ }
+}
+
+export function requestNotificationPermission() {
+ if (typeof window !== "undefined" && "Notification" in window) {
+ if (Notification.permission === "default") {
+ Notification.requestPermission();
+ }
+ }
+}
+
export async function setupConnection({
url,
info,
@@ -82,6 +124,7 @@ export async function setupConnection({
async function connectionLoop(url: string) {
while (true) {
try {
+ store.connectionStatus = "reconnecting";
store.signaling = await SignalingConnection.connect({
url: url,
info: store._proposingClient!,
@@ -90,6 +133,7 @@ async function connectionLoop(url: string) {
case "HELLO":
store.client = data.client;
store.peers = data.peers;
+ store.connectionStatus = "connected";
break;
case "JOIN":
store.peers = [...store.peers, data.peer];
@@ -120,13 +164,15 @@ async function connectionLoop(url: string) {
store.signaling = null;
store.client = null;
store.peers = [];
+ store.connectionStatus = "disconnected";
},
});
await store.signaling.waitUntilClose();
} catch (error) {
+ store.connectionStatus = "reconnecting";
console.log("Retrying connection in 5 seconds...");
- await new Promise((resolve) => setTimeout(resolve, 5000)); // Wait before retrying
+ await new Promise((resolve) => setTimeout(resolve, 5000));
}
}
}
@@ -192,6 +238,10 @@ export async function startSendSession({
},
onFileProgress: onFileProgress,
});
+ showToast("Transfer complete!", "success");
+ showBrowserNotification("LocalSend", "Files sent successfully!");
+ } catch (error) {
+ showToast("Transfer failed", "error");
} finally {
store.session.state = SessionState.idle;
}
@@ -250,6 +300,10 @@ export async function acceptOffer({
},
onFileProgress: onFileProgress,
});
+ showToast("Files received!", "success");
+ showBrowserNotification("LocalSend", "Files received successfully!");
+ } catch (error) {
+ showToast("Receiving failed", "error");
} finally {
store.session.state = SessionState.idle;
}
diff --git a/i18n/locales/en.json b/i18n/locales/en.json
index 51669a6..208e43d 100644
--- a/i18n/locales/en.json
+++ b/i18n/locales/en.json
@@ -20,7 +20,24 @@
"enterPin": "Enter PIN",
"progress": {
"titleSending": "Sending files...",
- "titleReceiving": "Receiving files..."
+ "titleReceiving": "Receiving files...",
+ "total": "Total",
+ "files": "Files",
+ "transferComplete": "Transfer complete!",
+ "transferFailed": "Transfer failed"
+ },
+ "connection": {
+ "connected": "Connected",
+ "reconnecting": "Reconnecting...",
+ "disconnected": "Disconnected"
+ },
+ "dragDrop": {
+ "hint": "Drop files here to send",
+ "noPeer": "No device available to send files"
+ },
+ "dialog": {
+ "confirm": "OK",
+ "cancel": "Cancel"
}
}
}