Skip to content

Commit cb1975e

Browse files
author
FolderView Plus Test
committed
Persist custom icon storage across reboot and upgrade
1 parent d63e9c9 commit cb1975e

11 files changed

Lines changed: 367 additions & 58 deletions

archive/folderview.plus-2026.04.11.01.txz.sha256

Lines changed: 0 additions & 1 deletion
This file was deleted.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
d637da0e9f97228b598d7274c765461a18d31ae124723c24b8caa35fa2eb1e10 folderview.plus-2026.04.14.13.txz

folderview.plus.plg

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,19 @@
66
<!ENTITY launch "Settings/FolderViewPlus">
77
<!ENTITY plugdir "/usr/local/emhttp/plugins/&name;">
88
<!ENTITY pluginURL "https://raw.githubusercontent.com/&github;/dev/folderview.plus.plg">
9-
<!ENTITY version "2026.04.14.12">
10-
<!ENTITY md5 "30d83c8cf8ca389091433e0bac0bb10c">
9+
<!ENTITY version "2026.04.14.13">
10+
<!ENTITY md5 "07376d047fc962390c36ae7661cd5085">
1111
]>
1212

1313
<PLUGIN name="&name;" author="&author;" version="&version;" launch="&launch;" pluginURL="&pluginURL;" icon="folder-icon.png" support="https://forums.unraid.net/topic/197631-plugin-folderview-plus/" min="7.0.0">
1414
<CHANGES>
1515

16+
###2026.04.14.13
17+
- UX: Folder editor flows, previews, and bootstrap behavior.
18+
- UX: Icon picker, bundled icon packs, and custom icon management.
19+
- Fix: Server endpoints, runtime payloads, and persistence or validation paths.
20+
21+
1622
###2026.04.14.12
1723
- Fix: Docker support-bundle snapshots, trace storage caps, and rendered-state diagnostics.
1824
- Fix: Docker runtime rows, folder state, and container interactions.

src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/Folder.page

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -697,7 +697,7 @@ try {
697697
<br>
698698
For 3rd-party icon folders, place files under <b>/usr/local/emhttp/plugins/folderview.plus/images/third-party-icons/&lt;folder-name&gt;/</b>.
699699
<br>
700-
Uploaded custom icons are saved in <b>/usr/local/emhttp/plugins/folderview.plus/images/custom/</b>.
700+
Uploaded custom icons are saved in <b>/boot/config/plugins/folderview.plus/images/custom/</b> and are restored into the live plugin path automatically after reboot or upgrade.
701701
<br>
702702
You can click the icon of the Docker/VM to set the folder icon.
703703
</p>

src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/lib.diagnostics.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -824,9 +824,10 @@ function diagnosticsBuildCustomIconUsageMap(): array {
824824
}
825825

826826
function diagnosticsBuildCustomIconStorage(string $privacyMode): array {
827-
global $sourceDir;
828827
$privacyMode = normalizeDiagnosticsPrivacyMode($privacyMode);
829-
$directory = "$sourceDir/images/custom";
828+
$directory = function_exists('fvplusCustomIconDirPath')
829+
? fvplusCustomIconDirPath()
830+
: '/boot/config/plugins/folderview.plus/images/custom';
830831
$descriptor = diagnosticsPathDescriptor($directory, $privacyMode);
831832
$extensions = diagnosticsCustomIconExtensions();
832833
$usageMap = diagnosticsBuildCustomIconUsageMap();

src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/lib.php

Lines changed: 306 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3872,11 +3872,306 @@ function createFile(string $type): void {
38723872
}
38733873
}
38743874

3875+
function fvplusAllowedCustomIconExtensions(): array {
3876+
return ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'avif'];
3877+
}
3878+
38753879
function fvplusCustomIconDirPath(): string {
3880+
global $configDir;
3881+
return "$configDir/images/custom";
3882+
}
3883+
3884+
function fvplusCustomIconRuntimeDirPath(): string {
38763885
global $sourceDir;
38773886
return "$sourceDir/images/custom";
38783887
}
38793888

3889+
function fvplusCustomIconRepairHintCommand(): string {
3890+
$dir = fvplusCustomIconDirPath();
3891+
return "mkdir -p " . escapeshellarg($dir) . " && chmod -R 775 " . escapeshellarg($dir);
3892+
}
3893+
3894+
function fvplusShouldManageCustomIconStorageEntry(string $name, bool $includeMetadata = true): bool {
3895+
$safeName = trim($name);
3896+
if ($safeName === '' || $safeName !== basename($safeName) || $safeName === '.' || $safeName === '..') {
3897+
return false;
3898+
}
3899+
if ($includeMetadata && $safeName === '.metadata.json') {
3900+
return true;
3901+
}
3902+
if ($safeName === 'README.txt') {
3903+
return true;
3904+
}
3905+
$extension = strtolower((string)pathinfo($safeName, PATHINFO_EXTENSION));
3906+
return $extension !== '' && in_array($extension, fvplusAllowedCustomIconExtensions(), true);
3907+
}
3908+
3909+
function fvplusCopyCustomIconStorageFile(string $sourcePath, string $targetPath): bool {
3910+
$targetDir = dirname($targetPath);
3911+
if (!is_dir($targetDir)) {
3912+
@mkdir($targetDir, 0770, true);
3913+
}
3914+
if (!@copy($sourcePath, $targetPath)) {
3915+
return false;
3916+
}
3917+
$mtime = (int)@filemtime($sourcePath);
3918+
if ($mtime > 0) {
3919+
@touch($targetPath, $mtime);
3920+
}
3921+
@chmod($targetPath, 0644);
3922+
return true;
3923+
}
3924+
3925+
function fvplusListCustomIconStorageEntries(string $directory, bool $includeMetadata = true): array {
3926+
$entries = [];
3927+
if (!is_dir($directory)) {
3928+
return $entries;
3929+
}
3930+
foreach ((array)@scandir($directory) as $name) {
3931+
if (!fvplusShouldManageCustomIconStorageEntry((string)$name, $includeMetadata)) {
3932+
continue;
3933+
}
3934+
$path = "$directory/$name";
3935+
if (!is_file($path)) {
3936+
continue;
3937+
}
3938+
$entries[(string)$name] = [
3939+
'path' => $path,
3940+
'mtime' => max(0, (int)@filemtime($path)),
3941+
'size' => max(0, (int)@filesize($path))
3942+
];
3943+
}
3944+
return $entries;
3945+
}
3946+
3947+
function fvplusMigrateRuntimeCustomIconsToPersistent(): array {
3948+
$runtimeDir = fvplusCustomIconRuntimeDirPath();
3949+
$storageDir = fvplusCustomIconDirPath();
3950+
$migratedFiles = [];
3951+
$migratedMetadata = false;
3952+
if (!is_dir($runtimeDir) || is_link($runtimeDir)) {
3953+
return [
3954+
'migratedFileCount' => 0,
3955+
'migratedMetadata' => false,
3956+
'migratedFiles' => []
3957+
];
3958+
}
3959+
if (!is_dir($storageDir)) {
3960+
@mkdir($storageDir, 0770, true);
3961+
}
3962+
$runtimeEntries = fvplusListCustomIconStorageEntries($runtimeDir, true);
3963+
foreach ($runtimeEntries as $name => $entry) {
3964+
if ($name === 'README.txt') {
3965+
continue;
3966+
}
3967+
$sourcePath = (string)($entry['path'] ?? '');
3968+
if ($sourcePath === '') {
3969+
continue;
3970+
}
3971+
$targetPath = "$storageDir/$name";
3972+
$shouldCopy = !is_file($targetPath);
3973+
if (!$shouldCopy) {
3974+
$targetSize = max(0, (int)@filesize($targetPath));
3975+
$targetMtime = max(0, (int)@filemtime($targetPath));
3976+
$shouldCopy = $targetSize !== (int)($entry['size'] ?? 0) || $targetMtime < (int)($entry['mtime'] ?? 0);
3977+
}
3978+
if (!$shouldCopy) {
3979+
continue;
3980+
}
3981+
if (!fvplusCopyCustomIconStorageFile($sourcePath, $targetPath)) {
3982+
continue;
3983+
}
3984+
if ($name === '.metadata.json') {
3985+
$migratedMetadata = true;
3986+
} else {
3987+
$migratedFiles[] = $name;
3988+
}
3989+
}
3990+
return [
3991+
'migratedFileCount' => count($migratedFiles),
3992+
'migratedMetadata' => $migratedMetadata,
3993+
'migratedFiles' => array_values($migratedFiles)
3994+
];
3995+
}
3996+
3997+
function fvplusRemoveRuntimeCustomIconDirForLink(): bool {
3998+
$runtimeDir = fvplusCustomIconRuntimeDirPath();
3999+
if (is_link($runtimeDir)) {
4000+
return @unlink($runtimeDir);
4001+
}
4002+
if (!is_dir($runtimeDir)) {
4003+
return !file_exists($runtimeDir);
4004+
}
4005+
foreach ((array)@scandir($runtimeDir) as $name) {
4006+
if ($name === '.' || $name === '..') {
4007+
continue;
4008+
}
4009+
if (!fvplusShouldManageCustomIconStorageEntry((string)$name, true)) {
4010+
return false;
4011+
}
4012+
$path = "$runtimeDir/$name";
4013+
if (!is_file($path) || !@unlink($path)) {
4014+
return false;
4015+
}
4016+
}
4017+
return @rmdir($runtimeDir);
4018+
}
4019+
4020+
function fvplusMirrorPersistentCustomIconsToRuntime(): array {
4021+
$storageDir = fvplusCustomIconDirPath();
4022+
$runtimeDir = fvplusCustomIconRuntimeDirPath();
4023+
$copied = 0;
4024+
$pruned = 0;
4025+
$runtimeParent = dirname($runtimeDir);
4026+
if (!is_dir($runtimeParent)) {
4027+
@mkdir($runtimeParent, 0770, true);
4028+
}
4029+
if (!is_dir($runtimeDir)) {
4030+
@mkdir($runtimeDir, 0770, true);
4031+
}
4032+
if (!is_dir($runtimeDir)) {
4033+
return [
4034+
'copiedCount' => 0,
4035+
'prunedCount' => 0,
4036+
'ready' => false
4037+
];
4038+
}
4039+
4040+
$storageEntries = fvplusListCustomIconStorageEntries($storageDir, false);
4041+
$runtimeEntries = fvplusListCustomIconStorageEntries($runtimeDir, false);
4042+
4043+
foreach ($storageEntries as $name => $entry) {
4044+
$sourcePath = (string)($entry['path'] ?? '');
4045+
$targetPath = "$runtimeDir/$name";
4046+
$targetExists = is_file($targetPath);
4047+
$targetSize = $targetExists ? max(0, (int)@filesize($targetPath)) : -1;
4048+
$targetMtime = $targetExists ? max(0, (int)@filemtime($targetPath)) : -1;
4049+
if ($targetExists && $targetSize === (int)($entry['size'] ?? 0) && $targetMtime >= (int)($entry['mtime'] ?? 0)) {
4050+
continue;
4051+
}
4052+
if (fvplusCopyCustomIconStorageFile($sourcePath, $targetPath)) {
4053+
$copied++;
4054+
}
4055+
}
4056+
4057+
foreach ($runtimeEntries as $name => $_entry) {
4058+
if (isset($storageEntries[$name])) {
4059+
continue;
4060+
}
4061+
if (@unlink("$runtimeDir/$name")) {
4062+
$pruned++;
4063+
}
4064+
}
4065+
4066+
return [
4067+
'copiedCount' => $copied,
4068+
'prunedCount' => $pruned,
4069+
'ready' => is_dir($runtimeDir) && is_readable($runtimeDir)
4070+
];
4071+
}
4072+
4073+
function fvplusEnsureCustomIconStorageReady(bool $requireWritable = false): array {
4074+
$storageDir = fvplusCustomIconDirPath();
4075+
$runtimeDir = fvplusCustomIconRuntimeDirPath();
4076+
$storageParent = dirname($storageDir);
4077+
$runtimeParent = dirname($runtimeDir);
4078+
$repairAttempted = false;
4079+
4080+
if (!is_dir($storageParent)) {
4081+
$repairAttempted = true;
4082+
@mkdir($storageParent, 0770, true);
4083+
}
4084+
if (!is_dir($storageDir)) {
4085+
$repairAttempted = true;
4086+
@mkdir($storageDir, 0770, true);
4087+
}
4088+
if (is_dir($storageDir) && !is_writable($storageDir)) {
4089+
$repairAttempted = true;
4090+
@chmod($storageDir, 0770);
4091+
}
4092+
4093+
$storageExists = is_dir($storageDir);
4094+
$storageWritable = $storageExists && is_writable($storageDir);
4095+
if (!$storageExists) {
4096+
throw new RuntimeException('Custom icon directory does not exist. Run: ' . fvplusCustomIconRepairHintCommand());
4097+
}
4098+
if ($requireWritable && !$storageWritable) {
4099+
throw new RuntimeException('Custom icon directory is not writable. Run: ' . fvplusCustomIconRepairHintCommand());
4100+
}
4101+
4102+
$migration = fvplusMigrateRuntimeCustomIconsToPersistent();
4103+
$repairAttempted = $repairAttempted
4104+
|| ((int)($migration['migratedFileCount'] ?? 0) > 0)
4105+
|| (($migration['migratedMetadata'] ?? false) === true);
4106+
4107+
$publicMode = 'missing';
4108+
$publicReady = false;
4109+
if (is_link($runtimeDir)) {
4110+
$runtimeResolved = @realpath($runtimeDir);
4111+
$storageResolved = @realpath($storageDir);
4112+
if (is_string($runtimeResolved) && $runtimeResolved !== '' && $runtimeResolved === $storageResolved) {
4113+
$publicMode = 'symlink';
4114+
$publicReady = true;
4115+
} else {
4116+
$repairAttempted = true;
4117+
@unlink($runtimeDir);
4118+
}
4119+
}
4120+
4121+
$symlinkCreated = false;
4122+
if (!$publicReady && function_exists('symlink')) {
4123+
if (!is_dir($runtimeParent)) {
4124+
$repairAttempted = true;
4125+
@mkdir($runtimeParent, 0770, true);
4126+
}
4127+
if (!file_exists($runtimeDir) || fvplusRemoveRuntimeCustomIconDirForLink()) {
4128+
$repairAttempted = true;
4129+
$symlinkCreated = @symlink($storageDir, $runtimeDir);
4130+
}
4131+
if ($symlinkCreated) {
4132+
$publicMode = 'symlink';
4133+
$publicReady = true;
4134+
}
4135+
}
4136+
4137+
$mirror = ['copiedCount' => 0, 'prunedCount' => 0, 'ready' => false];
4138+
if (!$publicReady) {
4139+
$repairAttempted = true;
4140+
$mirror = fvplusMirrorPersistentCustomIconsToRuntime();
4141+
$publicMode = 'mirror';
4142+
$publicReady = ($mirror['ready'] ?? false) === true;
4143+
}
4144+
4145+
return [
4146+
'storageDir' => $storageDir,
4147+
'runtimeDir' => $runtimeDir,
4148+
'storageExists' => $storageExists,
4149+
'storageWritable' => $storageWritable,
4150+
'publicMode' => $publicMode,
4151+
'publicReady' => $publicReady,
4152+
'repairAttempted' => $repairAttempted,
4153+
'repairSucceeded' => $storageExists && (!$requireWritable || $storageWritable) && $publicReady,
4154+
'repairHint' => fvplusCustomIconRepairHintCommand(),
4155+
'migratedFileCount' => (int)($migration['migratedFileCount'] ?? 0),
4156+
'migratedMetadata' => ($migration['migratedMetadata'] ?? false) === true,
4157+
'mirrorCopiedCount' => (int)($mirror['copiedCount'] ?? 0),
4158+
'mirrorPrunedCount' => (int)($mirror['prunedCount'] ?? 0)
4159+
];
4160+
}
4161+
4162+
function fvplusBootstrapCustomIconStorage(): void {
4163+
static $bootstrapped = false;
4164+
if ($bootstrapped) {
4165+
return;
4166+
}
4167+
$bootstrapped = true;
4168+
try {
4169+
fvplusEnsureCustomIconStorageReady(false);
4170+
} catch (Throwable $_error) {
4171+
// Keep runtime bootstrap non-fatal; diagnostics will surface path issues.
4172+
}
4173+
}
4174+
38804175
function fvplusRepairMissingCustomIconReferences(): array {
38814176
$customIconDir = fvplusCustomIconDirPath();
38824177
$existingIcons = [];
@@ -4049,13 +4344,13 @@ function repairPluginPaths(): array {
40494344
$created[] = $path;
40504345
}
40514346
}
4052-
$customIconDir = fvplusCustomIconDirPath();
4053-
if (!is_dir($customIconDir)) {
4054-
@mkdir($customIconDir, 0770, true);
4055-
$created[] = $customIconDir;
4056-
}
4057-
if (is_dir($customIconDir)) {
4058-
@chmod($customIconDir, 0770);
4347+
$customIconHealth = fvplusEnsureCustomIconStorageReady(false);
4348+
$customIconDir = (string)($customIconHealth['storageDir'] ?? fvplusCustomIconDirPath());
4349+
$customIconRuntimeDir = (string)($customIconHealth['runtimeDir'] ?? fvplusCustomIconRuntimeDirPath());
4350+
foreach ([$customIconDir, dirname($customIconDir)] as $path) {
4351+
if (is_string($path) && $path !== '' && !in_array($path, $created, true) && file_exists($path)) {
4352+
$created[] = $path;
4353+
}
40594354
}
40604355
foreach (FVPLUS_ALLOWED_TYPES as $type) {
40614356
createFile($type);
@@ -4064,10 +4359,13 @@ function repairPluginPaths(): array {
40644359
return [
40654360
'createdPaths' => $created,
40664361
'configDir' => $configDir,
4067-
'customIconDir' => $customIconDir
4362+
'customIconDir' => $customIconDir,
4363+
'customIconRuntimeDir' => $customIconRuntimeDir
40684364
];
40694365
}
40704366

4367+
fvplusBootstrapCustomIconStorage();
4368+
40714369
function getDockerTemplateCachePath(): string {
40724370
return fv3_cache_root() . '/docker-template-index/cache.json';
40734371
}

0 commit comments

Comments
 (0)