Use background jobs for file transfers
This commit is contained in:
+1
-1
@@ -2,7 +2,7 @@
|
||||
set -eu
|
||||
|
||||
PLUGIN="u-navigator"
|
||||
VERSION="2026.06.23.r003"
|
||||
VERSION="2026.06.23.r004"
|
||||
PKG_DIR="packages"
|
||||
WORK_DIR=".plugin-build"
|
||||
PKG_NAME="${PLUGIN}-${VERSION}.tgz"
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
const UNAV_DEFAULT_ROOT = '/mnt/user';
|
||||
const UNAV_JOB_DIR = '/tmp/u-navigator/jobs';
|
||||
|
||||
function unav_roots(): array {
|
||||
$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');
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
require_once __DIR__ . '/common.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
||||
unav_json(unav_job_read($_GET['id'] ?? ''));
|
||||
}
|
||||
|
||||
unav_assert_writable();
|
||||
|
||||
$action = $_GET['action'] ?? '';
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
||||
unav_json([
|
||||
'id' => $_GET['id'] ?? '0',
|
||||
'type' => 'unknown',
|
||||
'status' => 'completed',
|
||||
'progress' => 100,
|
||||
'message' => 'Completed',
|
||||
'error' => null,
|
||||
'createdAt' => date(DATE_ATOM),
|
||||
'updatedAt' => date(DATE_ATOM),
|
||||
]);
|
||||
if (!in_array($action, ['move', 'copy'], true)) {
|
||||
unav_error(404, 'Unknown job action');
|
||||
}
|
||||
|
||||
$data = unav_request_json();
|
||||
@@ -22,24 +17,29 @@ $source = unav_existing_path($data['source'] ?? null);
|
||||
$destination = unav_destination_path($data['destination'] ?? null);
|
||||
unav_guard_destination($source['path'], $destination['path']);
|
||||
|
||||
if ($action === 'move') {
|
||||
if (!rename($source['path'], $destination['path'])) {
|
||||
unav_copy_recursive($source['path'], $destination['path']);
|
||||
unav_delete_recursive($source['path']);
|
||||
}
|
||||
} elseif ($action === 'copy') {
|
||||
unav_copy_recursive($source['path'], $destination['path']);
|
||||
$id = str_replace('.', '', uniqid($action . '-', true));
|
||||
$now = date(DATE_ATOM);
|
||||
$job = [
|
||||
'id' => $id,
|
||||
'type' => $action,
|
||||
'status' => 'queued',
|
||||
'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 {
|
||||
unav_error(404, 'Unknown job action');
|
||||
require $worker;
|
||||
unav_run_job($id);
|
||||
}
|
||||
|
||||
unav_json([
|
||||
'id' => (string)time(),
|
||||
'type' => $action,
|
||||
'status' => 'completed',
|
||||
'progress' => 100,
|
||||
'message' => 'Completed',
|
||||
'error' => null,
|
||||
'createdAt' => date(DATE_ATOM),
|
||||
'updatedAt' => date(DATE_ATOM),
|
||||
], 202);
|
||||
unav_json($job, 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
@@ -209,7 +209,7 @@ function renderExplorer(body, win) {
|
||||
dropZone.addEventListener('dragover', (event) => {
|
||||
if (hasDropPayload(event)) {
|
||||
event.preventDefault();
|
||||
setDropEffect(event, event.altKey ? 'copy' : 'move');
|
||||
setDropEffect(event, 'copy');
|
||||
data.dragOver = true;
|
||||
dropZone.classList.add('drag-over');
|
||||
}
|
||||
@@ -235,6 +235,9 @@ function renderExplorer(body, win) {
|
||||
|
||||
for (const row of body.querySelectorAll('.file-row')) {
|
||||
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) => {
|
||||
lockExplorerScroll(win, body);
|
||||
startItemPointerDrag(event, win, entry);
|
||||
@@ -334,18 +337,6 @@ async function handleDrop(event, win, targetPath) {
|
||||
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);
|
||||
if (files.length) {
|
||||
const form = new FormData();
|
||||
@@ -378,7 +369,8 @@ async function reloadExplorerWindows(ids) {
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -546,7 +538,7 @@ function updatePropertiesWindows() {
|
||||
}
|
||||
|
||||
function startItemPointerDrag(event, win, entry) {
|
||||
if (event.button !== 0) return;
|
||||
if (event.button !== 0 || !entry) return;
|
||||
|
||||
state.pointerDrag = {
|
||||
sourceWindowId: win.id,
|
||||
@@ -563,6 +555,9 @@ function startItemPointerDrag(event, win, entry) {
|
||||
const up = (upEvent) => finishItemPointerDrag(upEvent, move, up);
|
||||
document.addEventListener('pointermove', move);
|
||||
document.addEventListener('pointerup', up, { once: true });
|
||||
try {
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function updateItemPointerDrag(event) {
|
||||
@@ -597,6 +592,7 @@ async function finishItemPointerDrag(event, move, up) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
state.suppressClickPath = drag.path;
|
||||
const target = findPointerDropTarget(event);
|
||||
if (!target) {
|
||||
|
||||
@@ -225,6 +225,7 @@
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
cursor: default;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
|
||||
.u-nav .file-row:hover,
|
||||
@@ -242,6 +243,7 @@
|
||||
}
|
||||
|
||||
.u-nav .file-row {
|
||||
-webkit-user-drag: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
@@ -257,7 +259,9 @@
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
-webkit-user-drag: none;
|
||||
min-width: 150px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.u-nav .badge {
|
||||
@@ -269,6 +273,8 @@
|
||||
justify-content: center;
|
||||
min-width: 58px;
|
||||
padding: 2px 8px;
|
||||
-webkit-user-drag: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.u-nav .context-menu {
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
<!DOCTYPE PLUGIN [
|
||||
<!ENTITY name "u-navigator">
|
||||
<!ENTITY author "michael">
|
||||
<!ENTITY version "2026.06.23.r003">
|
||||
<!ENTITY version "2026.06.23.r004">
|
||||
<!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">
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
<FILE Name="/boot/config/plugins/&name;/&package;">
|
||||
<URL>https://git.casaderoll.de/michael/Unraid-Navigator/raw/branch/main/packages/&package;</URL>
|
||||
<MD5>c1ad74583e3a83dae808677aceb76d79</MD5>
|
||||
<MD5>da98c5f068d4f12dfddb0e7143a7aaa0</MD5>
|
||||
</FILE>
|
||||
|
||||
<FILE Run="/bin/bash">
|
||||
|
||||
Reference in New Issue
Block a user