Files
MyStart/webview2/Program.cs
2026-08-07 08:53:16 +02:00

403 lines
16 KiB
C#

using System.Reflection;
using System.Text.Json;
using Microsoft.Win32;
using Microsoft.Web.WebView2.Core;
using Microsoft.Web.WebView2.WinForms;
namespace MyStart;
static class Program
{
[STAThread]
static void Main(string[] args)
{
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
Application.SetHighDpiMode(HighDpiMode.PerMonitorV2);
bool minimized = args.Any(a =>
a.Equals("--minimized", StringComparison.OrdinalIgnoreCase) ||
a.Equals("--min", StringComparison.OrdinalIgnoreCase) ||
a.Equals("--autostart", StringComparison.OrdinalIgnoreCase));
Application.Run(new MainForm(minimized));
}
}
class MainForm : Form
{
private readonly WebView2 _web = new() { Dock = DockStyle.Fill };
private const string Host = "mystart.local";
private bool _pendingMaximize;
private System.Windows.Forms.Timer? _syncTimer;
private string? _syncFilePath; // OneDrive\MyStart\sync.json, or null if no OneDrive found
private string? _syncMarkerPath; // local record of what we last pushed/pulled, survives restarts
private string? _lastSyncedDataRaw;
private bool _syncInProgress;
public MainForm(bool startMinimized = false)
{
Text = "MyStart";
StartPosition = FormStartPosition.CenterScreen;
Width = 1280;
Height = 800;
MinimumSize = new System.Drawing.Size(480, 360);
ShowInTaskbar = true;
BackColor = System.Drawing.Color.Black;
// No title bar / border. Close with Alt+F4, minimize via the taskbar button.
FormBorderStyle = FormBorderStyle.None;
if (startMinimized)
{
// Autostart: sit minimized on the taskbar; restore to a normal window on first open.
WindowState = FormWindowState.Minimized;
_pendingMaximize = true;
Resize += (_, _) =>
{
if (_pendingMaximize && WindowState != FormWindowState.Minimized)
{
_pendingMaximize = false;
WindowState = FormWindowState.Normal;
}
};
}
// else: leave WindowState at its default (Normal) -> starts as a normal
// 1280x800 window, not maximized/fullscreen.
try
{
var asm = Assembly.GetExecutingAssembly();
var icoName = asm.GetManifestResourceNames()
.FirstOrDefault(n => n.EndsWith("app.ico", StringComparison.OrdinalIgnoreCase));
if (icoName != null)
{
using var s = asm.GetManifestResourceStream(icoName);
if (s != null) Icon = new Icon(s);
}
}
catch { /* icon optional */ }
Controls.Add(_web);
Shown += async (_, _) => await InitAsync();
}
// Keep a borderless maximized window from covering the taskbar.
protected override void OnHandleCreated(EventArgs e)
{
base.OnHandleCreated(e);
MaximizedBounds = Screen.FromHandle(Handle).WorkingArea;
}
private async Task InitAsync()
{
// Persistent data dir -> localStorage (settings/bookmarks) survive across runs
// and independent of where the .exe lives.
string dataDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"MyStart");
Directory.CreateDirectory(dataDir);
// Optional OneDrive sync: if OneDrive is set up on this PC, drop a JSON
// snapshot of the whole app state (all Reiter/profiles) in a MyStart
// subfolder there. OneDrive itself does the actual transfer between
// PCs; we just poll that file. No OneDrive -> sync silently stays off.
string? oneDriveRoot = Environment.GetEnvironmentVariable("OneDrive")
?? Environment.GetEnvironmentVariable("OneDriveConsumer")
?? Environment.GetEnvironmentVariable("OneDriveCommercial");
if (!string.IsNullOrEmpty(oneDriveRoot))
{
try
{
string syncDir = Path.Combine(oneDriveRoot, "MyStart");
Directory.CreateDirectory(syncDir);
_syncFilePath = Path.Combine(syncDir, "sync.json");
_syncMarkerPath = Path.Combine(dataDir, "sync-marker.json");
_lastSyncedDataRaw = File.Exists(_syncMarkerPath) ? File.ReadAllText(_syncMarkerPath) : null;
}
catch { _syncFilePath = null; }
}
var env = await CoreWebView2Environment.CreateAsync(null, dataDir);
await _web.EnsureCoreWebView2Async(env);
// Extract the embedded app to a fixed folder, served via a stable origin.
string appDir = Path.Combine(dataDir, "app");
Directory.CreateDirectory(appDir);
string htmlPath = Path.Combine(appDir, "index.html");
var asm = Assembly.GetExecutingAssembly();
string resName = asm.GetManifestResourceNames()
.First(n => n.EndsWith("app-index.html", StringComparison.OrdinalIgnoreCase));
await using (var res = asm.GetManifestResourceStream(resName)!)
await using (var file = File.Create(htmlPath))
await res.CopyToAsync(file);
var core = _web.CoreWebView2;
core.SetVirtualHostNameToFolderMapping(Host, appDir, CoreWebView2HostResourceAccessKind.Allow);
core.Settings.IsStatusBarEnabled = false;
core.Settings.AreDefaultContextMenusEnabled = true;
core.Settings.IsWebMessageEnabled = true;
// Autostart toggle from the in-app "MyStart" info page.
core.WebMessageReceived += (_, e) =>
{
try
{
using var doc = JsonDocument.Parse(e.WebMessageAsJson);
var root = doc.RootElement;
if (!root.TryGetProperty("type", out var t)) return;
switch (t.GetString())
{
case "autostart-get":
PostAutostartState();
break;
case "autostart-set":
bool on = root.TryGetProperty("value", out var v) &&
v.ValueKind == JsonValueKind.True;
SetAutostart(on);
PostAutostartState();
break;
case "window-drag":
StartWindowDrag();
break;
case "window-resize":
if (root.TryGetProperty("edge", out var edgeProp))
StartWindowResize(edgeProp.GetString());
break;
case "window-min":
WindowState = FormWindowState.Minimized;
break;
case "window-max":
WindowState = WindowState == FormWindowState.Maximized
? FormWindowState.Normal : FormWindowState.Maximized;
break;
case "window-close":
Close();
break;
}
}
catch { /* ignore malformed messages */ }
};
// Bookmark links that open a new window -> user's default browser.
core.NewWindowRequested += (_, e) =>
{
e.Handled = true;
OpenExternal(e.Uri);
};
// Same-tab clicks to external sites -> default browser, keep the app loaded.
core.NavigationStarting += (_, e) =>
{
var uri = e.Uri ?? string.Empty;
if (uri.StartsWith("https://" + Host, StringComparison.OrdinalIgnoreCase)) return; // internal
if (uri.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
uri.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
e.Cancel = true;
OpenExternal(uri);
}
};
// Kick a sync check after every completed load (first start, and any
// reload the sync pull/push itself triggers), then keep polling.
core.NavigationCompleted += async (_, e) =>
{
if (!e.IsSuccess) return;
await SyncTickAsync(isClosing: false);
if (_syncTimer == null && _syncFilePath != null)
{
_syncTimer = new System.Windows.Forms.Timer { Interval = 30_000 };
_syncTimer.Tick += async (_, _) => await SyncTickAsync(isClosing: false);
_syncTimer.Start();
}
};
core.Navigate("https://" + Host + "/index.html");
}
// --- OneDrive sync tick: export current state from the page (opaque JSON
// blob, the page understands its own structure -- we don't), compare with
// what's on disk, and push or pull whichever side is behind. "Local
// changed since last sync" always wins a push; a pull is skipped while
// the window is actively focused so we never yank a reload out from under
// someone mid-edit -- it'll catch up on the next tick after they click away. ---
private async Task SyncTickAsync(bool isClosing)
{
if (_syncFilePath == null || _syncInProgress) return;
_syncInProgress = true;
try
{
string raw = await _web.CoreWebView2.ExecuteScriptAsync(
"window.__mystartSyncExport && window.__mystartSyncExport()");
string? exportJson = JsonSerializer.Deserialize<string>(raw);
if (string.IsNullOrEmpty(exportJson)) return;
string? localDataRaw = ExtractDataRaw(exportJson);
if (localDataRaw == null) return;
string? fileFullJson = null;
string? fileDataRaw = null;
if (File.Exists(_syncFilePath))
{
fileFullJson = await File.ReadAllTextAsync(_syncFilePath);
fileDataRaw = ExtractDataRaw(fileFullJson);
}
if (localDataRaw != _lastSyncedDataRaw)
{
// Something changed here since we last synced -> push (last writer wins).
await File.WriteAllTextAsync(_syncFilePath, exportJson);
_lastSyncedDataRaw = localDataRaw;
TryWriteMarker(localDataRaw);
PostSyncStatus(true, "gesendet");
}
else if (fileDataRaw != null && fileDataRaw != localDataRaw && !isClosing)
{
// Nothing changed locally, but the shared file moved on -> pull,
// unless we're actively being used right now.
if (!ContainsFocus || WindowState == FormWindowState.Minimized)
{
string js = "window.__mystartSyncImport && window.__mystartSyncImport(" +
JsonSerializer.Serialize(fileFullJson) + ")";
await _web.CoreWebView2.ExecuteScriptAsync(js);
_lastSyncedDataRaw = fileDataRaw;
TryWriteMarker(fileDataRaw);
PostSyncStatus(true, "aktualisiert");
}
}
else
{
PostSyncStatus(true, "aktuell");
}
}
catch (Exception ex)
{
PostSyncStatus(false, ex.Message);
}
finally
{
_syncInProgress = false;
}
}
private static string? ExtractDataRaw(string json)
{
try
{
using var doc = JsonDocument.Parse(json);
return doc.RootElement.TryGetProperty("data", out var data) ? data.GetRawText() : null;
}
catch { return null; }
}
private void TryWriteMarker(string dataRaw)
{
try { if (_syncMarkerPath != null) File.WriteAllText(_syncMarkerPath, dataRaw); }
catch { /* best effort */ }
}
private void PostSyncStatus(bool ok, string message)
{
try
{
var json = JsonSerializer.Serialize(new { type = "sync-status", ok, message });
_web.CoreWebView2.PostWebMessageAsJson(json);
}
catch { /* page may be mid-reload */ }
}
protected override void OnFormClosing(FormClosingEventArgs e)
{
base.OnFormClosing(e);
// Best-effort final push; not awaited/blocking (would risk deadlocking
// the UI thread against WebView2's own message pump on shutdown). The
// 30s poll already covers normal use -- this just shaves the tail end.
if (_syncFilePath != null) _ = SyncTickAsync(isClosing: true);
}
// --- Autostart (per-user HKCU Run key, points at THIS exe with --minimized) ---
private const string RunKeyPath = @"Software\Microsoft\Windows\CurrentVersion\Run";
private const string RunValueName = "MyStart";
private static string ExePath => Environment.ProcessPath ?? Application.ExecutablePath;
private static bool IsAutostartEnabled()
{
try
{
using var k = Registry.CurrentUser.OpenSubKey(RunKeyPath, false);
return k?.GetValue(RunValueName) is string v && v.Length > 0;
}
catch { return false; }
}
private static void SetAutostart(bool on)
{
try
{
using var k = Registry.CurrentUser.OpenSubKey(RunKeyPath, true)
?? Registry.CurrentUser.CreateSubKey(RunKeyPath, true);
if (k == null) return;
if (on) k.SetValue(RunValueName, "\"" + ExePath + "\" --minimized");
else if (k.GetValue(RunValueName) != null) k.DeleteValue(RunValueName, false);
}
catch { /* ignore */ }
}
private void PostAutostartState()
{
var json = "{\"type\":\"autostart\",\"enabled\":" +
(IsAutostartEnabled() ? "true" : "false") + "}";
try { _web.CoreWebView2.PostWebMessageAsJson(json); } catch { }
}
// --- Free window drag for the borderless window (page sends "window-drag"
// on grip mousedown / Alt+drag; we hand off to the native move loop). ---
[System.Runtime.InteropServices.DllImport("user32.dll")]
private static extern bool ReleaseCapture();
[System.Runtime.InteropServices.DllImport("user32.dll")]
private static extern IntPtr SendMessage(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam);
private const int WM_NCLBUTTONDOWN = 0xA1;
private const int HTCAPTION = 0x2;
private void StartWindowDrag()
{
// A maximized window can't be moved -> restore first, centred under cursor-ish.
if (WindowState == FormWindowState.Maximized)
WindowState = FormWindowState.Normal;
ReleaseCapture();
SendMessage(Handle, WM_NCLBUTTONDOWN, (IntPtr)HTCAPTION, IntPtr.Zero);
}
// --- Free window resize for the borderless window. The WebView2 control
// fills the entire client area, so the OS never gets a chance to hit-test
// the window's own edges for a resize cursor/drag -- every mouse message
// lands in the browser first. The page (bundle.js) instead watches the
// mouse position itself, and on mousedown within a few px of an edge/
// corner sends "window-resize" with which edge; we hand off to the same
// native resize loop Windows uses for a normal sizable window. ---
private static readonly Dictionary<string, int> ResizeEdges = new()
{
["n"] = 12, ["s"] = 15, ["e"] = 11, ["w"] = 10,
["ne"] = 14, ["nw"] = 13, ["se"] = 17, ["sw"] = 16,
};
private void StartWindowResize(string? edge)
{
// Only a normal (non-maximized, non-minimized) window can be resized this way.
if (WindowState != FormWindowState.Normal) return;
if (edge == null || !ResizeEdges.TryGetValue(edge, out var htCode)) return;
ReleaseCapture();
SendMessage(Handle, WM_NCLBUTTONDOWN, (IntPtr)htCode, IntPtr.Zero);
}
private static void OpenExternal(string uri)
{
try
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
{
FileName = uri,
UseShellExecute = true
});
}
catch { /* ignore */ }
}
}