Use background jobs for file transfers

This commit is contained in:
Mikei386
2026-06-23 06:52:25 +02:00
parent 11b76f4572
commit 3fec83cf73
9 changed files with 201 additions and 47 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
set -eu set -eu
PLUGIN="u-navigator" PLUGIN="u-navigator"
VERSION="2026.06.23.r003" VERSION="2026.06.23.r004"
PKG_DIR="packages" PKG_DIR="packages"
WORK_DIR=".plugin-build" WORK_DIR=".plugin-build"
PKG_NAME="${PLUGIN}-${VERSION}.tgz" PKG_NAME="${PLUGIN}-${VERSION}.tgz"
Binary file not shown.
Binary file not shown.
@@ -1,6 +1,7 @@
<?php <?php
const UNAV_DEFAULT_ROOT = '/mnt/user'; const UNAV_DEFAULT_ROOT = '/mnt/user';
const UNAV_JOB_DIR = '/tmp/u-navigator/jobs';
function unav_roots(): array { function unav_roots(): array {
$configured = getenv('U_NAV_ROOTS') ?: UNAV_DEFAULT_ROOT; $configured = getenv('U_NAV_ROOTS') ?: UNAV_DEFAULT_ROOT;
@@ -152,3 +153,35 @@ function unav_guard_destination(string $source, string $destination): void {
unav_error(400, 'Destination cannot be inside source'); unav_error(400, 'Destination cannot be inside source');
} }
} }
function unav_job_dir(): string {
if (!is_dir(UNAV_JOB_DIR) && !mkdir(UNAV_JOB_DIR, 0777, true) && !is_dir(UNAV_JOB_DIR)) {
unav_error(500, 'Could not create job directory');
}
return UNAV_JOB_DIR;
}
function unav_job_path(string $id): string {
if (!preg_match('/^[A-Za-z0-9._-]+$/', $id)) {
unav_error(400, 'Invalid job id');
}
return unav_job_dir() . '/' . $id . '.json';
}
function unav_job_read(string $id): array {
$path = unav_job_path($id);
if (!is_file($path)) {
unav_error(404, 'Job not found');
}
$job = json_decode(file_get_contents($path) ?: '{}', true);
if (!is_array($job)) {
unav_error(500, 'Invalid job state');
}
return $job;
}
function unav_job_write(array $job): void {
$id = $job['id'] ?? '';
$job['updatedAt'] = date(DATE_ATOM);
file_put_contents(unav_job_path($id), json_encode($job, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT), LOCK_EX);
}
@@ -1,20 +1,15 @@
<?php <?php
require_once __DIR__ . '/common.php'; require_once __DIR__ . '/common.php';
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
unav_json(unav_job_read($_GET['id'] ?? ''));
}
unav_assert_writable(); unav_assert_writable();
$action = $_GET['action'] ?? ''; $action = $_GET['action'] ?? '';
if ($_SERVER['REQUEST_METHOD'] === 'GET') { if (!in_array($action, ['move', 'copy'], true)) {
unav_json([ unav_error(404, 'Unknown job action');
'id' => $_GET['id'] ?? '0',
'type' => 'unknown',
'status' => 'completed',
'progress' => 100,
'message' => 'Completed',
'error' => null,
'createdAt' => date(DATE_ATOM),
'updatedAt' => date(DATE_ATOM),
]);
} }
$data = unav_request_json(); $data = unav_request_json();
@@ -22,24 +17,29 @@ $source = unav_existing_path($data['source'] ?? null);
$destination = unav_destination_path($data['destination'] ?? null); $destination = unav_destination_path($data['destination'] ?? null);
unav_guard_destination($source['path'], $destination['path']); unav_guard_destination($source['path'], $destination['path']);
if ($action === 'move') { $id = str_replace('.', '', uniqid($action . '-', true));
if (!rename($source['path'], $destination['path'])) { $now = date(DATE_ATOM);
unav_copy_recursive($source['path'], $destination['path']); $job = [
unav_delete_recursive($source['path']); 'id' => $id,
} 'type' => $action,
} elseif ($action === 'copy') { 'status' => 'queued',
unav_copy_recursive($source['path'], $destination['path']); 'progress' => 0,
'message' => 'Queued',
'error' => null,
'source' => $source['path'],
'destination' => $destination['path'],
'createdAt' => $now,
'updatedAt' => $now,
];
unav_job_write($job);
$worker = __DIR__ . '/job_worker.php';
$php = is_executable('/usr/bin/php') ? '/usr/bin/php' : (PHP_BINARY ?: 'php');
if (function_exists('exec') && is_file($worker)) {
exec(escapeshellarg($php) . ' ' . escapeshellarg($worker) . ' ' . escapeshellarg($id) . ' > /dev/null 2>&1 &');
} else { } else {
unav_error(404, 'Unknown job action'); require $worker;
unav_run_job($id);
} }
unav_json([ unav_json($job, 202);
'id' => (string)time(),
'type' => $action,
'status' => 'completed',
'progress' => 100,
'message' => 'Completed',
'error' => null,
'createdAt' => date(DATE_ATOM),
'updatedAt' => date(DATE_ATOM),
], 202);
@@ -0,0 +1,119 @@
<?php
require_once __DIR__ . '/common.php';
if (PHP_SAPI === 'cli' && realpath($_SERVER['SCRIPT_FILENAME'] ?? '') === __FILE__) {
unav_run_job($argv[1] ?? '');
}
function unav_run_job(string $id): void {
$job = unav_job_read($id);
try {
unav_worker_update($job, 'running', 5, 'Preparing');
unav_worker_guard($job['source'], $job['destination']);
if ($job['type'] === 'move') {
unav_worker_move($job['source'], $job['destination'], $job);
} elseif ($job['type'] === 'copy') {
unav_worker_copy($job['source'], $job['destination'], $job);
} else {
throw new RuntimeException('Unknown job action');
}
unav_worker_update($job, 'completed', 100, 'Completed');
} catch (Throwable $error) {
$job['error'] = $error->getMessage();
unav_worker_update($job, 'failed', 100, $error->getMessage());
}
}
function unav_worker_update(array &$job, string $status, int $progress, string $message): void {
$job['status'] = $status;
$job['progress'] = $progress;
$job['message'] = $message;
unav_job_write($job);
}
function unav_worker_guard(string $source, string $destination): void {
$sourceReal = realpath($source);
if ($sourceReal === false) {
throw new RuntimeException('Source not found');
}
unav_worker_assert_inside_root($sourceReal);
$parentReal = realpath(dirname($destination));
if ($parentReal === false) {
throw new RuntimeException('Destination parent not found');
}
unav_worker_assert_inside_root($parentReal);
$destinationPath = $parentReal . '/' . basename($destination);
if (file_exists($destinationPath)) {
throw new RuntimeException('Destination already exists');
}
if ($sourceReal === $destinationPath || str_starts_with($destinationPath, rtrim($sourceReal, '/') . '/')) {
throw new RuntimeException('Destination cannot be inside source');
}
}
function unav_worker_assert_inside_root(string $path): void {
foreach (unav_roots() as $root) {
if (unav_is_inside($path, $root['path'])) {
return;
}
}
throw new RuntimeException('Path is outside allowed roots');
}
function unav_worker_move(string $source, string $destination, array &$job): void {
unav_worker_update($job, 'running', 15, 'Moving');
if (@rename($source, $destination)) {
return;
}
unav_worker_copy($source, $destination, $job);
unav_worker_delete($source);
}
function unav_worker_copy(string $source, string $destination, array &$job): void {
if (is_link($source)) {
throw new RuntimeException('Copying symlinks is not allowed');
}
if (is_dir($source)) {
if (!mkdir($destination, 0777, true) && !is_dir($destination)) {
throw new RuntimeException('Could not create destination directory');
}
$entries = array_values(array_filter(scandir($source) ?: [], fn($entry) => $entry !== '.' && $entry !== '..'));
$total = max(count($entries), 1);
foreach ($entries as $index => $entry) {
unav_worker_copy($source . '/' . $entry, $destination . '/' . $entry, $job);
unav_worker_update($job, 'running', min(95, 10 + (int)((($index + 1) / $total) * 80)), 'Copying directory');
}
return;
}
if (!is_file($source)) {
throw new RuntimeException('Only files and directories can be copied');
}
unav_worker_update($job, 'running', 35, 'Copying file');
if (!copy($source, $destination)) {
throw new RuntimeException('Copy failed');
}
}
function unav_worker_delete(string $path): void {
if (is_link($path) || is_file($path)) {
if (!unlink($path)) {
throw new RuntimeException('Could not remove source after move');
}
return;
}
if (is_dir($path)) {
foreach (scandir($path) ?: [] as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
unav_worker_delete($path . '/' . $entry);
}
if (!rmdir($path)) {
throw new RuntimeException('Could not remove source directory after move');
}
}
}
+11 -15
View File
@@ -209,7 +209,7 @@ function renderExplorer(body, win) {
dropZone.addEventListener('dragover', (event) => { dropZone.addEventListener('dragover', (event) => {
if (hasDropPayload(event)) { if (hasDropPayload(event)) {
event.preventDefault(); event.preventDefault();
setDropEffect(event, event.altKey ? 'copy' : 'move'); setDropEffect(event, 'copy');
data.dragOver = true; data.dragOver = true;
dropZone.classList.add('drag-over'); dropZone.classList.add('drag-over');
} }
@@ -235,6 +235,9 @@ function renderExplorer(body, win) {
for (const row of body.querySelectorAll('.file-row')) { for (const row of body.querySelectorAll('.file-row')) {
const entry = data.entries.find((item) => item.path === row.dataset.path); const entry = data.entries.find((item) => item.path === row.dataset.path);
row.draggable = false;
row.addEventListener('dragstart', (event) => event.preventDefault());
row.addEventListener('selectstart', (event) => event.preventDefault());
row.addEventListener('pointerdown', (event) => { row.addEventListener('pointerdown', (event) => {
lockExplorerScroll(win, body); lockExplorerScroll(win, body);
startItemPointerDrag(event, win, entry); startItemPointerDrag(event, win, entry);
@@ -334,18 +337,6 @@ async function handleDrop(event, win, targetPath) {
return; return;
} }
const internalPath = state.draggedEntry?.path || '';
if (internalPath) {
const name = internalPath.split('/').filter(Boolean).at(-1);
const destination = `${targetPath.replace(/\/$/, '')}/${name}`;
if (internalPath === destination) {
throw new Error('Quelle und Ziel sind identisch.');
}
await createJob(event.altKey ? 'copy' : 'move', internalPath, destination);
await reloadExplorerWindows([win.id, state.draggedEntry?.sourceWindowId].filter(Boolean));
return;
}
const files = getDroppedFiles(event); const files = getDroppedFiles(event);
if (files.length) { if (files.length) {
const form = new FormData(); const form = new FormData();
@@ -378,7 +369,8 @@ async function reloadExplorerWindows(ids) {
} }
function hasDropPayload(event) { function hasDropPayload(event) {
return Boolean(state.draggedEntry || getDroppedFiles(event).length); const types = [...(event.dataTransfer?.types || [])];
return types.includes('Files') || getDroppedFiles(event).length > 0;
} }
function showError(error) { function showError(error) {
@@ -546,7 +538,7 @@ function updatePropertiesWindows() {
} }
function startItemPointerDrag(event, win, entry) { function startItemPointerDrag(event, win, entry) {
if (event.button !== 0) return; if (event.button !== 0 || !entry) return;
state.pointerDrag = { state.pointerDrag = {
sourceWindowId: win.id, sourceWindowId: win.id,
@@ -563,6 +555,9 @@ function startItemPointerDrag(event, win, entry) {
const up = (upEvent) => finishItemPointerDrag(upEvent, move, up); const up = (upEvent) => finishItemPointerDrag(upEvent, move, up);
document.addEventListener('pointermove', move); document.addEventListener('pointermove', move);
document.addEventListener('pointerup', up, { once: true }); document.addEventListener('pointerup', up, { once: true });
try {
event.currentTarget.setPointerCapture(event.pointerId);
} catch {}
} }
function updateItemPointerDrag(event) { function updateItemPointerDrag(event) {
@@ -597,6 +592,7 @@ async function finishItemPointerDrag(event, move, up) {
return; return;
} }
event.preventDefault();
state.suppressClickPath = drag.path; state.suppressClickPath = drag.path;
const target = findPointerDropTarget(event); const target = findPointerDropTarget(event);
if (!target) { if (!target) {
+6
View File
@@ -225,6 +225,7 @@
background: var(--surface); background: var(--surface);
color: var(--text); color: var(--text);
cursor: default; cursor: default;
-webkit-user-drag: none;
} }
.u-nav .file-row:hover, .u-nav .file-row:hover,
@@ -242,6 +243,7 @@
} }
.u-nav .file-row { .u-nav .file-row {
-webkit-user-drag: none;
user-select: none; user-select: none;
} }
@@ -257,7 +259,9 @@
align-items: center; align-items: center;
display: flex; display: flex;
gap: 8px; gap: 8px;
-webkit-user-drag: none;
min-width: 150px; min-width: 150px;
user-select: none;
} }
.u-nav .badge { .u-nav .badge {
@@ -269,6 +273,8 @@
justify-content: center; justify-content: center;
min-width: 58px; min-width: 58px;
padding: 2px 8px; padding: 2px 8px;
-webkit-user-drag: none;
user-select: none;
} }
.u-nav .context-menu { .u-nav .context-menu {
+2 -2
View File
@@ -1,7 +1,7 @@
<!DOCTYPE PLUGIN [ <!DOCTYPE PLUGIN [
<!ENTITY name "u-navigator"> <!ENTITY name "u-navigator">
<!ENTITY author "michael"> <!ENTITY author "michael">
<!ENTITY version "2026.06.23.r003"> <!ENTITY version "2026.06.23.r004">
<!ENTITY package "&name;-&version;.tgz"> <!ENTITY package "&name;-&version;.tgz">
]> ]>
<PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="https://git.casaderoll.de/michael/Unraid-Navigator/raw/branch/main/u-navigator.plg"> <PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="https://git.casaderoll.de/michael/Unraid-Navigator/raw/branch/main/u-navigator.plg">
@@ -14,7 +14,7 @@
<FILE Name="/boot/config/plugins/&name;/&package;"> <FILE Name="/boot/config/plugins/&name;/&package;">
<URL>https://git.casaderoll.de/michael/Unraid-Navigator/raw/branch/main/packages/&package;</URL> <URL>https://git.casaderoll.de/michael/Unraid-Navigator/raw/branch/main/packages/&package;</URL>
<MD5>c1ad74583e3a83dae808677aceb76d79</MD5> <MD5>da98c5f068d4f12dfddb0e7143a7aaa0</MD5>
</FILE> </FILE>
<FILE Run="/bin/bash"> <FILE Run="/bin/bash">