Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 29 additions & 2 deletions .github/workflows/build-persistentsd.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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:

Expand All @@ -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
Expand All @@ -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
Expand Down
55 changes: 41 additions & 14 deletions PersistentSD.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
39 changes: 32 additions & 7 deletions PersistentSDManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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))
Expand All @@ -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); }
}
Expand Down
Loading
Loading