An index by app, then one section per crate: what each app deletes, and what it calls instead. Each move is its own change in the app's repository, made after the crate lands here. Paths are relative to the app's repository root; line counts are estimates taken when the crate was extracted.
Depend on a pinned revision:
fastframe-<name> = { git = "https://github.com/crmne/fastframe", rev = "<commit>" }Keep behaviour: each section lists where the crate differs from what an app did, so the app either accepts the change on purpose or keeps its own code for that part.
fastframe needs Rust 1.98 (rust-version in every crate). An app pinned to
an older toolchain in rust-toolchain.toml fails to resolve on macOS and
Windows with "rustc 1.97.1 is not supported by fastframe-*"; bump the app's
toolchain and rust-version to 1.98 first. Keep the app's [patch.crates-io]
block for the egui and winit forks: patches only apply at the app's root.
The getting started guide has the revisions to use.
Which crates each app adopts, and which it leaves for later. Each crate's section below has a subsection per app with the details.
| App | Adopts | Not now |
|---|---|---|
| ZapFast | text, fonts (with the system's face), emoji, icons, theme, i18n, log, tray, shell, titlebar, update | |
| Spotifast | autostart, text, fonts, icons, theme, i18n, log, tray, shell, titlebar, update | emoji until it lays out colour emoji (it keeps Inter and its monochrome face for now) |
| Solco | text, fonts, icons, titlebar (macOS only); update (with the pre-release channel); log (only the facade-free redaction and panic line, with default-features = false) later |
theme and i18n until it adds custom themes or translations; shell (no tray or background mode); its tracing logger stays |
| TonePush | text, fonts, update (with portable_executable) |
icons later (its own macros and layout); log (it uses eprintln!); shell; theme and i18n until it adds custom themes or translations |
| Chat with Work Local Agent | text | fonts (it draws with the platform's UI font); tray and shell (one winit loop with pump_app_events, tray-icon 0.25); log until it wants a log file; theme and i18n until it adds custom themes or translations |
Shared widgets have not moved; see Not moved yet: widgets.
Follows the desktop's font rendering. Every app replaces its hand-set
hinting and color_transfer_function with it:
let rendering = fastframe_text::detect(); // once per process, outside a tokio runtime
rendering.apply_to(&mut fonts); // before `ctx.set_fonts`
ctx.all_styles_mut(|style| rendering.apply_to_visuals(&mut style.visuals));- Linux reads the desktop portal, then fontconfig. With the usual slight hinting this is egui's default hinting with sub-pixel positions, and linear coverage in both themes, which matches GTK's weight.
- macOS is unhinted and Windows slightly hinted; both keep egui's coverage per theme, so dark text there is a little heavier than on Linux.
- Apps that shift a galley by hand (ZapFast's selectable bubbles, Spotifast's
right-to-left runs) round both ends with
snap_to_pixels. - The
watchfeature follows portal changes live; callapply_toandctx.set_fontsagain from its callback.
Adopted by all five apps: ZapFast, Spotifast, Solco, TonePush and Chat with Work (58da999, which applies it to the system UI font it uses).
Bundled Inter (or, with Primary::System, the platform's interface face)
at the app's weights, the monospace choice, companions such as a bundled
emoji face, and the system fallback faces with their baseline and Arabic
adjustments. Each app keeps its sizes and text styles, its own
extra faces (ZapFast's colour emoji overlay, Spotifast's skin font and
playlist face, TonePush's Plex Mono file), and when it installs fonts.
Everywhere, the helpers become one-liners or go away:
pub fn regular(size: f32) -> egui::FontId { fastframe_fonts::Weight::Regular.font_id(size) }
pub fn medium(size: f32) -> egui::FontId { fastframe_fonts::Weight::Medium.font_id(size) }
pub fn semibold(size: f32) -> egui::FontId { fastframe_fonts::Weight::SemiBold.font_id(size) }
pub fn bold(size: f32) -> egui::FontId { fastframe_fonts::Weight::Bold.font_id(size) }The family names inter-medium, inter-semibold and inter-bold and the
regular key inter are the ones ZapFast and Spotifast already use.
| Delete | Lines | Instead |
|---|---|---|
src/system_fonts.rs |
462 | fastframe_fonts::system::fallbacks() (used by FontSetup) |
src/theme.rs install_fonts |
52 | FontSetup::default().install(ctx), or .definitions() then ctx.set_fonts if the app adjusts them (for example with fastframe-text) |
src/theme.rs INTER_MEDIUM, INTER_SEMIBOLD, INTER_BOLD |
3 | Weight::*.family() / Weight::*.name() |
src/theme.rs test inter_figures_are_tabular |
20 | the crate's figures_are_tabular |
assets/fonts/InterVariable.ttf, assets/fonts/README.md |
2 files | fastframe_fonts::INTER and the crate's fonts/README.md. Keep Inter-LICENSE.txt if packaging ships it from there. |
Cargo.toml memmap2 |
1 | the crate (keep skrifa: emoji.rs and the demo use it) |
About 540 lines. Demo and screenshot builds call .system_fallbacks(false)
if they want output independent of the machine's fonts (the current code
always adds them).
Behaviour that changes, all from Spotifast's newer copy: fallbacks are moved
onto Inter's baseline (y_offset_factor); macOS asks CoreText instead of
scanning /System/Library/Fonts (which never finds PingFang); on Linux the
directories fontconfig's configuration names are probed too (NixOS); a
Japanese face no longer serves Han on a Chinese desktop when a Chinese one
exists; Windows reads its display language for the Han cut; Yi and the ★
probe are added. The ♡ probe ZapFast called symbols is now suits, so its
font data key becomes fallback-suits when a different face than the one for
★ covers it.
| Delete | Lines | Instead |
|---|---|---|
src/system_fonts.rs except pledit_face, find_family, walk_fonts and rank_family |
800 | fastframe_fonts::system::fallbacks(); the playlist face walks fastframe_fonts::system::font_directories() |
src/mac_fonts.rs |
485 | the crate's macOS module (the same CoreText code) |
src/theme.rs install_fonts |
76 | FontSetup::default().companion("noto_emoji", Arc::new(FontData::from_static(include_bytes!("../assets/fonts/NotoEmoji.ttf")))).definitions(), then ctx.set_fonts |
src/theme.rs fallback_baseline_y_offset and its three tests |
47 + 70 | the crate's baseline_offset and fallback_glyphs_are_painted_on_the_latin_baseline |
src/theme.rs INTER_* constants, inter_figures_are_tabular |
25 | as for ZapFast |
assets/fonts/InterVariable.ttf, assets/fonts/README.md |
2 files | the crate |
tests/fixtures/yi/ |
3 files | the crate's copy, used by a_font_that_draws_a_yi_name_is_chosen |
Keep memmap2: the playlist face lookup that stays in Spotifast maps font
files with it. About 1,500 lines. milkdrop/overlay.rs and ui/winamp/pixel_text.rs read
fastframe_fonts::system::fallbacks() instead; Fallback gains scale and
y_offset_factor, which they may ignore. Code that looked up
system_fonts::FALLBACK_SCRIPTS for a probe character uses the fallback's
own face instead (the list is internal to the crate now).
Behaviour that changes, from ZapFast's copy: a small Arabic face is enlarged up to 25% to match Inter's x-height, and Javanese, mathematical alphanumerics, enclosed alphanumerics and ♡ get fallbacks too.
| Delete | Lines | Instead |
|---|---|---|
src/theme.rs install_fonts |
38 | FontSetup::default().weights(&[Weight::SemiBold]).monospace(Monospace::Inter).install(context) |
src/theme.rs SEMIBOLD |
1 | Weight::SemiBold.family() in semibold(size) |
assets/fonts/InterVariable.ttf, assets/fonts/README.md |
2 files | the crate |
About 40 lines. Solco has no system fallbacks today: the default adds
them, so track names in CJK, Arabic or Indic scripts stop drawing as boxes.
Pass .system_fallbacks(false) to keep today's behaviour. The font data keys
change from solco-inter to inter.
| Delete | Lines | Instead |
|---|---|---|
src/theme.rs fonts (the loop and family set-up) |
45 | FontSetup::default().weights(&[Weight::SemiBold]).monospace(Monospace::Font { name: "plex-mono".into(), data: Arc::new(FontData::from_static(include_bytes!("../assets/fonts/IBMPlexMono-Regular.ttf"))) }).install(ctx) |
assets/fonts/Inter-Regular.ttf, assets/fonts/Inter-SemiBold.ttf |
2 files | fastframe_fonts::INTER at 400 and 600 |
About 45 lines. The SEMIBOLD family name changes from semibold to
inter-semibold (use Weight::SemiBold.family()). Inter's figures become
tabular in labels too, and system fallbacks are added unless turned off.
It draws with the platform's UI font and bundles none; nothing to move.
Colour emoji from the platform's font (Apple Color Emoji, Segoe UI Emoji, the desktop's bitmap emoji font) with the app's bundled font behind it, laid out as placeholders and drawn on a worker thread. See the crate's DESIGN.md.
| Delete | Lines | Instead |
|---|---|---|
src/emoji.rs font loading, ligature table, bitmap decoding and texture cache |
about 450 | EmojiSetup::default().bundled(NOTO).synchronous(demo).install() and fastframe_emoji::warm_up on the thread that already warms the font |
src/emoji.rs pieces, is_emoji, only_emoji, append, editor_job, paint, paint_cluster, placeholder_rects, PLACEHOLDER |
about 250 | the same names in fastframe_emoji (a module that re-exports them keeps the call sites) |
Behaviour that changes: emoji are drawn in the platform's style on macOS and Windows (flags on Windows still come from the bundled Noto); on Linux fontconfig's emoji face is used wherever it is installed; a new emoji appears one frame after its text rather than stalling the frame that shows it; pictures are framed in each font's cell (Noto's is the same as before).
Keeps Inter and its monochrome NotoEmoji.ttf companion until it lays
out colour emoji in egui text with append and paint. Its own renderers
(milkdrop/overlay.rs, ui/winamp/pixel_text.rs) can call
fastframe_emoji::get().render(cluster, height) for a premultiplied
picture, off the interface thread, and keep the monochrome face as their
fallback.
The icons! macro, the icon enum it now generates, the bytes loader, and the
43 Lucide files ZapFast and Spotifast ship byte for byte alike. Each app keeps
its own SVG files, its icon widgets (icon, paint_icon, icon_button),
and its sizes and tints.
Every app does the same:
-
Replace the
macro_rules! icons, thepub enum Icon { .. }list, theconst ICONS: &[(Icon, &str, &[u8])] = icons! { .. }table,impl Icon(uri,image) andregister_iconswith one block:fastframe_icons::icons! { pub enum Icon { prefix: "zapfast-icon-", // the app's current bytes:// prefix directory: "../assets/icons/", Archive => "archive", // the old table's entries, unchanged Check => lucide "check", // a shared icon: see step 3 // ... } }
-
Call
fastframe_icons::install::<Icon>(ctx)whereregister_icons(ctx)was called (afteregui_extras::install_image_loaders). -
For each of the 43 shared names (
fastframe_icons::lucide::ALL), writelucide "name"and deleteassets/icons/<name>.svg. Keepassets/icons/LICENSE.txt: the app still ships its own Lucide files. -
Code that iterated
ICONSusesIcon::ALLwithicon.uri()andicon.bytes().
| Delete | Lines | Instead |
|---|---|---|
src/theme.rs macro_rules! icons |
9 | the crate's icons! |
src/theme.rs pub enum Icon (variant list) |
78 | generated by icons! |
src/theme.rs impl Icon { uri, image } |
14 | generated |
src/theme.rs struct IconBytes and its BytesLoader impl, register_icons |
38 | fastframe_icons::install::<Icon>(ctx) (the same never-forget loader) |
src/theme.rs tests every_icon_has_a_file, icon_bytes_outlive_forgetting |
40 | covered by the crate's loader tests |
assets/icons/*.svg: the 43 shared names |
43 files | lucide "name" |
About 180 lines of Rust and 43 files. The ICONS table becomes the
icons! block (the same length).
| Delete | Lines | Instead |
|---|---|---|
src/theme.rs macro_rules! icons |
9 | the crate's icons! |
src/theme.rs pub enum Icon |
92 | generated |
src/theme.rs impl Icon { uri, image } |
14 | generated |
src/theme.rs register_icons (ctx.include_bytes per icon) |
5 | fastframe_icons::install::<Icon>(ctx) |
assets/icons/*.svg: the 43 shared names |
43 files | lucide "name" |
About 120 lines and 43 files. Behaviour change: icons are served by the
never-forget loader instead of ctx.include_bytes, which fixes the red
placeholder if Spotifast ever turns on reduce_texture_memory.
| Delete | Lines | Instead |
|---|---|---|
src/theme.rs macro_rules! icons |
9 | the crate's icons! with prefix: "solco-" |
src/theme.rs enum Icon |
57 | generated (declare it pub(crate) enum Icon or keep it private, as now) |
src/theme.rs impl Icon { uri }, register_icons |
13 | generated, fastframe_icons::install::<Icon>(context) |
About 80 lines. Its SVGs are minified lucide-static files that differ
from the shared ones byte for byte, so it keeps all of them. Its draw-time
helpers keep their shape: icon_image(icon, color, size) becomes
icon.image(color, size) (the same egui::Image, tinted and sized), and
icon(ui, ..), icon_button and transport_button keep calling
icon.uri().
Its UI and category icons use their own macros and file layout. It can move
to icons! later; nothing is shared with it yet.
Palette files, the background catalogue, the shared palettes, Omarchy
following and file watching. Each app keeps its Palette struct and its
dark and light defaults, theme::apply (the mapping onto egui::Visuals
and widget styling), the Settings picker and its wording, the
reload-themes command on its single-instance channel, and the packaging
files in contrib/omarchy/.
Every app does the same:
impl fastframe_theme::Palette for Palette:basereturnsPalette::dark()orPalette::light();setis the oldmatch namefromparse_palette, returningfalsefor unknown names; ZapFast'sderiveholds its "Spotifast palettes share the sixteen interface colours" block.theme::custom::CustomThemebecomesfastframe_theme::CustomTheme<Palette>andtheme::custom::Catalogbecomesfastframe_theme::Catalog<Palette>. The serialized cache (custom_theme_cache,system_theme_cache) keeps its shape, so existingsettings.jsonfiles read as before; pointdeserialize_withat"fastframe_theme::read_cached_theme".theme::custom::labelbecomesfastframe_theme::display_name.Catalog::start(directory, selected, &waker)takes afastframe_theme::Waker: build one once withfastframe_theme::Waker::new({ let waker = self.waker.clone(); move || waker.wake() }).- The status line:
catalog.status(selected)returnsStatus::Loading,Status::SelectedUnavailableorStatus::Problem(..); the app maps each to its (translated) sentence. The sentences both apps use today: "Loading local themes…", "The selected theme is unavailable. Keeping the last usable appearance. See the log for details.", and perProblem:Unreadable"The themes folder could not be read. See the log for details.",TooManyEntries"The themes folder has more than 512 entries. Keep fewer files there to list the custom palettes.",TooManyThemes"Only 128 custom palettes can be listed. Keep fewer JSON files in the themes folder to see the rest.",LoaderFailed"Custom themes could not be loaded. Run reload-themes to try again.",OmarchyUnreadable"The Omarchy palette could not be loaded. Keeping the last usable appearance. See the log for details." - Add a test that the shipped hook has not drifted:
assert_eq!(include_str!("../contrib/omarchy/<slug>-theme"), fastframe_theme::omarchy::hook_script("<slug>"));
| Delete | Lines | Instead |
|---|---|---|
src/theme/custom.rs |
794 | the crate; keep only the Palette impl (about 45 lines, in theme.rs) |
src/theme/omarchy.rs |
390 | fastframe_theme::omarchy (setup, rendering, install), used by the catalogue |
src/theme/presets.rs |
95 | fastframe_theme::presets (presets::themes::<Palette>() in the demo and in bubble_text_is_readable_in_every_palette). Keep spotifast_palettes_also_colour_the_conversation as an app test of derive. |
src/theme/watch.rs |
79 | the crate's watcher |
assets/themes/*.json |
8 files | fastframe_theme::presets::FILES |
tests/fixtures/omarchy/ |
5 files | the crate's fixtures (they render the base template; keep ZapFast's own if it wants a test of its extended template through fastframe_theme::omarchy::render_seed::<Palette>) |
Cargo.toml notify |
1 | the crate (Linux only) |
About 1,300 lines, less the 45-line Palette impl and a 25-line status
mapping. In App::new: app.custom_themes.enable_desktop_themes(DesktopThemes { slug: "zapfast", omarchy_template: include_str!("../contrib/omarchy/zapfast.json.tpl"), omarchy_previous_templates: &[], presets: true }).
Behaviour is unchanged.
| Delete | Lines | Instead |
|---|---|---|
src/theme/custom.rs |
684 | the crate; keep the Palette impl (about 30 lines) and Problem::text becomes the status mapping above |
src/theme/omarchy.rs |
328 | fastframe_theme::omarchy, except the fastpotify hook upgrade and the legacy-profile check (see below) |
tests/fixtures/omarchy/ |
5 files | the crate's fixtures (identical) |
About 950 lines. entrypoint.rs calls
app.custom_themes.enable_desktop_themes(DesktopThemes { slug: "spotifast", omarchy_template: include_str!("../contrib/omarchy/spotifast.json.tpl"), omarchy_previous_templates: &[], presets: true })
(presets: true adds the eight shared palettes to its picker, which it does
not show today). Keep in the app, run before that call on Linux:
- the upgrade of an installed hook that still says
/fastpotify/themes(the block at the top ofSetup::install, about 20 lines), and - skipping
enable_desktop_themeswhen the themes directory is the legacyfastpotifyone during an updater trial.
tests/omarchy.rs (which runs the shipped hook with bash) can stay, or give
way to the hook_script equality test.
Behaviour that changes, from ZapFast's newer copy: Omarchy is followed
whenever it is set up, not only when a package installed the assets, and
its palette is read from Omarchy's rendering (or rendered from its colours)
rather than from themes/omarchy.json alone; the themes folder and the
current Omarchy theme are watched, so palettes reload without the hook or a
restart.
Solco and TonePush draw one fixed dark palette, and Chat with Work
follows the platform's light or dark setting; none reads palette files.
They can adopt the crate by implementing Palette when they add custom
themes or Omarchy support.
The PO compiler, the lookup functions, language tag parsing, and the
template update script. Each app keeps its Locale enum, its tags, native
names, language picker, settings.json format, and its own phrase helpers
(Spotifast's song_count and friends).
| Delete | Lines | Instead |
|---|---|---|
build_support/catalogs.rs |
45 | fastframe_i18n::build::compile (used by the next row) |
build.rs: the catalogs module, the two rerun-if-changed lines, and the loop over assets/i18n that writes catalogs.rs |
30 | fastframe_i18n::build::compile_catalogs("assets/i18n"); at the top of main |
Cargo.toml [build-dependencies] include-po, polib |
4 | fastframe-i18n = { ..., features = ["build"] } as a build-dependency |
Cargo.toml tr |
1 | nothing: generated catalogs implement fastframe_i18n::Translator |
src/i18n.rs: Locale::translator, gettext, pgettext, ngettext |
45 | impl fastframe_i18n::Locale for Locale { fn catalog(self) ... } (the old translator body) and pub use fastframe_i18n::{gettext, ngettext, pgettext}; |
src/i18n.rs: the subtag split in Locale::from_system |
6 | fastframe_i18n::LanguageTag::parse; match on tag.language |
src/i18n.rs: sys_locale::get_locale() in detect |
4 | fastframe_i18n::detect(from_tag) (keep the cfg!(test) guard in the app). This walks every preferred language, not only the first: a desktop listing Norwegian then German now gets German. |
Cargo.toml sys-locale |
1 | comes with the crate |
.github/scripts/update-translations.sh |
32 | a wrapper calling the crate's scripts/update-translations.sh --package ZapFast --domain zapfast --bugs '<translation issue URL>' --keyword translated:2 --fuzzy-matching "$@" (see the crate README for finding the script). Drop --fuzzy-matching to adopt Spotifast's choice. |
About 130 lines. Call sites (crate::i18n::gettext(app.locale, ..)) stay as
they are because crate::i18n re-exports the functions. The tests that
check real catalogs (german_catalog_translates_the_pilot and the plural
tests) stay in the app.
| Delete | Lines | Instead |
|---|---|---|
build_support/catalogs.rs |
45 | fastframe_i18n::build::compile |
build.rs: the catalogs module, its rerun-if-changed lines, the loop over assets/i18n, and the tests/fixtures/translation.po compile with test_catalog.rs |
45 | fastframe_i18n::build::compile_catalogs("assets/i18n"); |
tests/localization.rs: compiled_po_omits_unfinished_messages_and_uses_locale_plural_rules, and tests/fixtures/translation.po |
30 + 32 | covered by fastframe-i18n (build tests and the tests/i18n-app fixture). Keep catalogs_cover_the_template_and_preserve_named_placeholders (it needs polib as a dev-dependency still). |
Cargo.toml [build-dependencies] polib, include-po; tr |
5 | the crate, as above |
src/i18n.rs: Locale::translator, gettext, pgettext, ngettext |
50 | impl fastframe_i18n::Locale for Locale and pub use fastframe_i18n::{gettext, ngettext, pgettext}; |
src/i18n.rs: the tag splitting at the top of from_language_tag, and from_preferred |
20 | LanguageTag::parse(tag) then the existing match on language, script, region; fastframe_i18n::first_supported(tags, ..) |
src/i18n.rs: the OnceLock and sys_locale::get_locales() in from_system |
8 | fastframe_i18n::detect(..) (cached per process in the crate) |
.github/scripts/update-translations.sh |
33 | a wrapper: --package Spotifast --domain spotifast --bugs '<translation issue URL>' (no fuzzy matching is the default) |
About 200 lines. Locale::from_tag keeps using clap::ValueEnum.
No translations yet. Start with the crate when they add one.
The logger (stderr plus a per-run file), the panic hook, and redaction
helpers. Each app keeps its default filter strings (and their tests), its
paths (log_file, panic_log), and its own redactors.
| Delete | Lines | Instead |
|---|---|---|
src/main.rs: the env_logger::Builder block, the File::create/Tee target, and the logger.format(..) closure |
33 | fastframe_log::Logging::new("zapfast", env!("CARGO_PKG_VERSION")).filter(default_log_filter(cli.verbose)), then .file(dirs.log_file()) unless demo, .panic_log(dirs.panic_log()), .redact(redact_protocol), .init() |
src/main.rs: struct Tee and its Write impl |
15 | inside the crate |
src/main.rs: fn log_panics |
25 | .panic_log(..) above (same line format, payload still omitted) |
src/backend/worker/stickers.rs: fn redacted |
14 | fastframe_log::redact::words(&error, |w| redact::is_link(w) || w.contains("/v/") || w.contains("oh=")); keep its test against the crate call |
Cargo.toml env_logger |
1 | comes with the crate (log stays) |
Keep src/diagnostics.rs; add a three-line adapter for .redact:
fn redact_protocol(record: &log::Record<'_>, message: &str) -> Option<Cow<'static, str>> {
(is_protocol_target(record.target())
|| is_protocol_target(record.module_path().unwrap_or_default()))
.then(|| protocol_summary(message).into())
}About 90 lines. Keep default_log_filter and its two tests.
Differences: init also logs Starting zapfast <version> on <os> (<arch>)
at info, which the file keeps at the default filter. A log file that cannot
be created is reported through the logger (on stderr) instead of
eprintln!.
| Delete | Lines | Instead |
|---|---|---|
src/entrypoint.rs: the env_logger::Builder block, the log-file match, logger.init(), and the Starting Spotifast ... line |
20 | fastframe_log::Logging::new("spotifast", env!("CARGO_PKG_VERSION")).filter(default_filter).file(dirs.log_file()).panic_log(dirs.panic_log()).panic_message(PanicMessage::Redacted(fastframe_log::redact::links)).init() |
src/entrypoint.rs: struct Tee and its Write impl |
15 | inside the crate |
src/entrypoint.rs: fn log_panics |
25 | .panic_log(..) with .panic_message(..) above |
Cargo.toml env_logger |
1 | comes with the crate |
About 60 lines. http.rs keeps error.without_url() (reqwest does it
better on its own errors); redact::links is for errors that are already
strings.
Differences, all deliberate:
- The panic log keeps the payload, with links removed
(
PanicMessage::Redacted(redact::links)), on one line after the location. Spotifast wrote{info}with the raw payload; a payload can quote the data being handled, such as a URL with a token. The default hook is no longer chained, so the raw payload is not printed to stderr either. (ZapFast keepsPanicMessage::Omit, the default.) - Log lines use
[time LEVEL target] messagewith the level unpadded (env_logger's default pads it and colours it on a terminal). - The start line says
spotifast(the name passed toLogging::new, which the panic line also uses) where it saidSpotifast.
Not moving the logger now. Solco logs through tracing-subscriber
(an EnvFilter in main.rs, stderr only) and records panics as structured
session evidence (src/diagnostics/sessions.rs), which chains the previous
hook and already never captures the payload. It can take the facade-free
parts with default-features = false: redact::links/words for error
strings, and, if it wants a human-readable panic line next to its session
records, log_panics installed before Session::install_panic_hook (so the
session hook chains it). Its logger stays as it is; the crate has no
tracing subscriber until a second app logs through tracing.
TonePush uses eprintln!. Chat with Work logs to stderr only and reads
CWW_APP_LOG, not RUST_LOG; it can adopt the crate once it wants a log
file, which would change its variable.
The tray item on all three platforms, with the menu supplied by the app.
Each app keeps its menu labels (and translations), its icon drawing
(util::app_icon_rgba, util::tray_template_rgba), and the mapping from
tray events to its own actions.
| Delete | Lines | Instead |
|---|---|---|
src/tray.rs |
120 | fastframe_tray |
src/tray_native.rs |
400 | fastframe_tray |
src/lib.rs: the two cfg'd pub mod tray declarations |
5 | nothing |
src/app.rs: TrayService::spawn(move || waker.wake()) |
1 | fastframe_tray::Tray::spawn(Config { id: "zapfast", title: "ZapFast".into(), icon: util::app_icon_rgba, template_icon: Some(util::tray_template_rgba), menu: vec![MenuItem::action("show", "Show or hide ZapFast"), MenuItem::Separator, MenuItem::action("quit", "Quit")] }, move || waker.wake()) |
src/app.rs handle_tray: the TrayCommand match |
10 | for event in tray.events(): Event::Show to ShowWindow; Event::Toggle and Event::Menu("show") to show or hide; Event::Menu("quit") to Quit |
src/app.rs: tray.hidden() in window_gone |
3 | nothing (it did nothing) |
src/main.rs: zapfast::tray::idle(..) |
1 | .idle(fastframe_tray::idle) on the shell (see fastframe-shell) |
src/macos.rs attach: the menu handler |
0 | call if fastframe_tray::claim_menu_event(&event.id.0) { return; } first in the MenuEvent handler |
src/macos.rs action: the "show" arm shared with the tray |
7 | nothing: the tray's ids are its own (fastframe-tray:show) and reach tray.events(). Keep "quit" for the app menu's Quit. |
Cargo.toml ksni, tray-icon |
2 | come with the crate. tray-icon stays if macos.rs keeps building the menu bar with muda (it does). |
About 540 lines. The dock_reopen_requests_a_window_only_when_none_is_visible
test moves with the code (the crate's macOS test covers the new behaviour).
Differences, all taken from Spotifast on purpose:
- Windows: a left click on the icon now always shows the window
(
Event::Show) and no longer opens the menu; the menu opens on right click (Spotifast #310, 5eb054e). - macOS: a Dock click now brings back a minimized window too, and wakes the app so the request is read (Spotifast ec75951). ZapFast only asked when AppKit reported no visible window, which a minimized window is not.
- Flatpak: the item registers the unique bus name (Spotifast 1c03c21).
- On Windows, dropping the tray now ends its thread and removes the icon (before, it lived until the process ended).
| Delete | Lines | Instead |
|---|---|---|
src/tray.rs |
281 | fastframe_tray |
src/tray_native.rs |
507 | fastframe_tray |
src/lib.rs: the pub mod tray declarations |
5 | nothing |
src/app.rs: TrayService::spawn(..) |
1 | Tray::spawn(Config { id: "spotifast", title: "Spotifast".into(), icon: util::app_icon_rgba, template_icon: Some(util::tray_template_rgba), menu: vec![action("show", ..), Separator, action("play-pause", "Play"), action("next", "Next"), action("previous", "Previous"), Separator, action("quit", "Quit")] }, ..) |
src/app.rs: the TrayCommand match |
12 | match Event::Toggle/Menu("show"), Show, Menu("play-pause"), Menu("next"), Menu("previous"), Menu("quit") |
src/app.rs: tray.set_playing(playing) |
1 | tray.set_label("play-pause", if playing { "Pause" } else { "Play" }) (the crate does not skip repeats; keep the app's playing comparison, or call only on change) |
src/app.rs: tray.hidden() |
3 | nothing |
src/entrypoint.rs: spotifast::tray::idle(..) |
1 | .idle(fastframe_tray::idle) |
Cargo.toml ksni, tray-icon |
2 | come with the crate |
About 800 lines, of which 140 are the Flatpak private-bus test in
tray.rs. fastframe tests do not start dbus-daemon, so that test is not
in the crate; to keep it, rewrite it in Spotifast's tests/ against
fastframe_tray::Tray::spawn and events(), without the "previous
registration method must fail" half (which needs ksni's own builder).
Differences:
- macOS: the headless pump now runs
-[NSApplication run]in slices (ZapFast 655509b). Spotifast'snextEventMatchingMask:/sendEvent:loop can let an Objective-C exception unwind into Rust and abort (ZapFast #199). Media keys and the menu bar keep working, since AppKit's own loop serves them. - The labels are the app's; Spotifast's untranslated
"Play","Next"... can now go through its gettext.
Not moving now: its tray shares one winit loop with the window
(pump_app_events) and uses tray-icon 0.25. Its menu also needs what the
crate doesn't have yet: disabled status lines, a tooltip that changes, a
dimmed icon while paused, and an item that relabels itself. It can adopt the
crate once the crate grows those and moves to tray-icon 0.25, and if the app
moves to the run-native loop.
The keep-running loop around eframe::run_native, the Waker, start
hidden, and off-screen window recovery. Each app keeps its eframe::App
wrapper (demo shots, tours, thumb bar, menus), its native options, and its
decisions about when to hide, show, reopen and quit. Single instance stays in
each app (see the crate README for why).
| Delete | Lines | Instead |
|---|---|---|
src/backend.rs: struct Waker and its impl |
26 | pub use fastframe_shell::Waker; (same methods, including wake_after) |
src/main.rs: slot, the start_hidden computation's hides_to_tray part, the outer loop, the headless inner loop, the final shutdown |
100 | impl fastframe_shell::Resident for App (below), then Shell::new(app, &waker).start_hidden(cli.start_hidden && !demo && update_receipt.is_none()).idle(fastframe_tray::idle).run(|lease| eframe::run_native("ZapFast", native_options(..), Box::new(move |cc| { let mut app = lease.take(&cc.egui_ctx); app.attach(&cc.egui_ctx); ... Ok(Box::new(Shell { app, .. })) }))) |
src/main.rs: creator_waker.attach(..), the creator_slot take, waker.detach() |
10 | lease.take(&cc.egui_ctx) and the shell |
src/main.rs Shell: app: Option<App>, slot, impl Drop for Shell |
8 | app: fastframe_shell::Held<App>; self.app.as_mut() becomes &mut self.app |
src/main.rs: recovered_window_position, recover_offscreen_window |
80 | fastframe_shell::window::recover_offscreen(ctx, frame) behind the existing window_recovery_checked flag (drop the cfg: the crate does nothing on macOS) |
src/main.rs: mod window_tests |
70 | the same cases are in the crate |
src/app.rs: hide_intent/quit_requested/wants_show reads in main |
0 | the Resident impl |
The Resident impl (about 25 lines in app.rs):
impl fastframe_shell::Resident for App {
fn closed(&self) -> Closed {
if !self.quit_requested && self.hide_intent { Closed::Hide } else { Closed::Quit }
}
fn window_gone(&mut self) { App::window_gone(self) }
fn headless_frame(&mut self, ctx: &egui::Context) -> Headless {
self.background_frame(ctx);
if self.quit_requested { Headless::Quit }
else if self.wants_show { Headless::Show }
else { Headless::Wait }
}
fn start_hidden(&mut self) -> bool {
if !self.hides_to_tray() { return false; }
App::start_hidden(self); // sets hide_intent, releases the backend
true
}
fn shutdown(&mut self) { App::shutdown(self) }
}About 270 lines out, 25 in. Keep a_hidden_start_starts_the_backend_without_a_frame
in app.rs: the crate tests that the shell calls start_hidden before any
window, the app test that it releases the backend.
No behaviour changes. Moving the hide-to-tray check into start_hidden
keeps the rule "without a tray there is no way back, so show the window".
| Delete | Lines | Instead |
|---|---|---|
src/backend.rs: struct Waker and its impl |
24 | pub use fastframe_shell::Waker; |
src/entrypoint.rs: slot, the outer loop's bookkeeping (creator_slot, waker.detach(), the switch/hide reads, the headless inner loop, the final shutdown) |
70 | impl Resident for App with closed() returning Closed::Reopen for switch_intent, then Shell::new(app, &waker).idle(fastframe_tray::idle).run(|lease| { let mini = ...; let options = ...; eframe::run_native("Spotifast", options, Box::new(move |cc| { ...; let mut app = lease.take(&cc.egui_ctx); ... })) }) |
src/entrypoint.rs Shell: app: Option<App>, slot, the slot line in Drop |
4 | app: Held<App>; Drop keeps only thumbbar.detach() (the app returns after it, when the field drops) |
About 95 lines out, 25 in. The per-window option building (MiniWindow,
native_options, profile_options, the thumb bar) moves into the run
closure unchanged, since the closure runs once per window.
Off-screen recovery: Spotifast checks its own saved session positions before
applying them (window::can_restore, the Windows work-area test of the
title bar), which stays. It can add recover_offscreen on the main window's
first frame as a net for positions eframe restored itself; that is new
behaviour on X11 and Windows, so it is optional.
No other behaviour changes.
Not moving: Solco and TonePush have no tray or background mode. Chat
with Work keeps its window alive with pump_app_events on one winit loop, a
different design.
The title bar an app draws itself, and what was fastframe-macos (removed
in favour of this crate). On macOS: the traffic lights centred in the app's
header, their room, and a header drag that starts on the press and answers
a double-click as System Settings says. On Windows and Linux, behind the
user's choice: caption buttons, the header drag with double-click to
maximize, and resizable edges. Each app keeps its header heights, which
panel moves down when its header is too narrow, its setting, and its menus.
Renamed from fastframe-macos:
| Was | Now |
|---|---|
fastframe_macos::align_traffic_lights(frame, ctx, height) |
fastframe_titlebar::align_traffic_lights(frame, ctx, height) |
fastframe_macos::traffic_light_inset(ctx) |
fastframe_titlebar::footprint(ctx, height).left |
fastframe_macos::TRAFFIC_LIGHTS_WIDTH |
fastframe_titlebar::TRAFFIC_LIGHTS_WIDTH |
fastframe_macos::double_click_action() and the app's own drag |
fastframe_titlebar::drag(ui, rect) |
| Delete | Instead |
|---|---|
src/window.rs: WindowButton, LinuxWindowControls, LINUX_CONTROLS, LINUX_ICONS, init_linux_window_controls, linux_window_controls, parse_linux_controls and its test |
fastframe_titlebar::init(), Button, Layout, layout() |
src/window.rs: CUSTOM_TITLEBAR, custom_titlebar, set_custom_titlebar, custom_titlebar_for and its test |
fastframe_titlebar::set_enabled, enabled |
src/window.rs: macos_titlebar_should_drag |
inside drag |
src/ui/mod.rs: titlebar_drag |
fastframe_titlebar::drag |
src/ui/mod.rs: window_controls, caption_feedback |
fastframe_titlebar::controls(ui, &Style { icon, button, header_height: theme::CUSTOM_TOP_BAR_HEIGHT }, &Labels { .. }), with the palette's secondary (Windows) or text (Linux) as icon and surface_hover as button |
src/ui/mod.rs: window_resize, window_resize_enabled, resize_direction, the resize constants and their tests |
fastframe_titlebar::resize |
src/ui/mod.rs: the controls' widths and heights in window_controls_reservation and linux_left_controls_inset |
fastframe_titlebar::footprint(ctx, theme::CUSTOM_TOP_BAR_HEIGHT), and Footprint::reserve(topbar_width, 510.0) for the top bar; the queue and lyrics rules stay |
Call fastframe_titlebar::install(ctx) beside fastframe_icons::install.
No behaviour change: this is Spotifast's code, and its setting stays off by
default.
| Delete | Instead |
|---|---|
src/ui/mod.rs: titlebar_drag |
fastframe_titlebar::drag, which adds the double-click ZapFast lacked |
fastframe_macos::align_traffic_lights in Shell::logic |
fastframe_titlebar::align_traffic_lights |
fastframe_macos::traffic_light_inset and TRAFFIC_LIGHTS_WIDTH in src/theme.rs |
fastframe_titlebar::footprint(ctx, height).left and fastframe_titlebar::TRAFFIC_LIGHTS_WIDTH (the demo's macOS preview) |
Optionally, the drawn title bar on Windows and Linux as a setting, off by
default: set_enabled at startup, with_decorations(!enabled()), and
controls, resize and footprint in the header. ZapFast's Mica backdrop
extends the frame itself; the crate leaves the frame alone.
| Delete | Instead |
|---|---|
src/ui.rs: title_bar_drag |
fastframe_titlebar::drag: the drag now starts on the press, as AppKit requires, and minimize on double-click works |
fastframe_macos::align_traffic_lights |
fastframe_titlebar::align_traffic_lights |
fastframe_macos::traffic_light_inset |
fastframe_titlebar::footprint(ctx, TITLE_BAR_HEIGHT).left (keep the 12 fallback) |
Solco keeps the native frame on Windows and Linux: it never calls
set_enabled, so drag does nothing there.
Self-update from GitHub releases. Each app keeps its UpdateConfig (names,
legacy names, macOS identity, publisher key), its HTTP client settings, and
its update interface. The move below targets ZapFast 0.16.x and Spotifast
0.10.x. Take the reqwest feature:
fastframe-update = { git = "https://github.com/crmne/fastframe", rev = "<commit>", features = ["reqwest"] }Both apps already build reqwest 0.12 with blocking, so the reqwest
feature adds nothing to their trees; ring, sha2 0.11, url, serde and anyhow
are already there too.
The first release built on the crate is installed by the previous release's
own updater, which is unchanged. What that old helper needs from the new
binary stays the same: --version prints <slug> <version>, the new app
accepts --update-receipt <job> and --update-error <message> (now through
intercept), and it writes started next to the receipt. The crate's
compat tests cover the receipts of both apps' current releases.
| File | Lines |
|---|---|
src/updates.rs |
123 |
src/updates/install.rs |
798 |
src/updates/macos.rs |
440 |
src/updates/signing.rs |
85 |
src/updates/transfer.rs |
498 |
| Total | 1,944 |
The 19 updater tests in those files move into the crate. Keep
assets/update-public-key.hex, packaging/zapfast-portable.txt,
packaging/windows/zapfast-installer.txt and packaging/UPDATE_SIGNING.md
(point its verification paragraph at this crate).
A new src/updates.rs of about 35 lines:
pub use fastframe_update::{
CHECK_INTERVAL, DownloadState, Installation, Kind, Prepared, Release, Source, Unsupported,
Updater,
};
use fastframe_update::{MacConfig, ReqwestTransport, UpdateConfig};
pub const CONFIG: UpdateConfig = UpdateConfig {
// Cask and bundle names from before the rename. This also accepts
// fastsapp-* marker files and `fastsapp <version>` answers, which no
// release produces.
legacy_names: &["fastsapp"],
macos: MacConfig {
bundle_ids: &["me.paolino.fastsapp"],
executable_names: &[],
legacy_bundle_names: &["FastsApp.app"],
},
publisher_key: Some(include_str!("../assets/update-public-key.hex")),
..UpdateConfig::new("crmne/zapfast", "ZapFast", "zapfast", env!("CARGO_PKG_VERSION"))
};
/// An updater on the proxy-aware reqwest client.
pub fn updater() -> anyhow::Result<Updater> {
let mut builder = reqwest::blocking::Client::builder();
if let Some(proxy) = crate::proxy::reqwest_proxy() {
builder = builder.proxy(proxy);
}
Ok(Updater::new(CONFIG, ReqwestTransport::new(builder)?))
}
#[test]
fn update_config_is_valid() {
CONFIG.validate().unwrap();
}| Where | Today | With |
|---|---|---|
main.rs start |
if arguments.len() == 3 && arguments[1] == "--apply-update" { run_helper(..) } |
let launch = fastframe_update::intercept(&updates::CONFIG); |
main.rs Cli |
hidden update_receipt and update_error arguments; Cli::parse() |
remove both; Cli::parse_from(&launch.arguments) |
main.rs |
cli.update_error toast |
launch.error |
main.rs |
update_receipt: Option<PathBuf>, update_receipt.is_none() before starting hidden |
Option<fastframe_update::Receipt>, launch.receipt.is_none() |
main.rs ui |
install::acknowledge(&receipt) |
receipt.acknowledge() |
worker.rs InspectUpdate |
install::detect() |
updates::updater()?.installation() (map the error with to_string()) |
worker.rs DownloadUpdate |
updates::download(&release, &source, progress) |
updater.with_source(source).download(&release, progress) |
worker.rs InstallUpdate |
install::handoff(&prepared, arguments) |
updater.handoff(*prepared, arguments) |
worker.rs CheckForUpdates |
updates::newer_release() over proxy::agent() |
updater.check() |
app.rs, demo.rs tests |
Prepared { installation, directory, payload, sha256, version } |
Prepared::sample(installation, "99.0.0") |
Source::GitHub |
enum variant | Source::github(); source.is_github() |
Command::InstallUpdate already moves the Prepared out of
DownloadState::Ready with mem::replace, which suits the non-Clone
Prepared.
Net: about 1,944 lines deleted and 60 added or changed, so roughly 1,880 fewer lines.
- The helper's standard error goes to
helper.login the staging folder (as Spotifast does), andhandoffnames that file when the helper dies. - A Nix install says "Update this installation with Nix." instead of "with Nix or Homebrew".
- A handoff into a staging folder that already holds a marker is refused (none can today, since each download gets a new folder).
result.txtis written before the rolled-back app restarts, not after.
| File | Lines |
|---|---|
src/updates.rs |
122 |
src/updates/install.rs |
791 |
src/updates/macos.rs |
503 |
src/updates/transfer.rs |
587 |
| Total | 2,003 |
The 21 updater tests move into the crate. Keep the marker files in
packaging/ (spotifast- and fastpotify-portable.txt,
spotifast- and fastpotify-installer.txt) and the CI step that runs
--apply-update on a missing job inside a real bundle: intercept prints
the error to standard error and exits with 1, as today.
examples/updater-inspect.rs (11 lines) becomes a call to
Updater::installation_at(&path).
A new src/updates.rs of about 40 lines:
pub use fastframe_update::{
CHECK_INTERVAL, DownloadState, Installation, Kind, Prepared, Release, Source, Unsupported,
Updater,
};
use fastframe_update::{MacConfig, ReqwestTransport, UpdateConfig};
pub const CONFIG: UpdateConfig = UpdateConfig {
legacy_names: &["fastpotify"],
legacy_windows_installs: &["Programs/Fastpotify/fastpotify.exe"],
macos: MacConfig {
bundle_ids: &["rocks.spotifast.Spotifast", "me.paolino.fastpotify"],
// 0.9.1 kept "fastpotify" for older clients' validation; later
// releases may rename it to "Spotifast" (#538).
executable_names: &["fastpotify", "Spotifast"],
legacy_bundle_names: &["Fastpotify.app"],
},
// Checksums only until releases are signed; see below.
publisher_key: None,
..UpdateConfig::new("crmne/spotifast", "Spotifast", "spotifast", env!("CARGO_PKG_VERSION"))
};
pub fn updater(proxy: &crate::settings::ProxyConfig) -> anyhow::Result<Updater> {
let builder = crate::http::blocking_builder(proxy).map_err(anyhow::Error::msg)?;
Ok(Updater::new(CONFIG, ReqwestTransport::new(builder)?))
}| Where | Today | With |
|---|---|---|
entrypoint.rs start |
--apply-update block with eprintln! and exit |
let launch = fastframe_update::intercept(&updates::CONFIG); (keep it before the MilkDrop child check) |
entrypoint.rs Cli |
hidden update_receipt/update_error; Cli::command().name(name).get_matches() |
remove both; .get_matches_from(&launch.arguments). The fastpotify/spotifast name choice from argv[0] stays: old updaters check fastpotify <version>. |
entrypoint.rs |
cli.update_receipt.is_some() for migration and AppDirs::for_launch |
launch.receipt.is_some() |
entrypoint.rs demo feed |
updates::Source::local(feed) |
fastframe_update::Source::local(feed) |
backend.rs check_for_updates |
async newer_release_from(&http, &source) |
tokio::task::spawn_blocking(move || updater.with_source(source).check()) |
backend.rs InspectUpdate |
install::detect() |
updater.installation() |
backend.rs DownloadUpdate |
updates::download(&release, &source, &proxy, progress) |
updates::updater(&proxy)?.with_source(source).download(&release, progress) |
backend.rs InstallUpdate |
install::handoff(&prepared, arguments) |
updater.handoff(*prepared, arguments) |
app.rs InstallUpdate |
prepared.clone() from &self.update_download |
move it out with std::mem::replace(&mut self.update_download, DownloadState::Installing); Prepared is not Clone |
app.rs |
matches!(self.update_source, Source::GitHub) |
self.update_source.is_github() |
app.rs receipt |
install::acknowledge(&receipt) |
receipt.acknowledge() |
demo.rs, app.rs tests |
Prepared { .. } literals |
Prepared::sample(installation, version) |
Net: about 2,003 lines deleted and 70 added or changed, so roughly 1,930 fewer lines.
- The macOS helper runs from a copy of the whole bundle (ZapFast's fix), not from the installed bundle. The installed bundle can then be moved and restored without touching the helper's own code.
portable_entry's special case for the 0.8.0 and 0.9.0 archive layout is gone. Those versions are older than 0.10, so no app built on the crate can be offered them.- The pre-marker Windows location now also checks the setup program's
current default,
Programs/Spotifast/spotifast.exe, as well asPrograms/Fastpotify/fastpotify.exe. - A Nix install says "Update this installation with Nix."
Spotifast releases are not signed yet, so publisher_key stays None and
the update is verified against checksums.txt alone. To sign:
- Add native-packages'
sign-releasestep and arelease-signingenvironment with the private key to the release workflow, as ZapFast has, and publish a release that carrieschecksums.txt.sig. - Only then ship a version with
publisher_key: Some(include_str!(..)). From that version on, a release without a valid signature is refused, so every later release must be signed.
Older installs keep updating on checksums until they reach a version that embeds the key.
TonePush has no updater of its own to delete: 0.6.1 only asks GitHub for
the newest tag and links to it. It adopts the crate over its own ureq
client, implementing Transport with redirects turned off, and without the
reqwest feature.
Its archives hold two programs, the tonepush command-line tool and the
tonepush-gui editor, beside one marker, so it sets portable_executable:
pub const CONFIG: UpdateConfig = UpdateConfig {
portable_executable: Some("tonepush-gui"),
macos: MacConfig {
bundle_ids: &["rocks.tonepush.editor"],
executable_names: &[],
legacy_bundle_names: &[],
},
publisher_key: Some(include_str!("../../../assets/update-public-key.hex")),
..UpdateConfig::new("crmne/tonepush", "TonePush", "tonepush", env!("CARGO_PKG_VERSION"))
};The editor answers --version with tonepush <version>, the slug, and the
release workflow puts tonepush-portable.txt (tonepush-portable-v1) into
the Linux and Windows archives. Only a running tonepush-gui beside that
marker updates itself; the tonepush tool next to it never matches. Inside
the macOS bundle the editor's executable is tonepush, so the bundle needs
no executable names. ZapFast and Spotifast leave portable_executable at
None and nothing changes for them.
Solco has no updater yet, and every release so far is a GitHub
pre-release (v0.5.0-alpha.2), so releases/latest answers 404. It turns on
the pre-release channel:
pub const CONFIG: UpdateConfig = UpdateConfig {
prereleases: Prereleases::WhenRunningPrerelease,
mac_target: MacTarget::Arm64Only,
publisher_key: Some(include_str!("../assets/update-public-key.hex")),
..UpdateConfig::new("crmne/solco-releases", "Solco", "solco", env!("CARGO_PKG_VERSION"))
};An alpha is offered the highest newer release, alpha or stable; a stable
build never sees an alpha. Its macOS build is Apple silicon only, so
MacTarget::Arm64Only looks for solco-v<version>-macos-arm64.dmg,
the name its release workflow already uses, and refuses an x86_64 copy with
Unsupported::Platform. Before it ships:
- An expired alpha must still reach the update check, the download, the
handoff and
Receipt::acknowledge. Put the expiry gate afterinterceptand keep the update banner usable when the alpha has expired, or an expired copy can only be replaced by hand. --versionprintssolco 0.5.0-alpha.2, the full version. The macOS bundle'sCFBundleShortVersionStringmay stay the numeric0.5.0thatpackaging/macos/bundle.shwrites.- The Linux and Windows archives need
solco-portable.txt(solco-portable-v1), or those copies reportNotPortable. - A portable update replaces only the executable. The
modelsfolder, bundled libraries andalpha-release.jsonin the archive are not updated; a release that changes them needs the app to cope with the old ones, or a manual download. - Once a stable release exists, publish it as a normal (latest) release:
a stable build reads only
releases/latest. - The stable Windows installer is published as
solco-v<version>-windows-x86_64-setup.exe; the updater expectssolco-v<version>-x86_64-pc-windows-msvc-setup.exe. Rename it in the release workflow before installer copies are meant to update.
An AppImage refuses to update itself (NotPortable) until the app sets
appimage: true in its UpdateConfig. Turn it on only once the release
lists its AppImages in the signed checksums.txt:
- Build
<slug>-<version>-<arch>.AppImage(x86_64,aarch64; the name native-packages gives it) before the release is published, in the release workflow's build jobs, as the Windows Installer packages are, and put it in the folder whose fileschecksums.txtlists andchecksums.txt.sigsigns. An AppImage built afterwards by the packaging workflow lands inpackaging-checksums.txt, which is not signed, and an app that carries a publisher key refuses it. - Keep those AppImages out of the packaging workflow's targets, so the release has one of each.
--versionmust print<slug> <version>from the AppImage, as from the archive's executable: the updater runs the downloaded AppImage once before it installs it.- Translate
Unsupported::NotWritable, which an AppImage the user cannot replace reports.
Copies started from an earlier release's AppImage keep using the download page: their updater does not know AppImages. Their next manual download is the last.
About 3,950 lines of updater code and tests leave the two apps, replaced by about 75 lines of configuration and 130 of changed call sites. The crate is about 6,000 lines as formatted: about 2,900 of code and documentation and 3,100 of tests and test fakes.
Starting at login, with the window or hidden in the tray. The platform's own
record is the state: Settings reads Autostart::state() back on a worker
instead of keeping a copy.
let autostart = fastframe_autostart::Autostart::new("zapfast", "ZapFast");
// On a worker; both block.
let state = autostart.state();
let state = autostart.set(Some(fastframe_autostart::Start::Hidden))?;New, no code to delete (crmne/spotifast#122): a Settings row with Off, the
window, or the tray. packaging/macos/bundle.sh writes
macos_agent_plist("rocks.spotifast.Spotifast", "--start-hidden") to
Contents/Library/LaunchAgents/rocks.spotifast.Spotifast.autostart.plist
before signing.
| Delete | Lines | Instead |
|---|---|---|
src/autostart.rs |
275 | Autostart::new("zapfast", "ZapFast"); set(Some(Start::Hidden)) or set(None) |
Behaviour changes: the Linux entry is written atomically and an entry the
desktop turned off reads as off; the Windows value is read back with Task
Manager's switch. On macOS the login item moves from a LaunchAgent in
~/Library/LaunchAgents/me.paolino.zapfast.plist to SMAppService with the
bundled agent: delete that file once (when it exists, register through the
crate and remove it), and add the agent to the bundle. A Flatpak, which
supported() excluded, can now register through the portal.
| Delete | Lines | Instead |
|---|---|---|
app/src/autostart.rs: entry_path, is_enabled, set_enabled, entry, desktop_exec_arg and their tests |
160 | Autostart::new("cww-app", "ChatWithWorkLocalAgent").hidden_argument("--background") |
The Windows Run value keeps its name (ChatWithWorkLocalAgent), so existing
registrations read back; the desktop entry's Name becomes that too, unless
the app keeps its own Linux text. windows_registry stays for its other
reads. The macOS LaunchAgent com.chatwithwork.cww-app.plist moves to the
bundled agent as for ZapFast.
Widgets stay in the apps for now. These exist in two or more apps and are
the candidates for a later fastframe-widgets, once the look crates above
have settled:
| Widget | Apps (where) |
|---|---|
| Switch (toggle) | ZapFast (ui/widgets.rs switch), Spotifast (ui/widgets.rs), Solco (theme.rs), Chat with Work (ui/widgets.rs) |
| Settings row (label, detail, control) | ZapFast (setting_row), Spotifast (setting_row, setting_row_sized), Solco (setting_row, settings_section, setting_divider), Chat with Work (setting_row, section, group) |
| Icon button and icon painting | ZapFast (theme.rs icon, paint_icon, icon_button), Spotifast (same names), Solco (icon, icon_image, icon_button, toolbar_icon_button), TonePush (icon_button, tinted_icon_button), Chat with Work (paint_icon) |
| Primary, secondary and danger buttons | Solco (primary_button, secondary_button, danger_button), Chat with Work (primary_button, destructive_button); ZapFast and Spotifast use pill_button, soft_button, circle_button for the same roles |
| Dialog title and footer | Solco (dialog_title, dialog_note, dialog_footer), Chat with Work (dialog_buttons), ZapFast (dialog_scroll_height) |
| Segmented control, chips | Solco (segmented, deck_segment), ZapFast (chip, filter_chip), Spotifast (chips), TonePush (category_chip) |
| Menu item, separator, frame | ZapFast (menu_item, menu_item_enabled, menu_separator, menu_frame, submenu), Spotifast (the same, plus menu_submenu) |
| Search field | ZapFast, Spotifast (search_field) |
| Empty state, loading and error rows | ZapFast (empty_state), Spotifast (empty_state, loading_row, error_row) |
| Spinner | ZapFast (spinner, paint_spinner), Spotifast (spinner, circle_spinner), Solco (spinner, paced for an event-driven loop), TonePush (spinner) |
| Status dot, badge | Solco (status_dot), Chat with Work (status_dot, badge), TonePush (status_dot), ZapFast (badge, unread_dot) |
| Focus ring | ZapFast (focus_outline, reveal_focus), Spotifast (focus_ring) |
| Credits line, logo | ZapFast, Spotifast (credit, logo) |
| Navigation row | Solco (navigation_row, navigation_highlight), Spotifast (sidebar rows), ZapFast (chat rows) |
| Drag ghost | Spotifast, TonePush (drag_ghost) |
| Vertical gradient | ZapFast, Spotifast (paint_vertical_gradient) |
Their sizes and colours differ by app, so a shared crate would take the
palette through a small trait (or the BASE_COLORS names) rather than fix
any app's look.
Use fastframe-notifications for platform delivery, including headless agents.
Replace direct notify-rust, mac-usernotifications and Windows toast calls with
Client, Notification and Handle. Set the actual bundle ID / Windows shortcut
AppUserModelID and the Linux desktop entry. Keep handles while clicks or updates
matter; dropping one leaves desktop history intact, while close withdraws it.
Permission checks and permission prompts are separate. Query from a daemon and
request after the user enables notifications. A macOS daemon needs a signed
bundled broker and a main run loop; run_main pumps it without creating a window.
The older fastframe_desktop::macos_notifications_allowed guard remains for
compatibility but is unnecessary with the new client.
Solco retains its task generations and progress throttling. ZapFast retains account/chat scoping, rate limits, private-content rules and audio previews. Spotifast and TonePush currently have no system notification adapter to migrate. For an app such as Chat with Work, use its daemon as the native delivery owner for the GUI and TUI. Its server owns preferences, presence and browser deduplication.
fastframe-dirs keeps a portable Windows copy's config, state and cache in
<slug>-data next to the executable, and an installed copy's in the
per-user folders. An app moving onto it replaces its own
directories::ProjectDirs lookup with App::discover, which gives the same
per-user paths (config, state or local data, cache) when the copy is not
portable, so nothing moves for installed copies or on Linux and macOS.
- Call
App::discoverafter claiming the single-instance slot and before the window opens, and log itsmodeonce logging has started. - Give a portable copy a slot of its own: scope the slot with
App::portable().instance_scope(), keeping it in the user's profile. - Name in
not_copiedthe state entries a running installed copy holds locked (its instance lock, on Windows) and the logs. - Anything the app ties to its folder's path (a credential profile derived
from it, say) is fixed in the
adaptclosure, on the copy, before it is put in place.
Spotifast is the first app on it.
0.14 names the app and its streams in the system's volume mixer
(set_app_info, and media_name and role on OutputOptions and
InputOptions; see the crate README). Two changes in each app:
-
Pin cpal to the same fork. fastframe-audio now gets cpal from
crmne/cpalatfcd8b6ebee9d822426fa8d6839cdd89af6e0d491(cpal 0.18.2 with RustAudio/cpal#1409), until a cpal release includes it. An app that also gets cpal 0.18 from crates.io, through kira or directly, adds at its root:[patch.crates-io] cpal = { git = "https://github.com/crmne/cpal", rev = "fcd8b6ebee9d822426fa8d6839cdd89af6e0d491" }
Without it the app builds two cpal crates, its own and fastframe's.
-
Struct literals get the new fields. An
OutputOptionsorInputOptionsliteral without..Default::default()addsmedia_name: None, role: None.
Then, to be named: call fastframe_audio::set_app_info once at startup,
before opening or listing devices, with the app's name and its freedesktop
icon name. ZapFast's call streams set role: Some(StreamRole::Communication)
and Solco's Some(StreamRole::Production) if it wants one; without a role
each app keeps its own remembered volume. A media name is fixed while the
stream is open, so Spotifast leaves it at the app's name rather than the
song title.
Output with the app's renderer, and since 0.12 input into the app's
capturer, device ids, an idle pause and an optional rodio mixer. Upgrading
to 0.12 needs no change in Solco or Spotifast: Maintained, Device::Named
and output_device_names() are as they were. Code that matches Device
without a wildcard adds an arm for Device::Id, and an OutputOptions
literal without ..OutputOptions::default() adds pause_when_idle: None.
Nothing to change. Its match on Maintained::Reopened { device, sample_rate, channels, reason } in src/preview.rs still compiles. To
tell devices apart by id rather than by name when it decides the output
Moved, read output.device_id() after a Reopened.
Nothing is required: Device::Named and output_device_names() behave as
before. Later, as separate changes:
- Save the device's id rather than its name: list with
output_devices(), saveDeviceInfo::id, open withDevice::Id. A name saved by an earlier version still opens throughDevice::Named, so read an old setting as a name and write it back as the id of the device it opened (Output::device_id(), which after aReopenedis the new device's), but only when that name matched: check it againstoutput_devices()first. When it did not, the default played instead, and saving the default's id would pin the user to whatever the default was then; keep the name until the device is back. src/sink.rs'sMixerRender(about 40 lines) applies its volume ramp inrender, which the crate'sMixerRender(therodiofeature) does not. Move the ramp into a rodio source in front of the mixer first, or keep the app's adapter.
Its call audio (src/call_audio.rs) records through rodio's microphone and
plays through rodio's output. Input and Output cover both, with the
pause, reopen and real-time behaviour Spotifast and Solco have; moving is a
separate change in ZapFast, once its echo and resampling needs are checked
against them.