449 lines
18 KiB
C#
449 lines
18 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? _syncDir; // OneDrive\MyStart, or null if no OneDrive found
|
|
private string? _syncOwnFilePath; // OneDrive\MyStart\sync-<this PC's name>.json -- WE are the only writer of this file
|
|
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 files there. No OneDrive -> sync silently stays off.
|
|
//
|
|
// Each PC writes ONLY its own file (sync-<machine name>.json) and reads
|
|
// everyone else's. Two devices never write the same path, so there is
|
|
// nothing for OneDrive to see as a conflict -- a shared single file that
|
|
// both PCs wrote to used to make OneDrive spawn "conflicted copy" files
|
|
// whenever both saved around the same moment.
|
|
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);
|
|
_syncDir = syncDir;
|
|
string device = Environment.MachineName;
|
|
foreach (char c in Path.GetInvalidFileNameChars()) device = device.Replace(c, '_');
|
|
_syncOwnFilePath = Path.Combine(syncDir, "sync-" + device + ".json");
|
|
_syncMarkerPath = Path.Combine(dataDir, "sync-marker.json");
|
|
_lastSyncedDataRaw = File.Exists(_syncMarkerPath) ? File.ReadAllText(_syncMarkerPath) : null;
|
|
}
|
|
catch { _syncDir = null; _syncOwnFilePath = 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 && _syncDir != 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). If it's new
|
|
// since our last known-synced state, push it to OUR OWN file (never a
|
|
// shared one -- see the comment in InitAsync on why). Otherwise look at
|
|
// every other PC's file and pull in whichever is newest, if it actually
|
|
// differs -- 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 (_syncDir == null || _syncOwnFilePath == 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;
|
|
|
|
if (localDataRaw != _lastSyncedDataRaw)
|
|
{
|
|
// Something changed here since we last synced -> push to our own file.
|
|
await File.WriteAllTextAsync(_syncOwnFilePath, exportJson);
|
|
_lastSyncedDataRaw = localDataRaw;
|
|
TryWriteMarker(localDataRaw);
|
|
PostSyncStatus(true, "gesendet");
|
|
}
|
|
else if (!isClosing)
|
|
{
|
|
var peer = FindLatestPeer();
|
|
if (peer == null || peer.Value.DataRaw == localDataRaw)
|
|
{
|
|
PostSyncStatus(true, "aktuell");
|
|
}
|
|
else if (!ContainsFocus || WindowState == FormWindowState.Minimized)
|
|
{
|
|
string js = "window.__mystartSyncImport && window.__mystartSyncImport(" +
|
|
JsonSerializer.Serialize(peer.Value.FullJson) + ")";
|
|
await _web.CoreWebView2.ExecuteScriptAsync(js);
|
|
_lastSyncedDataRaw = peer.Value.DataRaw;
|
|
TryWriteMarker(peer.Value.DataRaw);
|
|
PostSyncStatus(true, "aktualisiert");
|
|
}
|
|
else
|
|
{
|
|
PostSyncStatus(true, "wartet (Fenster gerade aktiv)");
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
PostSyncStatus(false, ex.Message);
|
|
}
|
|
finally
|
|
{
|
|
_syncInProgress = false;
|
|
}
|
|
}
|
|
|
|
// Every other PC's sync-*.json (plus a one-time look at the old shared
|
|
// sync.json from before each PC had its own file, so nothing already
|
|
// written there gets silently stranded), newest "savedAt" wins.
|
|
private (string FullJson, string DataRaw)? FindLatestPeer()
|
|
{
|
|
if (_syncDir == null) return null;
|
|
string? bestSavedAt = null;
|
|
string? bestFullJson = null;
|
|
string? bestDataRaw = null;
|
|
|
|
var candidates = Directory.EnumerateFiles(_syncDir, "sync-*.json")
|
|
.Where(p => !string.Equals(p, _syncOwnFilePath, StringComparison.OrdinalIgnoreCase));
|
|
string legacyPath = Path.Combine(_syncDir, "sync.json");
|
|
if (File.Exists(legacyPath)) candidates = candidates.Append(legacyPath);
|
|
|
|
foreach (var path in candidates)
|
|
{
|
|
string json;
|
|
try { json = File.ReadAllText(path); } catch { continue; }
|
|
try
|
|
{
|
|
using var doc = JsonDocument.Parse(json);
|
|
if (!doc.RootElement.TryGetProperty("savedAt", out var saProp)) continue;
|
|
if (!doc.RootElement.TryGetProperty("data", out var dataProp)) continue;
|
|
string? savedAt = saProp.GetString();
|
|
if (savedAt == null) continue;
|
|
if (bestSavedAt == null || string.CompareOrdinal(savedAt, bestSavedAt) > 0)
|
|
{
|
|
bestSavedAt = savedAt;
|
|
bestFullJson = json;
|
|
bestDataRaw = dataProp.GetRawText();
|
|
}
|
|
}
|
|
catch { /* skip unreadable/mid-write file, try again next tick */ }
|
|
}
|
|
|
|
return bestFullJson != null && bestDataRaw != null ? (bestFullJson, bestDataRaw) : null;
|
|
}
|
|
|
|
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 (_syncDir != 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 */ }
|
|
}
|
|
}
|