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
1 change: 1 addition & 0 deletions design/mockups/per-target-launch-options/APPROVED
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
2026-07-08 feat/launch-commands-editor approved 02-dark-pro.html
2026-07-08 fix/product-safety-review reused 02-dark-pro.html for non-visual drop safety changes
29 changes: 21 additions & 8 deletions src/Dropwheel/Services/FileOps.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.IO;
using System.Runtime.InteropServices;
using Dropwheel.Models;

Expand All @@ -9,7 +10,7 @@ public static class FileOps
{
private const uint FO_MOVE = 0x0001, FO_COPY = 0x0002, FO_DELETE = 0x0003;
private const ushort FOF_ALLOWUNDO = 0x0040, FOF_NOCONFIRMMKDIR = 0x0200, FOF_NOCONFIRMATION = 0x0010,
FOF_SILENT = 0x0004, FOF_NOERRORUI = 0x0400;
FOF_SILENT = 0x0004, FOF_RENAMEONCOLLISION = 0x0008, FOF_NOERRORUI = 0x0400;

[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct SHFILEOPSTRUCT
Expand All @@ -35,27 +36,39 @@ public static bool Execute(IEnumerable<string> files, string destFolder, DropAct
var list = files.ToArray();
if (list.Length == 0) return true; // nothing to do — don't call SHFileOperation with an empty list
ushort flags = FOF_ALLOWUNDO | FOF_NOCONFIRMMKDIR;
if (silent) flags |= (ushort)(FOF_SILENT | FOF_NOERRORUI | FOF_NOCONFIRMATION);
if (silent) flags |= (ushort)(FOF_SILENT | FOF_NOERRORUI | FOF_NOCONFIRMATION | FOF_RENAMEONCOLLISION);
var op = new SHFILEOPSTRUCT
{
wFunc = action == DropAction.Move ? FO_MOVE : FO_COPY,
pFrom = string.Join("\0", list) + "\0\0",
pTo = destFolder + "\0\0",
wFunc = action == DropAction.Move ? FO_MOVE : FO_COPY,
pFrom = string.Join("\0", list) + "\0\0",
pTo = destFolder + "\0\0",
fFlags = flags,
};
return SHFileOperation(ref op) == 0 && !op.fAnyOperationsAborted;
}

public static bool HasDestinationCollision(IEnumerable<string> sources, string destFolder)
{
foreach (var source in sources)
{
var name = Path.GetFileName(source);
if (string.IsNullOrEmpty(name)) continue;
var dest = Path.Combine(destFolder, name);
if (File.Exists(dest) || Directory.Exists(dest)) return true;
}
return false;
}

/// <summary>Delete to Recycle Bin without confirmation (for Undo after a copy).</summary>
public static bool Delete(IEnumerable<string> paths)
{
var list = paths.ToArray();
if (list.Length == 0) return true;
var op = new SHFILEOPSTRUCT
{
wFunc = FO_DELETE,
pFrom = string.Join("\0", list) + "\0\0",
pTo = "\0\0",
wFunc = FO_DELETE,
pFrom = string.Join("\0", list) + "\0\0",
pTo = "\0\0",
fFlags = FOF_ALLOWUNDO | FOF_NOCONFIRMATION,
};
return SHFileOperation(ref op) == 0 && !op.fAnyOperationsAborted;
Expand Down
34 changes: 31 additions & 3 deletions src/Dropwheel/Services/SortService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ namespace Dropwheel.Services;
/// <summary>Distributes files according to a sorter target's rules.</summary>
public static class SortService
{
private static readonly TimeSpan RegexTimeout = TimeSpan.FromMilliseconds(250);

/// <summary>Returns a plan: destination folder → files. Uses the rich Rules engine when
/// the target has Rules, otherwise the legacy SortRules. With no match and no catch-all
/// a file goes to the target root (t.Path).</summary>
Expand Down Expand Up @@ -111,7 +113,7 @@ private static Dictionary<string, string> CollectGroups(SortRule rule, string fi
foreach (var c in rule.All)
{
if (c.Field != ConditionField.NameRegex || Compiled(c.Value) is not { } rx) continue;
var m = rx.Match(fileName);
if (!TryMatch(rx, fileName, c.Value, out var m)) continue;
if (!m.Success) continue;
foreach (var name in rx.GetGroupNames())
{
Expand Down Expand Up @@ -154,7 +156,7 @@ private static string SanitizeSegment(string value)
{
ConditionField.Extension => MatchExtension(c.Value, meta.Ext),
ConditionField.NameContains => meta.Name.Contains(c.Value, StringComparison.OrdinalIgnoreCase),
ConditionField.NameRegex => Compiled(c.Value) is { } rx && rx.IsMatch(meta.Name),
ConditionField.NameRegex => Compiled(c.Value) is { } rx && IsMatch(rx, meta.Name, c.Value),
ConditionField.SizeMb => MatchNumber(c.Op, meta.SizeMb, c.Value),
ConditionField.AgeDays => MatchNumber(c.Op, meta.AgeDays, c.Value),
_ => false,
Expand Down Expand Up @@ -198,7 +200,8 @@ private static bool MatchNumber(CompareOp op, double actual, string value)
try
{
rx = new Regex(pattern,
RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant,
RegexTimeout);
}
catch (ArgumentException ex) // invalid pattern (RegexParseException derives from this)
{
Expand All @@ -208,4 +211,29 @@ private static bool MatchNumber(CompareOp op, double actual, string value)
RegexCache[pattern] = rx;
return rx;
}

private static bool IsMatch(Regex rx, string input, string pattern)
{
try { return rx.IsMatch(input); }
catch (RegexMatchTimeoutException ex)
{
ErrorLog.Write($"Regular expression timed out in rule: '{pattern}'", ex);
return false;
}
}

private static bool TryMatch(Regex rx, string input, string pattern, out System.Text.RegularExpressions.Match match)
{
try
{
match = rx.Match(input);
return true;
}
catch (RegexMatchTimeoutException ex)
{
ErrorLog.Write($"Regular expression timed out in rule: '{pattern}'", ex);
match = System.Text.RegularExpressions.Match.Empty;
return false;
}
}
}
28 changes: 24 additions & 4 deletions src/Dropwheel/Services/TargetStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ public static class TargetStore

public static void Load()
{
bool shouldBackup = false;
if (File.Exists(FilePath))
{
try
Expand All @@ -40,14 +41,34 @@ public static void Load()
if (Config.Presets == null) { Config.Presets = PresetService.Defaults(); Save(); }
return;
}
catch (JsonException) { /* corrupted config — recreate with defaults */ }
catch (IOException) { /* unreadable config — recreate with defaults */ }
catch (UnauthorizedAccessException) { /* unreadable config — recreate with defaults */ }
catch (JsonException ex) { ErrorLog.Write("Config is corrupted; backing it up and recreating defaults", ex); shouldBackup = true; }
catch (IOException ex) { ErrorLog.Write("Config is unreadable; backing it up and recreating defaults", ex); shouldBackup = true; }
catch (UnauthorizedAccessException ex) { ErrorLog.Write("Config is unreadable; backing it up and recreating defaults", ex); shouldBackup = true; }
}
if (shouldBackup) BackupBadConfig(DateTime.Now);
Config = Defaults();
Save();
}

internal static string BackupPath(DateTime now)
{
var stamp = now.ToString("yyyyMMdd_HHmmss");
return Path.Combine(Dir, $"config.bad.{stamp}.json");
}

private static void BackupBadConfig(DateTime now)
{
try
{
if (!File.Exists(FilePath)) return;
var backup = BackupPath(now);
for (int i = 2; File.Exists(backup); i++)
backup = Path.Combine(Dir, $"config.bad.{now:yyyyMMdd_HHmmss}.{i}.json");
File.Copy(FilePath, backup);
}
catch (Exception ex) { ErrorLog.Write("Failed to back up bad config", ex); }
}

/// <summary>Writes via a temp file then renames it: if the process is killed mid-write, the
/// target config.json stays intact instead of becoming half-empty.</summary>
public static void Save()
Expand Down Expand Up @@ -100,5 +121,4 @@ private static AppConfig Defaults()
},
};
}

}
31 changes: 24 additions & 7 deletions src/Dropwheel/Services/VirtualFileService.Streams.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ public static partial class VirtualFileService

private static bool SaveContents(IComData com, int index, string path)
{
var tmp = TempPathFor(path);
var fmt = new FORMATETC
{
cfFormat = (short)System.Windows.DataFormats.GetDataFormat(ContentsFormat).Id,
Expand All @@ -25,14 +26,24 @@ private static bool SaveContents(IComData com, int index, string path)
try
{
if (med.unionmember == IntPtr.Zero) return false; // the source gave no medium for this index
if (med.tymed == TYMED.TYMED_ISTREAM) { SaveIStream(med.unionmember, path); return true; }
if (med.tymed == TYMED.TYMED_HGLOBAL) { SaveHGlobal(med.unionmember, path); return true; }
return false;
var saved = med.tymed switch
{
TYMED.TYMED_ISTREAM => SaveIStream(med.unionmember, tmp),
TYMED.TYMED_HGLOBAL => SaveHGlobal(med.unionmember, tmp),
_ => false,
};
if (!saved) return false;
File.Move(tmp, path);
return true;
}
finally
{
try { if (File.Exists(tmp)) File.Delete(tmp); } catch { }
ReleaseStgMedium(ref med);
}
finally { ReleaseStgMedium(ref med); }
}

private static void SaveIStream(IntPtr punk, string path)
private static bool SaveIStream(IntPtr punk, string path)
{
var stream = (IStream)Marshal.GetObjectForIUnknown(punk);
try
Expand All @@ -51,6 +62,7 @@ private static void SaveIStream(IntPtr punk, string path)
}
}
finally { Marshal.FreeHGlobal(pRead); }
return true;
}
finally { Marshal.ReleaseComObject(stream); }
}
Expand All @@ -60,22 +72,27 @@ private static void SaveIStream(IntPtr punk, string path)
/// in the file. HGLOBAL for CFSTR_FILECONTENTS has no reliable "real length" field, and trimming
/// trailing zeros is wrong (a legitimate binary also contains zeros). In practice sources deliver
/// files via ISTREAM (the branch above); this is a rare fallback.</summary>
private static void SaveHGlobal(IntPtr h, string path)
private static bool SaveHGlobal(IntPtr h, string path)
{
var p = GlobalLock(h);
if (p == IntPtr.Zero) return;
if (p == IntPtr.Zero) return false;
try
{
var buf = new byte[(long)GlobalSize(h)];
Marshal.Copy(p, buf, 0, buf.Length);
File.WriteAllBytes(path, buf);
return true;
}
finally { GlobalUnlock(h); }
}

internal static string TempPathFor(string path) =>
Path.Combine(Path.GetDirectoryName(path) ?? "", $".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp");

private static string UniquePath(string folder, string name)
{
name = string.Join("_", name.Split(Path.GetInvalidFileNameChars()));
if (string.IsNullOrWhiteSpace(name)) name = "file";
var path = Path.Combine(folder, name);
if (!File.Exists(path) && !Directory.Exists(path)) return path;
string stem = Path.GetFileNameWithoutExtension(name), ext = Path.GetExtension(name);
Expand Down
7 changes: 4 additions & 3 deletions src/Dropwheel/UI/OverlayWindow.Dnd.cs
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,9 @@ private void OnBubbleDropCore(TargetItem t, DragEventArgs e)
return;
}
var act = Resolve(t, e);
bool hadCollision = FileOps.HasDestinationCollision(files, dest);
bool ok = FileOps.Execute(files, dest, act);
if (ok) RememberOp(act, files, dest);
if (ok) RememberOpIfUnambiguous(act, files, dest, hadCollision);
ShowToast(ok
? $"{(act == DropAction.Move ? "➜ Moved" : "⧉ Copied")}: {files.Length} item(s) → {t.Name}"
: "Operation was not completed", ok);
Expand All @@ -89,7 +90,7 @@ private void OnBubbleDropCore(TargetItem t, DragEventArgs e)
if (saved.Length > 0)
{
if (t.IsSorter) SortSavedVirtuals(t, saved);
else RememberOp(DropAction.Copy, saved, dest);
else RememberOpIfUnambiguous(DropAction.Copy, saved, dest, hadCollision: false);
}
ShowToast(saved.Length > 0
? $"⧉ Saved: {saved.Length} item(s) → {t.Name}"
Expand All @@ -101,7 +102,7 @@ private void OnBubbleDropCore(TargetItem t, DragEventArgs e)
if (saved is { } path)
{
if (t.IsSorter) SortSavedVirtuals(t, new[] { path });
else RememberOp(DropAction.Copy, new[] { path }, dest);
else RememberOpIfUnambiguous(DropAction.Copy, new[] { path }, dest, hadCollision: false);
}
ShowToast(saved != null
? $"≡ Saved text → {System.IO.Path.GetFileName(saved)}"
Expand Down
16 changes: 9 additions & 7 deletions src/Dropwheel/UI/OverlayWindow.Sort.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,15 @@ private void DropSorted(TargetItem t, string[] files, DropAction act)
{
var plan = SortService.Plan(t, files);
bool ok = true;
var ops = new List<(DropAction, string[], string)>();
var ops = new List<(DropAction, string[], string, bool)>();
foreach (var (folder, group) in plan)
{
Directory.CreateDirectory(folder);
if (FileOps.Execute(group, folder, act)) ops.Add((act, group.ToArray(), folder));
bool hadCollision = FileOps.HasDestinationCollision(group, folder);
if (FileOps.Execute(group, folder, act)) ops.Add((act, group.ToArray(), folder, hadCollision));
else ok = false;
}
if (ops.Count > 0) RememberOps(ops);
if (ops.Count > 0) RememberOpsIfUnambiguous(ops);
ShowToast(ok
? $"⇅ Sorted: {files.Length} item(s) → {t.Name}"
: "Sorting was not completed", ops.Count > 0);
Expand All @@ -30,16 +31,17 @@ private void DropSorted(TargetItem t, string[] files, DropAction act)
private void SortSavedVirtuals(TargetItem t, string[] saved)
{
var plan = SortService.Plan(t, saved);
var ops = new List<(DropAction, string[], string)>();
var ops = new List<(DropAction, string[], string, bool)>();
string root = IOPath.GetFullPath(t.Path).TrimEnd('\\');
foreach (var (folder, group) in plan)
{
if (IOPath.GetFullPath(folder).TrimEnd('\\') == root)
{ ops.Add((DropAction.Copy, group.ToArray(), folder)); continue; }
{ ops.Add((DropAction.Copy, group.ToArray(), folder, false)); continue; }
Directory.CreateDirectory(folder);
bool hadCollision = FileOps.HasDestinationCollision(group, folder);
if (FileOps.Execute(group, folder, DropAction.Move))
ops.Add((DropAction.Copy, group.ToArray(), folder));
ops.Add((DropAction.Copy, group.ToArray(), folder, hadCollision));
}
if (ops.Count > 0) RememberOps(ops);
if (ops.Count > 0) RememberOpsIfUnambiguous(ops);
}
}
14 changes: 10 additions & 4 deletions src/Dropwheel/UI/OverlayWindow.Undo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,17 @@ public partial class OverlayWindow
// One drop operation may consist of several moves (sorter).
private readonly List<(DropAction Act, string[] Sources, string Dest)> _lastOps = new();

private void RememberOp(DropAction act, string[] sources, string dest)
{ _lastOps.Clear(); _lastOps.Add((act, sources, dest)); }
private void RememberOpIfUnambiguous(DropAction act, string[] sources, string dest, bool hadCollision)
{
_lastOps.Clear();
if (!hadCollision) _lastOps.Add((act, sources, dest));
}

private void RememberOps(IEnumerable<(DropAction, string[], string)> ops)
{ _lastOps.Clear(); _lastOps.AddRange(ops); }
private void RememberOpsIfUnambiguous(IEnumerable<(DropAction Act, string[] Sources, string Dest, bool HadCollision)> ops)
{
_lastOps.Clear();
_lastOps.AddRange(ops.Where(op => !op.HadCollision).Select(op => (op.Act, op.Sources, op.Dest)));
}

private void OnUndoClick(object sender, MouseButtonEventArgs e)
{
Expand Down
10 changes: 10 additions & 0 deletions tests/Dropwheel.Tests/AppConfigTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using System.IO;
using Dropwheel.Models;
using Dropwheel.Services;

namespace Dropwheel.Tests;

Expand All @@ -19,4 +21,12 @@ public void Default_open_animation_speed_is_normal()

Assert.Equal(1.0, config.OpenAnimationSpeed);
}

[Fact]
public void Bad_config_backup_path_is_timestamped_json()
{
var path = TargetStore.BackupPath(new DateTime(2026, 7, 8, 18, 30, 5));

Assert.EndsWith(Path.Combine("Dropwheel", "config.bad.20260708_183005.json"), path);
}
}
Loading