From 77bb3e6b9d7858ce8d3fa8ce6fdf39b9c1f0d27e Mon Sep 17 00:00:00 2001 From: M_Viper Date: Thu, 1 Oct 2026 20:19:32 +0200 Subject: [PATCH] DeskCore 1.0.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 12 + App.xaml | 19 + App.xaml.cs | 117 +++ Backup.cs | 108 +++ DeskCore.csproj | 39 + Dialog.cs | 129 ++++ Directory.Build.props | 6 + FenceWindow.xaml | 65 ++ FenceWindow.xaml.cs | 278 +++++++ Hotkey.cs | 138 ++++ IconLoader.cs | 49 ++ LICENSE.txt | 38 + MainWindow.xaml | 625 ++++++++++++++++ MainWindow.xaml.cs | 1434 ++++++++++++++++++++++++++++++++++++ Migration.cs | 76 ++ Models.cs | 97 +++ README.md | 138 ++++ Settings.cs | 129 ++++ Setup/App.xaml | 18 + Setup/App.xaml.cs | 70 ++ Setup/DeskCoreSetup.csproj | 32 + Setup/Installer.cs | 368 +++++++++ Setup/MainWindow.xaml | 165 +++++ Setup/MainWindow.xaml.cs | 249 +++++++ ShellIcon.cs | 100 +++ Storage.cs | 332 +++++++++ Theme.cs | 74 ++ Themes/Colors.Dark.xaml | 27 + Themes/Colors.Light.xaml | 27 + Themes/Controls.xaml | 651 ++++++++++++++++ Tray.cs | 49 ++ UndoManager.cs | 105 +++ Watcher.cs | 111 +++ app.ico | Bin 0 -> 77047 bytes app.png | Bin 0 -> 41272 bytes build.ps1 | 19 + 36 files changed, 5894 insertions(+) create mode 100644 .gitignore create mode 100644 App.xaml create mode 100644 App.xaml.cs create mode 100644 Backup.cs create mode 100644 DeskCore.csproj create mode 100644 Dialog.cs create mode 100644 Directory.Build.props create mode 100644 FenceWindow.xaml create mode 100644 FenceWindow.xaml.cs create mode 100644 Hotkey.cs create mode 100644 IconLoader.cs create mode 100644 LICENSE.txt create mode 100644 MainWindow.xaml create mode 100644 MainWindow.xaml.cs create mode 100644 Migration.cs create mode 100644 Models.cs create mode 100644 README.md create mode 100644 Settings.cs create mode 100644 Setup/App.xaml create mode 100644 Setup/App.xaml.cs create mode 100644 Setup/DeskCoreSetup.csproj create mode 100644 Setup/Installer.cs create mode 100644 Setup/MainWindow.xaml create mode 100644 Setup/MainWindow.xaml.cs create mode 100644 ShellIcon.cs create mode 100644 Storage.cs create mode 100644 Theme.cs create mode 100644 Themes/Colors.Dark.xaml create mode 100644 Themes/Colors.Light.xaml create mode 100644 Themes/Controls.xaml create mode 100644 Tray.cs create mode 100644 UndoManager.cs create mode 100644 Watcher.cs create mode 100644 app.ico create mode 100644 app.png create mode 100644 build.ps1 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +