diff --git a/build-plugin.sh b/build-plugin.sh
index 783970d..fe9af14 100755
--- a/build-plugin.sh
+++ b/build-plugin.sh
@@ -2,7 +2,7 @@
set -eu
PLUGIN="u-navigator"
-VERSION="2026.06.23.r037"
+VERSION="2026.06.23.r038"
PKG_DIR="packages"
WORK_DIR=".plugin-build"
PKG_NAME="${PLUGIN}-${VERSION}.tgz"
diff --git a/packages/u-navigator-2026.06.23.r037.tgz b/packages/u-navigator-2026.06.23.r037.tgz
deleted file mode 100644
index 07f3ed5..0000000
Binary files a/packages/u-navigator-2026.06.23.r037.tgz and /dev/null differ
diff --git a/packages/u-navigator-2026.06.23.r038.tgz b/packages/u-navigator-2026.06.23.r038.tgz
new file mode 100644
index 0000000..c5affce
Binary files /dev/null and b/packages/u-navigator-2026.06.23.r038.tgz differ
diff --git a/plugin-root/usr/local/emhttp/plugins/u-navigator/api/common.php b/plugin-root/usr/local/emhttp/plugins/u-navigator/api/common.php
index 7661240..ea1fde8 100644
--- a/plugin-root/usr/local/emhttp/plugins/u-navigator/api/common.php
+++ b/plugin-root/usr/local/emhttp/plugins/u-navigator/api/common.php
@@ -211,6 +211,33 @@ function unav_guard_destination(string $source, string $destination): void {
}
}
+function unav_permission_options(array $data): array {
+ $mode = trim((string)($data['mode'] ?? ''));
+ $owner = trim((string)($data['owner'] ?? ''));
+ $group = trim((string)($data['group'] ?? ''));
+ $recursive = filter_var($data['recursive'] ?? false, FILTER_VALIDATE_BOOLEAN);
+
+ if ($mode === '' && $owner === '' && $group === '') {
+ unav_error(400, 'No permission changes requested');
+ }
+ if ($mode !== '' && !preg_match('/^[0-7]{3,4}$/', $mode)) {
+ unav_error(400, 'Invalid permission mode');
+ }
+ if ($owner !== '' && !preg_match('/^[A-Za-z0-9._-]+$/', $owner)) {
+ unav_error(400, 'Invalid owner');
+ }
+ if ($group !== '' && !preg_match('/^[A-Za-z0-9._-]+$/', $group)) {
+ unav_error(400, 'Invalid group');
+ }
+
+ return [
+ 'mode' => $mode === '' ? null : $mode,
+ 'owner' => $owner === '' ? null : $owner,
+ 'group' => $group === '' ? null : $group,
+ 'recursive' => $recursive,
+ ];
+}
+
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');
diff --git a/plugin-root/usr/local/emhttp/plugins/u-navigator/api/job.php b/plugin-root/usr/local/emhttp/plugins/u-navigator/api/job.php
index 062989b..685cf9b 100644
--- a/plugin-root/usr/local/emhttp/plugins/u-navigator/api/job.php
+++ b/plugin-root/usr/local/emhttp/plugins/u-navigator/api/job.php
@@ -6,17 +6,21 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
}
$action = $_GET['action'] ?? '';
-if (!in_array($action, ['move', 'copy', 'size'], true)) {
+if (!in_array($action, ['move', 'copy', 'size', 'permissions'], true)) {
unav_error(404, 'Unknown job action');
}
$data = unav_request_data();
$source = unav_existing_path($data['source'] ?? null);
$destination = null;
+$permissions = null;
if (in_array($action, ['move', 'copy'], true)) {
unav_assert_writable();
$destination = unav_destination_path($data['destination'] ?? null);
unav_guard_destination($source['path'], $destination['path']);
+} elseif ($action === 'permissions') {
+ unav_assert_writable();
+ $permissions = unav_permission_options($data);
}
$id = str_replace('.', '', uniqid($action . '-', true));
@@ -30,6 +34,7 @@ $job = [
'error' => null,
'source' => $source['path'],
'destination' => $destination['path'] ?? null,
+ 'permissions' => $permissions,
'createdAt' => $now,
'updatedAt' => $now,
];
diff --git a/plugin-root/usr/local/emhttp/plugins/u-navigator/api/job_worker.php b/plugin-root/usr/local/emhttp/plugins/u-navigator/api/job_worker.php
index 04b364e..59cd125 100644
--- a/plugin-root/usr/local/emhttp/plugins/u-navigator/api/job_worker.php
+++ b/plugin-root/usr/local/emhttp/plugins/u-navigator/api/job_worker.php
@@ -19,6 +19,8 @@ function unav_run_job(string $id): void {
} elseif ($job['type'] === 'size') {
$result = unav_worker_size($job['source'], $job);
$job['result'] = $result;
+ } elseif ($job['type'] === 'permissions') {
+ unav_worker_permissions($job['source'], $job['permissions'] ?? [], $job);
} else {
throw new RuntimeException('Unknown job action');
}
@@ -120,6 +122,57 @@ function unav_worker_size_recursive(string $path): array {
return $total;
}
+function unav_worker_permissions(string $source, array $options, array &$job): void {
+ $sourceReal = unav_worker_guard_source($source);
+ $mode = $options['mode'] ?? null;
+ $owner = $options['owner'] ?? null;
+ $group = $options['group'] ?? null;
+ $recursive = filter_var($options['recursive'] ?? false, FILTER_VALIDATE_BOOLEAN);
+
+ if ($mode === null && $owner === null && $group === null) {
+ throw new RuntimeException('No permission changes requested');
+ }
+
+ unav_worker_update($job, 'running', 10, 'Updating permissions');
+ $count = 0;
+ unav_worker_permissions_apply($sourceReal, $mode, $owner, $group, $recursive, $job, $count);
+ $job['result'] = ['changed' => $count];
+ unav_worker_update($job, 'running', 95, 'Permissions updated');
+}
+
+function unav_worker_permissions_apply(string $path, ?string $mode, ?string $owner, ?string $group, bool $recursive, array &$job, int &$count): void {
+ if (is_link($path)) {
+ return;
+ }
+ if (!file_exists($path)) {
+ throw new RuntimeException('Path not found');
+ }
+
+ if ($mode !== null && !chmod($path, intval($mode, 8))) {
+ throw new RuntimeException('Could not change permissions');
+ }
+ if ($owner !== null && !chown($path, $owner)) {
+ throw new RuntimeException('Could not change owner');
+ }
+ if ($group !== null && !chgrp($path, $group)) {
+ throw new RuntimeException('Could not change group');
+ }
+ $count++;
+ if ($count % 100 === 0) {
+ unav_worker_update($job, 'running', 50, 'Updating permissions');
+ }
+
+ if (!$recursive || !is_dir($path)) {
+ return;
+ }
+ foreach (scandir($path) ?: [] as $entry) {
+ if ($entry === '.' || $entry === '..') {
+ continue;
+ }
+ unav_worker_permissions_apply($path . '/' . $entry, $mode, $owner, $group, true, $job, $count);
+ }
+}
+
function unav_worker_copy(string $source, string $destination, array &$job): void {
if (is_link($source)) {
throw new RuntimeException('Copying symlinks is not allowed');
diff --git a/server/public/app.js b/server/public/app.js
index ffda5de..7f8d51d 100644
--- a/server/public/app.js
+++ b/server/public/app.js
@@ -343,7 +343,13 @@ function renderExplorer(body, win) {
row.addEventListener('contextmenu', (event) => {
event.preventDefault();
event.stopPropagation();
- selectEntry(win, entry, null, event);
+ if (selectedPathSet(win).has(entry.path)) {
+ state.selectedEntry = entry;
+ focusWindow(win);
+ updatePropertiesWindows();
+ } else {
+ selectEntry(win, entry, null, event);
+ }
showContextMenu(event, win, entry);
});
}
@@ -875,15 +881,22 @@ async function handleDrop(event, win, targetPath) {
}
}
-async function createJob(type, source, destination, refreshWindowIds = []) {
- if (['move', 'copy'].includes(type) && isReadOnly()) {
+async function createJob(type, source, destination = null, refreshWindowIds = [], extra = {}) {
+ if (['move', 'copy', 'permissions'].includes(type) && isReadOnly()) {
showError(new Error('Read-only mode ist aktiv.'));
return null;
}
- recordDebug('job.create.request', { type, source, destination, refreshWindowIds });
+ recordDebug('job.create.request', { type, source, destination, refreshWindowIds, extra });
const body = new URLSearchParams();
body.set('source', source);
- body.set('destination', destination);
+ if (destination !== null) {
+ body.set('destination', destination);
+ }
+ for (const [key, value] of Object.entries(extra)) {
+ if (value !== null && value !== undefined) {
+ body.set(key, String(value));
+ }
+ }
appendCsrf(body);
const job = await api(apiUrl(`job.php?action=${encodeURIComponent(type)}`), {
method: 'POST',
@@ -999,6 +1012,35 @@ async function promptTransfer(win, entry, type) {
await createJob(type, entry.path, `${targetDir.replace(/\/$/, '')}/${entry.name}`, []);
}
+async function promptPermissions(win, entry) {
+ if (isReadOnly()) {
+ showError(new Error('Read-only mode ist aktiv.'));
+ return;
+ }
+ const entries = selectedPathSet(win).has(entry.path) ? selectedEntries(win) : [entry];
+ const mode = prompt('Berechtigungen als Oktalmodus, z.B. 0644 oder 0755', entry.permissions ? `0${entry.permissions}`.slice(-4) : '');
+ if (mode === null) return;
+ const owner = prompt('Besitzer ändern, leer lassen für unverändert', entry.owner || '');
+ if (owner === null) return;
+ const group = prompt('Gruppe ändern, leer lassen für unverändert', entry.group || '');
+ if (group === null) return;
+ const hasDirectory = entries.some((item) => item.type === 'directory');
+ const recursive = hasDirectory && confirm('Rekursiv auf Unterordner und Dateien anwenden?');
+ const options = {
+ mode: mode.trim(),
+ owner: owner.trim(),
+ group: group.trim(),
+ recursive: recursive ? '1' : '0'
+ };
+ if (!options.mode && !options.owner && !options.group) {
+ showError(new Error('Keine Änderung angegeben.'));
+ return;
+ }
+ for (const item of entries) {
+ await createJob('permissions', item.path, null, [win.id], options);
+ }
+}
+
function showPropertiesForEntry(win, entry) {
selectEntry(win, entry);
openProperties(entry);
@@ -1034,6 +1076,7 @@ function renderContextMenu() {
actions.push(['Eigenschaften', () => showPropertiesForEntry(win, entry)]);
if (!isReadOnly()) {
actions.push(['Umbenennen', () => renameEntry(win, entry)]);
+ actions.push(['Berechtigungen...', () => promptPermissions(win, entry)]);
actions.push(['Kopieren nach...', () => promptTransfer(win, entry, 'copy')]);
actions.push(['Verschieben nach...', () => promptTransfer(win, entry, 'move')]);
}
diff --git a/u-navigator.plg b/u-navigator.plg
index 9a1f749..5781309 100644
--- a/u-navigator.plg
+++ b/u-navigator.plg
@@ -1,7 +1,7 @@
-
+
@@ -24,7 +24,7 @@
https://git.casaderoll.de/michael/Unraid-Navigator/raw/branch/main/packages/&package;
-6ae4016e68bf99a8cb0c64714b8cb647
+87661843082c0f41bd627a315278e346