crates/fresh-gui/PRODUCTIZATION_PLAN.md
This plan turns the fresh-gui crate (winit + wgpu + ratatui-wgpu + muda) from
a working prototype into a production-grade, signed, auto-updating desktop
application on macOS and Windows (with Linux as a secondary target via the
existing AppImage flow).
It complements MACOS_TODO.md, which tracks platform-specific UX details.
This document is the cross-cutting roadmap — the work that has to land for the
GUI to be a polished product, not just a working binary.
What we already have:
crates/fresh-gui encapsulates all windowing/GPU deps;
fresh-editor opts in via the gui feature flag (cargo build --features gui produces a single fresh binary that runs as either TUI or windowed
GUI based on --gui).crates/fresh-gui/src/lib.rs already implements the winit
0.30 ApplicationHandler trait and defers window/wgpu init to resumed().muda is wired up for macOS via
crates/fresh-gui/src/macos/{menu.rs,menu_tracking.rs}, including
NSNotificationCenter integration via objc2-foundation + block2 to
prevent the event loop from freezing while a menu is open.Info.plist, Fresh.entitlements, and
create-app-bundle.sh exist under crates/fresh-gui/resources/macos/..github/workflows/gui-builds.yml builds GUI binaries for the five
primary targets (x86_64/aarch64 × {linux,darwin}, x86_64-windows-msvc),
produces an ad-hoc-signed .pkg on macOS and an AppImage on Linux, and
ships a raw .exe on Windows.What is missing or incomplete vs. the reference framework:
| Area | Gap |
|---|---|
| Windows manifest | No DPI awareness, no Common Controls v6, no embedded version info |
| Windows subsystem | No #![windows_subsystem = "windows"] — GUI launch from Explorer flashes a console |
| Windows icon | No .ico embedded via winresource (only the in-window winit icon) |
| Code signing | macOS uses ad-hoc only; Windows is unsigned → SmartScreen warnings |
| Notarization | No notarytool step; .pkg won't pass Gatekeeper on a fresh Mac |
| Universal binary | macOS x86_64 and aarch64 ship as separate .pkgs; no lipo step |
| Installers | Windows ships a bare .exe; no MSI / NSIS; no DMG on macOS |
| Single-instance | Opening a second file launches a second process, no IPC handoff |
| Auto-update | No update channel, no signed manifest, no in-app updater |
| Observability | No sentry crate panic handler; tracing not wired to a file sink in GUI mode |
| HiDPI text | ScaleFactorChanged not handled; surface is not reconfigured on monitor switch |
| File handling | Finder double-click / open -a / drag-and-drop not routed into the running app |
| Dual-mode console | hide_console_ng not used; CLI invocations of fresh.exe would lose stdout if we set the windows subsystem naively |
The reference document's stack converges with what we already use, so the work below is mostly filling in the production gaps around an existing architecture rather than rewriting it.
Surfaced early because empirical testing already showed icons appearing in some contexts but not others. "Set the window icon" is not a complete solution on either platform — each OS surface has a distinct source-of-truth and a distinct failure mode. Phases 1, 2, and 3 each touch part of this; the matrix below is the contract those phases must satisfy together.
| Surface | Driven by | Failure mode |
|---|---|---|
| Explorer / Desktop / Properties dialog | RT_GROUP_ICON resource embedded in .exe (via winresource) | .ico missing sizes → stretched fallback at that size |
| Window title bar (small icon) + Alt-Tab thumbnail (small) | winit Window::set_window_icon → WM_SETICON(ICON_SMALL) | If unset, falls back to exe resource — usually fine |
| Taskbar (large icon) + Alt-Tab thumbnail (large) | WM_SETICON(ICON_BIG); falls back to exe resource | winit historically only sets ICON_SMALL; ICON_BIG must be in the exe resource or set explicitly via Win32 platform extensions |
| Pinned-taskbar shortcut + Start Menu tile | The .lnk's Icon= attribute, set by the MSI shortcut definition | MSI omits the icon attribute → generic exe icon on the pin |
| Jump list, taskbar grouping, notifications | AppUserModelID — SetCurrentProcessExplicitAppUserModelID(L"dev.getfresh.Fresh") called early in main | Not set → Windows infers an AUMID from the exe path, groups runs of Fresh under the wrong identity, and the pinned shortcut points to a different AUMID than the running window |
| Apps & Features uninstall entry | MSI ARPPRODUCTICON property | Omitted → generic Windows Installer icon |
| SmartScreen "do you want to run this" prompt | Exe resource + version info block | Missing version info → "Unknown publisher: Unknown" even ignoring signing |
Required .ico size set: 16, 24, 32, 48, 64, 256 (the 256 entry must be
PNG-compressed, not BMP, or older Windows fails to render it). Generate from
the existing crates/fresh-gui/resources/icon_*.png set; winresource will
embed the result as both RT_GROUP_ICON/1 and the small/large pair Windows
expects.
Required code-side calls:
#[cfg(windows)]
unsafe {
use windows::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID;
use windows::core::w;
let _ = SetCurrentProcessExplicitAppUserModelID(w!("dev.getfresh.Fresh"));
}
// ... before creating the winit EventLoop
The AUMID string must match the one the MSI uses for its shortcut, or the "running window" and "pinned shortcut" stay separate in the taskbar.
| Surface | Driven by | Failure mode |
|---|---|---|
| Finder / Get Info / drag-out from title bar | CFBundleIconFile → Resources/Fresh.icns | .icns missing required types (ic07/ic08/ic09/ic10/ic11/ic12/ic13/ic14) → blank for that size |
| Dock (running app) + Cmd-Tab switcher | Same CFBundleIconFile, unless overridden via [NSApp setApplicationIconImage:] | Stale Launch Services icon cache — Finder/Dock keeps the old icon across rebuilds |
| About dialog | CFBundleIconFile | — |
| Notification Center | CFBundleIconFile | — |
| Window proxy icon (the file glyph in the title bar) | Per-document, [NSWindow setRepresentedFilename:] | This is not the app icon — common confusion source |
Required .icns type set: iconutil -c iconset Fresh.icns must list all
of icon_16x16, icon_16x16@2x, icon_32x32, icon_32x32@2x,
icon_128x128, icon_128x128@2x, icon_256x256, icon_256x256@2x,
icon_512x512, icon_512x512@2x. Missing any of those → blank icon at that
size in some surfaces. The current create-app-bundle.sh does generate
the full set; verify the output, don't just trust the script.
Bundle-level requirements:
CFBundleIconFile set in Info.plist (currently: Fresh.icns ✓).CFBundleIdentifier stable across releases — Launch Services keys icon
cache by bundle ID, not path. Changing it strands the cached icon.LSApplicationCategoryType set so Finder picks the right "kind" badge.Both OSes aggressively cache icons; a "wrong icon after rebuild" report is
50/50 a real bug vs. a stale cache. Document these in RELEASING.md as
sanity steps before signing off on a build:
sudo rm -rf /Library/Caches/com.apple.iconservices.store && killall Dock Finder. Bumping CFBundleVersion between dev rebuilds also
forces re-cache.ie4uinit.exe -show, or delete %LOCALAPPDATA%\IconCache.db
and %LOCALAPPDATA%\Microsoft\Windows\Explorer\iconcache_*.db, then
restart explorer.exe.Run on a clean machine after a fresh install, with caches invalidated:
/Applications/Fresh.app shows the icon at
every Finder icon size (toggle View → as Icons → slider).Subsections that own pieces of this matrix: §1.3 (Windows .ico +
winresource), §1.4 (macOS bundle assembly), §3.1 (MSI ARPPRODUCTICON +
shortcut icon + AUMID registration), §2.5 + §3.4 (acceptance tests roll up
the icon checklist).
Goal: a cargo build --release --features gui binary that, when launched from
the OS shell, looks and behaves like a real native app — without yet worrying
about signing or auto-update.
Add a build.rs to crates/fresh-editor (or a new dedicated gui build
script gated on cfg(target_os = "windows") + the gui feature) that uses
embed-manifest to embed an XML
manifest declaring:
<dpiAware>PerMonitorV2</dpiAware> and <dpiAwareness> — prevents Windows
from bitmap-scaling the window on a 4K monitor (the cause of "blurry
Electron" complaints).<dependentAssembly> for
Microsoft.Windows.Common-Controls) — gives any native dialogs a modern
look.requestedExecutionLevel level="asInvoker" — prevents the heuristic UAC
prompt that fires on binaries whose names contain "setup", "install",
"patch", etc.Today the binary is a single fresh.exe that switches between TUI and GUI at
runtime. We cannot unconditionally set #![windows_subsystem = "windows"]
because that would also suppress the console for fresh --help and TUI
sessions launched from cmd/PowerShell.
Plan:
#![cfg_attr(all(windows, feature = "gui"), windows_subsystem = "windows")] on the fresh binary so the GUI build defaults to no console.hide_console_ng (or a small custom wrapper around
AttachConsole(ATTACH_PARENT_PROCESS) + FreeConsole) so that:
fresh.exe is launched from a terminal with no --gui, it
re-attaches the parent console and behaves as a CLI.fresh.exe, fresh-gui.exe)
is the alternative if hide_console_ng proves flaky on older Windows; keep
that as the fallback.Add winresource to the editor crate and wire it up in build.rs. Owns
the Explorer / Alt-Tab / taskbar rows of the icon matrix in §0.1.
crates/fresh-gui/resources/windows/fresh.ico from the existing
crates/fresh-gui/resources/icon_*.png set, containing all of
16/24/32/48/64/256 (256 must be PNG-compressed, not BMP). Missing sizes
are the most common cause of the "right in Explorer, wrong in Alt-Tab"
problem.FileVersion, ProductVersion,
CompanyName, LegalCopyright, OriginalFilename. These show up in
Explorer's Properties dialog and in SmartScreen's "do you want to run this"
prompt.SetCurrentProcessExplicitAppUserModelID(L"dev.getfresh.Fresh") at
the very top of main (before the winit EventLoop is constructed). This
is what makes the running window and the pinned-taskbar shortcut group
under the same icon — without it, Windows generates an AUMID from the exe
path and the two desync. The string must match the AUMID the MSI sets on
the shortcut in §3.1.Today gui-builds.yml produces two separate .pkg files (x86_64, aarch64).
For a polished release we should also ship a single universal .pkg:
Add a job downstream of the two macOS matrix jobs that consumes both target
binaries via actions/download-artifact and runs:
lipo -create -output Fresh.app/Contents/MacOS/fresh \
x86_64-apple-darwin/release/fresh \
aarch64-apple-darwin/release/fresh
Run pkgbuild against the merged bundle to produce
fresh-editor-gui-universal-${VERSION}.pkg.
Keep the per-arch .pkgs as well, but make the universal build the
default download in the release notes.
crates/fresh-gui/resources/macos/Info.plist currently hard-codes
<string>0.2.5</string> and the CI patches it via sed. Replace with a
template Info.plist.in containing __VERSION__ placeholders and a small
xtask/script that fills it from cargo metadata. Same for the AppStream
metainfo XML in the AppImage flow. This eliminates the silent drift we
already have between Cargo.toml (0.3.1) and Info.plist (0.2.5).
Goal: a notarized .dmg that double-clicks open on a fresh Mac with no
right-click "Open Anyway" workaround, and a universal binary that runs
natively on Apple Silicon and Intel.
Pre-requisites (one-time, owner-action):
Developer ID Application certificate and a Developer ID Installer certificate; export both as .p12.APPLE_CERT_P12_BASE64, APPLE_CERT_PASSWORD,
APPLE_INSTALLER_CERT_P12_BASE64, APPLE_INSTALLER_CERT_PASSWORD,
APPLE_TEAM_ID, APPLE_API_KEY_ID, APPLE_API_ISSUER_ID,
APPLE_API_KEY_P8_BASE64 (for notarytool's App Store Connect API auth —
preferred over an app-specific password).CI changes in gui-builds.yml:
Decode the .p12 files and import into a temporary keychain (security create-keychain + security import + security set-key-partition-list).
Replace the codesign --force --deep --sign - ad-hoc step with:
codesign --force --deep --options=runtime \
--entitlements crates/fresh-gui/resources/macos/Fresh.entitlements \
--sign "Developer ID Application: <Team Name> (<TEAM_ID>)" \
Fresh.app
--options=runtime enables Hardened Runtime, which is required for
notarization.
Sign the .pkg with the installer cert: productsign (or sign the
pkgbuild output with --sign "Developer ID Installer: ...").
The current Fresh.entitlements was written before signing was real. Audit
it before flipping on --options=runtime:
com.apple.security.cs.allow-jit — only if rquickjs/QuickJS or any
embedded interpreter actually needs JIT. QuickJS is an interpreter, not a
JIT, so this should be removed.com.apple.security.cs.allow-unsigned-executable-memory — same as above,
remove unless proven necessary by a runtime crash on a notarized build.com.apple.security.cs.disable-library-validation — keep only if we plan
to load unsigned plugins; if plugins are embedded into the binary, remove.com.apple.security.network.client — keep (auto-update + LSP downloads).com.apple.security.files.user-selected.read-write — keep.The smaller the entitlements set, the smoother notarization is.
Add a CI step after signing:
ditto -c -k --keepParent Fresh.app Fresh.zip # or use the .pkg
xcrun notarytool submit Fresh.zip \
--key ~/private_keys/AuthKey_${APPLE_API_KEY_ID}.p8 \
--key-id "${APPLE_API_KEY_ID}" \
--issuer "${APPLE_API_ISSUER_ID}" \
--wait
xcrun stapler staple Fresh.app
Stapling the ticket onto the bundle is essential — it lets Gatekeeper verify the notarization offline, so a user without internet can still launch the app the first time.
.pkg is fine for unattended installs but most users expect a .dmg with a
drag-to-Applications layout. Add create-dmg (Homebrew) or
dmgbuild to the macOS matrix:
codesign works on DMGs).Fresh-${VERSION}-universal.dmg as the headline macOS download.Manual checklist before tagging a release:
Fresh.app. No Gatekeeper dialog should
appear (or at most a one-shot "downloaded from internet" prompt that
resolves on its own).spctl --assess --type execute -vv /Applications/Fresh.app reports
accepted and source=Notarized Developer ID.codesign --verify --deep --strict /Applications/Fresh.app exits 0.lipo -info /Applications/Fresh.app/Contents/MacOS/fresh lists both
x86_64 and arm64.Goal: a signed installer that doesn't trigger SmartScreen on a fresh Windows machine, looks like a real app in Start Menu / Add-Remove Programs, and upgrades cleanly across versions.
We currently ship a bare fresh.exe. That is not a product — it has no
uninstaller, no Start Menu entry, no per-user vs. per-machine choice, and no
upgrade path. Pick one primary installer format and stick to it:
cargo-wix — recommended.
Native Windows Installer, integrates with group policy and SCCM, supports
silent install (msiexec /i Fresh.msi /qn), produces a stable
ProductCode GUID for upgrades.cargo-packager — alternative if we also want a single-exe
installer with a custom UI. Smaller footprint, but no per-machine GPO story.Action: add wix/main.wxs under crates/fresh-editor/, configure
cargo-wix with:
UpgradeCode GUID — generate once, never change. Drives the upgrade
story across every future release.INSTALLDIR Start Menu shortcut + Desktop shortcut (opt-in). Each
shortcut element must set Icon="fresh.ico" and
Arguments="--gui" (so a Start Menu launch goes to GUI mode). The
shortcut also needs an <MsiShortcutProperty Id="AppUserModelID" Value="dev.getfresh.Fresh"/> — same string as the runtime call in §1.3,
or the pinned shortcut and the running window stay un-grouped on the
taskbar.ARPPRODUCTICON so the entry in "Apps & Features" shows the Fresh icon.This block owns the Start Menu / pinned shortcut / taskbar grouping / Apps & Features rows of the icon matrix in §0.1. §1.3 owns the rest.
Ad-hoc / unsigned binaries are fine for development; for distribution they trigger the "Windows Protected your PC" SmartScreen wall, which kills adoption.
Recommended path: Azure Trusted Signing. It's a managed service that:
CI integration via trusted-signing-cli (or the official
Azure/trusted-signing-action):
Set up an Azure subscription, a Trusted Signing account, an identity validation, and a certificate profile (one-time, owner action).
Store as GitHub Actions secrets:
AZURE_TENANT_ID, AZURE_CLIENT_ID, AZURE_CLIENT_SECRET,
AZURE_TS_ENDPOINT, AZURE_TS_ACCOUNT, AZURE_TS_PROFILE.
After the MSI is built, run:
trusted-signing-cli sign \
--endpoint "$AZURE_TS_ENDPOINT" \
--account "$AZURE_TS_ACCOUNT" \
--certificate-profile "$AZURE_TS_PROFILE" \
Fresh-${VERSION}-x64.msi
Sign the inner fresh.exe before packaging it into the MSI, then
sign the MSI itself. Otherwise SmartScreen will warn on the unsigned exe
the moment the installer extracts it.
Fallback if Trusted Signing isn't approved in time for v1.0: use a self-purchased OV code-signing cert (DigiCert / SSL.com / Certum). It will work, but reputation has to be earned over weeks of installs.
For users who don't want an installer, also ship:
.zip containing fresh.exe + an empty data\ subfolder
whose presence flips the app into "portable mode" (config + plugins read
relative to the exe instead of %APPDATA%).chocolatey/fresh.nuspec + chocolateyInstall.ps1
that downloads the signed MSI from the GitHub release. The winget
publisher script already exists at scripts/winget-publish.py; mirror that
for choco. Both are nice-to-have, not blocking for v1.0.Manual checklist on a fresh Windows 11 VM:
Get-AuthenticodeSignature .\fresh.exe reports Valid.fresh.exe from cmd.exe with no args still prints help
to the parent console (dual-mode behaviour from §1.2).%APPDATA%\Fresh is preserved.Goal: when a user double-clicks foo.rs in Finder/Explorer (or runs fresh foo.rs from a shell) and Fresh is already open, the file opens as a new
buffer in the existing window instead of spawning a second app.
Use ipc-channel — the same crate
Servo uses. It picks the fastest native primitive automatically: Mach ports
on macOS, named pipes on Windows, Unix domain sockets on Linux.
Architecture (in fresh-gui or a new fresh-ipc crate):
EventLoop, try to bind a
well-known channel name:
dev.getfresh.Fresh.ipc (Mach service name).\\.\pipe\Fresh-${USERSID}.${XDG_RUNTIME_DIR}/fresh.sock.OpenFile { path } /
Activate {} messages to the editor via an EventLoopProxy::send_event
custom user event.OpenFile { path: argv[1..] } plus Activate, and exit(0)
without ever creating a window.Finder double-click and open -a Fresh foo.rs do not pass the file as
argv. They send the running app an NSApplicationDelegate application:openURLs: event. With our current setup we miss those events
entirely.
Plan: in crates/fresh-gui/src/macos/, install an NSApplicationDelegate
shim (using objc2-app-kit, which we'd add alongside objc2-foundation)
that overrides:
application:openURLs: — extract NSURL paths, push them into the same
OpenFile channel used by the IPC layer.applicationShouldHandleReopen:hasVisibleWindows: — when the user clicks
the dock icon and we have no visible window, recreate one.This makes "drag a file onto the dock icon" and "double-click .rs in
Finder" work the same way as the IPC handoff in §4.1.
The MSI from §3.1 should register fresh.exe as a handler for an opt-in
list of extensions (.md, .rs, .ts, .json, …). Explorer will then
launch fresh.exe "C:\path\to\foo.rs". Combined with §4.1, this routes the
path into the running instance.
Use HKCU\Software\Classes\Applications\fresh.exe\shell\open\command rather
than hijacking the global HKCR\.rs mapping — users hate editors that
"steal" file associations on install.
fresh README.md.
The existing window focuses and opens README.md as a new buffer; no
second process appears in Activity Monitor / Task Manager..rs file → "Open with Fresh" → opens in the
existing window.Goal: when a production user hits a crash, we know within minutes — with a backtrace, OS, GPU adapter, and a breadcrumb trail of recent events — and we can ship a fix before they file an issue.
tracing to a rotating file sinkIn TUI mode, tracing output goes to stderr. In GUI mode there is no
stderr — Explorer/Finder swallows it. Wire tracing-appender to a daily
rotating file:
~/Library/Logs/Fresh/fresh.log (Console.app picks this up
automatically).%LOCALAPPDATA%\Fresh\logs\fresh.log.${XDG_STATE_HOME:-~/.local/state}/fresh/fresh.log.Initialize the sink in main.rs before tokio::runtime::Runtime::new()
so wgpu adapter selection logs are captured. Cap retention at 7 days /
50 MB so the log directory doesn't grow unbounded.
Add a "Help → Reveal Log File" menu item (already an open slot in muda)
that calls open / Finder.app / explorer.exe on the log directory —
makes "send me your log" a one-click ask in bug reports.
sentry panic and error reportingAdd sentry + sentry-tracing as optional deps gated on a telemetry
feature flag (default-on for release builds, default-off for dev builds and
for users who set FRESH_TELEMETRY=0).
Initialization order matters:
fn main() {
let _sentry = std::env::var("FRESH_TELEMETRY").map(|v| v != "0").unwrap_or(true)
.then(|| sentry::init((SENTRY_DSN, sentry::ClientOptions {
release: sentry::release_name!(),
traces_sample_rate: 0.0, // no perf tracing for now
send_default_pii: false, // no usernames, no file paths
attach_stacktrace: true,
..Default::default()
})));
// tracing → sentry breadcrumbs
tracing_subscriber::registry()
.with(file_layer)
.with(sentry_tracing::layer())
.init();
let rt = tokio::runtime::Builder::new_multi_thread().enable_all().build().unwrap();
rt.block_on(run_gui());
}
The _sentry guard must outlive main so its async flush runs on Drop.
Productizing telemetry is a trust contract. Document it explicitly:
config.json. Default is opt-in but the user can revoke any time from
Settings.sentry::ClientOptions::before_send hook that
strips $HOME and $USERNAME from any captured paths and replaces them
with ~ / <user>. File contents must never be uploaded.docs/privacy.md with the exact list of fields
Sentry receives — release version, OS, GPU adapter name, panic message,
redacted stack frames, breadcrumb log lines.Add a "Help → System Info" menu item that opens a modal with:
wgpu::AdapterInfo (vendor, device, driver, backend).This is a force-multiplier on bug reports: 90 % of GPU-related issues are "Intel UHD on Windows 10 with driver X" and the user has no way to know that without it.
Goal: a user running v(N) sees a non-blocking notification within hours of v(N+1) shipping, and one click installs it.
Two channels: stable and beta. Each is a small JSON manifest hosted on
GitHub Pages (the existing homepage/ deploy target works fine):
{
"version": "0.4.0",
"pub_date": "2026-05-12T15:00:00Z",
"platforms": {
"darwin-universal": {
"url": "https://github.com/sinelaw/fresh/releases/download/v0.4.0/Fresh-0.4.0-universal.dmg",
"signature": "<minisign signature over the file>"
},
"windows-x86_64": {
"url": "https://github.com/sinelaw/fresh/releases/download/v0.4.0/Fresh-0.4.0-x64.msi",
"signature": "<minisign signature over the file>"
}
}
}
The signature is verified against a public key embedded in the binary at compile time. This is the single most important property of an auto-updater: even if GitHub's CDN is compromised, an attacker cannot push a malicious update because they don't have the minisign secret key.
Use cargo-packager-updater
or roll a thin wrapper using minisign-verify + reqwest + the OS-native
installer launcher. The crate is the safer default — it already handles:
If-Modified-Since..app bundle in place via mv then relaunch.msiexec /i Fresh.msi /qb /norestart
(passive UI; small progress bar, no prompts), then exit so Windows
Installer can replace the running exe.Update check schedule: on startup (delayed 30s so it doesn't slow launch)
and every 6 hours thereafter via a tokio interval. Network errors are
silent — failed checks must never prompt the user or block the UI.
config.json so the banner doesn't
re-appear for the same release.Goal: text is crisp on every supported monitor configuration, including the awkward cases (mixed-DPI multi-monitor setups, dragging the window between a 1080p panel and a 4K external display).
crates/fresh-gui/src/lib.rs currently handles WindowEvent::Resized but
not WindowEvent::ScaleFactorChanged. Add a handler:
WindowEvent::ScaleFactorChanged { scale_factor, .. } => {
let physical = self.window.inner_size();
self.surface.configure(&self.device, &SurfaceConfiguration {
width: physical.width,
height: physical.height,
..self.surface_config.clone()
});
let cell_px = self.font.cell_size_for_scale(scale_factor);
let cols = (physical.width as f32 / cell_px.width ) as u16;
let rows = (physical.height as f32 / cell_px.height) as u16;
self.app.resize(cols, rows);
self.window.request_redraw();
}
Two invariants must hold:
PhysicalSize).ratatui-wgpu exposes a Font builder; we currently build it once at
startup. For HiDPI we need to either:
Pick the first option for v1.0 — it's the only one that produces crisp
text on Windows's common 125% / 150% scales. Cache the atlas keyed by
(font_size_px.round() as u32) so toggling between two monitors doesn't
re-rasterize on every frame.
ratatui's character grid is integer-aligned by definition, so subpixel glyph positioning is not in scope — but subpixel anti-aliasing is. Confirm that the embedded JetBrains Mono atlas is rendered with the glyphon / fontdue settings that produce LCD-subpixel output on Windows (where ClearType is the user expectation) and grayscale AA on macOS (where Apple removed subpixel AA in 10.14).
Goal: hit the threshold where the app feels native rather than ported.
Most of the macOS-specific items live in MACOS_TODO.md; this section
captures the cross-platform parity work.
Use dark-light (cross-platform,
~20 LOC of glue) to read the OS appearance and forward it to the editor's
theme system. Subscribe to changes:
NSDistributedNotificationCenter for
AppleInterfaceThemeChangedNotification.WM_SETTINGCHANGE with SPI_SETCLIENTAREAANIMATION.org.freedesktop.portal.Settings.When the user has theme: "system" in config.json, switch palettes live
without a restart.
<filename> — Fresh and a "modified" indicator
(a bullet on macOS via setDocumentEdited:, a * prefix on Windows).setRepresentedFilename: does this.NSDocumentController's
noteNewRecentDocumentURL: on macOS and the Windows
SHAddToRecentDocs API. The "Open Recent" submenu (already a TODO in
MACOS_TODO.md) reads from these system stores.Right now muda accelerators on Windows fire through a separate event
loop and can race with winit's keyboard input. Adopt the
EventLoopProxy<UserEvent> pattern:
MenuEvents are forwarded to a single UserEvent::Menu(id).UserEvent::Menu dispatch.This eliminates the class of bugs where Cmd-S triggers Save twice (once from the menu, once from the keymap).
.fresh-tour.json) and the docs.Goal: a single git tag v0.4.0 && git push --tags produces signed,
notarized, downloadable artifacts on every supported platform within ~30
minutes, with no human in the loop except the secret-holder for emergency
overrides.
Extend the existing .github/workflows/release.yml so that on a tag push:
gui-builds.yml, extended):
fresh.exe.lipo them into a
universal binary, build the .app, sign with Developer ID, build
.pkg and .dmg, sign the DMG, notarize, staple..exe and the MSI
itself via Trusted Signing.latest-stable.json with version + signed
download URLs + minisign signatures, sign it with the update key,
upload to the GitHub Pages site.release.yml): create the GitHub release, attach
all artifacts, run downstream homebrew/winget/AUR/npm publish jobs.fresh --version,
and reports green/red. Catches "we forgot to sign one of them" the
same hour the release ships.All signing secrets live as GitHub Actions repository secrets, never in
the repo. Group them under environment protection rules (production)
that require manual approval on tag pushes — so a compromised PR can't
exfiltrate them via a workflow change.
Document the rotation procedure in docs/internal/:
.p12 secrets
and the APPLE_TEAM_ID reference.crates/fresh-gui/src/updater_pubkey.minisig.pub and embedded into the
binary via include_bytes!. Compromise → cut a new key, ship a
bridge-release signed with both old and new keys.For risk reduction, run every commit to main through the same pipeline
but publish to latest-beta.json (signed with the same key, published to
the beta channel). The auto-updater opt-in makes this safe — only users
who flip "Settings → Updates → Use beta channel" see them. Internal
dogfooding catches signing/notarization regressions before they hit the
stable channel.
A minimal RELEASING.md lives next to this plan once Phase 9 lands.
Per-release manual gates:
Cargo.toml workspace version bumped.The phases are listed in priority order, but they parallelize naturally:
| Milestone | Phases | Outcome |
|---|---|---|
| M1 — Native polish | 1, 7 | A cargo build --release --features gui binary that looks native on every monitor and doesn't flash a console. No signing, no auto-update. Internal alpha. |
| M2 — Signed installers | 2, 3 | Notarized DMG + signed MSI. Users can install Fresh from a download link without scary OS warnings. Public beta. |
| M3 — Production runtime | 4, 5 | Single-instance + telemetry. We can debug user crashes and the app feels like a real editor when files are double-clicked. v1.0. |
| M4 — Continuous delivery | 6, 8, 9 | Auto-updates, system theme, recent files, automated release pipeline. Steady-state product. |
The two phase-spanning blockers worth surfacing early are secret
provisioning (Apple Developer enrollment, Azure Trusted Signing setup,
minisign keypair generation — all owner-only actions, all multi-day
turnaround) and the GPU regression risk in Phase 7 — ratatui-wgpu is
a git-only dependency on a young commit, and changing its font/scale
plumbing may require upstream patches before HiDPI is solid.
Both should be kicked off in parallel with Phase 1, not deferred to their own milestones.