Use background jobs for file transfers
This commit is contained in:
+1
-1
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user