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
12 changes: 12 additions & 0 deletions v2/mobile/src/lib/appFiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,18 @@ import type { FindResult } from "./types";
// Single source with desktop's Files tab: read the catalog, don't copy it.
import catalog from "../../../src/lib/app-files-catalog.json";

export interface FileConnection {
serial: string;
generation: number;
}

export function fileConnectionMatches(
target: FileConnection,
current: { serial: string | null; generation: number; isConnected: boolean },
): boolean {
return current.isConnected && target.serial === current.serial && target.generation === current.generation;
}

export interface AppFilesEntry {
id: string;
package: string;
Expand Down
79 changes: 53 additions & 26 deletions v2/mobile/src/screens/Files.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script lang="ts">
import { onMount } from "svelte";
import { onDestroy, onMount } from "svelte";
import { api } from "../lib/api";
import { session } from "../lib/session.svelte";
import type { FileEntry, FindResult, PulledFile } from "../lib/types";
Expand All @@ -8,10 +8,12 @@
appFilesCatalog,
baseName,
canDeleteOnTv,
fileConnectionMatches,
parentDir,
summarizeFind,
unsearchedMessage,
type AppFilesEntry,
type FileConnection,
} from "../lib/appFiles";
import ConfirmDialog from "../components/ConfirmDialog.svelte";
import FindRemoteButton from "../components/FindRemoteButton.svelte";
Expand All @@ -33,6 +35,19 @@
let entries = $state<FileEntry[]>([]);
let loadGeneration = 0;
let watchedSerial = session.serial;
let watchedGeneration = session.generation;
let watchedLive = session.isConnected;
let destroyed = false;

function current(target: FileConnection) {
return !destroyed && fileConnectionMatches(target, session);
}

onDestroy(() => {
destroyed = true;
++loadGeneration;
clearTimeout(toastTimer);
});

// Files pulled this session, newest first, plus the set of remote paths we've
// pulled so rows can show a check.
Expand All @@ -47,7 +62,7 @@

// A delete waiting on the confirmation dialog. Carries its own serial so a
// TV switch while the dialog is open can't redirect it.
let pendingDelete = $state<{ serial: string; path: string; name: string; isDir: boolean } | null>(
let pendingDelete = $state<(FileConnection & { path: string; name: string; isDir: boolean }) | null>(
null,
);
let deleting = $state(false);
Expand All @@ -65,9 +80,10 @@

async function load(target: string) {
const serial = session.serial;
const connection = { serial, generation: session.generation };
const generation = ++loadGeneration;
path = target;
if (!serial) {
if (!serial || !session.isConnected) {
error = "No TV connected. Go back and connect first.";
entries = [];
loading = false;
Expand All @@ -77,15 +93,15 @@
error = "";
try {
const rows = await api.listRemoteDir(serial, target);
if (generation !== loadGeneration || serial !== session.serial) return;
if (generation !== loadGeneration || !current(connection)) return;
entries = rows;
loadedPath = target;
} catch (e) {
if (generation !== loadGeneration || serial !== session.serial) return;
if (generation !== loadGeneration || !current(connection)) return;
error = String(e);
entries = [];
} finally {
if (generation === loadGeneration && serial === session.serial) loading = false;
if (generation === loadGeneration && current(connection)) loading = false;
}
}

Expand All @@ -95,8 +111,12 @@
// breadcrumb and the pulled-this-session markers all belonged to the old TV.
$effect(() => {
const serial = session.serial;
if (serial === watchedSerial) return;
const generation = session.generation;
const live = session.isConnected;
if (serial === watchedSerial && generation === watchedGeneration && live === watchedLive) return;
watchedSerial = serial;
watchedGeneration = generation;
watchedLive = live;
loadedPath = ROOT;
entries = [];
pulled = [];
Expand All @@ -105,6 +125,7 @@
appResults = {};
appBusy = "";
pendingDelete = null;
deleting = false;
void load(ROOT);
});

Expand Down Expand Up @@ -135,17 +156,19 @@
}

async function pull(e: FileEntry) {
if (busyName || e.is_dir || !session.serial) return;
if (busyName || e.is_dir || !session.isConnected) return;
const target = { serial: session.serial, generation: session.generation };
const remote = remotePathOf(e);
busyName = e.name;
try {
const file = await api.pullFile(session.serial, remote);
const file = await api.pullFile(target.serial, remote);
if (!current(target)) return;
recordPulled(file, remote);
showToast(`Copied ${file.name} into this app's storage.`, "success");
} catch (err) {
showToast(String(err), "error");
if (current(target)) showToast(String(err), "error");
} finally {
busyName = "";
if (current(target)) busyName = "";
}
}

Expand All @@ -156,68 +179,72 @@

async function findAppFiles(entry: AppFilesEntry) {
const serial = session.serial;
if (appBusy || !serial) return;
const target = { serial, generation: session.generation };
if (appBusy || !serial || !session.isConnected) return;
appBusy = entry.id;
try {
const result = await api.findFiles(serial, entry.search_dirs, entry.pattern);
if (serial !== session.serial) return;
if (!current(target)) return;
appResults = { ...appResults, [entry.id]: result };
} catch (err) {
if (serial !== session.serial) return;
if (!current(target)) return;
// A refused request (or a lost connection) is not a search result — drop
// any older answer rather than leave it looking current.
const { [entry.id]: _dropped, ...rest } = appResults;
appResults = rest;
showToast(String(err), "error");
} finally {
if (serial === session.serial) appBusy = "";
if (current(target)) appBusy = "";
}
}

async function pullFound(remote: string) {
const serial = session.serial;
if (appBusy || busyName || !serial) return;
const target = { serial, generation: session.generation };
if (appBusy || busyName || !serial || !session.isConnected) return;
appBusy = remote;
try {
const file = await api.pullFile(serial, remote);
if (serial !== session.serial) return;
if (!current(target)) return;
recordPulled(file, remote);
showToast(`Copied ${file.name} into this app's storage.`, "success");
} catch (err) {
if (serial === session.serial) showToast(String(err), "error");
if (current(target)) showToast(String(err), "error");
} finally {
if (serial === session.serial) appBusy = "";
if (current(target)) appBusy = "";
}
}

function askDelete(remote: string, isDir: boolean) {
const serial = session.serial;
if (!serial || deleting || !canDeleteOnTv(remote)) return;
pendingDelete = { serial, path: remote, name: baseName(remote), isDir };
if (!serial || !session.isConnected || deleting || !canDeleteOnTv(remote)) return;
pendingDelete = { serial, generation: session.generation, path: remote, name: baseName(remote), isDir };
}

async function confirmDelete() {
const target = pendingDelete;
pendingDelete = null;
if (!target || deleting) return;
if (target.serial !== session.serial) {
if (!current(target)) {
showToast("The TV changed before the delete was confirmed. Nothing was deleted.", "error");
return;
}
deleting = true;
busyName = target.name;
try {
const r = await api.deletePath(target.serial, target.path);
if (target.serial !== session.serial) return;
if (!current(target)) return;
showToast(r.message, r.ok ? "success" : "error");
// Found-file lists may now point at a file that is gone.
appResults = {};
if (r.ok) await load(loadedPath);
} catch (err) {
if (target.serial === session.serial) showToast(String(err), "error");
if (current(target)) showToast(String(err), "error");
} finally {
deleting = false;
busyName = "";
if (current(target)) {
deleting = false;
busyName = "";
}
}
}

Expand Down
27 changes: 27 additions & 0 deletions v2/mobile/tests/file-connection.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import assert from "node:assert/strict";
import { after, before, test } from "node:test";
import { startViteServer } from "./helpers/vite-harness.mjs";

let server;
let matches;
before(async () => {
({ server } = await startViteServer());
({ fileConnectionMatches: matches } = await server.ssrLoadModule("/src/lib/appFiles.ts"));
});
after(async () => server?.close());

const target = { serial: "A:5555", generation: 10 };

test("file results belong to the live connection that started them", () => {
assert.equal(matches(target, { ...target, isConnected: true }), true);
assert.equal(matches(target, { ...target, serial: "B:5555", isConnected: true }), false);
assert.equal(matches(target, { ...target, serial: null, isConnected: false }), false);
});

test("reconnecting to the same endpoint invalidates an old file action", () => {
assert.equal(matches(target, { ...target, generation: 11, isConnected: true }), false);
});

test("a lost connection cannot accept a file result or delete confirmation", () => {
assert.equal(matches(target, { ...target, isConnected: false }), false);
});
32 changes: 32 additions & 0 deletions v2/mobile/tests/files.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -245,3 +245,35 @@ test("a delete confirmed after a TV switch is not sent to the new TV", async (t)
await page.evaluate(() => new Promise((resolve) => setTimeout(resolve, 50)));
assert.equal((await callsOf(page, "delete_path")).length, 0);
});

test("reconnecting to the same endpoint clears an old delete confirmation", async (t) => {
const page = await open(t);
await page.getByRole("button", { name: "Delete old.zip from the TV" }).click();
await page.getByText("Delete file from the TV?").waitFor();
await page.evaluate(async () => {
const confirm = document.querySelector(".dialog-actions .danger-btn");
const reconnect = window.session.connect("A", 5555);
// The old dialog can still receive a click before Svelte flushes the reconnect.
confirm.click();
await reconnect;
});
await page.waitForFunction(() => !document.body.innerText.includes("Delete file from the TV?"));
assert.equal((await callsOf(page, "delete_path")).length, 0);
});

test("a copy completing after a TV switch does not mark the new TV's row copied", async (t) => {
const page = await open(t);
await page.evaluate(() => {
window.handlers.pull_file = () => new Promise((resolve) => { window.finishOldPull = resolve; });
});
await page.locator(".file-row").filter({ hasText: "old.zip" }).click();
await page.waitForFunction(() => typeof window.finishOldPull === "function");
await page.evaluate(async () => {
await window.session.connect("B", 5555);
window.finishOldPull({ name: "old.zip", path: "/app/downloads/old.zip", size_bytes: 10 });
});
await page.locator(".file-row").filter({ hasText: "old.zip" }).waitFor();
await page.evaluate(() => new Promise((resolve) => setTimeout(resolve, 50)));
assert.equal(await page.getByText("Copied old.zip into this app's storage.").count(), 0);
assert.equal(await page.locator(".dl-list").count(), 0);
});
Loading