commit 77bb3e6b9d7858ce8d3fa8ce6fdf39b9c1f0d27e Author: M_Viper Date: Thu Oct 1 20:19:32 2026 +0200 DeskCore 1.0.0 Desktop-Icons in eigene Kategorien sortieren: Windows-11-Design (hell/dunkel), Desktop-Boxen, automatisches Einsortieren nach Regeln, Rückgängig, Suche, Infobereich mit Tastenkürzel, Sicherung als ZIP und Installer ohne Adminrechte. Co-Authored-By: Claude Opus 5.5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fd81861 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# Build-Ausgaben +bin/ +obj/ +artifacts/ +Setup/bin/ +Setup/obj/ + +# Visual Studio / Rider +.vs/ +.idea/ +*.user +*.suo diff --git a/App.xaml b/App.xaml new file mode 100644 index 0000000..bb8c00d --- /dev/null +++ b/App.xaml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + diff --git a/App.xaml.cs b/App.xaml.cs new file mode 100644 index 0000000..9483c4b --- /dev/null +++ b/App.xaml.cs @@ -0,0 +1,117 @@ +using System.IO; +using System.Windows; +using System.Windows.Interop; + +namespace DeskCore; + +public partial class App : Application +{ + private const string MutexName = "DeskCore.Instanz"; + private const string ShowEventName = "DeskCore.Anzeigen"; + + public static MainWindow? Win { get; private set; } + public static Tray? TrayIcon { get; private set; } + public static bool Quitting { get; private set; } + + private static Hotkey? _hotkey; + private static Watcher? _watcher; + private Mutex? _mutex; + private EventWaitHandle? _showEvent; + + protected override void OnStartup(StartupEventArgs e) + { + // Nur eine Instanz: eine zweite holt einfach das vorhandene Fenster nach vorne + _mutex = new Mutex(true, MutexName, out bool first); + if (!first) + { + try { EventWaitHandle.OpenExisting(ShowEventName).Set(); } catch { } + Shutdown(); + return; + } + Migration.Run(); // alte "Desktop Sortierer"-Ordner übernehmen – vor allem anderen + _showEvent = new EventWaitHandle(false, EventResetMode.AutoReset, ShowEventName); + new Thread(() => + { + while (_showEvent.WaitOne()) + Dispatcher.BeginInvoke(() => Win?.ShowFromBackground()); + }) { IsBackground = true, Name = "ShowListener" }.Start(); + + DispatcherUnhandledException += (_, args) => + { + var log = Path.Combine(Storage.Root, "fehler.log"); + try { File.AppendAllText(log, $"[{DateTime.Now}] {args.Exception}\n\n"); } catch { } + MessageBox.Show($"Unerwarteter Fehler:\n\n{args.Exception.Message}\n\nDetails: {log}", "DeskCore", + MessageBoxButton.OK, MessageBoxImage.Error); + args.Handled = true; + }; + + base.OnStartup(e); + ShutdownMode = ShutdownMode.OnExplicitShutdown; + + Storage.FlushHolding(); // Reste eines Absturzes in den Papierkorb + Theme.Init(); + Theme.Changed += FenceManager.RefreshAll; + + Win = new MainWindow(); + MainWindow = Win; + var hwnd = new WindowInteropHelper(Win).EnsureHandle(); + + _hotkey = new Hotkey(hwnd); + _hotkey.Pressed += () => Win.ToggleFromHotkey(); + ApplyHotkey(); + + TrayIcon = new Tray(); + TrayIcon.Clicked += () => Win.ShowFromBackground(); + TrayIcon.BalloonClicked += () => Win.ShowFromBackground(); + TrayIcon.MenuRequested += () => Win.ShowTrayMenu(); + ApplyTray(); + + _watcher = new Watcher(Dispatcher); + _watcher.Changed += () => + { + Win.OnStorageChanged(); + FenceManager.RefreshAll(); + }; + _watcher.NewOnDesktop += Win.AutoSortNew; + + Autostart.RefreshPath(); + + bool minimized = e.Args.Contains("--minimized", StringComparer.OrdinalIgnoreCase); + if (!minimized || !AppSettings.Current.TrayEnabled) Win.Show(); + FenceManager.Sync(); + } + + /// Tastenkürzel neu registrieren. false = von anderem Programm belegt. + public static bool ApplyHotkey() + { + if (_hotkey is null) return false; + var s = AppSettings.Current; + if (!s.HotkeyEnabled) { _hotkey.Unregister(); return true; } + return _hotkey.Register(s.HotkeyModifiers, s.HotkeyKey); + } + + public static void ApplyTray() + { + if (TrayIcon is not null) TrayIcon.Visible = AppSettings.Current.TrayEnabled; + } + + public static void Quit() + { + if (Quitting) return; + Quitting = true; + Win?.Undo.FlushAll(); + FenceManager.CloseAll(); + _watcher?.Dispose(); + _hotkey?.Dispose(); + TrayIcon?.Dispose(); + Win?.Close(); + Current.Shutdown(); + } + + protected override void OnExit(ExitEventArgs e) + { + TrayIcon?.Dispose(); + _mutex?.Dispose(); + base.OnExit(e); + } +} diff --git a/Backup.cs b/Backup.cs new file mode 100644 index 0000000..b7f51f7 --- /dev/null +++ b/Backup.cs @@ -0,0 +1,108 @@ +using System.IO; +using System.IO.Compression; +using System.Text.Json; + +namespace DeskCore; + +/// Export/Import aller Kategorien samt Einstellungen als ZIP. +public static class Backup +{ + private const string CategoriesPrefix = "Kategorien/"; + private const string SettingsEntry = "einstellungen.json"; + + public static long StorageSize() + { + try + { + return Directory.EnumerateFiles(Storage.Root, "*", SearchOption.AllDirectories) + .Sum(f => { try { return new FileInfo(f).Length; } catch { return 0L; } }); + } + catch { return 0; } + } + + public static string FormatSize(long bytes) => bytes switch + { + < 1024 * 1024 => $"{Math.Max(1, bytes / 1024)} KB", + < 1024L * 1024 * 1024 => $"{bytes / 1024.0 / 1024:0.#} MB", + _ => $"{bytes / 1024.0 / 1024 / 1024:0.##} GB", + }; + + /// Gibt die Anzahl gesicherter Dateien zurück. + public static int Export(string zipPath) + { + if (File.Exists(zipPath)) File.Delete(zipPath); + int count = 0; + using var zip = ZipFile.Open(zipPath, ZipArchiveMode.Create); + foreach (var cat in Storage.Categories()) + { + var dir = Storage.CategoryPath(cat); + zip.CreateEntry(CategoriesPrefix + cat + "/"); // auch leere Kategorien sichern + foreach (var f in Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories)) + { + var rel = Path.GetRelativePath(Storage.Root, f).Replace('\\', '/'); + zip.CreateEntryFromFile(f, CategoriesPrefix + rel, CompressionLevel.Optimal); + count++; + } + foreach (var d in Directory.EnumerateDirectories(dir, "*", SearchOption.AllDirectories)) + if (!Directory.EnumerateFileSystemEntries(d).Any()) + zip.CreateEntry(CategoriesPrefix + Path.GetRelativePath(Storage.Root, d).Replace('\\', '/') + "/"); + } + var entry = zip.CreateEntry(SettingsEntry); + using (var w = new StreamWriter(entry.Open())) + w.Write(JsonSerializer.Serialize(AppSettings.Current, AppSettings.Json)); + return count; + } + + /// Führt eine Sicherung mit dem Bestand zusammen. Vorhandenes wird nicht überschrieben. + public static (int Added, int Skipped) Import(string zipPath) + { + int added = 0, skipped = 0; + using var zip = ZipFile.OpenRead(zipPath); + var root = Path.GetFullPath(Storage.Root); + + foreach (var e in zip.Entries) + { + if (!e.FullName.StartsWith(CategoriesPrefix, StringComparison.OrdinalIgnoreCase)) continue; + var rel = e.FullName[CategoriesPrefix.Length..]; + if (rel.Length == 0) continue; + var target = Path.GetFullPath(Path.Combine(root, rel)); + if (!Storage.IsUnder(target, root)) continue; // Schutz vor manipulierten ZIPs + + if (e.FullName.EndsWith('/')) + { + Directory.CreateDirectory(target); + continue; + } + if (File.Exists(target)) { skipped++; continue; } + Directory.CreateDirectory(Path.GetDirectoryName(target)!); + e.ExtractToFile(target); + added++; + } + + if (zip.GetEntry(SettingsEntry) is { } se) + { + try + { + using var r = new StreamReader(se.Open()); + var imported = JsonSerializer.Deserialize(r.ReadToEnd(), AppSettings.Json); + if (imported is not null) Merge(imported); + } + catch { } + } + return (added, skipped); + } + + private static void Merge(AppSettings from) + { + var s = AppSettings.Current; + foreach (var (name, meta) in from.Categories ?? []) + if (!s.Categories.ContainsKey(name)) s.Categories[name] = meta; + foreach (var n in from.CategoryOrder ?? []) + if (!s.CategoryOrder.Contains(n, StringComparer.OrdinalIgnoreCase)) s.CategoryOrder.Add(n); + foreach (var r in from.Rules ?? []) + if (!s.Rules.Any(x => string.Equals(x.Contains, r.Contains, StringComparison.OrdinalIgnoreCase) && + string.Equals(x.Category, r.Category, StringComparison.OrdinalIgnoreCase))) + s.Rules.Add(r); + s.Save(); + } +} diff --git a/DeskCore.csproj b/DeskCore.csproj new file mode 100644 index 0000000..8b5c086 --- /dev/null +++ b/DeskCore.csproj @@ -0,0 +1,39 @@ + + + WinExe + net9.0-windows + true + + true + enable + enable + DeskCore + DeskCore + app.ico + + DeskCore + DeskCore + Desktop-Icons in eigene Kategorien sortieren + M_Viper + M_Viper + Copyright (c) M_Viper. Alle Rechte vorbehalten. + de-DE + + + + + + + + + + + + + + + + + + diff --git a/Dialog.cs b/Dialog.cs new file mode 100644 index 0000000..8e029ee --- /dev/null +++ b/Dialog.cs @@ -0,0 +1,129 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; + +namespace DeskCore; + +/// Dialoge im Stil eines Windows-11-ContentDialogs. +public static class Dialog +{ + public static bool Confirm(Window owner, string title, string message, string primary = "OK", string? secondary = "Abbrechen") => + Show(owner, title, Text(message), primary, secondary, null); + + public static void Info(Window owner, string title, string message) => + Show(owner, title, Text(message), "OK", null, null); + + /// Eingabedialog. validate gibt eine Fehlermeldung oder null zurück. + public static string? Prompt(Window owner, string title, string label, string initial, string primary, Func validate) + { + var box = new TextBox { Text = initial, Margin = new Thickness(0, 8, 0, 0) }; + var panel = new StackPanel(); + panel.Children.Add(Text(label, secondary: true)); + panel.Children.Add(box); + var ok = Show(owner, title, panel, primary, "Abbrechen", () => validate(box.Text), box); + return ok ? box.Text.Trim() : null; + } + + public static TextBlock Text(string text, bool secondary = false) => new() + { + Text = text, TextWrapping = TextWrapping.Wrap, LineHeight = 20, + Foreground = (Brush)Application.Current.Resources[secondary ? "TextSecondaryBrush" : "TextBrush"], + }; + + /// Allgemeiner Dialog mit beliebigem Inhalt. validate: Fehlermeldung oder null. + public static bool Show(Window owner, string title, UIElement content, string primary, string? secondary, + Func? validate, IInputElement? focus = null, double width = 448) + { + var res = Application.Current.Resources; + + var error = new TextBlock + { + Foreground = (Brush)res["DangerBrush"], FontSize = 12, Margin = new Thickness(0, 8, 0, 0), + TextWrapping = TextWrapping.Wrap, Visibility = Visibility.Collapsed, + }; + + var body = new StackPanel { Margin = new Thickness(24, 22, 24, 24) }; + body.Children.Add(new TextBlock + { + Text = title, FontSize = 20, FontWeight = FontWeights.SemiBold, Margin = new Thickness(0, 0, 0, 12), + FontFamily = (FontFamily)res["DisplayFont"], TextWrapping = TextWrapping.Wrap, + }); + body.Children.Add(content); + body.Children.Add(error); + + var ok = new Button { Content = primary, IsDefault = true, Style = (Style)res["AccentButton"] }; + var footer = new Grid { Margin = new Thickness(24) }; + footer.ColumnDefinitions.Add(new ColumnDefinition()); + footer.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(8) }); + footer.ColumnDefinitions.Add(new ColumnDefinition()); + if (secondary is not null) + { + var cancel = new Button { Content = secondary, IsCancel = true }; + Grid.SetColumn(cancel, 2); + footer.Children.Add(cancel); + } + else Grid.SetColumn(ok, 2); // einzelner Button rechts, wie bei Windows 11 + footer.Children.Add(ok); + + var layout = new DockPanel(); + var footerBorder = new Border + { + Background = (Brush)res["DialogFooterBrush"], Child = footer, + BorderBrush = (Brush)res["FlyoutStrokeBrush"], BorderThickness = new Thickness(0, 1, 0, 0), + CornerRadius = new CornerRadius(0, 0, 8, 8), + }; + DockPanel.SetDock(footerBorder, Dock.Bottom); + layout.Children.Add(footerBorder); + layout.Children.Add(new ScrollViewer { Content = body, VerticalScrollBarVisibility = ScrollBarVisibility.Auto, MaxHeight = 560 }); + + var dlg = new Window + { + Owner = owner.IsVisible ? owner : null, Title = title, Width = width + 64, SizeToContent = SizeToContent.Height, + WindowStyle = WindowStyle.None, AllowsTransparency = true, Background = Brushes.Transparent, + ResizeMode = ResizeMode.NoResize, ShowInTaskbar = !owner.IsVisible, Topmost = !owner.IsVisible, + WindowStartupLocation = owner.IsVisible ? WindowStartupLocation.CenterOwner : WindowStartupLocation.CenterScreen, + FontFamily = (FontFamily)res["UiFont"], FontSize = 14, + Foreground = (Brush)res["TextBrush"], + Content = new Border + { + Margin = new Thickness(32), CornerRadius = new CornerRadius(8), + Background = (Brush)res["LayerBrush"], + BorderBrush = (Brush)res["FlyoutStrokeBrush"], BorderThickness = new Thickness(1), + Effect = (System.Windows.Media.Effects.Effect)res["DialogShadow"], Child = layout, + }, + }; + + ok.Click += (_, _) => + { + if (validate?.Invoke() is { } err) + { + error.Text = err; + error.Visibility = Visibility.Visible; + (focus as UIElement)?.Focus(); + return; + } + dlg.DialogResult = true; + }; + if (focus is TextBox tb) tb.TextChanged += (_, _) => error.Visibility = Visibility.Collapsed; + + dlg.PreviewKeyDown += (_, e) => { if (e.Key == Key.Escape) dlg.Close(); }; + dlg.MouseLeftButtonDown += (_, e) => { if (e.ButtonState == MouseButtonState.Pressed) dlg.DragMove(); }; + dlg.Loaded += (_, _) => + { + if (focus is TextBox t) { t.Focus(); t.SelectAll(); } + else if (focus is UIElement u) u.Focus(); + else ok.Focus(); + }; + + (owner as MainWindow)?.SetDimmed(true); + try + { + return dlg.ShowDialog() == true; + } + finally + { + (owner as MainWindow)?.SetDimmed(false); + } + } +} diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..949bb5c --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,6 @@ + + + + 1.0.0 + + diff --git a/FenceWindow.xaml b/FenceWindow.xaml new file mode 100644 index 0000000..2a700dd --- /dev/null +++ b/FenceWindow.xaml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/FenceWindow.xaml.cs b/FenceWindow.xaml.cs new file mode 100644 index 0000000..50482ba --- /dev/null +++ b/FenceWindow.xaml.cs @@ -0,0 +1,278 @@ +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Interop; +using System.Windows.Media; +using System.Windows.Threading; + +namespace DeskCore; + +/// Eine "Box" auf dem Desktop, die den Inhalt einer Kategorie zeigt. +public partial class FenceWindow : Window +{ + private const double CollapsedHeight = 38; + private readonly DispatcherTimer _saveTimer; + private bool _applying; + + public string Category { get; private set; } + + public FenceWindow(string category) + { + InitializeComponent(); + Category = category; + _saveTimer = new DispatcherTimer(TimeSpan.FromMilliseconds(700), DispatcherPriority.Background, (_, _) => + { + _saveTimer!.Stop(); + AppSettings.Current.Save(); + }, Dispatcher); + _saveTimer.Stop(); + + ApplyBounds(); + LocationChanged += (_, _) => RememberBounds(); + SizeChanged += (_, _) => RememberBounds(); + SourceInitialized += (_, _) => PinToDesktop(); + Refresh(); + } + + private CategoryMeta Meta => AppSettings.Current.Meta(Category); + + public void Rename(string newName) + { + Category = newName; + Refresh(); + } + + // ---------- Position/Größe ---------- + + private void ApplyBounds() + { + _applying = true; + var m = Meta; + var wa = SystemParameters.WorkArea; + Width = Math.Max(MinWidth, m.FenceW); + Height = m.FenceCollapsed ? CollapsedHeight : Math.Max(120, m.FenceH); + Left = Math.Clamp(m.FenceX ?? wa.Left + 24, wa.Left - Width + 80, wa.Right - 80); + Top = Math.Clamp(m.FenceY ?? wa.Top + 24, wa.Top, wa.Bottom - 40); + Body.Visibility = m.FenceCollapsed ? Visibility.Collapsed : Visibility.Visible; + _applying = false; + } + + private void RememberBounds() + { + if (_applying || !IsLoaded) return; + var m = Meta; + m.FenceX = Left; + m.FenceY = Top; + m.FenceW = Width; + if (!m.FenceCollapsed) m.FenceH = Height; + _saveTimer.Stop(); + _saveTimer.Start(); + } + + private void ToggleCollapsed() + { + Meta.FenceCollapsed = !Meta.FenceCollapsed; + ApplyBounds(); + AppSettings.Current.Save(); + } + + // ---------- Inhalt ---------- + + public void Refresh() + { + var meta = Meta; + TitleText.Text = Category; + GlyphText.Text = meta.Glyph ?? ""; + GlyphText.Foreground = CategoryColors.Brush(meta.Color) ?? (Brush)FindResource("TextBrush"); + + var items = Storage.Entries(Storage.CategoryPath(Category)) + .Select(p => new IconItem { Path = p }) + .OrderBy(i => !Directory.Exists(i.Path)).ThenBy(i => i.Name, StringComparer.CurrentCultureIgnoreCase) + .ToList(); + int px = (int)Math.Round(32 * VisualTreeHelper.GetDpi(this).DpiScaleX); + foreach (var i in items) IconLoader.Load(i, px); + Items.ItemsSource = items; + CountText.Text = items.Count.ToString(); + EmptyText.Visibility = items.Count == 0 ? Visibility.Visible : Visibility.Collapsed; + } + + private List Selected => Items.SelectedItems.Cast().ToList(); + + private static void Open(string path) + { + try { Process.Start(new ProcessStartInfo(path) { UseShellExecute = true }); } + catch { } + } + + private void Items_MouseDoubleClick(object sender, MouseButtonEventArgs e) + { + if (MainWindow.FindParent(e.OriginalSource as DependencyObject)?.DataContext is IconItem i) Open(i.Path); + } + + private void Items_KeyDown(object sender, KeyEventArgs e) + { + if (e.Key == Key.Enter) Selected.ForEach(i => Open(i.Path)); + } + + private void Items_ContextMenuOpening(object sender, ContextMenuEventArgs e) + { + var sel = Selected; + if (sel.Count == 0) { e.Handled = true; return; } + ItemMenu.Items.Clear(); + ItemMenu.Items.Add(MainWindow.MenuItem("Öffnen", "", () => sel.ForEach(i => Open(i.Path)), bold: true)); + if (sel.Count == 1) + ItemMenu.Items.Add(MainWindow.MenuItem("Dateispeicherort öffnen", "", () => + Process.Start("explorer.exe", $"/select,\"{sel[0].Path}\""))); + ItemMenu.Items.Add(new Separator()); + ItemMenu.Items.Add(MainWindow.MenuItem("In DeskCore zeigen", "", ShowInApp)); + } + + private void MenuButton_Click(object sender, RoutedEventArgs e) + { + FenceMenu.Items.Clear(); + FenceMenu.Items.Add(MainWindow.MenuItem("In DeskCore öffnen", "", ShowInApp)); + FenceMenu.Items.Add(MainWindow.MenuItem(Meta.FenceCollapsed ? "Ausklappen" : "Einklappen", + Meta.FenceCollapsed ? "" : "", ToggleCollapsed)); + FenceMenu.Items.Add(new Separator()); + FenceMenu.Items.Add(MainWindow.MenuItem("Box ausblenden", "", () => + { + Meta.Fence = false; + AppSettings.Current.Save(); + FenceManager.Sync(); + })); + FenceMenu.PlacementTarget = MenuButton; + FenceMenu.IsOpen = true; + } + + private void ShowInApp() => App.Win?.ShowCategory(Category); + + private void Items_DragOver(object sender, DragEventArgs e) + { + e.Effects = e.Data.GetDataPresent(DataFormats.FileDrop) + ? (e.AllowedEffects.HasFlag(DragDropEffects.Copy) ? DragDropEffects.Copy : DragDropEffects.Move) + : DragDropEffects.None; + e.Handled = true; + } + + private void Items_Drop(object sender, DragEventArgs e) + { + if (e.Data.GetData(DataFormats.FileDrop) is string[] files) + App.Win?.TransferAll(files, Storage.CategoryPath(Category)); + } + + // ---------- Auf dem Desktop festhalten ---------- + + private void PinToDesktop() + { + var hwnd = new WindowInteropHelper(this).Handle; + // Besitzer = Desktop (Progman): bleibt bei Win+D sichtbar und liegt über den Desktop-Icons + var progman = FindWindow("Progman", null); + if (progman != IntPtr.Zero) SetWindowLongPtr(hwnd, GWLP_HWNDPARENT, progman); + // Nicht in Alt+Tab + var ex = GetWindowLongPtr(hwnd, GWL_EXSTYLE).ToInt64(); + SetWindowLongPtr(hwnd, GWL_EXSTYLE, new IntPtr((ex | WS_EX_TOOLWINDOW) & ~WS_EX_APPWINDOW)); + HwndSource.FromHwnd(hwnd).AddHook(Hook); + SetWindowPos(hwnd, HWND_BOTTOM, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE); + } + + private IntPtr Hook(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled) + { + switch (msg) + { + case WM_WINDOWPOSCHANGING: + // immer ganz unten bleiben, nie über anderen Fenstern + var wp = Marshal.PtrToStructure(lParam); + wp.hwndInsertAfter = HWND_BOTTOM; + wp.flags &= ~SWP_NOZORDER; + Marshal.StructureToPtr(wp, lParam, false); + break; + case WM_NCLBUTTONDBLCLK: + ToggleCollapsed(); + handled = true; + break; + case WM_SYSCOMMAND when (wParam.ToInt32() & 0xFFF0) == SC_MAXIMIZE: + handled = true; + break; + } + return IntPtr.Zero; + } + + private const int GWL_EXSTYLE = -20, GWLP_HWNDPARENT = -8; + private const long WS_EX_TOOLWINDOW = 0x80, WS_EX_APPWINDOW = 0x40000; + private const int WM_WINDOWPOSCHANGING = 0x46, WM_NCLBUTTONDBLCLK = 0xA3, WM_SYSCOMMAND = 0x112, SC_MAXIMIZE = 0xF030; + private const uint SWP_NOSIZE = 0x1, SWP_NOMOVE = 0x2, SWP_NOZORDER = 0x4, SWP_NOACTIVATE = 0x10; + private static readonly IntPtr HWND_BOTTOM = new(1); + + [StructLayout(LayoutKind.Sequential)] + private struct WINDOWPOS + { + public IntPtr hwnd, hwndInsertAfter; + public int x, y, cx, cy; + public uint flags; + } + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] private static extern IntPtr FindWindow(string? cls, string? title); + [DllImport("user32.dll")] private static extern IntPtr GetWindowLongPtr(IntPtr h, int i); + [DllImport("user32.dll")] private static extern IntPtr SetWindowLongPtr(IntPtr h, int i, IntPtr v); + [DllImport("user32.dll")] private static extern bool SetWindowPos(IntPtr h, IntPtr after, int x, int y, int cx, int cy, uint flags); +} + +/// Hält die Desktop-Boxen passend zu den Einstellungen. +public static class FenceManager +{ + private static readonly List _open = []; + + public static void Sync() + { + var s = AppSettings.Current; + var wanted = s.FencesEnabled + ? Storage.Categories().Where(c => s.Categories.GetValueOrDefault(c)?.Fence == true).ToList() + : []; + + foreach (var f in _open.Where(f => !wanted.Contains(f.Category, StringComparer.OrdinalIgnoreCase)).ToList()) + { + f.Close(); + _open.Remove(f); + } + + var wa = SystemParameters.WorkArea; + foreach (var c in wanted) + { + var f = _open.FirstOrDefault(x => string.Equals(x.Category, c, StringComparison.OrdinalIgnoreCase)); + if (f is not null) { f.Refresh(); continue; } + + // Neue Box: neben die vorhandenen setzen + var meta = s.Meta(c); + if (meta.FenceX is null) + { + // oben rechts beginnen – links liegen meist die Desktop-Icons + int perRow = Math.Max(1, (int)((wa.Width - 48) / (meta.FenceW + 16))); + int i = _open.Count; + meta.FenceX = wa.Right - 24 - meta.FenceW - (i % perRow) * (meta.FenceW + 16); + meta.FenceY = wa.Top + 24 + (i / perRow) * (meta.FenceH + 16); + } + f = new FenceWindow(c); + _open.Add(f); + f.Show(); + } + } + + public static void RefreshAll() + { + foreach (var f in _open) f.Refresh(); + } + + public static void Renamed(string oldName, string newName) + { + _open.FirstOrDefault(x => string.Equals(x.Category, oldName, StringComparison.OrdinalIgnoreCase))?.Rename(newName); + } + + public static void CloseAll() + { + foreach (var f in _open) f.Close(); + _open.Clear(); + } +} diff --git a/Hotkey.cs b/Hotkey.cs new file mode 100644 index 0000000..10112fd --- /dev/null +++ b/Hotkey.cs @@ -0,0 +1,138 @@ +using System.Runtime.InteropServices; +using System.Windows.Input; +using System.Windows.Interop; +using Microsoft.Win32; + +namespace DeskCore; + +/// Systemweites Tastenkürzel (RegisterHotKey). +public sealed class Hotkey : IDisposable +{ + public const int ModAlt = 0x1, ModCtrl = 0x2, ModShift = 0x4, ModWin = 0x8; + private const int ModNoRepeat = 0x4000, WmHotkey = 0x0312, Id = 0xB001; + + private readonly IntPtr _hwnd; + private readonly HwndSource _source; + private bool _registered; + + public event Action? Pressed; + + public Hotkey(IntPtr hwnd) + { + _hwnd = hwnd; + _source = HwndSource.FromHwnd(hwnd); + _source.AddHook(Hook); + } + + /// Registriert das Kürzel. false = schon von einem anderen Programm belegt. + public bool Register(int modifiers, int vk) + { + Unregister(); + if (vk == 0) return false; + _registered = RegisterHotKey(_hwnd, Id, modifiers | ModNoRepeat, vk); + return _registered; + } + + public void Unregister() + { + if (_registered) UnregisterHotKey(_hwnd, Id); + _registered = false; + } + + private IntPtr Hook(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled) + { + if (msg == WmHotkey && wParam.ToInt32() == Id) + { + Pressed?.Invoke(); + handled = true; + } + return IntPtr.Zero; + } + + public static string Describe(int modifiers, int vk) + { + if (vk == 0) return "–"; + var parts = new List(); + if ((modifiers & ModWin) != 0) parts.Add("Win"); + if ((modifiers & ModCtrl) != 0) parts.Add("Strg"); + if ((modifiers & ModAlt) != 0) parts.Add("Alt"); + if ((modifiers & ModShift) != 0) parts.Add("Umschalt"); + var key = KeyInterop.KeyFromVirtualKey(vk); + parts.Add(key switch + { + >= Key.D0 and <= Key.D9 => ((char)('0' + (key - Key.D0))).ToString(), + Key.Space => "Leertaste", + _ => key.ToString(), + }); + return string.Join(" + ", parts); + } + + public void Dispose() + { + Unregister(); + _source.RemoveHook(Hook); + } + + [DllImport("user32.dll")] private static extern bool RegisterHotKey(IntPtr hWnd, int id, int fsModifiers, int vk); + [DllImport("user32.dll")] private static extern bool UnregisterHotKey(IntPtr hWnd, int id); +} + +/// Start mit Windows über HKCU\...\Run. +public static class Autostart +{ + private const string Key = @"Software\Microsoft\Windows\CurrentVersion\Run"; + private const string Name = "DeskCore"; + // Frühere Namen dieser App. "DeskHub" heißt auch ein anderes Programm → nur übernehmen, wenn der Eintrag eindeutig von uns ist. + private static readonly string[] OldNames = ["DesktopSortierer", "DeskHub"]; + + public static bool Enabled + { + get + { + try + { + using var k = Registry.CurrentUser.OpenSubKey(Key); + return k?.GetValue(Name) is string; + } + catch { return false; } + } + set + { + try + { + using var k = Registry.CurrentUser.OpenSubKey(Key, writable: true) ?? Registry.CurrentUser.CreateSubKey(Key); + if (value) k.SetValue(Name, $"\"{Environment.ProcessPath}\" --minimized"); + else k.DeleteValue(Name, throwOnMissingValue: false); + } + catch { } + } + } + + /// Alten Autostart-Eintrag ("DesktopSortierer") auf den neuen Namen umstellen. + public static void Migrate() + { + try + { + using var k = Registry.CurrentUser.OpenSubKey(Key, writable: true); + if (k is null) return; + bool wasEnabled = false; + foreach (var old in OldNames) + { + if (k.GetValue(old) is not string cmd) continue; + bool ours = old == "DesktopSortierer" || + (cmd.Contains($"{old}.exe", StringComparison.OrdinalIgnoreCase) && cmd.Contains("--minimized")); + if (!ours) continue; + k.DeleteValue(old, throwOnMissingValue: false); + wasEnabled = true; + } + if (wasEnabled) Enabled = true; + } + catch { } + } + + /// Falls die exe verschoben wurde: Pfad im Autostart aktualisieren. + public static void RefreshPath() + { + if (Enabled) Enabled = true; + } +} diff --git a/IconLoader.cs b/IconLoader.cs new file mode 100644 index 0000000..6105598 --- /dev/null +++ b/IconLoader.cs @@ -0,0 +1,49 @@ +using System.Collections.Concurrent; +using System.IO; +using System.Windows; +using System.Windows.Media; + +namespace DeskCore; + +/// Lädt Icons auf einem eigenen STA-Thread (Shell-COM braucht STA) und cached sie. +public static class IconLoader +{ + private static readonly ConcurrentDictionary _cache = new(StringComparer.OrdinalIgnoreCase); + private static readonly BlockingCollection<(IconItem Item, int Size)> _queue = new(); + + static IconLoader() + { + var t = new Thread(Work) { IsBackground = true, Name = "IconLoader" }; + t.SetApartmentState(ApartmentState.STA); + t.Start(); + } + + private static string Key(string path, int size) + { + DateTime stamp; + try { stamp = File.GetLastWriteTimeUtc(path); } catch { stamp = default; } + return $"{size}|{stamp.Ticks}|{path}"; + } + + /// Setzt das Icon sofort aus dem Cache oder lädt es im Hintergrund nach. + public static void Load(IconItem item, int size) + { + if (_cache.TryGetValue(Key(item.Path, size), out var icon)) item.Icon = icon; + else _queue.Add((item, size)); + } + + private static void Work() + { + foreach (var (item, size) in _queue.GetConsumingEnumerable()) + { + var key = Key(item.Path, size); + if (!_cache.TryGetValue(key, out var icon)) + { + icon = ShellIcon.Get(item.Path, size); + _cache[key] = icon; + } + var result = icon; + Application.Current?.Dispatcher.BeginInvoke(() => item.Icon = result); + } + } +} diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..dda0c59 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,38 @@ +DeskCore – Lizenz- und Nutzungsbedingungen + +Copyright (c) M_Viper. Alle Rechte vorbehalten. + +Diese Software („DeskCore") sowie sämtlicher zugehöriger Quellcode, Ressourcen und +Dokumentation sind urheberrechtlich geschützt. Mit der Nutzung dieser Software +erkennst du die folgenden Bedingungen an. + +1. Erlaubte Nutzung + Die Nutzung von DeskCore ist ausschließlich für den privaten, nicht-kommerziellen + Gebrauch gestattet. + +2. Untersagt ist insbesondere + - Die Weitergabe dieser Software oder von Teilen davon an Dritte + - Das Verändern, Dekompilieren, Zurückentwickeln (Reverse Engineering) oder + Bearbeiten des Codes + - Die Veröffentlichung – auch in veränderter oder abgeleiteter Form – + an anderer Stelle als den offiziellen Kanälen des Autors + - Der Verkauf sowie jede sonstige kommerzielle Nutzung + +3. Keine Gewährleistung + Die Software wird ohne jegliche Gewährleistung bereitgestellt, weder + ausdrücklich noch stillschweigend, einschließlich, aber nicht beschränkt auf + die Gewährleistung der Marktgängigkeit, der Eignung für einen bestimmten + Zweck und der Nichtverletzung von Rechten Dritter. Der Autor haftet nicht für + Schäden, die aus der Nutzung oder Unmöglichkeit der Nutzung der Software + entstehen. + +4. Rechtsfolgen bei Verstoß + Wer gegen diese Bedingungen verstößt, macht sich strafbar und kann rechtlich + belangt werden. Alle Rechte, die hier nicht ausdrücklich eingeräumt werden, + bleiben beim Autor vorbehalten. + +5. Markenhinweis + DeskCore ist kein offizielles Produkt von Microsoft. Windows ist + eine Marke der Microsoft Corporation. + +Kontakt: https://m-viper.de diff --git a/MainWindow.xaml b/MainWindow.xaml new file mode 100644 index 0000000..5d422b7 --- /dev/null +++ b/MainWindow.xaml @@ -0,0 +1,625 @@ + + + + + + + + + 108 + 116 + 48 + 0,14,0,0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +