From 22bfe3658bf6455491ecf6a947609b44c10cc889 Mon Sep 17 00:00:00 2001 From: LastResolution <128378329+LastResolution@users.noreply.github.com> Date: Sun, 8 Nov 2026 02:59:40 +0900 Subject: [PATCH 1/5] Use installed app tile artwork with an optional appearance setting --- scripts/test-tile-artwork.ps1 | 18 +++ src/ReStart.App/App.xaml.cs | 4 +- .../MainWindow.FidelityDiagnostics.cs | 1 + .../MainWindow.SettingsPreviewDiagnostics.cs | 6 + .../MainWindow.TileArtworkChecks.cs | 138 ++++++++++++++++++ .../Services/Catalog/TileArtworkLoader.cs | 91 ++++++++++++ .../Services/LiveTiles/LegacyTileAssets.cs | 12 +- .../Settings/SettingsWindow.Appearance.cs | 2 + .../Views/Start/MainWindow.Catalog.cs | 1 + .../Views/Start/MainWindow.Folders.cs | 2 +- .../Views/Start/MainWindow.LegacyTiles.cs | 4 + .../Views/Start/MainWindow.SettingsPreview.cs | 1 + .../Views/Start/MainWindow.TileArtwork.cs | 111 ++++++++++++++ .../Views/Start/MainWindow.xaml.cs | 11 +- src/ReStart.Core/Apps/AppTileArtwork.cs | 111 ++++++++++++++ src/ReStart.Core/Configuration/Preferences.cs | 2 + src/ReStart.Core/Locales/de.json | 2 + src/ReStart.Core/Locales/en.json | 2 + src/ReStart.Core/Locales/es.json | 2 + src/ReStart.Core/Locales/fr.json | 2 + src/ReStart.Core/Locales/ja.json | 2 + src/ReStart.Core/Locales/ko.json | 2 + src/ReStart.Core/Locales/zh-Hans.json | 2 + src/ReStart.Core/Locales/zh-Hant.json | 2 + src/ReStart.Core/Localization/TextKey.cs | 4 + .../Apps/AppTileArtworkTests.cs | 99 +++++++++++++ 26 files changed, 626 insertions(+), 8 deletions(-) create mode 100644 scripts/test-tile-artwork.ps1 create mode 100644 src/ReStart.App/Diagnostics/MainWindow.TileArtworkChecks.cs create mode 100644 src/ReStart.App/Services/Catalog/TileArtworkLoader.cs create mode 100644 src/ReStart.App/Views/Start/MainWindow.TileArtwork.cs create mode 100644 src/ReStart.Core/Apps/AppTileArtwork.cs create mode 100644 tests/ReStart.Core.Tests/Apps/AppTileArtworkTests.cs diff --git a/scripts/test-tile-artwork.ps1 b/scripts/test-tile-artwork.ps1 new file mode 100644 index 0000000..12f3f24 --- /dev/null +++ b/scripts/test-tile-artwork.ps1 @@ -0,0 +1,18 @@ +[CmdletBinding()] +param([Parameter(Mandatory)][string]$Executable) +$ErrorActionPreference = 'Stop' +$repoRoot = Split-Path -Parent $PSScriptRoot +$source = (Resolve-Path -LiteralPath $Executable).Path +$snapshot = Join-Path $repoRoot ('artifacts/tile-artwork/' + [Guid]::NewGuid().ToString('N') + '/start.png') +New-Item -ItemType Directory -Path (Split-Path -Parent $snapshot) -Force | Out-Null +$process = Start-Process -FilePath $source -ArgumentList @('--smoke-test','--tile-artwork-test','--snapshot',('"' + $snapshot + '"')) -WindowStyle Hidden -PassThru +try { + if (-not $process.WaitForExit(60000)) { $process.Kill(); throw 'Tile artwork verification timed out.' } + $result = Get-Content -LiteralPath ([IO.Path]::ChangeExtension($snapshot,'.json')) -Raw | ConvertFrom-Json + if ($process.ExitCode -ne 0 -or -not $result.success -or -not $result.tileArtwork) { + $result.error | Write-Output + throw 'Tile artwork verification failed.' + } + Write-Output "Verified $($result.tileArtwork) native artwork checks." +} +finally { $process.Dispose() } diff --git a/src/ReStart.App/App.xaml.cs b/src/ReStart.App/App.xaml.cs index a75f21c..a5e5e82 100644 --- a/src/ReStart.App/App.xaml.cs +++ b/src/ReStart.App/App.xaml.cs @@ -241,6 +241,7 @@ private void RunSmoke(string[] args) AppearanceSmokeResult? appearance = null; SettingsPreviewSmokeResult? settingsPreview = null; FidelitySmokeResult? fidelity = null; + int? tileArtwork = null; string? error = null; try { @@ -269,12 +270,13 @@ private void RunSmoke(string[] args) if (args.Contains("--settings-preview-test")) settingsPreview = await _window.VerifySettingsPreviewAsync(path); if (args.Contains("--appearance-test")) appearance = await _window.VerifyAppearanceAsync(path); if (args.Contains("--fidelity-test")) fidelity = await _window.VerifyFidelityAsync(path); + if (args.Contains("--tile-artwork-test")) tileArtwork = await _window.VerifyTileArtworkOnlyAsync(path); _window.SaveScreenshot(path); } catch (Exception ex) { success = false; error = ex.ToString(); Console.Error.WriteLine(ex); } try { - File.WriteAllText(Path.ChangeExtension(path, ".json"), JsonSerializer.Serialize(new { success, apps = _window.CatalogCount, icons = _window.IconCount, catalogFolders = _window.CatalogFolderCount, groups = _window.Document.Groups.Count, scriptTiles = _window.ScriptTileCount, scriptFrames = _window.ScriptFrameCount, query, searchResults = _window.SearchResultNames, searchInputVisible = _window.SearchInputInVisibleTree, navigation, placement, appFolders, firstInput, appMenus, nativeJumps, releaseInfo, scrolling, bundledTiles, pinIcons, motion, editing, folderDrops, tileData, searchSelection, startupPreparation, warmResume, appearance, settingsPreview, fidelity, startup = _window.StartupMetrics, error, elapsedMs = watch.ElapsedMilliseconds }, new JsonSerializerOptions { WriteIndented = true })); + File.WriteAllText(Path.ChangeExtension(path, ".json"), JsonSerializer.Serialize(new { success, apps = _window.CatalogCount, icons = _window.IconCount, catalogFolders = _window.CatalogFolderCount, groups = _window.Document.Groups.Count, scriptTiles = _window.ScriptTileCount, scriptFrames = _window.ScriptFrameCount, query, searchResults = _window.SearchResultNames, searchInputVisible = _window.SearchInputInVisibleTree, navigation, placement, appFolders, firstInput, appMenus, nativeJumps, releaseInfo, scrolling, bundledTiles, pinIcons, motion, editing, folderDrops, tileData, searchSelection, startupPreparation, warmResume, appearance, settingsPreview, fidelity, tileArtwork, startup = _window.StartupMetrics, error, elapsedMs = watch.ElapsedMilliseconds }, new JsonSerializerOptions { WriteIndented = true })); } catch (IOException) { success = false; } _window.Exit(); Shutdown(success ? 0 : 1); diff --git a/src/ReStart.App/Diagnostics/MainWindow.FidelityDiagnostics.cs b/src/ReStart.App/Diagnostics/MainWindow.FidelityDiagnostics.cs index ac059a1..1e79444 100644 --- a/src/ReStart.App/Diagnostics/MainWindow.FidelityDiagnostics.cs +++ b/src/ReStart.App/Diagnostics/MainWindow.FidelityDiagnostics.cs @@ -311,6 +311,7 @@ await AppShellCommands.OnSta(() => } await fixture.SaveSearchShowcaseAsync(directory); checks += await VerifySessionMenusAsync(fixture, directory); + checks += await VerifyTileArtworkAsync(fixture, directory); checks += await VerifyLegacyTileRenderingAsync(fixture, directory); checks += await VerifyWindowsSearchAsync(fixture, launched, directory); checks += await IndexedFileSearch.VerifyOwnedFieldsAsync(); diff --git a/src/ReStart.App/Diagnostics/MainWindow.SettingsPreviewDiagnostics.cs b/src/ReStart.App/Diagnostics/MainWindow.SettingsPreviewDiagnostics.cs index 64aeb71..e95820b 100644 --- a/src/ReStart.App/Diagnostics/MainWindow.SettingsPreviewDiagnostics.cs +++ b/src/ReStart.App/Diagnostics/MainWindow.SettingsPreviewDiagnostics.cs @@ -64,12 +64,15 @@ T Named(DependencyObject root, TextKey key) where T : DependencyObject Check(!VisualChildren(editor).Any(item => AutomationProperties.GetName(item) == L.T(K.SettingsTabletHide)), "The removed independent taskbar hide setting is still offered."); editor.SelectSection(L.T(K.SettingsAppearance)); + Named(editor, K.SettingsAppTileImages).IsChecked = false; Named(editor, K.SettingsBaseTheme).SelectedIndex = (int)mode; Named(editor, K.SettingsGroupWidth).SelectedIndex = 1; Named(editor, K.SettingsTileSize).Value = 1.25; Named(editor, K.SettingsOpacity).Value = .1; Named(editor, K.SettingsBlur).Value = 0; editor.ApplyDraftPreview(); fixture.UpdateLayout(); + Check(!fixture.DisplayPreferences.UseAppTileImages && fixture.Document.Preferences.UseAppTileImages, + "The app artwork preview changed committed preferences or failed to apply."); Check(fixture.DisplayPreferences.Theme == mode && fixture.GroupsPanel.TileColumns == 8 && Math.Abs(fixture.Cell - 65) < .01 && fixture.Wallpaper.Effect == null, "Draft appearance and tile geometry were not rendered before saving."); Check(((SolidColorBrush)fixture.Tint.Background).Color.A == (SystemParameters.HighContrast ? 255 : 25), "Draft tint was not applied."); @@ -98,6 +101,9 @@ T Named(DependencyObject root, TextKey key) where T : DependencyObject }); editor.ShowDialog(); if (error != null) throw new InvalidOperationException("Settings draft fixture failed.", error); Check(fixture._preferencesPreview == null, "A closed editor retained its display overlay."); + Check(fixture.Document.Preferences.UseAppTileImages == (decision != SettingsCloseChoice.Save) + && store.Load()!.Preferences.UseAppTileImages == (decision != SettingsCloseChoice.Save), + "The app artwork switch did not follow Settings save/discard."); Check(decision == SettingsCloseChoice.Save ? fixture.Document.Preferences.TileScale == 1.25 && store.Load()!.Preferences.Columns == 8 && store.Load()!.Preferences.TabletHideTaskbar && !store.Load()!.Preferences.ShowTaskbarWhileOpen : JsonSerializer.Serialize(fixture.Document.Preferences) == original && JsonSerializer.Serialize(store.Load()!.Preferences) == original, diff --git a/src/ReStart.App/Diagnostics/MainWindow.TileArtworkChecks.cs b/src/ReStart.App/Diagnostics/MainWindow.TileArtworkChecks.cs new file mode 100644 index 0000000..149a244 --- /dev/null +++ b/src/ReStart.App/Diagnostics/MainWindow.TileArtworkChecks.cs @@ -0,0 +1,138 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using ReStart.App.Services; +using ReStart.Core; + +namespace ReStart.App; + +public partial class MainWindow +{ + internal async Task VerifyTileArtworkOnlyAsync(string snapshotPath) + { + if (!_preview) throw new InvalidOperationException("Artwork verification requires preview mode."); + var directory = Path.Combine(Path.GetDirectoryName(snapshotPath)!, "artwork-" + Guid.NewGuid().ToString("N")); + var fixture = new MainWindow(new LayoutStore(Path.Combine(directory, "layout.json")), true, () => Task.FromResult(new List())) + { Owner = this, Width = 1280, Height = 800, Topmost = false }; + try + { + fixture.Present(); await fixture.InitializeAsync(); fixture._clock.Stop(); + return await VerifyTileArtworkAsync(fixture, directory); + } + finally { fixture.Exit(); } + } + + private static async Task VerifyTileArtworkAsync(MainWindow fixture, string directory) + { + var checks = 0; + void Check(bool condition, string message) { Require(condition, message); checks++; } + var root = Path.Combine(directory, "owned-artwork"); Directory.CreateDirectory(root); + var executable = Path.Combine(root, "Owned.exe"); File.WriteAllText(executable, "Not an executable; artwork fixture only."); + File.WriteAllText(Path.ChangeExtension(executable, ".VisualElementsManifest.xml"), + ""); + foreach (var (name, width, height) in new[] { ("small", 71, 71), ("medium", 150, 150), ("wide", 310, 150), ("large", 310, 310) }) + foreach (var light in new[] { false, true }) + { + var pixels = new byte[width * height * 4]; + for (var i = 0; i < pixels.Length; i += 4) { pixels[i] = 160; pixels[i + 1] = light ? (byte)220 : (byte)100; pixels[i + 2] = 40; pixels[i + 3] = 255; } + var encoder = new PngBitmapEncoder(); encoder.Frames.Add(BitmapFrame.Create(BitmapSource.Create(width, height, 96, 96, PixelFormats.Bgra32, null, pixels, width * 4))); + using var stream = File.Create(Path.Combine(root, name + ".scale-100_theme-" + (light ? "light" : "dark") + ".png")); encoder.Save(stream); + } + const string appId = "ReStart.OwnedArtwork_123!Main"; + var app = new AppEntry(appId, "Owned tile", executable, false); + foreach (var light in new[] { false, true }) + { + var frames = await TileArtworkLoader.ReadAsync(Enum.GetValues().Select(size => new TileArtworkLoader.Request(app, size, 400, light, "en-US")).ToArray(), CancellationToken.None); + Check(frames.Count == 4, "Installed artwork was not resolved for all four declared sizes."); + foreach (var frame in frames.Values) + { + Check(frame.Image.IsFrozen && frame.Background == "#123456", "An artwork bitmap was not frozen or lost its declared color."); + var pixel = new byte[4]; new FormatConvertedBitmap(frame.Image, PixelFormats.Bgra32, null, 0).CopyPixels(new Int32Rect(0, 0, 1, 1), pixel, 4, 0); + Check(pixel[1] == (light ? 220 : 100), "Installed artwork selected the wrong theme-qualified file."); + } + Check(frames[(appId, TileSize.Wide)].Image.PixelWidth > frames[(appId, TileSize.Wide)].Image.PixelHeight, + "Wide artwork lost its aspect ratio."); + } + File.WriteAllText(Path.ChangeExtension(executable, ".VisualElementsManifest.xml"), ""); + Check((await TileArtworkLoader.ReadAsync([new(app, TileSize.Medium, 150, false, "en-US")], CancellationToken.None)).Count == 0, + "An artwork manifest escaped its installed app directory."); + File.WriteAllText(Path.Combine(root, "fake.png"), ""); + Check(LegacyTileAssets.LoadRaster(Path.Combine(root, "fake.png"), root) == null, "Active image content disguised as a raster was accepted."); + File.WriteAllText(Path.ChangeExtension(executable, ".VisualElementsManifest.xml"), ""); + using (var cancelled = new CancellationTokenSource()) + { + cancelled.Cancel(); + try { await TileArtworkLoader.ReadAsync([new(app, TileSize.Small, 70, false, "en-US")], cancelled.Token); Check(false, "Cancelled artwork reads completed."); } + catch (OperationCanceledException) { checks++; } + } + var reads = 0; + fixture._artworkFixture = async (requests, token) => + { + reads++; + return await TileArtworkLoader.ReadAsync(requests.Select(r => r with { App = app }).ToArray(), token); + }; + fixture.Document.Preferences.UseAppTileImages = true; + fixture.Document.Preferences.LegacyUwpLiveTiles = false; + fixture.Document.Preferences.Language = "en"; + fixture.ResetToPinnedView(); + var tiles = Enum.GetValues().Select(size => new Tile { AppId = appId, Target = executable, Label = "Owned tile", Size = size }).ToList(); + foreach (var theme in new[] { ReStart.Core.ThemeMode.Dark, ReStart.Core.ThemeMode.Light }) + foreach (var scale in new[] { .7, 1, 1.5 }) + { + fixture.Document.Preferences.Theme = theme; fixture.Document.Preferences.TileScale = scale; + fixture.Document.Groups = [new() { Name = "App tile artwork", Tiles = tiles }]; + fixture.ApplyAppearance(); fixture.RenderGroups(); await fixture._artworkReadTask!; fixture.UpdateLayout(); + foreach (var view in fixture._artworkViews) + { + Check(view.Current != null && view.Surface.Children.OfType().Single().Source == view.Current.Image, + "Declared artwork did not replace its icon at a supported size, scale and theme."); + var bounds = view.Surface.TransformToAncestor(view.Button).TransformBounds(new Rect(view.Surface.RenderSize)); + Check(bounds.Width <= view.Button.ActualWidth + 1 && bounds.Height <= view.Button.ActualHeight + 1 + && bounds.Left >= -1 && bounds.Top >= -1, "An artwork image extends beyond its tile."); + } + var sameReads = reads; fixture.RenderGroups(); await fixture._artworkReadTask!; + Check(reads == sameReads, "Rearranging unchanged pins unnecessarily reloaded their artwork."); + SaveFixtureElement(fixture.Root, Path.Combine(directory, "app-artwork-" + theme + "-" + scale.ToString("0.0", System.Globalization.CultureInfo.InvariantCulture) + ".png")); + } + fixture.Document.Preferences.UseAppTileImages = false; fixture.RenderGroups(); + Check(fixture._artworkFrames.Count == 0 && fixture._artworkViews.All(v => v.Current == null && v.Surface.Children.Contains(v.Icon)), + "Disabling app artwork did not restore the existing icons."); + var offReads = reads; fixture.RenderGroups(); Check(reads == offReads, "Disabled artwork still read app metadata."); + fixture.Document.Preferences.UseAppTileImages = true; + fixture._artworkHighContrastFixture = true; fixture.RenderGroups(); + Check(fixture._artworkFrames.Count == 0 && fixture._artworkViews.All(v => v.Current == null) && reads == offReads, + "High contrast used app-provided backgrounds or read unnecessary artwork."); + fixture._artworkHighContrastFixture = null; + fixture.Document.Groups = [new() { Tiles = [new() { Label = "Owned folder", Children = tiles }] }]; + fixture.RenderGroups(); await fixture._artworkReadTask!; fixture.UpdateLayout(); + Check(fixture._artworkViews.Count == 4 && fixture._artworkViews.All(v => v.Tile.Size == TileSize.Small && v.Current != null), + "Closed folder previews failed to use the small artwork layout."); + Check(tiles.Select(t => t.Size).SequenceEqual(Enum.GetValues()), "Folder artwork mutated persistent child sizes."); + + fixture.Document.Groups = [new() { Tiles = tiles }]; + fixture.Document.Preferences.LegacyUwpLiveTiles = true; + fixture._legacyTileFixture = (_, _) => Task.FromResult(new LegacyNotificationStore.Snapshot(true, + new Dictionary { [appId] = new(LegacyTileContent.Read("7Owned updateOwned updateOwned update"), "") })); + fixture._legacyRequested = ""; fixture._legacyNextPoll = 0; + fixture.RenderGroups(); await fixture._artworkReadTask!; + var deadline = Environment.TickCount64 + 3000; + while (!fixture._legacySnapshot.Apps.ContainsKey(appId) && Environment.TickCount64 < deadline) await Task.Delay(20); + fixture.ApplyLegacyFrames(); + Check(fixture._legacyViews.Count == 4 && fixture._legacyViews.All(v => v.View.HasTileContent && v.Icon.Visibility == Visibility.Collapsed), + "Static app artwork covered a live Windows tile update."); + fixture.Document.Preferences.LegacyUwpLiveTiles = false; fixture.RefreshLegacyTiles(); + Check(fixture._legacyViews.All(v => !v.View.HasTileContent && v.Icon.Visibility == Visibility.Visible), "Missing live data did not restore static artwork."); + + var pending = new TaskCompletionSource>(TaskCreationOptions.RunContinuationsAsynchronously); + fixture._artworkFixture = (_, _) => pending.Task; + fixture._artworkRequested = ""; fixture.RenderGroups(); var oldRead = fixture._artworkReadTask!; + fixture.Document.Preferences.UseAppTileImages = false; fixture.RenderGroups(); + pending.SetResult(new Dictionary<(string, TileSize), TileArtworkLoader.Frame>()); + await oldRead; + Check(fixture._artworkFrames.Count == 0 && fixture._artworkViews.All(v => v.Current == null), "A stale asynchronous read undid the disabled preference."); + fixture._artworkFixture = null; fixture._legacyTileFixture = null; fixture.StopLegacyTiles(); + fixture.Document.Preferences.UseAppTileImages = true; + return checks; + } +} diff --git a/src/ReStart.App/Services/Catalog/TileArtworkLoader.cs b/src/ReStart.App/Services/Catalog/TileArtworkLoader.cs new file mode 100644 index 0000000..d33b7f9 --- /dev/null +++ b/src/ReStart.App/Services/Catalog/TileArtworkLoader.cs @@ -0,0 +1,91 @@ +using System.Runtime.InteropServices; +using System.Windows.Media.Imaging; +using ReStart.Core; +using Windows.Management.Deployment; + +namespace ReStart.App.Services; + +/// Loads declared raster artwork for requested pins from installed package/desktop manifests, without network access or execution. +internal static class TileArtworkLoader +{ + internal sealed record Request(AppEntry App, TileSize Size, int Pixels, bool Light, string Language); + internal sealed record Frame(BitmapSource Image, string Background, string Foreground, bool ShowName); + + internal static Task> ReadAsync(IReadOnlyList requests, CancellationToken token) + { + var completion = new TaskCompletionSource>(TaskCreationOptions.RunContinuationsAsynchronously); + var thread = new Thread(() => + { + try { completion.SetResult(Read(requests, token)); } + catch (OperationCanceledException) when (token.IsCancellationRequested) { completion.SetCanceled(token); } + catch (Exception ex) { completion.SetException(ex); } + }) { IsBackground = true, Name = "ReStart tile artwork" }; + thread.SetApartmentState(ApartmentState.STA); thread.Start(); return completion.Task; + } + + internal static IReadOnlyDictionary<(string App, TileSize Size), Frame> Read(IReadOnlyList requests, CancellationToken token = default) + { + var result = new Dictionary<(string, TileSize), Frame>(); + var manifests = new Dictionary(StringComparer.OrdinalIgnoreCase); + var images = new Dictionary<(string Path, int Pixels), BitmapSource>(); + foreach (var request in requests.Take(256)) + { + token.ThrowIfCancellationRequested(); + try + { + if (!manifests.TryGetValue(request.App.Id, out var declaration)) manifests[request.App.Id] = declaration = ReadManifest(request.App); + if (declaration is not { } metadata || !metadata.Artwork.Images.TryGetValue(request.Size, out var reference)) continue; + if (LegacyTileAssetPath.Resolve(reference, metadata.Root, null) is not { } candidate) continue; + var directory = Path.GetDirectoryName(candidate)!; + if (!LegacyTileAssets.SafeFile(directory, metadata.Root)) continue; + var basePixels = request.Size == TileSize.Small ? (metadata.Artwork.Images.GetValueOrDefault(TileSize.Medium) == reference ? 150 : 71) + : request.Size == TileSize.Medium ? 150 : 310; + var files = Directory.EnumerateFiles(directory, Path.GetFileNameWithoutExtension(candidate) + "*" + Path.GetExtension(candidate)).Take(128).Select(Path.GetFileName).OfType(); + foreach (var name in AppTileArtwork.RankVariants(Path.GetFileName(candidate), files.Where(n => n.Equals(Path.GetFileName(candidate), StringComparison.OrdinalIgnoreCase) + || n.StartsWith(Path.GetFileNameWithoutExtension(candidate) + ".", StringComparison.OrdinalIgnoreCase)), basePixels, request.Pixels, request.Light, request.Language)) + { + token.ThrowIfCancellationRequested(); + var path = Path.Combine(directory, name); var pixels = Math.Clamp(request.Pixels, 1, 1024); + if (!images.TryGetValue((path, pixels), out var image)) + { + image = LegacyTileAssets.LoadRaster(path, metadata.Root, pixels); + if (image == null) continue; + if (images.Values.Sum(i => (long)i.PixelWidth * i.PixelHeight) + (long)image.PixelWidth * image.PixelHeight > 16_777_216) continue; + images[(path, pixels)] = image; + } + result[(request.App.Id, request.Size)] = new(image, metadata.Artwork.Background, metadata.Artwork.Foreground, metadata.Artwork.NamedSizes.Contains(request.Size)); + break; + } + } + catch (Exception ex) when (ex is COMException or IOException or UnauthorizedAccessException or System.Security.SecurityException + or ArgumentException or NotSupportedException or InvalidOperationException or System.IO.FileFormatException) { } + } + return result; + } + + private static (string Root, AppTileArtwork Artwork)? ReadManifest(AppEntry app) + { + string root, manifest; string? application = null; + if (app.IsShellApp && LegacyNotificationStore.IsPackageAppId(app.Id)) + { + var split = app.Id.IndexOf('!'); application = app.Id[(split + 1)..]; + var package = new PackageManager().FindPackagesForUser("", app.Id[..split]).FirstOrDefault(); + if (package == null) return null; + root = package.InstalledLocation.Path; manifest = Path.Combine(root, "AppxManifest.xml"); + } + else + { + var executable = app.ExecutablePath; + if (executable.Length == 0 && app.ShortcutPath.Length > 0) executable = AppCatalog.ReadShortcutMetadata(app.ShortcutPath).TargetPath; + if (executable.Length == 0 && !app.IsShellApp) + executable = app.LaunchTarget.EndsWith(".lnk", StringComparison.OrdinalIgnoreCase) ? AppCatalog.ReadShortcutMetadata(app.LaunchTarget).TargetPath : app.LaunchTarget; + if (!Path.IsPathFullyQualified(executable) || executable.StartsWith(@"\\", StringComparison.Ordinal) || !Path.GetExtension(executable).Equals(".exe", StringComparison.OrdinalIgnoreCase)) return null; + root = Path.GetDirectoryName(executable)!; + if (!LegacyTileAssets.SafeFile(executable, root)) return null; + manifest = Path.ChangeExtension(executable, ".VisualElementsManifest.xml"); + } + if (!LegacyTileAssets.SafeFile(manifest, root) || new FileInfo(manifest).Length is <= 0 or > 2_000_000) return null; + using var stream = File.Open(manifest, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + return AppTileArtwork.Read(stream, application) is { } artwork ? (root, artwork) : null; + } +} diff --git a/src/ReStart.App/Services/LiveTiles/LegacyTileAssets.cs b/src/ReStart.App/Services/LiveTiles/LegacyTileAssets.cs index 3672fe5..addb7fa 100644 --- a/src/ReStart.App/Services/LiveTiles/LegacyTileAssets.cs +++ b/src/ReStart.App/Services/LiveTiles/LegacyTileAssets.cs @@ -67,23 +67,29 @@ private sealed record Cached(long Stamp, long Length, BitmapSource Bitmap); .OrderBy(path => path, StringComparer.OrdinalIgnoreCase).FirstOrDefault(path => SafeFile(path, root)); } - internal static BitmapSource? LoadRaster(string path, string root) + internal static BitmapSource? LoadRaster(string path, string root, int maximumDimension = 256) { if (!SafeFile(path, root) || new FileInfo(path).Length is <= 0 or > 3_000_000) return null; using var stream = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + Span header = stackalloc byte[8]; var count = stream.Read(header); + if (!(count >= 8 && header.SequenceEqual(new byte[] { 137, 80, 78, 71, 13, 10, 26, 10 }) + || count >= 3 && header[0] == 255 && header[1] == 216 && header[2] == 255 + || count >= 2 && header[0] == 'B' && header[1] == 'M' + || count >= 6 && (header[..6].SequenceEqual("GIF87a"u8) || header[..6].SequenceEqual("GIF89a"u8)))) return null; + stream.Position = 0; var decoder = BitmapDecoder.Create(stream, BitmapCreateOptions.DelayCreation, BitmapCacheOption.None); var size = decoder.Frames[0]; var width = size.PixelWidth; var height = size.PixelHeight; if (width <= 0 || height <= 0 || (long)width * height > 16_777_216) return null; stream.Position = 0; var image = new BitmapImage(); image.BeginInit(); image.CacheOption = BitmapCacheOption.OnLoad; - if (width >= height) image.DecodePixelWidth = Math.Min(width, 256); else image.DecodePixelHeight = Math.Min(height, 256); + if (width >= height) image.DecodePixelWidth = Math.Min(width, Math.Clamp(maximumDimension, 1, 1024)); else image.DecodePixelHeight = Math.Min(height, Math.Clamp(maximumDimension, 1, 1024)); image.StreamSource = stream; image.EndInit(); image.Freeze(); return image; } private static IEnumerable Sources(IEnumerable elements) => elements.SelectMany(e => (e.Kind == "image" ? new[] { e.Source } : []).Concat(Sources(e.Children))); - private static bool SafeFile(string path, string root) + internal static bool SafeFile(string path, string root) { var current = Path.GetFullPath(path); var boundary = Path.GetFullPath(root).TrimEnd('\\'); if (!current.Equals(boundary, StringComparison.OrdinalIgnoreCase) && !current.StartsWith(boundary + "\\", StringComparison.OrdinalIgnoreCase)) return false; diff --git a/src/ReStart.App/Views/Settings/SettingsWindow.Appearance.cs b/src/ReStart.App/Views/Settings/SettingsWindow.Appearance.cs index f61e8f2..d77bdf6 100644 --- a/src/ReStart.App/Views/Settings/SettingsWindow.Appearance.cs +++ b/src/ReStart.App/Views/Settings/SettingsWindow.Appearance.cs @@ -59,6 +59,8 @@ private void BuildAppearance(Panel content) Note(content, L.T(K.SettingsStartAccentHelp)); BuildBackgroundImageSettings(content); Check(content, L.T(K.SettingsColoredTiles), _draft.ColoredTiles, v => _draft.ColoredTiles = v); + Check(content, L.T(K.SettingsAppTileImages), _draft.UseAppTileImages, v => _draft.UseAppTileImages = v); + Note(content, L.T(K.AppTileImagesHelp)); Check(content, L.T(K.SettingsAnimation), _draft.Animate, v => _draft.Animate = v); Select(content, L.T(K.SettingsGroupWidth), new[] { L.T(K.ColumnsThree), L.T(K.ColumnsFour) }, _draft.Columns == 8 ? 1 : 0, i => _draft.Columns = i == 1 ? 8 : 6); Slide(content, L.T(K.SettingsTileSize), 0.7, 1.5, _draft.TileScale, v => _draft.TileScale = v, true); diff --git a/src/ReStart.App/Views/Start/MainWindow.Catalog.cs b/src/ReStart.App/Views/Start/MainWindow.Catalog.cs index a52d5a2..cddb421 100644 --- a/src/ReStart.App/Views/Start/MainWindow.Catalog.cs +++ b/src/ReStart.App/Views/Start/MainWindow.Catalog.cs @@ -26,6 +26,7 @@ private async Task RefreshCatalogCoreAsync() await WaitForCatalogViewsAsync(); if (_exit) return; _apps = apps; + _artworkRequested = ""; RenderApps(); RefreshSearchResults(); await _catalog.LoadIconsAsync(apps); await WaitForCatalogViewsAsync(); diff --git a/src/ReStart.App/Views/Start/MainWindow.Folders.cs b/src/ReStart.App/Views/Start/MainWindow.Folders.cs index 9d069a5..eeee944 100644 --- a/src/ReStart.App/Views/Start/MainWindow.Folders.cs +++ b/src/ReStart.App/Views/Start/MainWindow.Folders.cs @@ -30,7 +30,7 @@ private FrameworkElement CreateFolderPreview(Tile folder) // changing miniature alignment. The persistent child is untouched. var copy = System.Text.Json.JsonSerializer.Deserialize(System.Text.Json.JsonSerializer.Serialize(child, LayoutStore.JsonOptions), LayoutStore.JsonOptions)!; copy.Size = TileSize.Small; - if (copy.Widget.Length == 0 && !(Document.Preferences.LegacyUwpLiveTiles && copy.Live && LegacyEligible(copy))) + if (copy.Widget.Length == 0 && !DisplayPreferences.UseAppTileImages && !(Document.Preferences.LegacyUwpLiveTiles && copy.Live && LegacyEligible(copy))) { var icon = CreateIcon(copy, extent / 3); icon.Margin = new(0); mini.Children.Add(new Border { Child = icon, Margin = new(1) }); diff --git a/src/ReStart.App/Views/Start/MainWindow.LegacyTiles.cs b/src/ReStart.App/Views/Start/MainWindow.LegacyTiles.cs index c729804..a64e1c0 100644 --- a/src/ReStart.App/Views/Start/MainWindow.LegacyTiles.cs +++ b/src/ReStart.App/Views/Start/MainWindow.LegacyTiles.cs @@ -61,6 +61,7 @@ private async Task ReadLegacyTilesAsync(string[] apps, CancellationTokenSource c private void ApplyLegacyFrames() { + ApplyTileArtworkViews(); foreach (var (tile, view, icon, button) in _legacyViews) { if (!TileInViewport(button)) continue; @@ -72,6 +73,9 @@ private void ApplyLegacyFrames() { name.Margin = new(2, 0, frame?.Badge.Length > 0 ? (frame.Badge.Length * 7 + 6) * DisplayPreferences.TileScale : 1, 0); name.Foreground = view.ContentForeground ?? button.Foreground; + name.Visibility = (view.HasTileContent ? DisplayPreferences.ShowLiveTileNames + : !DisplayPreferences.UseAppTileImages || !_artworkFrames.TryGetValue((tile.AppId, tile.Size), out var artwork) || artwork.ShowName) + ? Visibility.Visible : Visibility.Collapsed; } } } diff --git a/src/ReStart.App/Views/Start/MainWindow.SettingsPreview.cs b/src/ReStart.App/Views/Start/MainWindow.SettingsPreview.cs index 11ab9aa..8d56661 100644 --- a/src/ReStart.App/Views/Start/MainWindow.SettingsPreview.cs +++ b/src/ReStart.App/Views/Start/MainWindow.SettingsPreview.cs @@ -47,6 +47,7 @@ private static string VisualPreferenceSignature(Preferences p) => JsonSerializer p.BackgroundColor, p.ShowAccentOnStart, p.ColoredTiles, + p.UseAppTileImages, p.Columns, p.TileScale, p.ShowLiveTileNames, diff --git a/src/ReStart.App/Views/Start/MainWindow.TileArtwork.cs b/src/ReStart.App/Views/Start/MainWindow.TileArtwork.cs new file mode 100644 index 0000000..bab7333 --- /dev/null +++ b/src/ReStart.App/Views/Start/MainWindow.TileArtwork.cs @@ -0,0 +1,111 @@ +using System.Text.Json; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using ReStart.App.Services; +using ReStart.Core; + +namespace ReStart.App; + +public partial class MainWindow +{ + private sealed record ArtworkView(Tile Tile, Button Button, Grid Surface, FrameworkElement Icon, Brush Background, Brush Foreground) + { + internal TileArtworkLoader.Frame? Current { get; set; } + } + private readonly List _artworkViews = []; + private IReadOnlyDictionary<(string App, TileSize Size), TileArtworkLoader.Frame> _artworkFrames = new Dictionary<(string, TileSize), TileArtworkLoader.Frame>(); + private CancellationTokenSource? _artworkCancellation; + private Task? _artworkReadTask; + private string _artworkRequested = "", _artworkContext = ""; + private bool? _artworkHighContrastFixture; + private bool ArtworkHighContrast => _artworkHighContrastFixture ?? SystemParameters.HighContrast; + private Func, CancellationToken, Task>>? _artworkFixture; + + private FrameworkElement ArtworkSurface(Tile tile, Button button, FrameworkElement icon) + { + var surface = new Grid { ClipToBounds = true }; surface.Children.Add(icon); + if (tile.Widget.Length == 0 && !tile.IsFolder) + _artworkViews.Add(new(tile, button, surface, icon, button.Background, button.Foreground)); + return surface; + } + + private void RefreshTileArtwork() + { + if (_exit || !DisplayPreferences.UseAppTileImages || ArtworkHighContrast || _preview && _artworkFixture == null) + { + _artworkCancellation?.Cancel(); _artworkRequested = ""; _artworkContext = ""; + _artworkFrames = new Dictionary<(string, TileSize), TileArtworkLoader.Frame>(); ApplyTileArtworkViews(); return; + } + var dpi = VisualTreeHelper.GetDpi(this).DpiScaleX; + var context = JsonSerializer.Serialize(new { ThemeWindow.IsLight, Dpi = dpi, DisplayPreferences.TileScale, Language = L.Culture.Name }); + var requests = _artworkViews.Select(v => + { + var app = _apps.FirstOrDefault(a => a.Id.Equals(v.Tile.AppId, StringComparison.OrdinalIgnoreCase)) + ?? new(v.Tile.AppId, v.Tile.Label, v.Tile.Target, v.Tile.IsShellApp); + app = app with { Id = v.Tile.AppId }; + return new TileArtworkLoader.Request(app, v.Tile.Size, + (int)Math.Ceiling((TileLayout.Dimensions(v.Tile.Size).Width * Cell - 4) * dpi), ThemeWindow.IsLight, L.Culture.Name); + }).DistinctBy(r => (r.App.Id, r.Size)).OrderBy(r => r.App.Id, StringComparer.Ordinal).ThenBy(r => r.Size).Take(256).ToArray(); + var signature = context + JsonSerializer.Serialize(requests.Select(r => new { r.App.Id, r.App.LaunchTarget, r.App.ExecutablePath, r.Size, r.Pixels })); + if (_artworkContext != context) _artworkFrames = new Dictionary<(string, TileSize), TileArtworkLoader.Frame>(); + _artworkContext = context; ApplyTileArtworkViews(); + if (_artworkRequested == signature) return; + _artworkRequested = signature; _artworkCancellation?.Cancel(); + var cancellation = new CancellationTokenSource(); _artworkCancellation = cancellation; + _artworkReadTask = ReadTileArtworkAsync(requests, signature, cancellation); + } + + private async Task ReadTileArtworkAsync(TileArtworkLoader.Request[] requests, string signature, CancellationTokenSource cancellation) + { + try + { + var frames = await (_artworkFixture?.Invoke(requests, cancellation.Token) ?? TileArtworkLoader.ReadAsync(requests, cancellation.Token)); + if (_exit || cancellation.IsCancellationRequested || _artworkRequested != signature || !DisplayPreferences.UseAppTileImages) return; + _artworkFrames = frames; ApplyTileArtworkViews(); ApplyLegacyFrames(); + } + catch (OperationCanceledException) { } + finally + { + if (_artworkCancellation == cancellation) _artworkCancellation = null; + cancellation.Dispose(); + } + } + + private void ApplyTileArtworkViews() + { + foreach (var view in _artworkViews) + { + var frame = DisplayPreferences.UseAppTileImages && !ArtworkHighContrast ? _artworkFrames.GetValueOrDefault((view.Tile.AppId, view.Tile.Size)) : null; + if (ReferenceEquals(view.Current, frame)) continue; + if (!ReferenceEquals(view.Current, frame)) + { + view.Surface.Children.Clear(); view.Surface.Margin = new(frame == null ? 0 : -7); + if (frame == null) view.Surface.Children.Add(view.Icon); + else + { + var image = new Image { Source = frame.Image, Stretch = Stretch.Uniform, IsHitTestVisible = false }; + RenderOptions.SetBitmapScalingMode(image, System.Windows.Media.BitmapScalingMode.HighQuality); view.Surface.Children.Add(image); + } + view.Current = frame; + } + view.Button.Background = view.Background; view.Button.Foreground = view.Foreground; + if (frame != null && !view.Tile.UseCustomColor) + { + try + { + if (frame.Background.Length is > 0 and <= 32 && ColorConverter.ConvertFromString(frame.Background) is Color color && color.A > 0) + { view.Button.Background = new SolidColorBrush(color); view.Button.Foreground = ReadableInk(color); } + } + catch (Exception ex) when (ex is FormatException or NotSupportedException) { } + if (frame.Foreground.Equals("light", StringComparison.OrdinalIgnoreCase)) view.Button.Foreground = Brushes.White; + else if (frame.Foreground.Equals("dark", StringComparison.OrdinalIgnoreCase)) view.Button.Foreground = Brushes.Black; + } + if (view.Button.Content is Grid grid && grid.Children.OfType().FirstOrDefault(label => ReferenceEquals(label.Tag, view.Tile)) is { } name) + { + name.Visibility = frame == null || frame.ShowName ? Visibility.Visible : Visibility.Collapsed; + name.Foreground = view.Button.Foreground; + } + } + } +} diff --git a/src/ReStart.App/Views/Start/MainWindow.xaml.cs b/src/ReStart.App/Views/Start/MainWindow.xaml.cs index 3de31c8..f4db10f 100644 --- a/src/ReStart.App/Views/Start/MainWindow.xaml.cs +++ b/src/ReStart.App/Views/Start/MainWindow.xaml.cs @@ -118,6 +118,7 @@ internal MainWindow(LayoutStore store, bool preview, Func>>? PreviewMouseDown += (_, _) => CompleteEntrance(); IsVisibleChanged += (_, _) => { if (!IsVisible) { _touchPanning.Release(); StopMotion(); StopDragGhost(); ClearPlacementMotion(); SetLayoutEditing(false); } }; Loaded += async (_, _) => await InitializeAsync(); + Closed += (_, _) => _artworkCancellation?.Cancel(); SourceInitialized += (_, _) => { var square = 1; @@ -191,6 +192,7 @@ private async Task InitializeCoreAsync() IconsReady = true; MarkStartup("icons"); RenderGroups(); + if (_artworkReadTask != null) await _artworkReadTask; RenderApps(); RefreshSearchResults(); if (_store.RecoveryMessage == null) SetStatus(""); @@ -431,6 +433,7 @@ private void RenderGroups() _selectionViews.Clear(); _liveLabels.Clear(); _legacyViews.Clear(); + _artworkViews.Clear(); _scriptViews.Clear(); _usedHtmlTiles.Clear(); _folderThumbnailIds.Clear(); @@ -487,6 +490,7 @@ private void RenderGroups() GroupsPanel.Children.Add(button); } RetireHtmlTiles(); + RefreshTileArtwork(); UpdateLiveTiles(); FitPanels(); RefreshEntranceTargets(); @@ -573,12 +577,13 @@ private Button CreateTileButton(Tile tile, bool studioPreview = false, bool fold { var (width, height) = TileLayout.Dimensions(tile.Size); var available = Math.Min(width * Cell - 4, height * Cell - 4) - 14 - (tile.Size == TileSize.Small ? 0 : 8); - var icon = CreateIcon(tile, Math.Max(1, Math.Min(available, tile.Size == TileSize.Small ? 23 : tile.Size == TileSize.Large ? 60 : 40))); - grid.Children.Add(icon); + var icon = CreateIcon(tile, Math.Max(1, folderThumbnail ? available : Math.Min(available, tile.Size == TileSize.Small ? 23 : tile.Size == TileSize.Large ? 60 : 40))); + var surface = ArtworkSurface(tile, button, icon); + grid.Children.Add(surface); if (nativeLive && (!studioPreview || folderThumbnail)) { var native = new LegacyTileView { IsHitTestVisible = false, Focusable = false }; - grid.Children.Add(native); _legacyViews.Add((tile, native, icon, button)); + grid.Children.Add(native); _legacyViews.Add((tile, native, surface, button)); } } if (tile.Size != TileSize.Small && !tile.IsFolder && !webTile && (tile.Widget.Length == 0 && !nativeLive || DisplayPreferences.ShowLiveTileNames)) diff --git a/src/ReStart.Core/Apps/AppTileArtwork.cs b/src/ReStart.Core/Apps/AppTileArtwork.cs new file mode 100644 index 0000000..e3dad9a --- /dev/null +++ b/src/ReStart.Core/Apps/AppTileArtwork.cs @@ -0,0 +1,111 @@ +using System.Globalization; +using System.Xml; +using System.Xml.Linq; + +namespace ReStart.Core; + +/// Describes inert default-tile artwork declared by one installed application. +/// Package-relative raster references indexed by tile size. +/// Declared background color, validated by the presentation layer. +/// Desktop manifest text color, light or dark; empty uses automatic contrast. +/// Sizes on which the manifest requests an overlaid application name. +public sealed record AppTileArtwork(IReadOnlyDictionary Images, string Background, string Foreground, IReadOnlySet NamedSizes) +{ + /// Reads only the selected application's visual declarations without resolving XML entities. + /// Readable manifest stream, limited to two million characters. + /// Exact package Application Id; null selects a desktop VisualElementsManifest. + /// Declared tile artwork, or null for absent, malformed or unsupported metadata. + public static AppTileArtwork? Read(Stream stream, string? applicationId) + { + try + { + using var reader = XmlReader.Create(stream, new() { DtdProcessing = DtdProcessing.Prohibit, XmlResolver = null, MaxCharactersInDocument = 2_000_000 }); + var root = XDocument.Load(reader).Root; + XElement? visual; + var desktop = applicationId == null; + if (desktop) + visual = root?.Name.LocalName == "Application" && root.Name.NamespaceName is "" or "http://schemas.microsoft.com/2013/startmenu" ? root.Elements().FirstOrDefault(e => e.Name.LocalName == "VisualElements" + && e.Name.NamespaceName is "" or "http://schemas.microsoft.com/2013/startmenu") : null; + else + { + XNamespace package = "http://schemas.microsoft.com/appx/manifest/foundation/windows10"; + XNamespace uap = "http://schemas.microsoft.com/appx/manifest/uap/windows10"; + visual = root?.Name == package + "Package" ? root.Element(package + "Applications")?.Elements(package + "Application") + .FirstOrDefault(e => (string?)e.Attribute("Id") == applicationId)?.Element(uap + "VisualElements") : null; + } + if (visual == null) return null; + var images = new Dictionary(); + var tile = visual.Element(visual.Name.Namespace + "DefaultTile"); + foreach (var (size, attribute) in new[] { (TileSize.Small, desktop ? "Square70x70Logo" : "Square71x71Logo"), + (TileSize.Medium, "Square150x150Logo"), (TileSize.Wide, "Wide310x150Logo"), (TileSize.Large, "Square310x310Logo") }) + { + var value = (string?)(visual.Attribute(attribute) ?? tile?.Attribute(attribute)); + if (value is { Length: > 0 and <= 2048 }) images[size] = value; + } + if (!images.ContainsKey(TileSize.Small) && images.TryGetValue(TileSize.Medium, out var medium)) images[TileSize.Small] = medium; + var names = new HashSet(); + if (desktop) + { + if (!string.Equals((string?)visual.Attribute("ShowNameOnSquare150x150Logo"), "off", StringComparison.OrdinalIgnoreCase)) names.Add(TileSize.Medium); + } + else + { + var declared = tile?.Element(visual.Name.Namespace + "ShowNameOnTiles")?.Elements(visual.Name.Namespace + "ShowOn") + .Select(e => (string?)e.Attribute("Tile")).ToHashSet(StringComparer.OrdinalIgnoreCase) ?? []; + foreach (var (size, name) in new[] { (TileSize.Medium, "square150x150Logo"), (TileSize.Wide, "wide310x150Logo"), (TileSize.Large, "square310x310Logo") }) + if (declared.Contains(name)) names.Add(size); + } + return images.Count == 0 ? null : new(images, (string?)visual.Attribute("BackgroundColor") ?? "", (string?)visual.Attribute("ForegroundText") ?? "", names); + } + catch (Exception ex) when (ex is XmlException or InvalidOperationException or ArgumentException) { return null; } + } + + /// Orders file-name variants by language, theme and sufficient pixel resolution without filesystem access. + /// Manifest raster reference's file name. + /// Bounded candidate file names from that reference's own directory. + /// Manifest image width at scale 100. + /// Actual rendered physical width, including display DPI. + /// Whether the current surface uses light styling. + /// Current UI culture used to choose language-qualified assets. + /// Matching file names, best first; unknown or incompatible qualifiers are excluded. + public static IReadOnlyList RankVariants(string reference, IEnumerable candidates, int basePixels, int requestedPixels, bool light, string language) + { + var stem = Path.GetFileNameWithoutExtension(reference); var extension = Path.GetExtension(reference); + var matches = new List<(string Name, int Language, int Theme, int Pixels)>(); + foreach (var name in candidates.Take(128)) + { + if (name != Path.GetFileName(name) || !Path.GetExtension(name).Equals(extension, StringComparison.OrdinalIgnoreCase)) continue; + var candidate = Path.GetFileNameWithoutExtension(name); + if (!candidate.Equals(stem, StringComparison.OrdinalIgnoreCase) && !candidate.StartsWith(stem + ".", StringComparison.OrdinalIgnoreCase)) continue; + var qualifiers = candidate.Length == stem.Length ? [] : candidate[(stem.Length + 1)..].Split('_'); + var pixels = basePixels; var lang = 0; var theme = 0; var valid = true; var dimension = false; + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var part in qualifiers) + { + var split = part.IndexOf('-'); + if (split < 1 || !seen.Add(part[..split])) { valid = false; break; } + var key = part[..split].ToLowerInvariant(); var value = part[(split + 1)..].ToLowerInvariant(); + switch (key) + { + case "scale": case "targetsize": + if (dimension || !int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var number) || number is < 1 or > 4096) valid = false; + else { dimension = true; pixels = key == "scale" ? basePixels * number / 100 : number; } + break; + case "theme": if (value != (light ? "light" : "dark")) valid = false; else theme = 1; break; + case "language": + if (language.Equals(value, StringComparison.OrdinalIgnoreCase)) lang = 2; + else if (language.StartsWith(value + "-", StringComparison.OrdinalIgnoreCase)) lang = 1; + else valid = false; + break; + case "contrast": if (value != "standard") valid = false; break; + case "altform": if (value != "colorful") valid = false; break; + default: valid = false; break; + } + } + if (valid && pixels > 0) matches.Add((name, lang, theme, pixels)); + } + return matches.OrderByDescending(m => m.Language).ThenByDescending(m => m.Theme) + .ThenBy(m => m.Pixels >= requestedPixels ? 0 : 1).ThenBy(m => m.Pixels >= requestedPixels ? m.Pixels : -m.Pixels) + .ThenBy(m => m.Name, StringComparer.OrdinalIgnoreCase).Select(m => m.Name).ToArray(); + } +} diff --git a/src/ReStart.Core/Configuration/Preferences.cs b/src/ReStart.Core/Configuration/Preferences.cs index f364faa..821e045 100644 --- a/src/ReStart.Core/Configuration/Preferences.cs +++ b/src/ReStart.Core/Configuration/Preferences.cs @@ -95,6 +95,8 @@ public sealed class Preferences public string CustomBackgroundId { get; set; } = ""; /// Gets or sets whether app tiles use classic accent-colored backgrounds. public bool ColoredTiles { get; set; } + /// Gets or sets whether pinned apps use locally installed, size-qualified default-tile artwork when available. True by default; shell icons remain the fallback. + public bool UseAppTileImages { get; set; } = true; /// Gets or sets whether the dark Start surface uses the selected accent color, as in late Windows 10. public bool ShowAccentOnStart { get; set; } /// Gets or sets whether showing the menu animates when system accessibility settings permit. diff --git a/src/ReStart.Core/Locales/de.json b/src/ReStart.Core/Locales/de.json index ce331e5..4abe2d6 100644 --- a/src/ReStart.Core/Locales/de.json +++ b/src/ReStart.Core/Locales/de.json @@ -169,6 +169,8 @@ "ErrorFutureLayout": "Dieses Layout verwendet ein neueres Format und ist vor Änderungen geschützt.", "LanguageJapanese": "日本語", "SettingsColoredTiles": "Klassisch farbige Kacheln verwenden", + "SettingsAppTileImages": "Mitgelieferte Kachelbilder der Apps verwenden", + "AppTileImagesHelp": "Verwendet verfügbare Kachelbilder installierter Apps passend zu Kachelgröße, Anzeigeskalierung und Design. Andernfalls erscheint das App-Symbol. Live-Aktualisierungen werden dadurch nicht aktiviert.", "AddClock": "Uhrkachel", "SearchBestMatch": "Höchste Übereinstimmung", "ErrorUnsupported": "Nicht unterstützt", diff --git a/src/ReStart.Core/Locales/en.json b/src/ReStart.Core/Locales/en.json index 9c537a1..9bd9a78 100644 --- a/src/ReStart.Core/Locales/en.json +++ b/src/ReStart.Core/Locales/en.json @@ -169,6 +169,8 @@ "ErrorFutureLayout": "This layout uses a newer format and is protected from changes.", "LanguageJapanese": "日本語", "SettingsColoredTiles": "Use classic colored tiles", + "SettingsAppTileImages": "Use app-provided tile images", + "AppTileImagesHelp": "Use tile artwork included with installed apps when available, matched to tile size, display scale and theme. Otherwise show the app icon. This does not enable live updates.", "AddClock": "Clock tile", "SearchBestMatch": "Best match", "ErrorUnsupported": "Unsupported", diff --git a/src/ReStart.Core/Locales/es.json b/src/ReStart.Core/Locales/es.json index d7f8401..bf7d798 100644 --- a/src/ReStart.Core/Locales/es.json +++ b/src/ReStart.Core/Locales/es.json @@ -169,6 +169,8 @@ "ErrorFutureLayout": "Esta distribución usa un formato más reciente y está protegida contra cambios.", "LanguageJapanese": "日本語", "SettingsColoredTiles": "Usar mosaicos de colores clásicos", + "SettingsAppTileImages": "Usar imágenes de mosaico incluidas en las aplicaciones", + "AppTileImagesHelp": "Usa las imágenes disponibles en las aplicaciones instaladas según el tamaño del mosaico, la escala de pantalla y el tema. Si no hay imágenes, muestra el icono. No activa las actualizaciones dinámicas.", "AddClock": "Mosaico de reloj", "SearchBestMatch": "Mejor coincidencia", "ErrorUnsupported": "No compatible", diff --git a/src/ReStart.Core/Locales/fr.json b/src/ReStart.Core/Locales/fr.json index 8a4a52c..32fb366 100644 --- a/src/ReStart.Core/Locales/fr.json +++ b/src/ReStart.Core/Locales/fr.json @@ -169,6 +169,8 @@ "ErrorFutureLayout": "Cette disposition utilise un format plus récent et est protégée contre les modifications.", "LanguageJapanese": "日本語", "SettingsColoredTiles": "Utiliser les vignettes colorées classiques", + "SettingsAppTileImages": "Utiliser les images de vignettes fournies par les applications", + "AppTileImagesHelp": "Utilise les images incluses dans les applications installées, selon la taille de vignette, la mise à l’échelle et le thème. Sinon, affiche l’icône de l’application. N’active pas les mises à jour dynamiques.", "AddClock": "Vignette Horloge", "SearchBestMatch": "Meilleur résultat", "ErrorUnsupported": "Non pris en charge", diff --git a/src/ReStart.Core/Locales/ja.json b/src/ReStart.Core/Locales/ja.json index 3798c5c..d2a1d95 100644 --- a/src/ReStart.Core/Locales/ja.json +++ b/src/ReStart.Core/Locales/ja.json @@ -169,6 +169,8 @@ "ErrorFutureLayout": "新しい形式のレイアウトは保護されています。", "LanguageJapanese": "日本語", "SettingsColoredTiles": "旧スタートの色付きタイルを使う", + "SettingsAppTileImages": "アプリ付属のタイル画像を使う", + "AppTileImagesHelp": "インストール済みアプリにタイル画像があれば、タイルのサイズ・画面の拡大率・テーマに合わせて表示します。画像がなければアプリアイコンを表示します。ライブ更新は有効になりません。", "AddClock": "時計タイル", "SearchBestMatch": "最も一致するアプリ", "ErrorUnsupported": "未対応", diff --git a/src/ReStart.Core/Locales/ko.json b/src/ReStart.Core/Locales/ko.json index 52853f7..5356538 100644 --- a/src/ReStart.Core/Locales/ko.json +++ b/src/ReStart.Core/Locales/ko.json @@ -169,6 +169,8 @@ "ErrorFutureLayout": "이 레이아웃은 더 새로운 형식을 사용하므로 변경할 수 없습니다.", "LanguageJapanese": "日本語", "SettingsColoredTiles": "클래식 컬러 타일 사용", + "SettingsAppTileImages": "앱에 포함된 타일 이미지 사용", + "AppTileImagesHelp": "설치된 앱에 타일 이미지가 있으면 타일 크기, 화면 배율 및 테마에 맞게 표시합니다. 이미지가 없으면 앱 아이콘을 표시합니다. 실시간 업데이트를 활성화하지는 않습니다.", "AddClock": "시계 타일", "SearchBestMatch": "가장 정확한 결과", "ErrorUnsupported": "지원하지 않음", diff --git a/src/ReStart.Core/Locales/zh-Hans.json b/src/ReStart.Core/Locales/zh-Hans.json index fe56c70..43db665 100644 --- a/src/ReStart.Core/Locales/zh-Hans.json +++ b/src/ReStart.Core/Locales/zh-Hans.json @@ -169,6 +169,8 @@ "ErrorFutureLayout": "此布局使用较新格式,已禁止修改。", "LanguageJapanese": "日本語", "SettingsColoredTiles": "使用经典彩色磁贴", + "SettingsAppTileImages": "使用应用自带的磁贴图片", + "AppTileImagesHelp": "如果已安装的应用提供磁贴图片,则按磁贴大小、显示缩放比例和主题显示。否则显示应用图标。此选项不会启用动态更新。", "AddClock": "时钟磁贴", "SearchBestMatch": "最佳匹配", "ErrorUnsupported": "不支持", diff --git a/src/ReStart.Core/Locales/zh-Hant.json b/src/ReStart.Core/Locales/zh-Hant.json index e2e7ef4..4f1872b 100644 --- a/src/ReStart.Core/Locales/zh-Hant.json +++ b/src/ReStart.Core/Locales/zh-Hant.json @@ -169,6 +169,8 @@ "ErrorFutureLayout": "此配置使用較新格式,已禁止修改。", "LanguageJapanese": "日本語", "SettingsColoredTiles": "使用經典彩色動態磚", + "SettingsAppTileImages": "使用應用程式內附的磚圖片", + "AppTileImagesHelp": "若已安裝的應用程式提供磚圖片,則依磚大小、顯示縮放比例及佈景主題顯示。否則顯示應用程式圖示。此選項不會啟用動態更新。", "AddClock": "時鐘動態磚", "SearchBestMatch": "最佳比對", "ErrorUnsupported": "不支援", diff --git a/src/ReStart.Core/Localization/TextKey.cs b/src/ReStart.Core/Localization/TextKey.cs index 5127bae..e4825e2 100644 --- a/src/ReStart.Core/Localization/TextKey.cs +++ b/src/ReStart.Core/Localization/TextKey.cs @@ -509,6 +509,10 @@ public enum TextKey LanguageJapanese, /// Use classic colored tiles SettingsColoredTiles, + /// Use app-provided tile images + SettingsAppTileImages, + /// Explains installed tile artwork and ordinary icon fallback. + AppTileImagesHelp, /// Clock tile AddClock, /// Best match diff --git a/tests/ReStart.Core.Tests/Apps/AppTileArtworkTests.cs b/tests/ReStart.Core.Tests/Apps/AppTileArtworkTests.cs new file mode 100644 index 0000000..a21209f --- /dev/null +++ b/tests/ReStart.Core.Tests/Apps/AppTileArtworkTests.cs @@ -0,0 +1,99 @@ +using System.Text; +using System.Text.Json; + +namespace ReStart.Core.Tests; + +public class AppTileArtworkTests +{ + private static AppTileArtwork? Read(string xml, string? app = "Main") => AppTileArtwork.Read(new MemoryStream(Encoding.UTF8.GetBytes(xml)), app); + private static string Package(string body) => "" + body + ""; + + [Fact] + public void ReadsOnlyTheExactSelectedApplicationAndAllFourSizes() + { + var xml = Package("" + + ""); + var result = Read(xml)!; + Assert.Equal(4, result.Images.Count); + Assert.Equal("small.png", result.Images[TileSize.Small]); Assert.Equal("medium.png", result.Images[TileSize.Medium]); + Assert.Equal("wide.png", result.Images[TileSize.Wide]); Assert.Equal("large.png", result.Images[TileSize.Large]); + Assert.Equal("#123456", result.Background); Assert.Equal([TileSize.Wide], result.NamedSizes); + Assert.Null(Read(xml, "Missing")); Assert.Null(Read(xml, "main")); + } + + [Fact] + public void MissingSizesDoNotInventWideArtworkOrConfuseSplashAndListIcons() + { + var result = Read(Package(""))!; + Assert.Equal("tile.png", result.Images[TileSize.Small]); Assert.False(result.Images.ContainsKey(TileSize.Wide)); + Assert.False(result.Images.ContainsKey(TileSize.Large)); Assert.Empty(result.NamedSizes); + } + + [Theory] + [InlineData("")] + [InlineData("")] + public void ReadsDesktopManifestsWithoutLaunchingTheExecutable(string xml) + { + var result = Read(xml, null)!; + Assert.Equal("small.png", result.Images[TileSize.Small]); Assert.Equal("light", result.Foreground); Assert.Empty(result.NamedSizes); + } + + [Theory] + [InlineData("]>")] + [InlineData("")] + [InlineData("")] + [InlineData("")] + public void RejectsEntitiesUnrelatedNamespacesAndNestedDeclarations(string xml) => Assert.Null(Read(xml, null)); + + [Fact] + public void BoundsXmlAndRejectsSpoofedPackageVisuals() + { + Assert.Null(Read(new string(' ', 2_000_001))); + Assert.Null(Read(Package(""))); + } + + [Theory] + [InlineData(100, "tile.scale-100.png")] + [InlineData(180, "tile.scale-200.png")] + [InlineData(400, "tile.scale-400.png")] + [InlineData(800, "tile.scale-400.png")] + public void PrefersSufficientResolutionBeforeScalingUp(int pixels, string expected) + { + var result = AppTileArtwork.RankVariants("tile.png", ["tile.scale-400.png", "tile.scale-100.png", "tile.scale-200.png"], 100, pixels, false, "en-US"); + Assert.Equal(expected, result[0]); + } + + [Theory] + [InlineData(true, "en-US", "tile.scale-200_language-en-US_theme-light.png")] + [InlineData(false, "en-US", "tile.scale-200_language-en_theme-dark.png")] + [InlineData(true, "ja-JP", "tile.scale-100.png")] + public void SelectsThemeAndLanguageBeforeGenericImages(bool light, string language, string expected) + { + var result = AppTileArtwork.RankVariants("tile.png", ["tile.scale-100.png", "tile.scale-200_language-en-US_theme-light.png", "tile.scale-200_language-en_theme-dark.png"], 150, 150, light, language); + Assert.Equal(expected, result[0]); + } + + [Theory] + [InlineData("other.scale-200.png")] + [InlineData("tile.scale-0.png")] + [InlineData("tile.scale-200_scale-100.png")] + [InlineData("tile.scale-200_targetsize-64.png")] + [InlineData("tile.targetsize-64_altform-unplated.png")] + [InlineData("tile.scale-200_contrast-white.png")] + [InlineData("tile.scale-200_theme-dark.png")] + [InlineData("tile.scale-200_language-ja.png")] + [InlineData("tile.scale-200_unknown-value.png")] + [InlineData("../tile.png")] + public void ExcludesIncompatibleMalformedOrUnrelatedVariants(string candidate) + => Assert.Empty(AppTileArtwork.RankVariants("tile.png", [candidate], 150, 150, true, "en-US")); + + [Fact] + public void DefaultAndExplicitOffRoundTripWithoutEnablingNotificationAccess() + { + var older = JsonSerializer.Deserialize("{}")!; + Assert.True(older.UseAppTileImages); Assert.False(older.LegacyUwpLiveTiles); + older.UseAppTileImages = false; + var restored = JsonSerializer.Deserialize(JsonSerializer.Serialize(older))!; + Assert.False(restored.UseAppTileImages); Assert.False(restored.LegacyUwpLiveTiles); + } +} From b9949de89f0ab187e1a14670ced0e37b9c73197c Mon Sep 17 00:00:00 2001 From: LastResolution <128378329+LastResolution@users.noreply.github.com> Date: Sun, 8 Nov 2026 02:59:43 +0900 Subject: [PATCH 2/5] Document installed tile artwork and live update behavior --- README.de.md | 4 +++- README.es.md | 4 +++- README.fr.md | 4 +++- README.ja.md | 4 +++- README.ko.md | 4 +++- README.md | 4 +++- README.zh-CN.md | 4 +++- README.zh-TW.md | 4 +++- docs/architecture.md | 2 ++ docs/fidelity.md | 2 +- docs/start-behavior.md | 9 +++++++-- docs/testing.md | 2 ++ site/dist/guides/windows-11-live-tiles/index.html | 5 +++-- site/dist/index.html | 2 +- site/dist/sitemap.xml | 4 ++-- 15 files changed, 42 insertions(+), 16 deletions(-) diff --git a/README.de.md b/README.de.md index f683df1..769dcb1 100644 --- a/README.de.md +++ b/README.de.md @@ -56,10 +56,12 @@ Beim Ziehen folgt eine halbtransparente Kopie der Kachel, Gruppe oder Auswahl de Die Suche ergänzt Windows-eigene Einstellungsbegriffe und Systemsteuerungseinträge. Optionale Advanced Query Syntax bietet native Dateifilter und Relevanzsortierung; **In Windows suchen** übergibt weitere Quellen ausdrücklich an Windows. -Zwei getrennte Optionen sind zunächst aus: vorhandene Windows-App-Nutzungszahlen für Meistverwendet und schreibgeschützte Windows-Kachel-/Abzeichenkompatibilität. Häufige Größenlayouts, lokale Rasterbilder und Datenbindungen werden unterstützt. Eingestellte Push-Dienste werden nicht wiederhergestellt; ohne Daten bleibt das App-Symbol sichtbar. +Zwei getrennte Optionen sind zunächst aus: vorhandene Windows-App-Nutzungszahlen für Meistverwendet und schreibgeschützte Windows-Kachel-/Abzeichenkompatibilität. Häufige Größenlayouts, lokale Rasterbilder und Datenbindungen werden unterstützt. Eingestellte Push-Dienste werden nicht wiederhergestellt; ohne Daten bleiben das Standard-Kachelbild oder das App-Symbol sichtbar. [Einstellungen und Kompatibilität](docs/start-behavior.md) · [Abdeckung](docs/fidelity.md) +**Einstellungen → Darstellung → Mitgelieferte Kachelbilder der Apps verwenden** ist standardmäßig an. Angeheftete Apps nutzen verfügbare lokale Kachelbilder passend zu Größe, Anzeigeskalierung und Design; sonst bleibt das Symbol. Dies gilt auch für Ordnervorschauen und lässt sich vor dem Speichern ausprobieren. Keine Netzwerkanfragen; Live-Aktualisierungen bleiben eine getrennte Option. + ## Funktionen ![Öffnen und Schließen von ReStart](docs/images/start-motion.gif) diff --git a/README.es.md b/README.es.md index 4c3b931..3ea3751 100644 --- a/README.es.md +++ b/README.es.md @@ -56,10 +56,12 @@ El engranaje permite elegir temas, inicio con Windows, integración de la tecla La búsqueda añade palabras clave de los ajustes de Windows y elementos del Panel de control. La sintaxis AQS opcional ofrece filtros y relevancia nativos; **Buscar en Windows** deriva explícitamente la consulta a otras fuentes de Windows. -Dos opciones independientes están desactivadas al principio: los contadores de uso de Windows para las aplicaciones más usadas y la compatibilidad de solo lectura con mosaicos e insignias de Windows. Se admiten formatos habituales, imágenes locales y enlaces de datos. No se restauran servicios push retirados; si no hay datos, se conserva el icono. +Dos opciones independientes están desactivadas al principio: los contadores de uso de Windows para las aplicaciones más usadas y la compatibilidad de solo lectura con mosaicos e insignias de Windows. Se admiten formatos habituales, imágenes locales y enlaces de datos. No se restauran servicios push retirados; si no hay datos, se conserva la imagen predeterminada o el icono. [Ajustes y compatibilidad](docs/start-behavior.md) · [Alcance](docs/fidelity.md) +**Configuración → Apariencia → Usar imágenes de mosaico incluidas en las aplicaciones** está activado por defecto. Las aplicaciones ancladas usan imágenes locales disponibles según el tamaño, la escala y el tema; de lo contrario, conservan sus iconos. También se aplica a las vistas previas de carpetas y puede probarse antes de guardar. No hace solicitudes de red; las actualizaciones dinámicas son una opción independiente. + ## Funciones ![Apertura y cierre de ReStart](docs/images/start-motion.gif) diff --git a/README.fr.md b/README.fr.md index 1c3af67..09b88ea 100644 --- a/README.fr.md +++ b/README.fr.md @@ -56,10 +56,12 @@ La roue dentée permet de régler les thèmes, le lancement à l’ouverture de La recherche ajoute les mots-clés des paramètres Windows et les éléments du Panneau de configuration. La syntaxe AQS facultative apporte filtres de fichiers et pertinence natifs ; **Rechercher dans Windows** transmet explicitement la recherche aux sources supplémentaires de Windows. -Deux options distinctes sont désactivées au départ : les compteurs Windows existants pour les applications les plus utilisées, et la compatibilité en lecture seule des vignettes et badges Windows. Les formats courants, images locales et liaisons de données sont pris en charge. Les services push arrêtés ne sont pas rétablis ; sans données, l’icône reste affichée. +Deux options distinctes sont désactivées au départ : les compteurs Windows existants pour les applications les plus utilisées, et la compatibilité en lecture seule des vignettes et badges Windows. Les formats courants, images locales et liaisons de données sont pris en charge. Les services push arrêtés ne sont pas rétablis ; sans données, l’image par défaut ou l’icône reste affichée. [Paramètres et compatibilité](docs/start-behavior.md) · [Couverture](docs/fidelity.md) +**Paramètres → Apparence → Utiliser les images de vignettes fournies par les applications** est activé par défaut. Les applications épinglées utilisent les images locales disponibles selon la taille, la mise à l’échelle et le thème ; sinon, leur icône reste affichée. Les aperçus de dossiers sont inclus et le résultat peut être essayé avant l’enregistrement. Aucune requête réseau ; les mises à jour dynamiques restent une option distincte. + ## Fonctionnalités ![Ouverture et fermeture de ReStart](docs/images/start-motion.gif) diff --git a/README.ja.md b/README.ja.md index 175082d..d34f61c 100644 --- a/README.ja.md +++ b/README.ja.md @@ -74,10 +74,12 @@ HTML/CSS/JavaScript製のライブタイルを19種類同梱。Tile Studioでソ Windows由来の設定の検索語とControl Panel項目も検索できます。任意の高度な検索構文でファイルを絞り込み、関連度順に表示できます。**Windowsで検索**を選ぶと、追加の検索先をWindowsへ引き継ぎます。 -「よく使うアプリ」にWindowsの使用回数を含める機能と、Windowsアプリのタイル・バッジを読み取る互換機能は、個別に有効化でき、初期値はOFFです。主なサイズ別レイアウト、ローカル画像、データバインディングに対応します。終了したプッシュ配信を復活させるものではなく、データがなければアプリアイコンを表示します。 +「よく使うアプリ」にWindowsの使用回数を含める機能と、Windowsアプリのタイル・バッジを読み取る互換機能は、個別に有効化でき、初期値はOFFです。主なサイズ別レイアウト、ローカル画像、データバインディングに対応します。終了したプッシュ配信を復活させるものではなく、データがなければ既定のタイル画像またはアプリアイコンを表示します。 [設定と互換性の詳細](docs/start-behavior.md) · [対応範囲](docs/fidelity.md) +**設定 → 外観 → アプリ付属のタイル画像を使う**は初期状態でONです。アプリに画像があれば、サイズ・画面倍率・テーマに合わせて表示し、なければアイコンを使います。フォルダー内のプレビューにも適用され、保存前に確認できます。外部通信は行わず、ライブ更新とは独立した設定です。 + ## 自分のスタートに ![アプリとライブタイルを表示する ReStart の開閉アニメーション](docs/images/start-motion.gif) diff --git a/README.ko.md b/README.ko.md index bc07d8b..b6fc0b5 100644 --- a/README.ko.md +++ b/README.ko.md @@ -56,10 +56,12 @@ HTML/CSS/JavaScript 타일 19개를 제공합니다. Tile Studio에서 코드와 Windows 설정의 검색어와 제어판 항목도 검색합니다. 선택적인 AQS는 기본 파일 필터와 관련도 정렬을 제공하며, **Windows에서 검색**을 선택하면 추가 검색 소스로 명시적으로 넘깁니다. -Windows에 저장된 앱 사용 횟수와 Windows 앱 타일·배지의 읽기 전용 호환 기능은 별도 옵션이며 기본값은 꺼짐입니다. 일반적인 크기별 레이아웃, 로컬 이미지 및 데이터 바인딩을 지원합니다. 종료된 푸시 서비스를 복원하지 않으며 데이터가 없으면 앱 아이콘을 표시합니다. +Windows에 저장된 앱 사용 횟수와 Windows 앱 타일·배지의 읽기 전용 호환 기능은 별도 옵션이며 기본값은 꺼짐입니다. 일반적인 크기별 레이아웃, 로컬 이미지 및 데이터 바인딩을 지원합니다. 종료된 푸시 서비스를 복원하지 않으며 데이터가 없으면 기본 타일 이미지 또는 앱 아이콘을 표시합니다. [설정 및 호환성 안내](docs/start-behavior.md) · [지원 범위](docs/fidelity.md) +**설정 → 모양 → 앱에 포함된 타일 이미지 사용**은 기본적으로 켜져 있습니다. 고정된 앱은 크기, 화면 배율 및 테마에 맞는 로컬 타일 이미지를 사용하며, 이미지가 없으면 아이콘을 표시합니다. 폴더 미리 보기에도 적용되고 저장 전에 확인할 수 있습니다. 네트워크 요청은 없으며 실시간 업데이트는 별도 옵션입니다. + ## 기능 ![ReStart 열기 및 닫기](docs/images/start-motion.gif) diff --git a/README.md b/README.md index 13dc66a..3f30e6d 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,9 @@ Drag shortcut tips appear briefly at the bottom of Start and can be disabled in Search now also uses Windows-provided settings keywords and Control Panel entries. Optional Advanced Query Syntax adds native file filtering and relevance order. **Search Windows and cloud** opens the Windows interface with Win + S and transfers the current query when its input is available. -Two options are off by default: counts of app launches recorded by Windows for Most used, and a read-only compatibility reader for Windows app tiles and badges. The tile renderer supports common size bindings, local raster images and literal data bindings. It does not restore discontinued push services; unavailable data keeps the app icon. +Two options are off by default: counts of app launches recorded by Windows for Most used, and a read-only compatibility reader for Windows app tiles and badges. The tile renderer supports common size bindings, local raster images and literal data bindings. It does not restore discontinued push services; unavailable updates keep the app's default artwork or icon. + +**Settings → Appearance → Use app-provided tile images** is on by default. Pinned apps use locally installed tile artwork when available, matched to size, display scale and theme; otherwise they retain their icons. It also applies to folder previews, can be previewed before saving, and makes no network requests. Live updates remain a separate option. [Settings and compatibility details](docs/start-behavior.md) · [Coverage](docs/fidelity.md) diff --git a/README.zh-CN.md b/README.zh-CN.md index 133e1d5..c662d92 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -56,10 +56,12 @@ https://github.com/user-attachments/assets/a893cd20-8c82-4c47-b5fb-bf22743f67e1 搜索还包含 Windows 提供的设置关键词及控制面板项目。可选 AQS 提供原生文件筛选及相关性排序;选择 **在 Windows 中搜索** 后,才会将查询交给 Windows 的其他搜索来源。 -Windows 应用使用次数与 Windows 磁贴、徽章的只读兼容是两个独立选项,默认均关闭。兼容常用尺寸布局、本地图像及数据绑定,不会恢复已停用的推送服务;没有可用数据时保留应用图标。 +Windows 应用使用次数与 Windows 磁贴、徽章的只读兼容是两个独立选项,默认均关闭。兼容常用尺寸布局、本地图像及数据绑定,不会恢复已停用的推送服务;没有可用数据时保留默认磁贴图片或应用图标。 [设置与兼容性说明](docs/start-behavior.md) · [支持范围](docs/fidelity.md) +**设置 → 外观 → 使用应用自带的磁贴图片**默认开启。固定的应用会按大小、显示缩放比例和主题使用可用的本地磁贴图片,否则显示图标。文件夹预览也适用,并可在保存前预览。不发起网络请求;动态更新仍是独立选项。 + ## 功能 ![ReStart 的打开和关闭动画](docs/images/start-motion.gif) diff --git a/README.zh-TW.md b/README.zh-TW.md index 7dbba06..438195d 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -56,10 +56,12 @@ https://github.com/user-attachments/assets/a893cd20-8c82-4c47-b5fb-bf22743f67e1 搜尋也包含 Windows 提供的設定關鍵字及控制台項目。選用 AQS 可使用原生檔案篩選及相關性排序;選取 **在 Windows 中搜尋** 後,才會將查詢交給 Windows 的其他搜尋來源。 -Windows 應用程式使用次數,以及 Windows 動態磚與徽章的唯讀相容功能,是兩個獨立選項,預設皆關閉。支援常用尺寸配置、本機影像及資料繫結,不會恢復已停用的推播服務;沒有可用資料時會保留應用程式圖示。 +Windows 應用程式使用次數,以及 Windows 動態磚與徽章的唯讀相容功能,是兩個獨立選項,預設皆關閉。支援常用尺寸配置、本機影像及資料繫結,不會恢復已停用的推播服務;沒有可用資料時會保留預設磚圖片或應用程式圖示。 [設定與相容性說明](docs/start-behavior.md) · [支援範圍](docs/fidelity.md) +**設定 → 外觀 → 使用應用程式內附的磚圖片**預設開啟。釘選的應用程式會依大小、顯示縮放比例及佈景主題使用可用的本機磚圖片,否則顯示圖示。資料夾預覽也適用,並可在儲存前預覽。不發出網路要求;動態更新仍是獨立選項。 + ## 功能 ![ReStart 的開啟與關閉動畫](docs/images/start-motion.gif) diff --git a/docs/architecture.md b/docs/architecture.md index f72bd6b..9c4ca32 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -159,6 +159,8 @@ Schema 4 stores tiles, preferences, independent entries, producer version and th ## Rendering and resources +`AppTileArtwork` parses the selected application manifest and ranks bounded filename-qualified raster variants without OS dependencies. `TileArtworkLoader` reads only requested pins' package manifests or adjacent desktop VisualElements manifests on a background STA. Approved roots, XML/entity limits, reparse rejection, raster decoding bounds and a shared pixel budget keep these static assets separate from script execution and network sources. The presentation coordinator rejects stale reads, keeps image selection out of search/app-list icons, and updates image surfaces without rebuilding live tile browsers. Installed artwork is enabled by default; Settings preview/discard and high-contrast fallback apply independently of the default-off notification compatibility reader. Supported live notification frames take precedence over static images. + The native window uses the pointer monitor's working area and PerMonitorV2. WPF content uses DIPs and logical cells. The wallpaper blur is an approximation of the original composition. Warm presentation keeps the existing board, account picture and shortcut controls. Monitor wallpaper lookup, file metadata and image decoding run as one coalesced background operation; revision checks discard results superseded by preferences or another opening. Frozen pixels cross back to the dispatcher. Settings preview still applies its explicit wallpaper changes immediately. diff --git a/docs/fidelity.md b/docs/fidelity.md index f044797..e10adc0 100644 --- a/docs/fidelity.md +++ b/docs/fidelity.md @@ -111,7 +111,7 @@ Private profile backups, privacy-minimized bounded logs, viewport suspension and The session menus read Windows power capabilities and conventional Explorer/System policies when opened and recheck availability before an effect. Hibernate requires S4 and a full hibernation file; unsupported sleep/hibernate actions are omitted. Windows Update's public `ISystemInformation.RebootRequired` is read asynchronously with a bounded UI wait; an unavailable provider leaves ordinary power actions usable. Explicit update actions use `InitiateShutdown` with `SHUTDOWN_INSTALL_UPDATES`. No force-close flags are set, and the process's existing shutdown privilege is restored after the request. These are Windows update actions, separate from updating ReStart. Switch user disconnects only the current local session; Windows handles the sign-in screen and credential checks. It is omitted in Remote Desktop sessions and when fast switching is hidden by policy. The complete native account list is not copied into ReStart. -**Legacy UWP tile updates** use a default-off, read-only compatibility reader for the current user's notification store. Public `NotificationKinds` has no tile listener member, so this adapter depends on an undocumented schema. It selects active tile/badge records and bound values only for requested pinned package identities; it does not read toasts or recover deleted data. Common adaptive/legacy size bindings, groups, literal data bindings, approved local raster assets and badges are supported. Unknown formats and unavailable size bindings return to app icons. Original queue/peek animations, every template hint and discontinued push/background services are not reproduced. Owned SQLite fixtures cover scope, expiry, parameter binding, read-only preservation, WAL updates, cancellation/locking and malformed content. Owned WPF fixtures cover four sizes, both themes, folder thumbnails, raster bounds and unchanged-frame reuse. These checks do not establish a real Windows 10 app's end-to-end notification delivery. +**Legacy UWP tile updates** use a default-off, read-only compatibility reader for the current user's notification store. Public `NotificationKinds` has no tile listener member, so this adapter depends on an undocumented schema. It selects active tile/badge records and bound values only for requested pinned package identities; it does not read toasts or recover deleted data. Common adaptive/legacy size bindings, groups, literal data bindings, approved local raster assets and badges are supported. Unknown formats and unavailable size bindings return to default artwork or app icons. Original queue/peek animations, every template hint and discontinued push/background services are not reproduced. Owned SQLite fixtures cover scope, expiry, parameter binding, read-only preservation, WAL updates, cancellation/locking and malformed content. Owned WPF fixtures cover four sizes, both themes, folder thumbnails, raster bounds and unchanged-frame reuse. These checks do not establish a real Windows 10 app's end-to-end notification delivery. **Broader local search** combines the translated fallback with Windows settings keyword metadata and Control Panel identities. Native metadata labels follow the OS language. Only documented settings identifiers and restricted namespace paths become actions; arbitrary DeepLinks are ignored. Optional Windows AQS uses a fixed local catalog/projection/bound, with content predicates rejected when disabled. The explicit Windows Search handoff leaves Web/cloud behavior to Windows. It uses the documented Win+S shortcut; only explicitly choosing the action transfers query text through a verified focused Value provider. Routed fixtures intercept launch/dismiss effects, while an owned editable-window fixture exercises the production provider setter, Unicode preservation, cancellation, read-only controls, unrelated windows and foreground changes. This is not a test of every Windows build, cloud account or Search policy. Native settings metadata and UserAssist are not public substitutes for all of Start's private search/ranking inputs. diff --git a/docs/start-behavior.md b/docs/start-behavior.md index 5656c3e..32e756e 100644 --- a/docs/start-behavior.md +++ b/docs/start-behavior.md @@ -17,7 +17,8 @@ Open **ReStart settings → Start behavior** to choose how Start responds. Each | Show Windows Search handoff | On | Hides the explicit Windows Search action; typing never opens it | | Show web search in the default browser | On | Hides the explicit Bing action; typing never sends a query externally | | Rank equal app matches using enabled app history | On, active only with Most used history | Equal-quality matches use name order instead of enabled app usage; this setting does not start recording history | -| Windows app live tiles (compatibility) | Off | Does not open the Windows notification store; package apps retain their ordinary icons | +| Use app-provided tile images | On | Uses ordinary Shell icons instead of locally installed default-tile artwork; live updates are independent | +| Windows app live tiles (compatibility) | Off | Does not open the Windows notification store; package apps retain their default artwork or icons | | Recent/frequent documents in app menus | On | Does not query Windows document history | | Application tasks in app menus | On | Hides actions registered by installed applications | | Application-defined jump-list categories | On | Hides registered categories without deleting their destinations | @@ -88,9 +89,13 @@ Local app matching prioritizes exact display names and executable aliases, then ## Windows app tile compatibility +**Use app-provided tile images**, under Appearance, is enabled by default, including for existing settings that predate this option. It reads the selected application's `VisualElements`/`DefaultTile` declaration in its installed package, or a desktop executable's adjacent `.VisualElementsManifest.xml`. Declared small, medium, wide and large raster images are used only where supplied; a missing small image may use the medium image, while missing wide/large artwork retains the ordinary Shell icon. Filename-qualified resources are matched to the current language, theme and physical pixel size, including DPI. Unsupported resource layouts and unavailable/corrupt assets fall back to icons; this is not a complete emulation of the Windows resource manager. High contrast uses ordinary icons. Explicit tile colors take precedence over declared background colors. Manifest name-overlay choices are respected without changing the tile's accessible name or launch target. + +Folder thumbnails use small artwork without changing saved child sizes. Images are decoded on a background worker with file, pixel and total-memory bounds, and updates replace only the image presentation. The switch supports unsaved Settings preview and discard. No assets are copied into the distribution, no images are downloaded, and this option neither opens the notification database nor enables live updates. + **Show Windows app live tiles (compatibility)** is disabled by default. When enabled, it opens the current user's existing Windows notification database read-only and selects active tile/badge rows only for pinned package app identities (up to 32 visible identities per read). It does not select toast messages, enumerate unrelated app content, recover deleted rows, copy the database or create notification channels. Reads are bounded, cancelled when disabled/exiting and paused while the launcher is hidden. SQLite may maintain its ordinary shared reader-lock metadata; ReStart issues no notification-store writes. -The renderer supports the common small/medium/wide/large adaptive and legacy bindings: inert text, groups/subgroups, local raster images, exact notification-data placeholders and badges. Missing size bindings, unknown schemas, expired records, unavailable data and unsupported content fall back to the app icon. Folder thumbnails use the small binding. Existing per-tile live-update controls and **Show live tile names** also apply. Package assets and `ms-appdata` local assets stay inside the owning package's roots; reparse points, external URLs, active image formats and oversized images are rejected. No remote images are downloaded. Repeated unchanged frames retain their visuals. +The renderer supports the common small/medium/wide/large adaptive and legacy bindings: inert text, groups/subgroups, local raster images, exact notification-data placeholders and badges. Missing size bindings, unknown schemas, expired records, unavailable data and unsupported content fall back to the app’s default artwork or icon. Folder thumbnails use the small binding. Existing per-tile live-update controls and **Show live tile names** also apply. Package assets and `ms-appdata` local assets stay inside the owning package's roots; reparse points, external URLs, active image formats and oversized images are rejected. No remote images are downloaded. Repeated unchanged frames retain their visuals. This is compatibility with data Windows and the app still publish. It does not restore discontinued push services, schedule another app's background tasks, reproduce every XML presentation/queue animation, or guarantee that an old app still supplies updates on Windows 11. ReStart's HTML/CSS/JavaScript tiles remain a separate system and receive no Windows notification or app-usage data. diff --git a/docs/testing.md b/docs/testing.md index 7806087..d75c88f 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -1,5 +1,7 @@ # Testing +`scripts/test-tile-artwork.ps1 -Executable /ReStart.exe` runs the installed-artwork fixture independently of Windows Search availability. Owned manifests and raster files exercise four sizes, two themes, three tile scales, folder thumbnails, live-content precedence, high-contrast fallback, cancellation and disabling. The full fidelity suite includes the same checks. Settings preview tests cover the new appearance switch across all eight languages, both themes, and save/discard. Tests never need another application's notification content. + Run on Windows with the stable SDK in global.json. Exit ReStart when rebuilding its own output, or pass an isolated OutputPath as described in the README. ~~~powershell diff --git a/site/dist/guides/windows-11-live-tiles/index.html b/site/dist/guides/windows-11-live-tiles/index.html index b480e10..65fa717 100644 --- a/site/dist/guides/windows-11-live-tiles/index.html +++ b/site/dist/guides/windows-11-live-tiles/index.html @@ -47,7 +47,7 @@ "https://lastresolution.github.io/ReStart/assets/web-examples.png" ], "datePublished": "2026-10-22", - "dateModified": "2026-11-01", + "dateModified": "2026-11-08", "author": { "@type": "Organization", "name": "ReStart contributors", @@ -110,7 +110,8 @@

What happened to Live Tiles?

In Windows 8 and Windows 10, some pinned app tiles could display changing information rather than just an icon: a date, photo or app update, for example. The Windows 10 Start screen combined those tiles with ordinary app shortcuts.

Microsoft’s Windows 11 specifications list Live Tiles as removed and point to Widgets for glanceable information. Widgets are a separate Windows experience. ReStart instead places its own dynamic tiles alongside your pinned apps, using a different implementation.

-

An optional compatibility reader can also display active tile and badge data that Windows still retains for pinned packaged apps. It is off by default and uses a read-only adapter for Windows’ undocumented notification store. Common size bindings, local images and data placeholders are supported; missing or unsupported data falls back to the app icon. It does not reconnect retired feeds or restart another app’s background delivery. For a clock, PC dashboard, weather tile or custom web view with data you choose, use ReStart’s own web tile system. See the compatibility settings and limits.

+

A separate Use app-provided tile images option is on by default under Appearance. It displays supported artwork already included with installed apps, selecting by tile size, display scale and theme. It also applies to folder previews and needs no network access. This supplies static artwork; it does not restart a retired news feed.

+

An optional compatibility reader can also display active tile and badge data that Windows still retains for pinned packaged apps. It is off by default and uses a read-only adapter for Windows’ undocumented notification store. Common size bindings, local images and data placeholders are supported; missing or unsupported data falls back to the app’s default artwork or icon. It does not reconnect retired feeds or restart another app’s background delivery. For a clock, PC dashboard, weather tile or custom web view with data you choose, use ReStart’s own web tile system. See the compatibility settings and limits.

Add your first live tile

  1. Set up the complete portable package. Follow the download and setup steps. Keep the supporting folders with ReStart.exe. HTML rendering and Tile Studio previews require Microsoft WebView2 Runtime, which is maintained separately from the .NET runtime included in the ZIP.
  2. diff --git a/site/dist/index.html b/site/dist/index.html index 0373155..aae4ec0 100644 --- a/site/dist/index.html +++ b/site/dist/index.html @@ -76,7 +76,7 @@

    Bring full-screen
    -

    More than shortcuts

    A little life
    on your desktop.

    Time, focus, PC activity, your chosen photos, weather and headlines. Nineteen included tiles turn a launcher into a glanceable part of your day.

    • Connect weather, RSS, Web APIs or local JSON.
    • Choose photos from a folder on your PC.
    • Make your own with HTML, CSS and JavaScript.
    Make something of your own

    Data connections are opt-in. The weather sample uses Berlin until you change its coordinates. A separate, default-off Windows tile compatibility option can display supported tile and badge data still supplied by installed package apps; it does not restore retired feeds.

    +

    More than shortcuts

    A little life
    on your desktop.

    Time, focus, PC activity, your chosen photos, weather and headlines. Nineteen included tiles turn a launcher into a glanceable part of your day.

    • Connect weather, RSS, Web APIs or local JSON.
    • Choose photos from a folder on your PC.
    • Make your own with HTML, CSS and JavaScript.
    Make something of your own

    App-provided tile artwork is enabled by default for pinned apps, with ordinary icons as a fallback. Data connections are opt-in. The weather sample uses Berlin until you change its coordinates. A separate, default-off Windows tile compatibility option can display supported tile and badge data still supplied by installed package apps; it does not restore retired feeds.

    diff --git a/site/dist/sitemap.xml b/site/dist/sitemap.xml index 3f8b573..679e7b2 100644 --- a/site/dist/sitemap.xml +++ b/site/dist/sitemap.xml @@ -1,8 +1,8 @@ - https://lastresolution.github.io/ReStart/2026-11-07 + https://lastresolution.github.io/ReStart/2026-11-08 https://lastresolution.github.io/ReStart/developers/2026-11-07 https://lastresolution.github.io/ReStart/guides/ https://lastresolution.github.io/ReStart/guides/full-screen-start-menu/2026-11-07 - https://lastresolution.github.io/ReStart/guides/windows-11-live-tiles/2026-11-01 + https://lastresolution.github.io/ReStart/guides/windows-11-live-tiles/2026-11-08 From 0bd48911b6fa9030787c80882c808544ccf6ac3c Mon Sep 17 00:00:00 2001 From: LastResolution <128378329+LastResolution@users.noreply.github.com> Date: Sun, 8 Nov 2026 03:03:38 +0900 Subject: [PATCH 3/5] Keep tile artwork within local resource roots and align validation --- .../Diagnostics/MainWindow.TileArtworkChecks.cs | 1 + .../Services/Catalog/TileArtworkLoader.cs | 16 +++++++++++++--- .../Services/LiveTiles/LegacyTileAssets.cs | 10 +++++----- src/ReStart.Core/Apps/AppTileArtwork.cs | 3 ++- src/ReStart.Core/Locales/de.json | 2 +- src/ReStart.Core/Locales/en.json | 2 +- src/ReStart.Core/Locales/es.json | 2 +- src/ReStart.Core/Locales/fr.json | 2 +- src/ReStart.Core/Locales/ja.json | 2 +- src/ReStart.Core/Locales/ko.json | 2 +- src/ReStart.Core/Locales/zh-Hans.json | 2 +- src/ReStart.Core/Locales/zh-Hant.json | 2 +- 12 files changed, 29 insertions(+), 17 deletions(-) diff --git a/src/ReStart.App/Diagnostics/MainWindow.TileArtworkChecks.cs b/src/ReStart.App/Diagnostics/MainWindow.TileArtworkChecks.cs index 149a244..b7e6fb2 100644 --- a/src/ReStart.App/Diagnostics/MainWindow.TileArtworkChecks.cs +++ b/src/ReStart.App/Diagnostics/MainWindow.TileArtworkChecks.cs @@ -28,6 +28,7 @@ private static async Task VerifyTileArtworkAsync(MainWindow fixture, string var checks = 0; void Check(bool condition, string message) { Require(condition, message); checks++; } var root = Path.Combine(directory, "owned-artwork"); Directory.CreateDirectory(root); + Check(LegacyTileAssets.SafeFile(root, Path.GetPathRoot(root)!), "Drive-root containment rejected a local owned artwork directory."); var executable = Path.Combine(root, "Owned.exe"); File.WriteAllText(executable, "Not an executable; artwork fixture only."); File.WriteAllText(Path.ChangeExtension(executable, ".VisualElementsManifest.xml"), ""); diff --git a/src/ReStart.App/Services/Catalog/TileArtworkLoader.cs b/src/ReStart.App/Services/Catalog/TileArtworkLoader.cs index d33b7f9..75be016 100644 --- a/src/ReStart.App/Services/Catalog/TileArtworkLoader.cs +++ b/src/ReStart.App/Services/Catalog/TileArtworkLoader.cs @@ -19,7 +19,8 @@ internal sealed record Frame(BitmapSource Image, string Background, string Foreg try { completion.SetResult(Read(requests, token)); } catch (OperationCanceledException) when (token.IsCancellationRequested) { completion.SetCanceled(token); } catch (Exception ex) { completion.SetException(ex); } - }) { IsBackground = true, Name = "ReStart tile artwork" }; + }) + { IsBackground = true, Name = "ReStart tile artwork" }; thread.SetApartmentState(ApartmentState.STA); thread.Start(); return completion.Task; } @@ -58,7 +59,8 @@ internal sealed record Frame(BitmapSource Image, string Background, string Foreg } } catch (Exception ex) when (ex is COMException or IOException or UnauthorizedAccessException or System.Security.SecurityException - or ArgumentException or NotSupportedException or InvalidOperationException or System.IO.FileFormatException) { } + or ArgumentException or NotSupportedException or InvalidOperationException or System.IO.FileFormatException) + { } } return result; } @@ -81,11 +83,19 @@ private static (string Root, AppTileArtwork Artwork)? ReadManifest(AppEntry app) executable = app.LaunchTarget.EndsWith(".lnk", StringComparison.OrdinalIgnoreCase) ? AppCatalog.ReadShortcutMetadata(app.LaunchTarget).TargetPath : app.LaunchTarget; if (!Path.IsPathFullyQualified(executable) || executable.StartsWith(@"\\", StringComparison.Ordinal) || !Path.GetExtension(executable).Equals(".exe", StringComparison.OrdinalIgnoreCase)) return null; root = Path.GetDirectoryName(executable)!; + if (!LocalRoot(root)) return null; if (!LegacyTileAssets.SafeFile(executable, root)) return null; manifest = Path.ChangeExtension(executable, ".VisualElementsManifest.xml"); } - if (!LegacyTileAssets.SafeFile(manifest, root) || new FileInfo(manifest).Length is <= 0 or > 2_000_000) return null; + if (!LocalRoot(root) || !LegacyTileAssets.SafeFile(manifest, root) || new FileInfo(manifest).Length is <= 0 or > 2_000_000) return null; using var stream = File.Open(manifest, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); return AppTileArtwork.Read(stream, application) is { } artwork ? (root, artwork) : null; } + + private static bool LocalRoot(string root) + { + var drive = Path.GetPathRoot(root); + return drive is { Length: 3 } && drive[1] == ':' && new DriveInfo(drive).DriveType is DriveType.Fixed or DriveType.Removable or DriveType.CDRom or DriveType.Ram + && LegacyTileAssets.SafeFile(root, drive); + } } diff --git a/src/ReStart.App/Services/LiveTiles/LegacyTileAssets.cs b/src/ReStart.App/Services/LiveTiles/LegacyTileAssets.cs index addb7fa..916dc80 100644 --- a/src/ReStart.App/Services/LiveTiles/LegacyTileAssets.cs +++ b/src/ReStart.App/Services/LiveTiles/LegacyTileAssets.cs @@ -91,15 +91,15 @@ private static IEnumerable Sources(IEnumerable elemen internal static bool SafeFile(string path, string root) { - var current = Path.GetFullPath(path); var boundary = Path.GetFullPath(root).TrimEnd('\\'); - if (!current.Equals(boundary, StringComparison.OrdinalIgnoreCase) && !current.StartsWith(boundary + "\\", StringComparison.OrdinalIgnoreCase)) return false; - while (current.Length >= boundary.Length) + var current = Path.TrimEndingDirectorySeparator(Path.GetFullPath(path)); var boundary = Path.TrimEndingDirectorySeparator(Path.GetFullPath(root)); + if (!current.Equals(boundary, StringComparison.OrdinalIgnoreCase) && !current.StartsWith(boundary.TrimEnd('\\') + "\\", StringComparison.OrdinalIgnoreCase)) return false; + while (true) { if (!File.Exists(current) && !Directory.Exists(current) || (File.GetAttributes(current) & FileAttributes.ReparsePoint) != 0) return false; if (current.Equals(boundary, StringComparison.OrdinalIgnoreCase)) return true; - current = Path.GetDirectoryName(current)!; + if (Path.GetDirectoryName(current) is not { } parent) return false; + current = parent; } - return false; } private sealed class AssetKeyComparer : IEqualityComparer<(string App, string Source)> diff --git a/src/ReStart.Core/Apps/AppTileArtwork.cs b/src/ReStart.Core/Apps/AppTileArtwork.cs index e3dad9a..56a2c4a 100644 --- a/src/ReStart.Core/Apps/AppTileArtwork.cs +++ b/src/ReStart.Core/Apps/AppTileArtwork.cs @@ -87,7 +87,8 @@ public static IReadOnlyList RankVariants(string reference, IEnumerable 4096) valid = false; else { dimension = true; pixels = key == "scale" ? basePixels * number / 100 : number; } break; diff --git a/src/ReStart.Core/Locales/de.json b/src/ReStart.Core/Locales/de.json index 4abe2d6..abae4b7 100644 --- a/src/ReStart.Core/Locales/de.json +++ b/src/ReStart.Core/Locales/de.json @@ -578,7 +578,7 @@ "WindowsSearchHandoffHelp": "Öffnet die Windows-Suche und überträgt die aktuelle Suchanfrage, wenn das Eingabefeld verfügbar ist. Bei der Rückkehr zu ReStart wird dieses Suchfenster geschlossen. Cloud-Ergebnisse hängen von Windows-Berechtigungen und verbundenen Konten ab; ReStart greift nicht auf Cloud-Dateien zu.", "WindowsSearchQueryUnavailable": "Die Windows-Suche wurde geöffnet, hat die Suchanfrage aber nicht übernommen. Geben Sie sie dort ein.", "SettingsLegacyUwpTiles": "Live-Kacheln von Windows-Apps anzeigen (Kompatibilität)", - "LegacyUwpTilesHelp": "Liest aktive Kachel- und Abzeichendaten nur für angeheftete Paket-Apps. Verwendet einen undokumentierten Windows-Speicher; nicht verfügbare Daten werden durch Symbole ersetzt. Toasts werden nicht gelesen, entfernte Bilder nicht heruntergeladen.", + "LegacyUwpTilesHelp": "Liest aktive Kachel- und Abzeichendaten nur für angeheftete Paket-Apps. Verwendet einen undokumentierten Windows-Speicher; nicht verfügbare Daten werden durch Standard-Kachelbilder oder Symbole ersetzt. Toasts werden nicht gelesen, entfernte Bilder nicht heruntergeladen.", "SearchWebFor": "Im Web nach „{0}“ suchen", "SettingsWebSearch": "Websuche im Standardbrowser anbieten", "WebSearchHelp": "Öffnet Bing nur bei Auswahl dieser Aktion im Standardbrowser. Beim Tippen wird kein Suchtext an das Web gesendet.", diff --git a/src/ReStart.Core/Locales/en.json b/src/ReStart.Core/Locales/en.json index 9bd9a78..23900ec 100644 --- a/src/ReStart.Core/Locales/en.json +++ b/src/ReStart.Core/Locales/en.json @@ -578,7 +578,7 @@ "WindowsSearchHandoffHelp": "Opens Windows Search and transfers the current query when its input is available. Returning to ReStart dismisses that search window. Cloud results depend on Windows permissions and connected accounts; ReStart does not access cloud files.", "WindowsSearchQueryUnavailable": "Windows Search opened but did not accept the query. Enter it in Windows Search.", "SettingsLegacyUwpTiles": "Show Windows app live tiles (compatibility)", - "LegacyUwpTilesHelp": "Reads active tile and badge data only for pinned packaged apps. Uses an undocumented Windows store; unavailable data falls back to icons. Toasts are not read and remote images are not downloaded.", + "LegacyUwpTilesHelp": "Reads active tile and badge data only for pinned packaged apps. Uses an undocumented Windows store; unavailable data falls back to default tile images or icons. Toasts are not read and remote images are not downloaded.", "SearchWebFor": "Search the web for “{0}”", "SettingsWebSearch": "Show web search in the default browser", "WebSearchHelp": "Opens Bing in your default browser only when you choose this action. Typing does not send search text to the Web.", diff --git a/src/ReStart.Core/Locales/es.json b/src/ReStart.Core/Locales/es.json index bf7d798..0a29195 100644 --- a/src/ReStart.Core/Locales/es.json +++ b/src/ReStart.Core/Locales/es.json @@ -578,7 +578,7 @@ "WindowsSearchHandoffHelp": "Abre la búsqueda de Windows y transfiere la consulta actual si el campo está disponible. Al volver a ReStart se cierra esa ventana de búsqueda. Los resultados en la nube dependen de los permisos de Windows y las cuentas conectadas; ReStart no accede a archivos en la nube.", "WindowsSearchQueryUnavailable": "La búsqueda de Windows se abrió, pero no aceptó la consulta. Escríbela allí.", "SettingsLegacyUwpTiles": "Mostrar mosaicos dinámicos de aplicaciones de Windows (compatibilidad)", - "LegacyUwpTilesHelp": "Lee datos activos de mosaicos e insignias solo para aplicaciones empaquetadas ancladas. Usa un almacén de Windows no documentado; si no está disponible, muestra los iconos. No lee notificaciones emergentes ni descarga imágenes remotas.", + "LegacyUwpTilesHelp": "Lee datos activos de mosaicos e insignias solo para aplicaciones empaquetadas ancladas. Usa un almacén de Windows no documentado; si no está disponible, muestra las imágenes predeterminadas o los iconos. No lee notificaciones emergentes ni descarga imágenes remotas.", "SearchWebFor": "Buscar «{0}» en la Web", "SettingsWebSearch": "Ofrecer búsqueda web en el navegador predeterminado", "WebSearchHelp": "Abre Bing en el navegador predeterminado solo al elegir esta acción. Escribir no envía el texto de búsqueda a la Web.", diff --git a/src/ReStart.Core/Locales/fr.json b/src/ReStart.Core/Locales/fr.json index 32fb366..5538ed5 100644 --- a/src/ReStart.Core/Locales/fr.json +++ b/src/ReStart.Core/Locales/fr.json @@ -578,7 +578,7 @@ "WindowsSearchHandoffHelp": "Ouvre la recherche Windows et transfère la requête actuelle si son champ est disponible. Le retour à ReStart ferme cette fenêtre de recherche. Les résultats cloud dépendent des autorisations Windows et des comptes connectés ; ReStart n’accède pas aux fichiers cloud.", "WindowsSearchQueryUnavailable": "La recherche Windows s’est ouverte sans accepter la requête. Saisissez-la dans la recherche Windows.", "SettingsLegacyUwpTiles": "Afficher les vignettes dynamiques des applications Windows (compatibilité)", - "LegacyUwpTilesHelp": "Lit uniquement les données actives de vignettes et badges des applications empaquetées épinglées. Utilise un stockage Windows non documenté ; les icônes sont affichées si les données sont indisponibles. Ne lit pas les notifications toast et ne télécharge pas d’images distantes.", + "LegacyUwpTilesHelp": "Lit uniquement les données actives de vignettes et badges des applications empaquetées épinglées. Utilise un stockage Windows non documenté ; les images par défaut ou les icônes sont affichées si les données sont indisponibles. Ne lit pas les notifications toast et ne télécharge pas d’images distantes.", "SearchWebFor": "Rechercher « {0} » sur le Web", "SettingsWebSearch": "Proposer la recherche Web dans le navigateur par défaut", "WebSearchHelp": "Ouvre Bing dans le navigateur par défaut uniquement si vous choisissez cette action. La saisie seule n’envoie aucun texte au Web.", diff --git a/src/ReStart.Core/Locales/ja.json b/src/ReStart.Core/Locales/ja.json index d2a1d95..8c0825a 100644 --- a/src/ReStart.Core/Locales/ja.json +++ b/src/ReStart.Core/Locales/ja.json @@ -578,7 +578,7 @@ "WindowsSearchHandoffHelp": "Windows検索を開き、入力欄が利用可能なら現在の検索語を引き継ぎます。ReStartに戻ると検索画面を閉じます。クラウドの結果はWindowsの権限と接続済みアカウントに従い、ReStartがクラウドのファイルを読み取ることはありません。", "WindowsSearchQueryUnavailable": "Windows検索は開きましたが、検索語を引き継げませんでした。Windows検索で入力してください。", "SettingsLegacyUwpTiles": "Windowsアプリのライブタイルを表示(互換機能)", - "LegacyUwpTilesHelp": "ピン留めしたパッケージアプリの有効なタイルとバッジだけを読み取ります。Windowsの非公開データ形式を使用し、利用できない場合はアイコンを表示します。トースト通知は取得せず、外部画像もダウンロードしません。", + "LegacyUwpTilesHelp": "ピン留めしたパッケージアプリの有効なタイルとバッジだけを読み取ります。Windowsの非公開データ形式を使用し、利用できない場合は既定のタイル画像またはアイコンを表示します。トースト通知は取得せず、外部画像もダウンロードしません。", "SearchWebFor": "「{0}」をWebで検索", "SettingsWebSearch": "既定のブラウザーでWeb検索する項目を表示", "WebSearchHelp": "この操作を選んだときだけ、既定のブラウザーでBingを開きます。入力するだけでは検索語を外部へ送信しません。", diff --git a/src/ReStart.Core/Locales/ko.json b/src/ReStart.Core/Locales/ko.json index 5356538..2568d66 100644 --- a/src/ReStart.Core/Locales/ko.json +++ b/src/ReStart.Core/Locales/ko.json @@ -578,7 +578,7 @@ "WindowsSearchHandoffHelp": "Windows 검색을 열고 입력란을 사용할 수 있으면 현재 검색어를 전달합니다. ReStart로 돌아오면 해당 검색 창을 닫습니다. 클라우드 결과는 Windows 권한과 연결된 계정에 따라 달라지며 ReStart는 클라우드 파일에 접근하지 않습니다.", "WindowsSearchQueryUnavailable": "Windows 검색이 열렸지만 검색어를 전달하지 못했습니다. Windows 검색에서 입력하세요.", "SettingsLegacyUwpTiles": "Windows 앱 라이브 타일 표시(호환 기능)", - "LegacyUwpTilesHelp": "고정된 패키지 앱의 활성 타일과 배지 데이터만 읽습니다. 문서화되지 않은 Windows 저장소를 사용하며 데이터가 없으면 아이콘을 표시합니다. 토스트 알림은 읽지 않으며 원격 이미지를 다운로드하지 않습니다.", + "LegacyUwpTilesHelp": "고정된 패키지 앱의 활성 타일과 배지 데이터만 읽습니다. 문서화되지 않은 Windows 저장소를 사용하며 데이터가 없으면 기본 타일 이미지 또는 아이콘을 표시합니다. 토스트 알림은 읽지 않으며 원격 이미지를 다운로드하지 않습니다.", "SearchWebFor": "웹에서 “{0}” 검색", "SettingsWebSearch": "기본 브라우저에서 웹 검색 항목 표시", "WebSearchHelp": "이 작업을 선택할 때만 기본 브라우저에서 Bing을 엽니다. 입력만으로 검색어를 웹에 전송하지 않습니다.", diff --git a/src/ReStart.Core/Locales/zh-Hans.json b/src/ReStart.Core/Locales/zh-Hans.json index 43db665..9eaeb65 100644 --- a/src/ReStart.Core/Locales/zh-Hans.json +++ b/src/ReStart.Core/Locales/zh-Hans.json @@ -578,7 +578,7 @@ "WindowsSearchHandoffHelp": "打开 Windows 搜索,输入框可用时传递当前搜索词。返回 ReStart 时会关闭该搜索窗口。云端结果取决于 Windows 权限和已连接的帐户;ReStart 不会访问云端文件。", "WindowsSearchQueryUnavailable": "Windows 搜索已打开,但未能接收搜索词。请在 Windows 搜索中输入。", "SettingsLegacyUwpTiles": "显示 Windows 应用动态磁贴(兼容功能)", - "LegacyUwpTilesHelp": "仅读取已固定的打包应用的有效磁贴和徽章数据。使用未公开的 Windows 存储格式;数据不可用时显示图标。不读取弹出通知,也不下载远程图像。", + "LegacyUwpTilesHelp": "仅读取已固定的打包应用的有效磁贴和徽章数据。使用未公开的 Windows 存储格式;数据不可用时显示默认磁贴图片或图标。不读取弹出通知,也不下载远程图像。", "SearchWebFor": "在网上搜索“{0}”", "SettingsWebSearch": "显示通过默认浏览器进行网页搜索的选项", "WebSearchHelp": "仅在选择此操作时使用默认浏览器打开 Bing。仅输入文字不会将搜索词发送到网络。", diff --git a/src/ReStart.Core/Locales/zh-Hant.json b/src/ReStart.Core/Locales/zh-Hant.json index 4f1872b..43b5f04 100644 --- a/src/ReStart.Core/Locales/zh-Hant.json +++ b/src/ReStart.Core/Locales/zh-Hant.json @@ -578,7 +578,7 @@ "WindowsSearchHandoffHelp": "開啟 Windows 搜尋,輸入欄可用時傳遞目前的搜尋字詞。返回 ReStart 時會關閉該搜尋視窗。雲端結果取決於 Windows 權限和已連線的帳戶;ReStart 不會存取雲端檔案。", "WindowsSearchQueryUnavailable": "Windows 搜尋已開啟,但未能接收搜尋字詞。請在 Windows 搜尋中輸入。", "SettingsLegacyUwpTiles": "顯示 Windows 應用程式動態磚(相容功能)", - "LegacyUwpTilesHelp": "僅讀取已釘選封裝應用程式的有效動態磚與徽章資料。使用未公開的 Windows 儲存格式;資料無法使用時顯示圖示。不讀取快顯通知,也不下載遠端影像。", + "LegacyUwpTilesHelp": "僅讀取已釘選封裝應用程式的有效動態磚與徽章資料。使用未公開的 Windows 儲存格式;資料無法使用時顯示預設磚圖片或圖示。不讀取快顯通知,也不下載遠端影像。", "SearchWebFor": "在網路上搜尋「{0}」", "SettingsWebSearch": "顯示透過預設瀏覽器進行網頁搜尋的選項", "WebSearchHelp": "僅在選擇此操作時使用預設瀏覽器開啟 Bing。僅輸入文字不會將搜尋字詞傳送到網路。", From 1a7492648ec67d799ab9804c65a126faa8881634 Mon Sep 17 00:00:00 2001 From: LastResolution <128378329+LastResolution@users.noreply.github.com> Date: Sun, 8 Nov 2026 03:42:05 +0900 Subject: [PATCH 4/5] Prepare version 2.2.0 and validate artwork in portable builds --- .github/workflows/ci.yml | 2 ++ .github/workflows/release.yml | 2 ++ Directory.Build.props | 2 +- site/dist/guides/full-screen-start-menu/index.html | 6 +++--- site/dist/sitemap.xml | 2 +- .../Documentation/MainWindow.PresentationChecks.cs | 2 +- .../Diagnostics/MainWindow.DragHintDiagnostics.cs | 2 ++ 7 files changed, 12 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 33fd106..7251f0f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -177,6 +177,7 @@ jobs: ./scripts/test-app-menus.ps1 -Executable $executable ./scripts/test-jump-lists.ps1 -Executable $executable ./scripts/test-bundled-tiles.ps1 -Executable $executable + ./scripts/test-tile-artwork.ps1 -Executable $executable ./scripts/test-studio.ps1 -Executable $executable - name: Upload portable review package uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 @@ -207,5 +208,6 @@ jobs: artifacts/bundled-tiles/*/start.json artifacts/bundled-tiles/*/render.log artifacts/studio/*/verification.json + artifacts/tile-artwork/*/start.json artifacts/studio/*/render.log retention-days: 14 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 152782a..b4e71c0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -70,6 +70,7 @@ jobs: ./scripts/test-app-menus.ps1 -Executable $executable ./scripts/test-jump-lists.ps1 -Executable $executable ./scripts/test-bundled-tiles.ps1 -Executable $executable + ./scripts/test-tile-artwork.ps1 -Executable $executable ./scripts/test-studio.ps1 -Executable $executable - name: Preserve failed menu and update fixture diagnostics if: failure() && matrix.runtime == 'win-x64' @@ -78,6 +79,7 @@ jobs: name: portable-failure-diagnostics path: | artifacts/smoke/*.json + artifacts/tile-artwork/*/start.json artifacts/smoke/*.png artifacts/app-menus/*.json artifacts/jump-lists/*/start.json diff --git a/Directory.Build.props b/Directory.Build.props index 6a4166f..c6c9ab0 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -12,7 +12,7 @@ obj/$(RuntimeIdentifier)/ $(DefaultItemExcludes);**/obj/**;**/bin/** $(MSBuildProjectDirectory)/obj/packages.$(RuntimeIdentifier).lock.json - 2.1.1 + 2.2.0 ReStart contributors ReStart https://github.com/LastResolution/ReStart diff --git a/site/dist/guides/full-screen-start-menu/index.html b/site/dist/guides/full-screen-start-menu/index.html index 2a62ccb..e59ee29 100644 --- a/site/dist/guides/full-screen-start-menu/index.html +++ b/site/dist/guides/full-screen-start-menu/index.html @@ -47,7 +47,7 @@ "https://lastresolution.github.io/ReStart/assets/start-dark.png" ], "datePublished": "2026-10-22", - "dateModified": "2026-11-07", + "dateModified": "2026-11-08", "author": { "@type": "Organization", "name": "ReStart contributors", @@ -101,7 +101,7 @@
    -

    Start menu guide

    How to Restore the Windows 10 Full-Screen Start Menu on Windows 11

    If you used Start as a full-screen home for your apps, a compact list can feel like a step back. Here is how to get a tile-based workspace on Windows 11, with a reversible setup.

    By ReStart contributorsPublished Updated Covers ReStart v2.1.1
    +

    Start menu guide

    How to Restore the Windows 10 Full-Screen Start Menu on Windows 11

    If you used Start as a full-screen home for your apps, a compact list can feel like a step back. Here is how to get a tile-based workspace on Windows 11, with a reversible setup.

    By ReStart contributorsPublished Updated Covers ReStart v2.2.0
    @@ -151,7 +151,7 @@

    Can I go back to the normal Windows Start menu?

    How do I keep it updated without losing my arrangement?

    ReStart can check public GitHub Releases, with an optional startup notification. From v1.6.0, complete unmodified portable packages in writable folders offer Download update, then Restart and update after verification. A failed installation or candidate startup restores the previous files. Manual replacement remains available for developer builds and older packages. Local settings are stored separately. See the update and backup guide before moving between versions or PCs.

    Sources and coverage

    -

    This guide is maintained by the ReStart project and covers v2.1.1. Product instructions were checked against the release README, source code and Start behavior documentation. Windows behavior is sourced from Microsoft’s linked documentation; this article does not claim a new hands-on test of every supported device.

    +

    This guide is maintained by the ReStart project and covers v2.2.0. Product instructions were checked against the release README, source code and Start behavior documentation. Windows behavior is sourced from Microsoft’s linked documentation; this article does not claim a new hands-on test of every supported device.

    diff --git a/site/dist/sitemap.xml b/site/dist/sitemap.xml index 679e7b2..d71f5eb 100644 --- a/site/dist/sitemap.xml +++ b/site/dist/sitemap.xml @@ -3,6 +3,6 @@ https://lastresolution.github.io/ReStart/2026-11-08 https://lastresolution.github.io/ReStart/developers/2026-11-07 https://lastresolution.github.io/ReStart/guides/ - https://lastresolution.github.io/ReStart/guides/full-screen-start-menu/2026-11-07 + https://lastresolution.github.io/ReStart/guides/full-screen-start-menu/2026-11-08 https://lastresolution.github.io/ReStart/guides/windows-11-live-tiles/2026-11-08 diff --git a/src/ReStart.App/Diagnostics/Documentation/MainWindow.PresentationChecks.cs b/src/ReStart.App/Diagnostics/Documentation/MainWindow.PresentationChecks.cs index da8a586..d0ec333 100644 --- a/src/ReStart.App/Diagnostics/Documentation/MainWindow.PresentationChecks.cs +++ b/src/ReStart.App/Diagnostics/Documentation/MainWindow.PresentationChecks.cs @@ -56,7 +56,7 @@ string Picture(string name, int size) Check(boundary.Contains(mini.TransformToAncestor(button).TransformBounds(new Rect(mini.RenderSize))), "A folder preview exceeded the visible tile at " + size + "/" + scale); foreach (var image in VisualChildren(mini)) { - var slot = (Border)image.Parent; + var slot = mini.Children.OfType().Single(cell => VisualChildren(cell).Contains(image)); Check(new Rect(slot.RenderSize).Contains(image.TransformToAncestor(slot).TransformBounds(new Rect(image.RenderSize))), "A miniature icon was clipped by its cell."); } } diff --git a/src/ReStart.App/Diagnostics/MainWindow.DragHintDiagnostics.cs b/src/ReStart.App/Diagnostics/MainWindow.DragHintDiagnostics.cs index 58cadf7..01edb95 100644 --- a/src/ReStart.App/Diagnostics/MainWindow.DragHintDiagnostics.cs +++ b/src/ReStart.App/Diagnostics/MainWindow.DragHintDiagnostics.cs @@ -32,7 +32,9 @@ private async Task VerifyDragHintsAsync(string directory) Check(_dragShortcutToast!.TranslatePoint(new(), Root).Y > Root.ActualHeight / 2 && _dragShortcutToast.ActualWidth <= Root.ActualWidth - 64, "The toast is not within the lower viewport."); Check(!_dragShortcutToast.HasAnimatedProperties, "Disabled motion still animates the toast."); + Check(timer?.Interval == DragShortcutHintDuration, "The drag toast does not use its specified lifetime."); await Task.Delay(DragShortcutHintDuration + TimeSpan.FromMilliseconds(250)); + await Dispatcher.InvokeAsync(() => { }, System.Windows.Threading.DispatcherPriority.ApplicationIdle); Check(_dragging && !_dragShortcutToast.IsVisible && timer?.IsEnabled == false, "The toast did not expire during an ongoing drag."); ShowDragShortcutHint("ReStart.Tile"); StopDragGhost(); Check(!_dragShortcutToast.IsVisible && timer?.IsEnabled == false, "Cancelling a drag leaves the toast or timer alive."); From 3a2d7e65f463447d31ae342711913a815a533a09 Mon Sep 17 00:00:00 2001 From: LastResolution <128378329+LastResolution@users.noreply.github.com> Date: Sun, 8 Nov 2026 03:51:19 +0900 Subject: [PATCH 5/5] Isolate drag hint timing from queued editing completion --- .../Diagnostics/MainWindow.DragHintDiagnostics.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/ReStart.App/Diagnostics/MainWindow.DragHintDiagnostics.cs b/src/ReStart.App/Diagnostics/MainWindow.DragHintDiagnostics.cs index 01edb95..5e808b9 100644 --- a/src/ReStart.App/Diagnostics/MainWindow.DragHintDiagnostics.cs +++ b/src/ReStart.App/Diagnostics/MainWindow.DragHintDiagnostics.cs @@ -14,6 +14,9 @@ private async Task VerifyDragHintsAsync(string directory) { var checks = 0; void Check(bool condition, string message) { Require(condition, message); checks++; } + // Earlier editing operations can enqueue drag completion at Background + // priority. Complete that work before starting this independent drag. + await Dispatcher.InvokeAsync(() => { }, System.Windows.Threading.DispatcherPriority.ApplicationIdle); Check(new Preferences().ShowDragShortcutHints && JsonSerializer.Deserialize("{}")!.ShowDragShortcutHints, "New and existing settings do not default drag shortcut hints on."); var before = JsonSerializer.Serialize(Document.Groups); @@ -35,7 +38,8 @@ private async Task VerifyDragHintsAsync(string directory) Check(timer?.Interval == DragShortcutHintDuration, "The drag toast does not use its specified lifetime."); await Task.Delay(DragShortcutHintDuration + TimeSpan.FromMilliseconds(250)); await Dispatcher.InvokeAsync(() => { }, System.Windows.Threading.DispatcherPriority.ApplicationIdle); - Check(_dragging && !_dragShortcutToast.IsVisible && timer?.IsEnabled == false, "The toast did not expire during an ongoing drag."); + Check(_dragging && !_dragShortcutToast.IsVisible && timer?.IsEnabled == false, + $"The toast did not expire during an ongoing drag (dragging={_dragging}, visible={_dragShortcutToast.IsVisible}, timer={timer?.IsEnabled}, motion={MotionEnabled})."); ShowDragShortcutHint("ReStart.Tile"); StopDragGhost(); Check(!_dragShortcutToast.IsVisible && timer?.IsEnabled == false, "Cancelling a drag leaves the toast or timer alive."); ShowDragShortcutHint("ReStart.App"); Hide();