diff --git a/.github/workflows/build-persistentsd.yml b/.github/workflows/build-persistentsd.yml index 33323ac..437d04d 100644 --- a/.github/workflows/build-persistentsd.yml +++ b/.github/workflows/build-persistentsd.yml @@ -1,5 +1,17 @@ name: Build PersistentSD on: + pull_request: + branches: [main] + paths: + - 'PersistentSDManager.cs' + - 'PersistentSD.ps1' + - 'PersistentSDManager.exe.manifest' + - 'RazorBlade.ico' + - 'Build-PersistentSD.bat' + - 'README.md' + - 'TESTING.md' + - 'LICENSE' + - '.github/workflows/build-persistentsd.yml' push: branches: [main] paths: @@ -8,6 +20,9 @@ on: - 'PersistentSDManager.exe.manifest' - 'RazorBlade.ico' - 'Build-PersistentSD.bat' + - 'README.md' + - 'TESTING.md' + - 'LICENSE' - '.github/workflows/build-persistentsd.yml' workflow_dispatch: @@ -19,7 +34,19 @@ jobs: runs-on: windows-latest steps: - name: Check out source - uses: actions/checkout@v4 + uses: actions/checkout@v5 + + - name: Validate PowerShell template syntax + shell: pwsh + run: | + $tokens = $null + $errors = $null + [System.Management.Automation.Language.Parser]::ParseFile((Resolve-Path 'PersistentSD.ps1'), [ref]$tokens, [ref]$errors) | Out-Null + if ($errors.Count -gt 0) { + $errors | ForEach-Object { Write-Error $_.ToString() } + throw 'PowerShell template contains syntax errors.' + } + Write-Output 'PowerShell template syntax is valid.' - name: Compile Windows Forms application shell: pwsh @@ -44,7 +71,7 @@ jobs: Get-FileHash PersistentSD-build.zip -Algorithm SHA256 | Format-List - name: Upload build artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: PersistentSD-Windows-build path: PersistentSD-build.zip diff --git a/PersistentSD.ps1 b/PersistentSD.ps1 index 3c8aacc..f475714 100644 --- a/PersistentSD.ps1 +++ b/PersistentSD.ps1 @@ -71,31 +71,58 @@ function Set-PreferredDriveLetter { Write-Log 'PersistentSD started.' # Allow Windows storage discovery to settle; retry for up to about one minute. for ($attempt = 0; $attempt -lt 12; $attempt++) { - Set-PreferredDriveLetter - $disk = Get-TargetDisk - if ($disk) { - $hasLetter = @(Get-Partition -DiskNumber $disk.Number -ErrorAction SilentlyContinue | Where-Object { [string]$_.DriveLetter -eq $script:PreferredLetter }).Count -gt 0 - if ($hasLetter) { break } + try { + Set-PreferredDriveLetter + # Storage queries can fail transiently while Windows discovers a device. + # Keep the retry loop alive so the event watcher can still be registered. + $disk = Get-TargetDisk + if ($disk) { + $hasLetter = @(Get-Partition -DiskNumber $disk.Number -ErrorAction Stop | Where-Object { + [string]$_.DriveLetter -eq $script:PreferredLetter + }).Count -gt 0 + if ($hasLetter) { break } + } } - Start-Sleep -Seconds 5 + catch { + Write-Log ("Startup retry {0}/12 failed: {1}" -f ($attempt + 1), $_.Exception.Message) + } + if ($attempt -lt 11) { Start-Sleep -Seconds 5 } } +$watcherRegistered = $false try { Register-WmiEvent -Class Win32_VolumeChangeEvent -SourceIdentifier $script:EventSource -ErrorAction Stop | Out-Null + $watcherRegistered = $true Write-Log 'Volume arrival watcher registered.' +} +catch { + # A watcher failure must not terminate the task. Periodic retries still allow + # the selected device to receive its preferred letter when it becomes available. + Write-Log ("Volume arrival watcher registration failed; using periodic retries: {0}" -f $_.Exception.Message) +} + +try { while ($true) { - $event = Wait-Event -SourceIdentifier $script:EventSource -Timeout 60 - if ($event) { - if ($event.SourceEventArgs.NewEvent.EventType -eq 2) { - Start-Sleep -Seconds 2 + if ($watcherRegistered) { + $event = Wait-Event -SourceIdentifier $script:EventSource -Timeout 60 + if ($event) { + if ($event.SourceEventArgs.NewEvent.EventType -eq 2) { + Start-Sleep -Seconds 2 + Set-PreferredDriveLetter + } + Remove-Event -EventIdentifier $event.EventIdentifier -ErrorAction SilentlyContinue + } else { + # Periodic retry also covers cases where Windows misses an arrival event. Set-PreferredDriveLetter } - Remove-Event -EventIdentifier $event.EventIdentifier -ErrorAction SilentlyContinue } else { - # Periodic retry also covers cases where Windows misses an arrival event. + # Keep running even if WMI event registration is unavailable. + Start-Sleep -Seconds 60 Set-PreferredDriveLetter } } } -catch { Write-Log ("Watcher error: {0}" -f $_.Exception.Message); exit 1 } -finally { Unregister-Event -SourceIdentifier $script:EventSource -ErrorAction SilentlyContinue } +catch { Write-Log ("Runtime retry loop error: {0}" -f $_.Exception.Message); exit 1 } +finally { + if ($watcherRegistered) { Unregister-Event -SourceIdentifier $script:EventSource -ErrorAction SilentlyContinue } +} diff --git a/PersistentSDManager.cs b/PersistentSDManager.cs index f689518..0e3b8f8 100644 --- a/PersistentSDManager.cs +++ b/PersistentSDManager.cs @@ -215,6 +215,8 @@ private void InstallTask(object sender, EventArgs e) if (generated.Contains("__DEVICE_UNIQUE_ID_BASE64__") || generated.Contains("__PREFERRED_LETTER__")) throw new InvalidOperationException("The script template contains unresolved configuration placeholders. No files were changed."); string backupPath = null; + string targetScriptBackupPath = null; + bool targetScriptExisted = File.Exists(targetScript); string backupDir = Path.Combine(destination, "PersistentSD-Backups", DateTime.Now.ToString("yyyyMMdd-HHmmss")); // Always create a recovery point before replacing an existing task, even when // its script path cannot be resolved or the new target file does not exist yet. @@ -225,6 +227,7 @@ private void InstallTask(object sender, EventArgs e) { backupPath = Path.Combine(backupDir, Path.GetFileName(targetScript) + ".bak"); File.Copy(targetScript, backupPath, false); + targetScriptBackupPath = backupPath; } // If switching destinations, back up the old task's actual script too. Never remove it here. if (existingScript != null && File.Exists(existingScript) && !String.Equals(Path.GetFullPath(existingScript), Path.GetFullPath(targetScript), StringComparison.OrdinalIgnoreCase)) @@ -242,23 +245,45 @@ private void InstallTask(object sender, EventArgs e) } } File.WriteAllText(targetScript, generated, new UTF8Encoding(false)); - SavePreferences(disk.Id, letters.SelectedItem.ToString().TrimEnd(':')); string xml = BuildTaskXml(targetScript); string temp = Path.Combine(Path.GetTempPath(), "PersistentSD-" + Guid.NewGuid().ToString("N") + ".xml"); File.WriteAllText(temp, xml, new UTF8Encoding(false)); var p = Run("schtasks.exe", "/Create /TN " + Quote("\\" + (existingTask ? existingName : TaskName)) + " /XML " + Quote(temp) + " /F", false); try { File.Delete(temp); } catch { } if (p.ExitCode == 0) { + // Task registration succeeded. Saving preferences is separate; report a + // preference failure accurately without implying the task was not installed. + string preferenceWarning = ""; + try { SavePreferences(disk.Id, selectedLetter.TrimEnd(':')); } + catch (Exception preferenceError) { + preferenceWarning = "\n\nWARNING: The task was registered, but preferences could not be saved: " + preferenceError.Message + + "\nThe generated script still contains the selected device ID and drive letter. Resolve the error, then save preferences again."; + } string verified = FindExistingScript(); if (verified != null && String.Equals(Path.GetFullPath(verified), Path.GetFullPath(targetScript), StringComparison.OrdinalIgnoreCase)) { - status.Text = "Installed and verified. Task: " + (existingTask ? existingName : TaskName) + " | Letter: " + letters.SelectedItem + (backupPath == null ? "" : " | Backup: " + backupPath); - status.ForeColor = Color.LightGreen; - MessageBox.Show(this, "PersistentSD was installed and verified successfully.\n\nRestart Windows for the preferred drive-letter assignment to take effect at startup. If that letter is already being used by another volume, PersistentSD will leave that volume untouched and will not force the letter.", TaskName, MessageBoxButtons.OK, MessageBoxIcon.Information); + status.Text = "Task registered and script path verified. Task: " + (existingTask ? existingName : TaskName) + " | Letter: " + selectedLetter + (backupPath == null ? "" : " | Backup: " + backupPath); + status.ForeColor = preferenceWarning.Length == 0 ? Color.LightGreen : Color.Khaki; + MessageBox.Show(this, "PersistentSD task registration and script path verification succeeded." + preferenceWarning + "\n\nRestart Windows for the preferred drive-letter assignment to take effect at startup. If that letter is already being used by another volume, PersistentSD will leave that volume untouched and will not force the letter.", TaskName, MessageBoxButtons.OK, preferenceWarning.Length == 0 ? MessageBoxIcon.Information : MessageBoxIcon.Warning); } else { - status.Text = "Task registration returned success, but script path verification failed. Backup retained: " + (backupPath ?? "none"); + status.Text = "Task registration succeeded, but script path verification failed. Backup retained: " + (backupPath ?? "none"); status.ForeColor = Color.Khaki; - MessageBox.Show(this, status.Text + "\n\nReview Task Scheduler before relying on this setup.", TaskName, MessageBoxButtons.OK, MessageBoxIcon.Warning); + MessageBox.Show(this, status.Text + preferenceWarning + "\n\nThe task may already be active. Inspect Task Scheduler and its action before retrying; do not assume the task was rolled back.", TaskName, MessageBoxButtons.OK, MessageBoxIcon.Warning); + } + } + else { + string recovery = ""; + try { + if (targetScriptBackupPath != null && File.Exists(targetScriptBackupPath)) { + File.Copy(targetScriptBackupPath, targetScript, true); + recovery = " The previous script was restored from its backup."; + } else if (!targetScriptExisted && File.Exists(targetScript) && + (!existingTask || existingScript == null || !String.Equals(Path.GetFullPath(existingScript), Path.GetFullPath(targetScript), StringComparison.OrdinalIgnoreCase))) { + File.Delete(targetScript); + recovery = " The newly generated script was removed."; + } + } catch (Exception recoveryError) { + recovery = " Automatic script recovery failed: " + recoveryError.Message; } + MessageBox.Show(this, "Task creation failed. Preferences were not updated.\n\n" + p.Output + "\n\n" + recovery + "\n\nRun this utility as Administrator and inspect the backup folder before retrying.", TaskName, MessageBoxButtons.OK, MessageBoxIcon.Error); } - else MessageBox.Show(this, "Task creation failed. The generated script was saved, but the task was not confirmed.\n\n" + p.Output + "\n\nRun this utility as Administrator.", TaskName, MessageBoxButtons.OK, MessageBoxIcon.Error); RefreshStatus(); } catch (Exception ex) { MessageBox.Show(this, ex.Message, TaskName, MessageBoxButtons.OK, MessageBoxIcon.Error); } } diff --git a/README.md b/README.md index f5ba9d9..639893f 100644 --- a/README.md +++ b/README.md @@ -8,12 +8,12 @@ A lightweight Windows utility designed to configure a preferred drive letter and PersistentSD application window showing the scheduled task status -[Download v1.0](https://github.com/Cutter81/PersistentSD/releases/tag/v1.0) · [Report a problem](https://github.com/Cutter81/PersistentSD/issues) +[Download latest release](https://github.com/Cutter81/PersistentSD/releases) · [Report a problem](https://github.com/Cutter81/PersistentSD/issues) -> [!WARNING] -> **Read before installing:** PersistentSD may remove the selected drive letter from another non-protected volume to assign it to your chosen device. That can break shortcuts, applications, or other paths that depend on the previous letter. Double-check the disk and letter before confirming any changes. +> [!IMPORTANT] +> **Drive-letter safety:** The current runtime script checks whether the preferred letter is already assigned to a partition. If another partition owns it, PersistentSD logs the conflict and leaves that volume and its drive letter untouched. It does not force the reassignment. Always double-check the selected disk and preferred letter before installing. ## What PersistentSD is designed to do @@ -26,7 +26,7 @@ The manager uses the Windows disk `UniqueId` to identify the selected device rat - **Choose the device** from the detected Windows storage disks. - **Choose a preferred letter** from D: to Z:. - **Install or update** a startup task that runs as SYSTEM. -- **Check status** to inspect the scheduled task and the script it runs. +- **Check status** to inspect whether the task is registered and whether its script path can be resolved. This is not a complete health check of runtime drive assignment. - **Manage the task** from the app, including opening Task Scheduler or removing the task. - **Keep a safety trail** with backups of scripts being replaced and logs from the runtime script. @@ -34,12 +34,12 @@ The manager uses the Windows disk `UniqueId` to identify the selected device rat ### Option 1 — Download the release -1. Open the [PersistentSD v1.0 release page](https://github.com/Cutter81/PersistentSD/releases/tag/v1.0). -2. Download `PersistentSD-v1.0.zip` from the release assets. +1. Open the [PersistentSD releases page](https://github.com/Cutter81/PersistentSD/releases). +2. Download the ZIP attached to the release you intend to use. 3. Extract the ZIP to a folder you can keep in place. 4. Keep `PersistentSD.exe` and `PersistentSD.ps1` together in that extracted folder, then run the manager as **Administrator**. -The ZIP includes the application, script template, license, and quick-start instructions. You do not need to download the EXE or script separately. +The ZIP includes the application, script template, license, and README. You do not need to download the EXE or script separately. ### Option 2 — Build from source @@ -50,7 +50,7 @@ The ZIP includes the application, script template, license, and quick-start inst 3. Run `Build-PersistentSD.bat` on Windows. 4. After a successful build, launch `PersistentSD.exe` as **Administrator**. -The build script looks for the .NET Framework compiler in the standard 64-bit and 32-bit Windows locations. +The build script looks for the .NET Framework compiler in the standard 64-bit and 32-bit Windows locations. GitHub Actions also compiles and packages the app, but that automated build does not exercise real disk assignment behavior. ## How to use it @@ -59,7 +59,7 @@ The build script looks for the .NET Framework compiler in the standard 64-bit an 3. Select the preferred drive letter (**D:** through **Z:**). 4. Choose one of the offered Scripts folders. 5. Select **Save / Install / Update** and read the confirmation prompts carefully. -6. Use **Check Status** to review the registered task and script path. +6. Use **Check Status** to review whether the task and its script path can be found. Task Scheduler can also be opened from the app if you want to inspect the task directly. @@ -67,17 +67,21 @@ Task Scheduler can also be opened from the app if you want to inspect the task d | Area | What to know | | --- | --- | -| **Device matching** | The script is designed to match the selected disk using its Windows `UniqueId`. If it cannot find exactly one matching disk, it should make no drive-letter changes. | -| **Partition selection** | The script is designed to skip system, boot, and recovery partitions. Among eligible data partitions on the selected disk, it prefers one already using the chosen letter, then one with an existing drive letter, then the first eligible partition. | -| **Protected partitions** | System, boot, and recovery partitions are intended to be excluded from reassignment. | -| **Existing drive letters** | A preferred letter may be taken from another non-protected partition. Check what currently uses that letter first. | -| **Backups** | Existing target scripts are backed up before replacement. When changing script locations, the previously referenced script is backed up where applicable. | +| **Device matching** | The script matches the selected disk using its Windows `UniqueId`. If it cannot find exactly one matching disk, it should make no drive-letter changes. | +| **Partition selection** | The script skips partitions marked as system, boot, or recovery and certain partition types. Among eligible data partitions on the selected disk, it prefers one already using the chosen letter, then one with an existing drive letter, then the first eligible partition. | +| **Protected partitions** | System, boot, and recovery partitions are intended to be excluded from reassignment. Validate unusual disk layouts before relying on the utility. | +| **Existing drive letters** | If another partition already owns the preferred letter, the runtime script leaves it untouched and retries later. It does not remove the other partition's letter. | +| **Backups** | Existing target scripts and scheduled-task XML are backed up before replacement when the manager can identify and read them. Backups are recovery copies, not an automatic rollback mechanism. | +| **Failed updates** | If task registration fails, the manager attempts to restore the previous target script (or remove a newly created one) and does not save new preferences. Recovery itself can fail; inspect the error message, Task Scheduler, and backup folder before retrying. If registration succeeds but later verification fails, inspect the task and script manually. | | **Old files** | Previous scripts are not automatically deleted. Verify the new setup before manually removing any old copies. | -| **Failures** | Backups are retained if a later step fails; do not assume the app has automatically rolled everything back. | | **Disk state** | The script does not automatically bring an offline disk online or clear its read-only flag. | -| **Logging** | Runtime activity is written to `PersistentSD.log`. | +| **Logging** | Runtime activity is written to `PersistentSD.log` beside the generated script, subject to the script folder being writable. | -Preferences are stored at `%LOCALAPPDATA%\PersistentSD\preferences.txt`. The startup task runs as **SYSTEM**, so its configured script must remain at the registered path and accessible to that account. +Preferences are stored at `%LOCALAPPDATA%\PersistentSD\preferences.txt`. The startup task runs as **SYSTEM**, so its configured script must remain at the registered path and accessible to that account. The user-profile preference file is not the runtime task's source of truth; the generated script contains the selected device ID and drive letter. + +## Testing status + +The automated workflow verifies that the C# application compiles and that a ZIP can be packaged. It does not run the application or validate Windows storage changes. See the [manual Windows test plan](TESTING.md) for installation, failure-recovery, drive-letter conflict, and device reconnect checks. Treat behavior as unverified until the relevant tests have actually been run on a test system. ## Project files @@ -89,10 +93,7 @@ Preferences are stored at `%LOCALAPPDATA%\PersistentSD\preferences.txt`. The sta | `RazorBlade.ico` | Application icon | | `PersistentSD.ps1` | PowerShell script template used by the manager | | `PersistentSD.png` | Application screenshot displayed above | - -## Testing notes - -This README describes the software's intended behavior and documented safeguards; it is not a claim that every Windows version, disk layout, or failure scenario has been exhaustively tested. Before installing or updating the startup task, verify the selected disk and current drive-letter assignments, and read each confirmation prompt carefully. +| `TESTING.md` | Manual Windows validation checklist | ## Feedback diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..d616259 --- /dev/null +++ b/TESTING.md @@ -0,0 +1,57 @@ +# PersistentSD Manual Test Plan + +This checklist is for validating a build on a disposable test setup before publishing a release. The GitHub Actions build verifies compilation and packaging; it does **not** execute these Windows storage-behavior tests. + +## Before testing + +- Use a test PC or disposable Windows installation where possible. +- Back up important data and record current drive-letter assignments. +- Do not use a disk containing irreplaceable data for destructive or ambiguous test cases. +- Run the manager elevated, and keep the release ZIP and source/build artifact separate from any existing installed copy. +- Record Windows version, build commit, test result, and relevant PersistentSD.log entries. + +## Manager and configuration + +- [ ] Launch the manager as Administrator. +- [ ] Refresh the disk list; confirm device names, sizes, and IDs are understandable. +- [ ] Confirm a saved device selection and preferred letter survive closing/reopening the manager. +- [ ] Confirm the profile/Documents script-location preference survives closing/reopening. +- [ ] Confirm the UI rejects missing devices, missing letters, and unsupported letters. +- [ ] Confirm Check Status clearly reports missing tasks and unresolved script paths. +- [ ] Confirm Remove Task requires confirmation and leaves scripts/configuration intact. + +## Installation and recovery + +- [ ] Install with no existing task and a valid destination. +- [ ] Confirm the task exists, its action points to the generated script, and it runs as SYSTEM at startup. +- [ ] Update an existing task in the same destination; verify a backup of the old script and task XML is created. +- [ ] Update an existing task whose script is in the other supported destination; verify the prompt and backups are correct. +- [ ] Test with an existing task whose action cannot be parsed or whose script is missing. Confirm the app does not silently claim the task is healthy. +- [ ] Test a read-only/unwritable destination and a task-registration failure. Confirm preferences are not saved on registration failure and the manager attempts to restore the prior script or remove a newly created script; record any recovery failure. +- [ ] Confirm backups are readable and no previous script is deleted automatically. + +## Drive-letter safety (use disposable test disks) + +- [ ] With the preferred letter free, confirm the intended eligible data partition receives it. +- [ ] With the preferred letter owned by a different volume, confirm that volume keeps its letter and PersistentSD logs the conflict. +- [ ] Confirm system, boot, recovery, and reserved partitions are not selected for reassignment. +- [ ] Test the selected disk offline; confirm PersistentSD does not bring it online automatically. +- [ ] Disconnect/reconnect the selected removable disk and confirm the arrival watcher and periodic retry behave as expected. +- [ ] Test a disk that is absent at startup and connected later. +- [ ] Test a disk with multiple eligible partitions and verify the chosen partition matches the documented selection order. +- [ ] Test a missing, duplicate, or changed disk UniqueId; confirm no drive-letter changes occur unless exactly one disk matches. +- [ ] Inspect logs for useful error messages when storage queries or assignment attempts fail. +- [ ] Simulate volume-arrival watcher registration failure where practical; confirm the task stays running and falls back to periodic retries rather than exiting. + +## Release checks + +- [ ] Confirm the build workflow succeeds on the exact commit intended for release. +- [ ] Download and extract the packaged ZIP on a clean test account or test machine. +- [ ] Confirm the ZIP contains the EXE, PowerShell template, README, and license, with the EXE and template together. +- [ ] Test the exact ZIP intended for publication, not only an EXE built locally. +- [ ] Publish a new version tag/release for changed binaries; do not overwrite an existing version's asset. +- [ ] Update release notes to distinguish successful compilation from runtime testing. + +## Reporting results + +For each failed or skipped item, record the step, Windows version, expected result, actual result, and relevant log output. Do not describe runtime behavior as verified unless the corresponding test was actually performed.