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.
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.
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.
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
- 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.
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 @@ - Connect weather, RSS, Web APIs or local JSON.
- Choose photos from a folder on your PC.
- Make your own with HTML, CSS and JavaScript.
- Connect weather, RSS, Web APIs or local JSON.
- Choose photos from a folder on your PC.
- Make your own with HTML, CSS and JavaScript.
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.
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.
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..d71f5eb 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/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/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/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..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);
@@ -32,8 +35,11 @@ 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));
- Check(_dragging && !_dragShortcutToast.IsVisible && timer?.IsEnabled == false, "The toast did not expire during an ongoing drag.");
+ await Dispatcher.InvokeAsync(() => { }, System.Windows.Threading.DispatcherPriority.ApplicationIdle);
+ 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();
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..b7e6fb2
--- /dev/null
+++ b/src/ReStart.App/Diagnostics/MainWindow.TileArtworkChecks.cs
@@ -0,0 +1,139 @@
+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);
+ 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"),
+ " ");
+ 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("7 Owned update Owned update Owned 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..75be016
--- /dev/null
+++ b/src/ReStart.App/Services/Catalog/TileArtworkLoader.cs
@@ -0,0 +1,101 @@
+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 (!LocalRoot(root)) return null;
+ if (!LegacyTileAssets.SafeFile(executable, root)) return null;
+ manifest = Path.ChangeExtension(executable, ".VisualElementsManifest.xml");
+ }
+ 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 3672fe5..916dc80 100644
--- a/src/ReStart.App/Services/LiveTiles/LegacyTileAssets.cs
+++ b/src/ReStart.App/Services/LiveTiles/LegacyTileAssets.cs
@@ -67,33 +67,39 @@ 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;
- 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.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..56a2c4a
--- /dev/null
+++ b/src/ReStart.Core/Apps/AppTileArtwork.cs
@@ -0,0 +1,112 @@
+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..abae4b7 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",
@@ -576,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 9c537a1..23900ec 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",
@@ -576,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 d7f8401..0a29195 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",
@@ -576,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 8a4a52c..5538ed5 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",
@@ -576,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 3798c5c..8c0825a 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": "未対応",
@@ -576,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 52853f7..2568d66 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": "지원하지 않음",
@@ -576,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 fe56c70..9eaeb65 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": "不支持",
@@ -576,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 e2e7ef4..43b5f04 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": "不支援",
@@ -576,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/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);
+ }
+}






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.
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.
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.