<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Justin - FromTheArchitect</title>
    <description>The latest articles on DEV Community by Justin - FromTheArchitect (@fromthearchitect).</description>
    <link>https://dev.to/fromthearchitect</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3846970%2F105073c7-9d87-4514-b33f-d466199f9dd2.jpeg</url>
      <title>DEV Community: Justin - FromTheArchitect</title>
      <link>https://dev.to/fromthearchitect</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/fromthearchitect"/>
    <language>en</language>
    <item>
      <title>Excavating Legacy ETL: The AI Never Asserts a Fact It Could Look Up</title>
      <dc:creator>Justin - FromTheArchitect</dc:creator>
      <pubDate>Tue, 25 Aug 2026 23:00:00 +0000</pubDate>
      <link>https://dev.to/fromthearchitect/excavating-legacy-etl-the-ai-never-asserts-a-fact-it-could-look-up-1594</link>
      <guid>https://dev.to/fromthearchitect/excavating-legacy-etl-the-ai-never-asserts-a-fact-it-could-look-up-1594</guid>
      <description>&lt;p&gt;Every platform modernisation programme has the same first act, and it is never the interesting one. Before anyone gets to write a line of &lt;a href="https://www.getdbt.com/" rel="noopener noreferrer"&gt;dbt&lt;/a&gt;, somebody has to work out what the current thing actually &lt;em&gt;does&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;In the &lt;a href="https://help.sap.com/docs/SAP_DATA_SERVICES" rel="noopener noreferrer"&gt;SAP BusinessObjects Data Services&lt;/a&gt; case — BODS, if you've had the pleasure — that means inheriting a few hundred dataflows whose authors left the organisation years ago. The logic lives in exported XML. The business rationale lived in the heads of people who are now at other companies. Nobody can tell you which flows are duplicates, which ones nothing reads any more, or what any given one is &lt;em&gt;for&lt;/em&gt;. There is a spreadsheet somewhere claiming to be an inventory. It is wrong in ways nobody can enumerate.&lt;/p&gt;

&lt;p&gt;This is archaeology. It is also, on paper, the exact shape of problem that large language models should eat for breakfast: mountains of semi-structured text, pattern recognition, summarisation into prose a human can read.&lt;/p&gt;

&lt;p&gt;So I tried the obvious thing, and the obvious thing failed in a way that turned out to be the most useful finding of the whole exercise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why pointing an LLM at the XML doesn't work
&lt;/h2&gt;

&lt;p&gt;Paste a BODS dataflow export into a chat window and ask what it does, and you get an answer. It is fluent, well-structured, and mostly correct.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Mostly&lt;/em&gt; is the problem.&lt;/p&gt;

&lt;p&gt;The output is a blend of two very different things: columns and mappings that are genuinely in the XML, and plausible-sounding business context that is not. &lt;code&gt;AMOUNT_USD&lt;/code&gt; is a currency-normalised revenue measure — fine, that's a reasonable inference. But it will also tell you, with identical confidence and no change of register, that the flow runs nightly, or that &lt;code&gt;STATUS&lt;/code&gt; follows the standard active/inactive convention, or that a column feeds the finance dashboard. None of those facts are in the file. They are priors about what ETL usually looks like, rendered in the same voice as the facts.&lt;/p&gt;

&lt;p&gt;For most LLM tasks that's an acceptable cost. For this one it's fatal, because on a migration the facts &lt;em&gt;are&lt;/em&gt; the deliverable. A spec that gets the join predicate subtly wrong doesn't fail loudly — it produces a fact table where every row has silently been stamped with the wrong foreign key. You find out during reconciliation, six weeks and a lot of goodwill later.&lt;/p&gt;

&lt;p&gt;The failure mode isn't that the model can't read the XML. It reads it fine. The failure mode is that the output gives you no way to tell which sentences came from the file and which came from the model's expectations, and a human reviewer holding a 40-page spec cannot re-derive every claim by hand. That's the whole reason you asked for help in the first place.&lt;/p&gt;

&lt;p&gt;So I stopped trying to make the model more accurate, and changed what it was allowed to do.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one rule the whole design hangs on
&lt;/h2&gt;

&lt;p&gt;The system I ended up with — &lt;a href="https://github.com/justinf555/etl-archaeologist" rel="noopener noreferrer"&gt;etl-archaeologist&lt;/a&gt; — is built around a single constraint:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The AI never asserts a fact it could look up. The deterministic layer never interprets.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Two layers, one hard boundary. A plain Python layer parses the BODS exports into a fact store: sources, targets, columns, datatypes, mapping expressions, column lineage, join predicates, cross-flow dependencies. No model involved. Same input, same facts, every time.&lt;/p&gt;

&lt;p&gt;An agent layer then &lt;em&gt;reads that store&lt;/em&gt; and writes the parts a script genuinely cannot: what the pipeline is for, what the grain and keys probably are, whether to lift-and-shift it or redesign it, whether to migrate it at all.&lt;/p&gt;

&lt;p&gt;That split sounds like process pedantry. It isn't — it's what makes the output reviewable. Every factual section of a finished spec traces to a row in a database. Every interpretive sentence carries &lt;code&gt;(inferred — confirm with SME)&lt;/code&gt;. A reviewer reading the spec knows, at every line, whether they're checking a fact or challenging a judgement. Those are completely different cognitive tasks and mixing them is what makes AI-generated documentation exhausting to review.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer one: facts
&lt;/h2&gt;

&lt;p&gt;The extractor is unglamorous on purpose. &lt;code&gt;lxml&lt;/code&gt; in, &lt;a href="https://duckdb.org/" rel="noopener noreferrer"&gt;DuckDB&lt;/a&gt; out, about 150 lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;TABLE&lt;/span&gt; &lt;span class="nf"&gt;dataflow      &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df_name&lt;/span&gt; &lt;span class="n"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;job&lt;/span&gt; &lt;span class="n"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;project&lt;/span&gt; &lt;span class="n"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;source_file&lt;/span&gt; &lt;span class="n"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;TABLE&lt;/span&gt; &lt;span class="nf"&gt;reads         &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df_name&lt;/span&gt; &lt;span class="n"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;object_key&lt;/span&gt; &lt;span class="n"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;datastore&lt;/span&gt; &lt;span class="n"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;table_name&lt;/span&gt; &lt;span class="n"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;TABLE&lt;/span&gt; &lt;span class="nf"&gt;writes        &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df_name&lt;/span&gt; &lt;span class="n"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;object_key&lt;/span&gt; &lt;span class="n"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;datastore&lt;/span&gt; &lt;span class="n"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;table_name&lt;/span&gt; &lt;span class="n"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;TABLE&lt;/span&gt; &lt;span class="nf"&gt;column_def    &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df_name&lt;/span&gt; &lt;span class="n"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;col_name&lt;/span&gt; &lt;span class="n"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;datatype&lt;/span&gt; &lt;span class="n"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mapping_text&lt;/span&gt; &lt;span class="n"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;TABLE&lt;/span&gt; &lt;span class="nf"&gt;column_lineage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df_name&lt;/span&gt; &lt;span class="n"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;target_col&lt;/span&gt; &lt;span class="n"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;src_table&lt;/span&gt; &lt;span class="n"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;src_col&lt;/span&gt; &lt;span class="n"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;TABLE&lt;/span&gt; &lt;span class="nf"&gt;source_column &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df_name&lt;/span&gt; &lt;span class="n"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;datastore&lt;/span&gt; &lt;span class="n"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;table_name&lt;/span&gt; &lt;span class="n"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;col_name&lt;/span&gt; &lt;span class="n"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;TABLE&lt;/span&gt; &lt;span class="nf"&gt;predicate     &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df_name&lt;/span&gt; &lt;span class="n"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;predicate_text&lt;/span&gt; &lt;span class="n"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;TABLE&lt;/span&gt; &lt;span class="nf"&gt;predicate_ref &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df_name&lt;/span&gt; &lt;span class="n"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;src_table&lt;/span&gt; &lt;span class="n"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;src_col&lt;/span&gt; &lt;span class="n"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Predicates get stored twice, which looks redundant until you need it. &lt;code&gt;predicate&lt;/code&gt; keeps the join or filter expression as verbatim text, because that's what has to be reproduced exactly on the target platform. &lt;code&gt;predicate_ref&lt;/code&gt; holds the same expression exploded into the individual source columns it touches — the extractor regexes &lt;code&gt;table.column&lt;/code&gt; references out of the predicate and keeps the ones that resolve to a real source table. Without that second table, any column used &lt;em&gt;only&lt;/em&gt; as a join key looks unreferenced, and the dead-code query below would confidently recommend dropping the very columns holding the estate together.&lt;/p&gt;

&lt;p&gt;The one piece that needed actual thought is column lineage. A BODS dataflow isn't a single mapping step; it's a chain of transforms, and most columns pass through several of them untouched before landing in the target. If you only record each hop, the final target column's origin is a graph walk away. So the extractor threads origins forward hop by hop — a mapped column records the source columns its expression references, a generated column (a surrogate key, an SCD date) records that it was generated, and a pass-through column inherits whatever the upstream column already knew:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="c1"&gt;# pass-through: inherit the upstream column's origin
&lt;/span&gt;    &lt;span class="n"&gt;prev&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;acc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;src&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;expr&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;«pass-through»&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...})&lt;/span&gt;
    &lt;span class="n"&gt;info&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;src&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;src&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;expr&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;expr&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;...}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By the last transform, every target column knows the source columns it came from and the expression that produced it. That's the fact an SME can confirm or deny in five seconds, and it's the one an LLM is most tempted to smooth over.&lt;/p&gt;

&lt;p&gt;The thing I underrated going in: this layer is not just an input to the AI. &lt;strong&gt;It's the test oracle.&lt;/strong&gt; Because it's exact and reproducible, you can later use it to check the agent's homework — which turns out to be the entire trick.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer two: the questions one file can't answer
&lt;/h2&gt;

&lt;p&gt;A second deterministic pass looks across all pipelines at once, because there is a whole class of question that no amount of careful reading of a single export can answer. "Is this a duplicate?" is unanswerable from inside the file.&lt;/p&gt;

&lt;p&gt;Four queries do most of the work. Dependencies are a join between who writes an object and who reads it. Duplicate candidates are pairs of flows writing the same target, scored by column-set Jaccard overlap. Dead-flow candidates are flows nothing else reads. And dead code — my favourite, because it's pure profit — is source columns pulled into a flow and then never referenced by any mapping &lt;em&gt;or&lt;/em&gt; any predicate, which is where &lt;code&gt;predicate_ref&lt;/code&gt; earns its place:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;sc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;df_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;table_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;col_name&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;source_column&lt;/span&gt; &lt;span class="n"&gt;sc&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;column_lineage&lt;/span&gt; &lt;span class="n"&gt;cl&lt;/span&gt;
                  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;cl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;df_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;df_name&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;cl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;src_table&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;table_name&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;cl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;src_col&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;col_name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;predicate_ref&lt;/span&gt; &lt;span class="n"&gt;pr&lt;/span&gt;
                  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;pr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;df_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;df_name&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;pr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;src_table&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;table_name&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;pr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;src_col&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;col_name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Across seven pipelines that found seven columns being extracted for no reason at all. On a real estate that number is a rounding error on the migration effort you don't have to spend.&lt;/p&gt;

&lt;p&gt;Worth being precise about what "dead" means here, because it's narrower than it sounds. The column never reaches the target, so nothing downstream of &lt;em&gt;this flow&lt;/em&gt; can be reading it — that part is safe. What it doesn't mean is that the column is dead in the source system. Another flow may well use it, and something outside BODS almost certainly does. The finding is "stop pulling this column into this pipeline", not "drop this column".&lt;/p&gt;

&lt;p&gt;Jaccard — intersection over union — is about as crude as similarity metrics get, and I chose it for that. It needs no tuning and no threshold anyone will argue about in a workshop, and the hard part of the question has already been answered by the join: these two flows write the &lt;em&gt;same table&lt;/em&gt;. The score only has to rank what's left for a human to look at. I don't know it's the right metric. I know it hasn't yet surfaced a pair that wasn't worth two minutes of attention.&lt;/p&gt;

&lt;p&gt;Notice what all four have in common: they're detection, not judgement. Exact, exhaustive, auditable, and completely free of opinion. Which is exactly what you want feeding the next layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer three: making "look it up" cheaper than guessing
&lt;/h2&gt;

&lt;p&gt;The boundary between the layers only holds if the agent has an easy way to &lt;em&gt;actually&lt;/em&gt; look things up. So the fact store — the DuckDB file — gets a fact &lt;em&gt;server&lt;/em&gt; in front of it: a read-only &lt;a href="https://modelcontextprotocol.io/" rel="noopener noreferrer"&gt;MCP&lt;/a&gt; interface exposing &lt;code&gt;list_pipelines&lt;/code&gt;, &lt;code&gt;get_pipeline_facts&lt;/code&gt;, &lt;code&gt;rationalisation_report&lt;/code&gt;, and a general &lt;code&gt;query&lt;/code&gt; for anything the first three don't cover. Store is the data; server is the only door the agent gets to walk through, and the door is deliberately one-way:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;select&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;with&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;read-only fact store: only SELECT / WITH queries are allowed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;_q&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the part I'd most encourage people to steal. Telling a model "don't hallucinate" is a wish. Giving it a cheap, obvious, structured way to get the real answer is a design. When the ground truth is one tool call away, the model reaches for it — and when it does invent something anyway, the same tool is sitting right there to catch it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer four: the part that actually needs judgement
&lt;/h2&gt;

&lt;p&gt;With facts handled, what's left for the agent is genuinely interesting, and it's more than prose polish.&lt;/p&gt;

&lt;p&gt;Take the dead-flow signal. The detector flagged four candidates, all with identical structural evidence: &lt;em&gt;nothing else in the corpus reads your output&lt;/em&gt;. Deterministically they are indistinguishable. And yet the right answer differs for every one of them, because "no downstream consumer in the BODS metadata" means completely different things depending on what the flow is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;monthly sales aggregate&lt;/strong&gt; with no downstream reader isn't dead. It's terminal — the consumer is a BI tool the fact store cannot see. &lt;strong&gt;Keep&lt;/strong&gt;, and go confirm the dashboard exists.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;conformed product dimension&lt;/strong&gt; with no downstream reader is the same story, only more so — a conformed dimension exists precisely so that things outside the warehouse can slice on it. &lt;strong&gt;Keep&lt;/strong&gt;, verify.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;customer SCD2 history table&lt;/strong&gt; — same shape again. It's a history mart. Terminal by design.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;two-column pass-through in an isolated &lt;code&gt;Legacy&lt;/code&gt; project&lt;/strong&gt;, reading a source table literally named &lt;code&gt;OLD_ACCOUNTS&lt;/code&gt;, writing to a staging area, referenced by nothing: that one is a genuine orphan. &lt;strong&gt;Retire.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One signal, four different verdicts, and the discriminator in every case is context that lives nowhere in the metadata — naming conventions, which datastore the target sits in, what kind of table it is, whether "terminal" is normal for that kind of table. That's the work. The detector found the candidates; the agent adjudicated them; and it overruled the detector three times out of four, which is precisely what you want a judgement layer to do.&lt;/p&gt;

&lt;p&gt;The second thing the agent is good at is smelling migration risk in an expression. From the order fact load:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;ifthenelse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ORDERS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CURRENCY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'USD'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ORDERS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AMOUNT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ORDERS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AMOUNT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;66&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A hard-coded FX rate. One rate, for every currency, for all time. The agent flagged it — and then, correctly, refused to fix it: preserve &lt;code&gt;0.66&lt;/code&gt; exactly through cutover so reconciliation is byte-for-byte, and raise the rate-table question as a &lt;em&gt;deferred&lt;/em&gt; redesign afterwards. That's the right call and it's a slightly subtle one. The instinct on seeing bad code is to improve it. The instinct that survives a migration is to reproduce it faithfully and change it in a separate, visible step.&lt;/p&gt;

&lt;p&gt;The find I liked most, though, came out of the duplicate pair. Two flows both writing &lt;code&gt;DIM_CUSTOMER&lt;/code&gt;, 60% column overlap, obviously redundant. But look at how each one derives &lt;code&gt;STATUS&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;DF_DIM_CUSTOMER&lt;/span&gt;          &lt;span class="n"&gt;ifthenelse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CUSTOMER&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ACTIVE_FLG&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'Y'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'ACTIVE'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'INACTIVE'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;DF_DIM_CUSTOMER_REFRESH&lt;/span&gt;  &lt;span class="n"&gt;ifthenelse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CUSTOMER&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ACTIVE_FLG&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'A'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'ACTIVE'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'INACTIVE'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same column, same source, different sentinel. &lt;code&gt;'Y'&lt;/code&gt; in one, &lt;code&gt;'A'&lt;/code&gt; in the other. Merge those two flows by picking whichever expression you happened to be looking at, and you silently flip the active/inactive status of some portion of the customer base. It's a two-character difference across two files, and it went straight into the report as a hard blocker on the merge. That's the class of thing manual archaeology misses at 3pm on a Thursday, and it's a good illustration of what the two-layer split buys you: the &lt;em&gt;comparison&lt;/em&gt; was deterministic, the &lt;em&gt;significance&lt;/em&gt; was judgement, and neither layer could have produced the finding alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixed templates, because consistency is a feature
&lt;/h2&gt;

&lt;p&gt;Every agent-authored artefact fills a fixed template — one for pipeline specs, one for the portfolio report, one for verification verdicts. Same headings, same order, every run, with &lt;code&gt;- none&lt;/code&gt; where a section is empty rather than the section quietly vanishing.&lt;/p&gt;

&lt;p&gt;This is a small thing that pays continuously. Consistent structure means specs diff cleanly — against each other and against their own previous versions — reviewers build muscle memory, and changing the house style is a one-file edit instead of a re-prompt. Mostly it kills a whole category of drift you'd otherwise catch by feel, late, in review.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gate: assume the spec is wrong
&lt;/h2&gt;

&lt;p&gt;Here's the honest bit. Everything above still leaves you with output from a non-deterministic process. Constraints reduce the error rate; they don't take it to zero. Trusting the specs because the architecture is nice would be exactly the mistake I started out trying to avoid.&lt;/p&gt;

&lt;p&gt;So the last step is an adversarial verification pass. One subagent per spec, with an explicit brief: &lt;strong&gt;try to break it.&lt;/strong&gt; Assume every factual claim is wrong until ground truth proves it right. Do not read the spec and then go looking for support — establish ground truth independently first, from the fact store &lt;em&gt;and&lt;/em&gt; the raw XML, then extract every factual claim from the spec and classify it: SUPPORTED, UNSUPPORTED, CONTRADICTED, or MISLABELLED.&lt;/p&gt;

&lt;p&gt;The output is a claim-by-claim table with a PASS/FAIL verdict:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Claim&lt;/th&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;th&gt;Evidence&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Dead code: &lt;code&gt;OLD_ACCOUNTS.CLOSED_DT&lt;/code&gt; read but never mapped&lt;/td&gt;
&lt;td&gt;SUPPORTED&lt;/td&gt;
&lt;td&gt;facts &lt;code&gt;unused_source_columns = [{OLD_ACCOUNTS, CLOSED_DT}]&lt;/code&gt;; XML declares &lt;code&gt;CLOSED_DT&lt;/code&gt; in &lt;code&gt;&amp;lt;DISource&amp;gt;&lt;/code&gt; schema but it is absent from the &lt;code&gt;Query&lt;/code&gt; transform output schema&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Join/filter predicates: none&lt;/td&gt;
&lt;td&gt;SUPPORTED&lt;/td&gt;
&lt;td&gt;facts &lt;code&gt;predicates = []&lt;/code&gt; — genuinely empty. The added "None" line is accurate, not invented&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That second row is the one that made me trust the design. The verifier didn't just check that the stated facts were right; it checked that a claim of &lt;em&gt;absence&lt;/em&gt; was honest. "There are no filters here" is exactly the kind of statement that's easy to write and easy to be wrong about.&lt;/p&gt;

&lt;p&gt;The obvious objection is that I've now got a non-deterministic process checking a non-deterministic process, and that's fair. The verifier is an agent too; it can miss things. What it can't easily do is miss things &lt;em&gt;in the same direction&lt;/em&gt; as the author, because it isn't reading the author's reasoning — it goes back to the fact store and the raw XML and builds its own picture first. Two independent passes over the same ground truth have to fail in correlated ways to let an error through, which is a meaningfully harder thing to do by accident. That's a reduction in risk, not a proof of correctness, and it's exactly why the gate needs calibrating rather than trusting.&lt;/p&gt;

&lt;p&gt;Which is what the planted errors are for. Two things came out of running the gate for real. First, on a deliberate planted-error test it caught every injected error while still passing the clean spec — so it isn't just manufacturing objections to look diligent. Second, and more usefully, it caught a defect I wasn't looking for: a spec that had been correct when written and had gone &lt;strong&gt;stale&lt;/strong&gt;, because a new pipeline was added to the corpus afterwards and its dependency list no longer reflected reality.&lt;/p&gt;

&lt;p&gt;That reframed the whole thing for me. I built the gate expecting to catch hallucination. What it actually earns its keep on is staleness — documentation drifting behind the source. Which, if you think about it, is the failure mode that has plagued every hand-written data dictionary ever produced. The difference is that here regenerating and re-verifying costs minutes, so the documentation can be treated as &lt;em&gt;derived&lt;/em&gt; rather than authored, and re-derived whenever the source moves.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell someone starting this
&lt;/h2&gt;

&lt;p&gt;Split facts from judgement before you write a single prompt — not as a review step afterwards, as an architecture. If a script can determine it, a script should determine it, and the model should be reading the script's output rather than the raw source. Every other decision here fell out of that one, including the ones I didn't see coming.&lt;/p&gt;

&lt;p&gt;The corollary is that ground truth has to be &lt;em&gt;cheap to reach&lt;/em&gt;. A read-only fact server does more for accuracy than any amount of prompt engineering about accuracy, because it changes the economics rather than the instructions: guessing has to be the more expensive option, or eventually it won't be the one taken.&lt;/p&gt;

&lt;p&gt;Then label inference at the sentence level. A disclaimer at the top of a document is decoration — a reviewer stops seeing it by page two. &lt;code&gt;(inferred — confirm with SME)&lt;/code&gt; sitting in front of the specific claim is a routing instruction: this line is for the data owner, that line is for the migration engineer.&lt;/p&gt;

&lt;p&gt;And when you verify, verify against the source rather than the summary. A checker that reads the spec and goes looking for support will find it every time; one that reconstructs ground truth independently and &lt;em&gt;then&lt;/em&gt; hunts for contradictions is doing a completely different job, and only the second one tells you anything you didn't already believe.&lt;/p&gt;

&lt;p&gt;Then the caveats, because I said I'd be honest about them.&lt;/p&gt;

&lt;p&gt;The corpus I've been describing is synthetic — deliberately planted with a known answer key so I could measure whether the method recovers the truths rather than just producing confident-looking documents. It's a test of the approach, not a sample of any real estate, and no production data goes anywhere near it. Real BODS XML also nests its attributes differently to my samples, so pointing this at a live export means recalibrating the XPath in the extractor once. That's the only part that changes — but it does have to change, and I'd expect it to be a fiddlier afternoon than it sounds.&lt;/p&gt;

&lt;p&gt;The larger limit is that detection only sees what BODS records. It cannot see Tableau, Athena, or the analyst with a scheduled query nobody remembers writing. That's precisely why every retire and merge recommendation is gated on a human plus external-consumer evidence rather than fired automatically — the tool is allowed to nominate, never to decide.&lt;/p&gt;

&lt;p&gt;None of this made the model smarter. It made the model's job smaller. The interesting judgement — is this dead, is this a duplicate, is that hard-coded rate a bug or a contract — is exactly as hard as it was before, and it's still the reason anyone is doing the work. What changed is that it's no longer tangled up with a hundred low-level facts a database can answer exactly, and a reviewer can finally tell the two apart at a glance.&lt;/p&gt;

&lt;p&gt;Archaeology, done properly, is mostly about knowing which layer you're digging in.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The &lt;code&gt;etl-archaeologist&lt;/code&gt; tooling was written with Claude Code — the co-author trailers are in its commit history. The two-layer split and the constraint it hangs on are mine; most of the implementation is not. I didn't keep the prompt transcripts, which for a post arguing you should be able to audit what the AI did is an omission I notice.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>dataengineering</category>
      <category>etl</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Two YubiKeys, One Prompt Too Many</title>
      <dc:creator>Justin - FromTheArchitect</dc:creator>
      <pubDate>Thu, 20 Aug 2026 23:00:00 +0000</pubDate>
      <link>https://dev.to/fromthearchitect/two-yubikeys-one-prompt-too-many-d78</link>
      <guid>https://dev.to/fromthearchitect/two-yubikeys-one-prompt-too-many-d78</guid>
      <description>&lt;p&gt;I carry two YubiKeys. One lives on my keyring, one lives in a drawer, and both are enrolled everywhere that matters. A hardware token with no backup is a single point of failure you have chosen on purpose.&lt;/p&gt;

&lt;p&gt;For months I had a small irritation I never bothered to chase. Some days &lt;code&gt;git push&lt;/code&gt; asked for my PIN, I typed it, and it worked. Other days it asked for my PIN, failed, and then asked again, and the second one worked. Same command, same repo, same laptop. The variable turned out to be which of the two keys happened to be in the USB port.&lt;/p&gt;

&lt;p&gt;It is a five-second annoyance — and I hit it maybe fifteen times a day.&lt;/p&gt;

&lt;h2&gt;
  
  
  What was actually happening
&lt;/h2&gt;

&lt;p&gt;Both keys are &lt;code&gt;sk-ssh-ed25519&lt;/code&gt; credentials, and both get loaded into the gnome-keyring agent automatically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;ssh-add &lt;span class="nt"&gt;-l&lt;/span&gt;
256 SHA256:5imEVNLX...  yk1-12345678 &lt;span class="o"&gt;(&lt;/span&gt;ED25519-SK&lt;span class="o"&gt;)&lt;/span&gt;
256 SHA256:ATug2xnP...  yk2-12345679 &lt;span class="o"&gt;(&lt;/span&gt;ED25519-SK&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I had no &lt;code&gt;~/.ssh/config&lt;/code&gt; at all. Without one, ssh offers the agent's keys in the order the agent hands them over, and the server takes the first one it recognises. GitHub has both of these enrolled, so it accepts the first offer every time. Which means ssh commits to &lt;code&gt;yk1&lt;/code&gt; before anything has checked whether the token in the port can actually satisfy it.&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;yk1&lt;/code&gt; in the port, that guess is right and you see one prompt. With &lt;code&gt;yk2&lt;/code&gt; in the port, ssh asks the attached token to sign for a credential it has never heard of. You get a PIN dialog, you type your PIN, it fails, ssh falls through to the second key, and that one works.&lt;/p&gt;

&lt;p&gt;The detail that makes this worse than it needs to be is how these keys were generated. Both are resident keys with user verification required. The flags byte in the private key blob is &lt;code&gt;0x25&lt;/code&gt;, which decodes, using the constant names from OpenSSH's &lt;code&gt;sk-api.h&lt;/code&gt;, as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0x25 = SSH_SK_USER_PRESENCE_REQD | SSH_SK_USER_VERIFICATION_REQD | SSH_SK_RESIDENT_KEY
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;USER_VERIFICATION_REQD&lt;/code&gt; is what turns this from invisible into annoying, and the ordering that causes it sits above the token rather than inside it. Before ssh can request an assertion at all, the client has to obtain a PIN/UV auth token from the authenticator, and that is the step that puts the dialog on screen. Only after you have typed the PIN does the assertion request go down the wire, and only then does the key answer that it has never held this credential. Without the flag, the wrong-key attempt would fail silently and I would never have noticed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The information was already there
&lt;/h2&gt;

&lt;p&gt;Here is the part I feel slightly silly about. Look at the comments on those keys again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;yk1-12345678
yk2-12345679
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Past me, generating these, had already written the serial number of the owning YubiKey into each key's comment. The mapping from "which token is plugged in" to "which key can possibly work" had been sitting in &lt;code&gt;ssh-add -l&lt;/code&gt; output the entire time, and I had spent months not reading it.&lt;/p&gt;

&lt;p&gt;OpenSSH gives you exactly the right hook for this. &lt;code&gt;Match exec&lt;/code&gt; runs a shell command and applies the block if it exits zero:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Match host github.com,gitlab.com exec "%d/.ssh/yk-plugged 12345678"
    IdentityFile ~/.ssh/id_ed25519_sk_yk1
    IdentitiesOnly yes

Match host github.com,gitlab.com exec "%d/.ssh/yk-plugged 12345679"
    IdentityFile ~/.ssh/id_ed25519_sk_yk2
    IdentitiesOnly yes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the &lt;code&gt;host&lt;/code&gt; criterion, because leaving it off is the mistake that turns a papercut fix into a bad afternoon. &lt;code&gt;Match exec&lt;/code&gt; on its own applies to every destination, and the first block that matches pins &lt;code&gt;IdentitiesOnly&lt;/code&gt; and a single &lt;code&gt;IdentityFile&lt;/code&gt; for whatever you happen to be connecting to. Every other host, with every other key, stops authenticating.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;IdentitiesOnly yes&lt;/code&gt; is the load-bearing line, and it is the one people leave out. I had assumed the name meant "ignore the agent". The &lt;code&gt;-v&lt;/code&gt; output says otherwise: the agent still returns both keys. What the option actually restricts is which of them are eligible to be offered: only the ones whose public half matches an &lt;code&gt;IdentityFile&lt;/code&gt; entry in scope. Two keys in, one considered.&lt;/p&gt;

&lt;p&gt;That matching is also why the &lt;code&gt;IdentityFile&lt;/code&gt; paths have to exist on disk even though the keys live in the agent. Since these are resident credentials, it is entirely possible to arrive on a fresh machine, pull them off the token with &lt;code&gt;ssh-add -K&lt;/code&gt;, and have nothing in &lt;code&gt;~/.ssh&lt;/code&gt; at all. In that state the blocks match nothing and do nothing, which is confusing for about ten minutes.&lt;/p&gt;

&lt;p&gt;If neither serial matches, no block applies, and you fall back to the old behaviour of trying everything. That is the correct failure mode: a config that gets worse than no config when it does not understand the situation is a config you will eventually curse. If both keys happen to be attached, both blocks match and both &lt;code&gt;IdentityFile&lt;/code&gt; entries land in scope (&lt;code&gt;IdentityFile&lt;/code&gt; accumulates across directives, unlike most keywords), so you are back to a coin flip and possibly one doomed prompt. Correct behaviour, but it does mean the fix looks broken if you plug in the drawer key without unplugging the keyring one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bit that was actually hard
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Match exec&lt;/code&gt; runs on every single ssh invocation. Every &lt;code&gt;git fetch&lt;/code&gt;, every &lt;code&gt;scp&lt;/code&gt;, every &lt;code&gt;ssh -G&lt;/code&gt; a tool makes to introspect your config. Whatever goes in there has to be cheap.&lt;/p&gt;

&lt;p&gt;My first instinct was to read the serial out of sysfs, the way you would for most USB devices. That does not work:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /sys/bus/usb/devices/3-4/serial

&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /sys/bus/usb/devices/3-4/product
YubiKey OTP+FIDO+CCID
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Empty. A YubiKey does not publish its serial in the USB device descriptor. You have to ask the device over one of its application interfaces, which in practice means &lt;code&gt;ykman&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;ykman list &lt;span class="nt"&gt;--serials&lt;/span&gt;
12345678
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That call takes about 350 milliseconds on my laptop, most of it spent bringing the CCID interface up. Two &lt;code&gt;Match exec&lt;/code&gt; blocks means two invocations, so seven tenths of a second on every git operation in order to save five seconds fifteen times a day. The arithmetic does not work.&lt;/p&gt;

&lt;p&gt;So the helper caches. The question is what to key the cache on, and "a timeout" is the wrong answer. A 60-second TTL buys you either a minute of wrong answers after a swap or a minute of pointless &lt;code&gt;ykman&lt;/code&gt; calls, and you get to choose which.&lt;/p&gt;

&lt;p&gt;There is a better key available. The Linux USB stack assigns a device number on enumeration, and it increments. Swap tokens, even into the same physical port, and the number changes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="c"&gt;# yk1 attached
&lt;/span&gt;/&lt;span class="n"&gt;sys&lt;/span&gt;/&lt;span class="n"&gt;bus&lt;/span&gt;/&lt;span class="n"&gt;usb&lt;/span&gt;/&lt;span class="n"&gt;devices&lt;/span&gt;/&lt;span class="m"&gt;3&lt;/span&gt;-&lt;span class="m"&gt;4&lt;/span&gt;/  &lt;span class="n"&gt;busnum&lt;/span&gt;=&lt;span class="m"&gt;3&lt;/span&gt; &lt;span class="n"&gt;devnum&lt;/span&gt;=&lt;span class="m"&gt;10&lt;/span&gt;

&lt;span class="c"&gt;# swapped to yk2, same port
&lt;/span&gt;/&lt;span class="n"&gt;sys&lt;/span&gt;/&lt;span class="n"&gt;bus&lt;/span&gt;/&lt;span class="n"&gt;usb&lt;/span&gt;/&lt;span class="n"&gt;devices&lt;/span&gt;/&lt;span class="m"&gt;3&lt;/span&gt;-&lt;span class="m"&gt;4&lt;/span&gt;/  &lt;span class="n"&gt;busnum&lt;/span&gt;=&lt;span class="m"&gt;3&lt;/span&gt; &lt;span class="n"&gt;devnum&lt;/span&gt;=&lt;span class="m"&gt;11&lt;/span&gt;

&lt;span class="c"&gt;# swapped back to yk1
&lt;/span&gt;/&lt;span class="n"&gt;sys&lt;/span&gt;/&lt;span class="n"&gt;bus&lt;/span&gt;/&lt;span class="n"&gt;usb&lt;/span&gt;/&lt;span class="n"&gt;devices&lt;/span&gt;/&lt;span class="m"&gt;3&lt;/span&gt;-&lt;span class="m"&gt;4&lt;/span&gt;/  &lt;span class="n"&gt;busnum&lt;/span&gt;=&lt;span class="m"&gt;3&lt;/span&gt; &lt;span class="n"&gt;devnum&lt;/span&gt;=&lt;span class="m"&gt;12&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That gives a cache key derived from the thing whose change I care about. Reading it costs a &lt;code&gt;cat&lt;/code&gt; of a sysfs file. No timeout, no staleness window, no daemon watching udev.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/sh&lt;/span&gt;
&lt;span class="c"&gt;# Exit 0 if a YubiKey with the given serial number is attached.&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-eu&lt;/span&gt;
&lt;span class="nv"&gt;want&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;

&lt;span class="c"&gt;# Fingerprint the attached Yubico (vendor 1050) USB devices.&lt;/span&gt;
&lt;span class="nv"&gt;fp&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;d &lt;span class="k"&gt;in&lt;/span&gt; /sys/bus/usb/devices/&lt;span class="k"&gt;*&lt;/span&gt;/&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$d&lt;/span&gt;&lt;span class="s2"&gt;/idVendor"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;
    &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$d&lt;/span&gt;&lt;span class="s2"&gt;/idVendor"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; 1050 &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;continue
    &lt;/span&gt;&lt;span class="nv"&gt;fp&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$fp&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$d&lt;/span&gt;&lt;span class="s2"&gt;/busnum"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;-&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$d&lt;/span&gt;&lt;span class="s2"&gt;/devnum"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;,"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$fp&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1  &lt;span class="c"&gt;# nothing attached; no point spawning ykman&lt;/span&gt;

&lt;span class="c"&gt;# Deliberately no /tmp fallback; see below.&lt;/span&gt;
&lt;span class="nv"&gt;cache&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;XDG_RUNTIME_DIR&lt;/span&gt;:?&lt;span class="k"&gt;}&lt;/span&gt;/yk-serials
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$cache&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-n1&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$cache&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$fp&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nv"&gt;serials&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; +2 &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$cache&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;else
    &lt;/span&gt;&lt;span class="nv"&gt;serials&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;ykman list &lt;span class="nt"&gt;--serials&lt;/span&gt; 2&amp;gt;/dev/null&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nv"&gt;serials&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="c"&gt;# Don't cache a failed lookup (busy pcscd, ykman missing); it would stick&lt;/span&gt;
    &lt;span class="c"&gt;# until the next replug.&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$serials&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
        &lt;/span&gt;&lt;span class="nv"&gt;tmp&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$cache&lt;/span&gt;.&lt;span class="nv"&gt;$$&lt;/span&gt;
        &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;umask &lt;/span&gt;077&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s\n%s\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$fp&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$serials&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$tmp&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;mv&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$tmp&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$cache&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;fi
fi

&lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$serials&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-qx&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$want&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details in there are scar tissue rather than design. The early exit when no Yubico device is present keeps &lt;code&gt;ykman&lt;/code&gt; from being spawned at all on a laptop with nothing plugged in, which is the common case when I am working offline. And declining to cache an empty result matters because a transient failure — a busy &lt;code&gt;pcscd&lt;/code&gt;, a &lt;code&gt;ykman&lt;/code&gt; that is not installed yet — would otherwise be cached against a fingerprint that stays valid until the next replug.&lt;/p&gt;

&lt;p&gt;The hard requirement on &lt;code&gt;XDG_RUNTIME_DIR&lt;/code&gt; is there for a related reason. It is torn down at logout, so the cache can never outlive a boot. Fall back to &lt;code&gt;/tmp&lt;/code&gt; on a system where &lt;code&gt;/tmp&lt;/code&gt; survives a reboot and the fingerprint stops meaning anything: device numbers are allocated from the low end again after every boot, so a stale entry has a very good chance of matching one.&lt;/p&gt;

&lt;p&gt;Warm, the helper returns in about 9ms. Resolving the whole config with both &lt;code&gt;Match&lt;/code&gt; blocks in it (&lt;code&gt;ssh -G github.com&lt;/code&gt;) comes to 87ms, the remainder being ssh's own startup rather than anything I added. Cold, on the first connection after a swap, you pay the 350ms once.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checking it actually works
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ssh -v&lt;/code&gt; is useful here because it prints the whole decision path, key by key, rather than just the result. With the backup key in the port:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;debug1: get_agent_identities: agent returned 2 keys
debug1: Will attempt key: ~/.ssh/id_ed25519_sk_yk2 ED25519-SK
debug1: Offering public key: ~/.ssh/id_ed25519_sk_yk2 ED25519-SK
debug1: Server accepts key: ~/.ssh/id_ed25519_sk_yk2 ED25519-SK
Authenticated to github.com using &lt;span class="s2"&gt;"publickey"&lt;/span&gt;&lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two keys in, one attempt out. &lt;code&gt;agent returned 2 keys&lt;/code&gt; alongside a single &lt;code&gt;Offering public key&lt;/code&gt; is the whole fix between them: the agent's inventory is unchanged, and the filtering happened before anything went out on the wire, let alone reached the token.&lt;/p&gt;

&lt;p&gt;I tested the other direction too, and I want to be honest about what that proved, because it is less than it looks. With &lt;code&gt;yk1&lt;/code&gt; attached, the run is identical and authentication succeeds on the first attempt. But &lt;strong&gt;that direction already worked before any of this&lt;/strong&gt;, purely because &lt;code&gt;id_ed25519_sk_yk1&lt;/code&gt; sorts before &lt;code&gt;id_ed25519_sk_yk2&lt;/code&gt; and got offered first by luck. The value of testing it was confirming I had not broken the path that was fine. The &lt;code&gt;yk2&lt;/code&gt; case is the one that fixed a real failure.&lt;/p&gt;

&lt;p&gt;That asymmetry is also why the bug survived so long. Half my usage was already correct, so it never looked like a broken configuration — it looked like the drawer key being flaky.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does not fix
&lt;/h2&gt;

&lt;p&gt;It does not remove the PIN prompt. It removes the &lt;em&gt;doomed&lt;/em&gt; one. I still verify and still touch, which is the entire reason for carrying a hardware token at all; a change that removed those would be trading the whole point for a fraction of a second.&lt;/p&gt;

&lt;p&gt;There is one theoretical hole. USB device numbers wrap at 128, so a sufficiently unlucky sequence of replugs could land on a device number matching the cached one while holding a different token. The cost would be a single stale prompt, self-correcting on the next swap. I thought about defending against it for about a minute and then decided that engineering around a 1-in-128 chance of reproducing the exact bug I started with was not a good use of an afternoon.&lt;/p&gt;

&lt;h2&gt;
  
  
  The general shape
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Match exec&lt;/code&gt; is the most underused thing in &lt;code&gt;ssh_config&lt;/code&gt;. Most people know &lt;code&gt;Match host&lt;/code&gt; and stop there, and then work around everything else with wrapper scripts or shell aliases or a mess of &lt;code&gt;-i&lt;/code&gt; flags in their git remotes. But the predicate can be &lt;em&gt;any command&lt;/em&gt; — which means your ssh config can respond to essentially any fact about the machine it is running on: which VPN is up, which network you are on, whether a particular file exists, which hardware token is in the port.&lt;/p&gt;

&lt;p&gt;The constraint that makes it interesting is that it sits in the hot path of every connection, so the fact you want has to be cheap to establish. Half the work here was not "how do I select the right key", which is four lines. It was "how do I answer a 350ms question in under 10ms without ever answering it wrong". That is a caching problem, and the trick is nearly always the same — find the thing that already changes when your answer changes, and key on that instead of on the clock.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Setup notes: Fedora, OpenSSH 10.2p1, YubiKey 5C NFC on firmware 5.4.3, keys held by the gnome-keyring agent at &lt;code&gt;$XDG_RUNTIME_DIR/gcr/ssh&lt;/code&gt;. Serial numbers in the examples are made up; use your own from &lt;code&gt;ykman list --serials&lt;/code&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Reviewed with Claude, which caught the unscoped &lt;code&gt;Match&lt;/code&gt; blocks and an error in how I'd explained the PIN/UV ordering. Prose and conclusions are mine.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ssh</category>
      <category>yubikey</category>
      <category>fido2</category>
      <category>linux</category>
    </item>
    <item>
      <title>Building GNOME Apps with Rust, Part 6: Fetching Feeds</title>
      <dc:creator>Justin - FromTheArchitect</dc:creator>
      <pubDate>Tue, 18 Aug 2026 23:16:31 +0000</pubDate>
      <link>https://dev.to/fromthearchitect/building-gnome-apps-with-rust-part-6-fetching-feeds-bjf</link>
      <guid>https://dev.to/fromthearchitect/building-gnome-apps-with-rust-part-6-fetching-feeds-bjf</guid>
      <description>&lt;p&gt;&lt;em&gt;This is Part 6 of a series taking a GNOME app from an empty directory to GNOME Circle. &lt;a href="https://dev.to/fromthearchitect/building-gnome-apps-with-rust-part-5-state-and-signals-4pb9"&gt;Part 5&lt;/a&gt; wired the sidebar to a real &lt;code&gt;Feed&lt;/code&gt; GObject and a &lt;code&gt;feed-selected&lt;/code&gt; signal — selecting a row updates the content pane and prints a line to the terminal. This is the post where that line stops being a placeholder and becomes a real network fetch.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The click that still does nothing real
&lt;/h2&gt;

&lt;p&gt;Select &lt;em&gt;This Week in GNOME&lt;/em&gt;, then select &lt;em&gt;Hacker News&lt;/em&gt;. The placeholder swaps in the new name and URL — everything the app knows about that feed, and none of what's actually in it. Every &lt;code&gt;Feed&lt;/code&gt; in the sidebar has a &lt;code&gt;uri&lt;/code&gt; pointing at a real RSS or Atom document on the actual internet, and nothing in the codebase has ever asked one of them what's there.&lt;/p&gt;

&lt;p&gt;The obvious fix — fetch the URL when &lt;code&gt;feed-selected&lt;/code&gt; fires — is one line of code and the wrong line of code. GTK doesn't have a spare thread lying around to do that on, and the thread it does have is busy.&lt;/p&gt;

&lt;p&gt;By the end of this post, selecting &lt;em&gt;Hacker News&lt;/em&gt; in the sidebar prints real headlines to the terminal, fetched over the network, without the window so much as flickering. We'll build a Tokio runtime alongside GTK's own, give &lt;code&gt;Feed&lt;/code&gt; somewhere to put what it fetches, and draw a hard line neither executor is allowed to cross.&lt;/p&gt;




&lt;h2&gt;
  
  
  The freeze
&lt;/h2&gt;

&lt;p&gt;Wire the obvious fix into the &lt;code&gt;feed-selected&lt;/code&gt; handler first, so you can watch it fail:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="nf"&gt;.connect_closure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"feed-selected"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nn"&gt;glib&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nd"&gt;closure_local!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;move&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="n"&gt;_window&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;GazetteWindow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;feed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Feed&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Don't do this.&lt;/span&gt;
        &lt;span class="nn"&gt;std&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;thread&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;std&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;time&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from_secs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
        &lt;span class="nd"&gt;eprintln!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"fetched (fake): {}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;feed&lt;/span&gt;&lt;span class="nf"&gt;.name&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;std::thread::sleep&lt;/code&gt; stands in for "network request in flight." Run the app and click a feed. For two seconds the window stops responding to anything — no hover states, no resize, no cursor blink, nothing repaints. Click a different feed while the first is still "fetching" and the click doesn't register until the sleep ends; it queues up invisibly and the window only notices you clicked at all once the first one finishes.&lt;/p&gt;

&lt;p&gt;If you have GTK Inspector open (&lt;code&gt;GTK_DEBUG=interactive&lt;/code&gt;, or Ctrl+Shift+I) while you try this, its own live views stall for the same two seconds — Inspector needs the app's main loop to be responsive to answer its own queries, so a frozen loop is invisible to Inspector too. Worth knowing the next time you're not sure whether &lt;em&gt;your&lt;/em&gt; code froze or something else did.&lt;/p&gt;

&lt;p&gt;Revert the handler to the logging-only version from Part 5 before continuing. The freeze was the demonstration, not a step you keep.&lt;/p&gt;




&lt;h2&gt;
  
  
  One main loop, nothing else runs until you give it back
&lt;/h2&gt;

&lt;p&gt;GTK schedules everything through a single &lt;code&gt;glib::MainContext&lt;/code&gt;, running cooperatively on a single thread: one job finishes before the next one starts. When you clicked a feed a moment ago, the redraw that should have shown the hover state, the cursor update, the eventual repaint once the click registered — all of it queued behind your &lt;code&gt;feed-selected&lt;/code&gt; handler, waiting for &lt;code&gt;std::thread::sleep&lt;/code&gt; to let go of the only thread any of it can run on.&lt;/p&gt;

&lt;p&gt;That's a deliberate tradeoff. Exactly one thread ever touches a widget, so there's no locking, no data races, no &lt;code&gt;Mutex&amp;lt;Widget&amp;gt;&lt;/code&gt;. The price is that the one thread has to keep coming back. Block it — a sleep, a synchronous file read, a blocking network call, anything — and every other queued job waits behind you.&lt;/p&gt;

&lt;p&gt;This is also why "just spawn a thread and do it there" isn't a fix, only a different failure. Almost every GTK/GObject type is &lt;code&gt;!Send&lt;/code&gt;: the bindings won't even let you move a &lt;code&gt;Feed&lt;/code&gt; or a widget handle across a thread boundary — it's a compile error, not a runtime one. GObject's own machinery assumes single-threaded access and doesn't defend against concurrent calls from a second thread. A background thread can compute a result, but it can't hand that result to a widget directly. Something still has to get the result back onto the one thread allowed to touch GTK state.&lt;/p&gt;




&lt;h2&gt;
  
  
  Two executors, one job each
&lt;/h2&gt;

&lt;p&gt;The pattern that resolves this has a name because the shape recurs in every GTK app that talks to a network: two executors, each with a rule it never breaks.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;GLib main loop&lt;/strong&gt; owns every widget, every GObject, every signal, and it must never block.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tokio&lt;/strong&gt; owns anything that can block — the network request here, file I/O or database queries later. It runs on its own thread pool, and it never touches a widget or a GObject directly. That's its rule.&lt;/p&gt;

&lt;p&gt;The two only meet at one seam: a future running on the GLib main context can &lt;code&gt;.await&lt;/code&gt; a handle to work Tokio is doing, and when that work finishes, execution resumes back on the main context with the result now sitting there as a plain value. Nothing crosses except that value — no &lt;code&gt;Feed&lt;/code&gt;, no &lt;code&gt;gtk::Label&lt;/code&gt;, nothing GTK owns ever travels to a Tokio thread, and no raw socket or blocking read ever runs on the GLib thread. The rest of this post is standing up both executors and building that one seam.&lt;/p&gt;




&lt;h2&gt;
  
  
  Standing up the second executor
&lt;/h2&gt;

&lt;p&gt;Add the dependencies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="py"&gt;tokio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"1.53.1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="py"&gt;features&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"rt-multi-thread"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"net"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"time"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="py"&gt;feed-rs&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"2.4.0"&lt;/span&gt;
&lt;span class="py"&gt;reqwest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"0.12"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="py"&gt;default-features&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="py"&gt;features&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"rustls-tls"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;rt-multi-thread&lt;/code&gt; gives the runtime its worker threads; &lt;code&gt;net&lt;/code&gt; and &lt;code&gt;time&lt;/code&gt; give it the I/O and timer drivers &lt;code&gt;reqwest&lt;/code&gt; — and any future &lt;code&gt;tokio::time::sleep&lt;/code&gt; — actually needs. That matters more than it looks like it should: &lt;code&gt;reqwest&lt;/code&gt; also depends on &lt;code&gt;tokio&lt;/code&gt; and pulls in &lt;code&gt;net&lt;/code&gt;/&lt;code&gt;time&lt;/code&gt; itself, and Cargo unifies features across the whole dependency graph. Leave them off &lt;code&gt;tokio&lt;/code&gt;'s own line and the project still compiles today, by accident of what else happens to be in &lt;code&gt;Cargo.toml&lt;/code&gt;. It breaks the day &lt;code&gt;reqwest&lt;/code&gt; changes its feature list, or the day someone copies this dependency line into a project with no &lt;code&gt;reqwest&lt;/code&gt; in it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;reqwest&lt;/code&gt; with &lt;code&gt;default-features = false&lt;/code&gt; and &lt;code&gt;rustls-tls&lt;/code&gt; pulls in a pure-Rust TLS stack instead of linking against the system's OpenSSL — one less thing the Flatpak manifest needs to account for. The &lt;code&gt;0.12&lt;/code&gt; pin is deliberate too: &lt;code&gt;0.13&lt;/code&gt; renamed that feature to &lt;code&gt;rustls&lt;/code&gt; and made it the default backend, which would leave the line above redundant rather than wrong, but &lt;code&gt;0.12&lt;/code&gt; is what this post was written and tested against, and mixing versions is a worse trap than an old pin.&lt;/p&gt;

&lt;p&gt;The runtime itself is built once, in &lt;code&gt;main()&lt;/code&gt;, before the GTK application exists:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The second executor. Built before the GTK application and dropped&lt;/span&gt;
&lt;span class="c1"&gt;// after it exits, so it outlives every fetch that borrows its handle —&lt;/span&gt;
&lt;span class="c1"&gt;// the GLib main loop is the other executor, and it never blocks&lt;/span&gt;
&lt;span class="c1"&gt;// waiting on this one.&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;tokio_runtime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;tokio&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;runtime&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;Builder&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new_multi_thread&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nf"&gt;.worker_threads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;.thread_name&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"gazette-fetch"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;.enable_all&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nf"&gt;.build&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nf"&gt;.expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"failed to build Tokio runtime"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;GazetteApplication&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"io.github.fromthearchitect.gazette"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nn"&gt;gio&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;ApplicationFlags&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;empty&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;tokio_runtime&lt;/span&gt;&lt;span class="nf"&gt;.handle&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.clone&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;exit_code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="nf"&gt;.run&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// `tokio_runtime` would drop here anyway at end of scope; the explicit&lt;/span&gt;
&lt;span class="c1"&gt;// drop just documents the ordering. `Runtime::drop` blocks this thread&lt;/span&gt;
&lt;span class="c1"&gt;// until every worker stops and abandons whatever tasks haven't&lt;/span&gt;
&lt;span class="c1"&gt;// finished — abrupt, not a cooperative cancel — which is still better&lt;/span&gt;
&lt;span class="c1"&gt;// than a fetch outliving the window that would have consumed it.&lt;/span&gt;
&lt;span class="nf"&gt;drop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tokio_runtime&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;exit_code&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;app.run()&lt;/code&gt; blocks until the GTK application quits — that's the GLib main loop, occupying this thread for the entire life of the app. The Tokio runtime doesn't need this thread; &lt;code&gt;Builder::new_multi_thread&lt;/code&gt; spins up its own worker threads (two of them here, named for easy identification in a debugger) and hands back a &lt;code&gt;Runtime&lt;/code&gt; you only need to keep alive, not sit inside. &lt;code&gt;tokio_runtime.handle().clone()&lt;/code&gt; is the thing that actually travels: a cheap, cloneable reference that can schedule work onto the runtime from any thread, including the GLib main thread.&lt;/p&gt;

&lt;p&gt;One trap worth flagging on its own: &lt;strong&gt;forgetting &lt;code&gt;.enable_all()&lt;/code&gt; fails silently until the first real request.&lt;/strong&gt; A &lt;code&gt;Builder&lt;/code&gt; without it produces a runtime with no I/O or timer driver — a different gap from the missing Cargo features above, this one is about whether a correctly-compiled runtime &lt;em&gt;instance&lt;/em&gt; turns its drivers on. It builds fine, &lt;code&gt;main()&lt;/code&gt; runs fine, the window opens fine, and the first time a spawned task tries to do the thing a runtime is for — &lt;code&gt;reqwest::get&lt;/code&gt; or &lt;code&gt;tokio::time::sleep&lt;/code&gt; — it panics with a message about no I/O driver running. The bug is invisible until the exact code path that needs the missing driver executes, which is exactly the fetch path this post is building.&lt;/p&gt;

&lt;p&gt;That &lt;code&gt;Handle&lt;/code&gt; needs a home the rest of the app can reach. &lt;code&gt;GazetteApplication&lt;/code&gt; already owns everything else global to a running instance, so it owns this too — the same &lt;code&gt;OnceCell&lt;/code&gt; shape Part 5 used for &lt;code&gt;feeds: OnceCell&amp;lt;gio::ListStore&amp;gt;&lt;/code&gt; on the window: set once, read forever after.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="nd"&gt;#[derive(Debug,&lt;/span&gt; &lt;span class="nd"&gt;Default)]&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;GazetteApplication&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// The second executor. Built once in `main()`, before the GTK&lt;/span&gt;
    &lt;span class="c1"&gt;// application runs, and outlives every fetch that borrows it.&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;tokio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;OnceCell&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nn"&gt;tokio&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;runtime&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Handle&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;impl&lt;/span&gt; &lt;span class="n"&gt;GazetteApplication&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;application_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;flags&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nn"&gt;gio&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;ApplicationFlags&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;tokio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nn"&gt;tokio&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;runtime&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Handle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;Self&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;Self&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;glib&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="nf"&gt;.property&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"application-id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;application_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="nf"&gt;.property&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"flags"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;flags&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="nf"&gt;.property&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"resource-base-path"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"/io/github/fromthearchitect/gazette"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="nf"&gt;.build&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="nf"&gt;.imp&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="py"&gt;.tokio&lt;/span&gt;
            &lt;span class="nf"&gt;.set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tokio&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="nf"&gt;.expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"tokio handle set once at construction"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;app&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="cd"&gt;/// The shared Tokio runtime handle — the executor every network fetch&lt;/span&gt;
    &lt;span class="cd"&gt;/// runs on. Available from any `GazetteWindow` via its `application()`.&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;tokio_handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nn"&gt;tokio&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;runtime&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Handle&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="nf"&gt;.imp&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="py"&gt;.tokio&lt;/span&gt;&lt;span class="nf"&gt;.get&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"tokio handle set"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.clone&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;build()&lt;/code&gt; returns a fully constructed object. If &lt;code&gt;GazetteApplication&lt;/code&gt; ever grows a &lt;code&gt;constructed()&lt;/code&gt; that reaches for &lt;code&gt;tokio_handle()&lt;/code&gt;, it will find the cell empty — &lt;code&gt;constructed()&lt;/code&gt; runs &lt;em&gt;during&lt;/em&gt; &lt;code&gt;build()&lt;/code&gt;, before the line that sets &lt;code&gt;tokio&lt;/code&gt; ever executes. Today's code escapes that trap by timing rather than by ordering: nothing that needs the handle runs until GTK calls &lt;code&gt;activate&lt;/code&gt;, and &lt;code&gt;activate&lt;/code&gt; doesn't fire until &lt;code&gt;app.run()&lt;/code&gt;, long after &lt;code&gt;new&lt;/code&gt; has returned with &lt;code&gt;tokio&lt;/code&gt; set. Any window can reach the runtime through &lt;code&gt;window.application()&lt;/code&gt;, downcast to &lt;code&gt;GazetteApplication&lt;/code&gt;, and call &lt;code&gt;tokio_handle()&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Fetching, off the main thread
&lt;/h2&gt;

&lt;p&gt;The fetch itself lives in a new file, &lt;code&gt;src/fetch.rs&lt;/code&gt;, deliberately separate from &lt;code&gt;feed.rs&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;std&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="cd"&gt;/// A single parsed entry from a feed. Plain data — not a GObject. It's&lt;/span&gt;
&lt;span class="cd"&gt;/// built on the Tokio runtime and only crosses onto the GLib main context&lt;/span&gt;
&lt;span class="cd"&gt;/// as a value, via `Feed::set_items`.&lt;/span&gt;
&lt;span class="nd"&gt;#[derive(Debug,&lt;/span&gt; &lt;span class="nd"&gt;Clone)]&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;FeedItem&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;link&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;#[derive(Debug)]&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;enum&lt;/span&gt; &lt;span class="n"&gt;FetchError&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;reqwest&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nf"&gt;Parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;feed_rs&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;parser&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;ParseFeedError&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;impl&lt;/span&gt; &lt;span class="nn"&gt;fmt&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Display&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;FetchError&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;fmt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="nn"&gt;fmt&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Formatter&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nv"&gt;'_&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nn"&gt;fmt&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;Result&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nn"&gt;FetchError&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nd"&gt;write!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"request failed: {e}"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="nn"&gt;FetchError&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;Parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nd"&gt;write!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"couldn't parse feed: {e}"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="cd"&gt;/// Fetches and parses a feed at `uri`. Runs entirely on the Tokio runtime —&lt;/span&gt;
&lt;span class="cd"&gt;/// nothing in this function, or anything it calls, touches a widget or a&lt;/span&gt;
&lt;span class="cd"&gt;/// GObject. That's what makes it safe to run off the GLib main thread.&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;fetch_feed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;FeedItem&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;FetchError&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;reqwest&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;.await&lt;/span&gt;
        &lt;span class="nf"&gt;.map_err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;FetchError&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;
        &lt;span class="nf"&gt;.error_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="nf"&gt;.map_err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;FetchError&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="nf"&gt;.bytes&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="nf"&gt;.map_err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;FetchError&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;feed_rs&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;parser&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="nf"&gt;.map_err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;FetchError&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Parse&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parsed&lt;/span&gt;
        &lt;span class="py"&gt;.entries&lt;/span&gt;
        &lt;span class="nf"&gt;.into_iter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="nf"&gt;.map&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;FeedItem&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt;
                &lt;span class="py"&gt;.title&lt;/span&gt;
                &lt;span class="nf"&gt;.map&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="py"&gt;.content&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="nf"&gt;.unwrap_or_else&lt;/span&gt;&lt;span class="p"&gt;(||&lt;/span&gt; &lt;span class="s"&gt;"Untitled"&lt;/span&gt;&lt;span class="nf"&gt;.to_string&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
            &lt;span class="n"&gt;link&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt;
                &lt;span class="py"&gt;.links&lt;/span&gt;
                &lt;span class="nf"&gt;.first&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                &lt;span class="nf"&gt;.map&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;link&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;link&lt;/span&gt;&lt;span class="py"&gt;.href&lt;/span&gt;&lt;span class="nf"&gt;.clone&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
                &lt;span class="nf"&gt;.unwrap_or_default&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="py"&gt;.summary&lt;/span&gt;&lt;span class="nf"&gt;.map&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="py"&gt;.content&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="nf"&gt;.collect&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;FeedItem&lt;/code&gt; is a plain struct rather than a &lt;code&gt;glib::wrapper!&lt;/code&gt; type, and that matters: constructing a GObject means calling into GObject's type system, and this function has no business doing that from a Tokio worker thread. &lt;code&gt;fetch_feed&lt;/code&gt; produces data; something back on the main thread decides what GObject, if any, that data becomes.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;error_for_status()&lt;/code&gt; matters more than it looks. Without it, &lt;code&gt;reqwest::get&lt;/code&gt; resolves &lt;code&gt;Ok&lt;/code&gt; for a 404, a 500, or a hosting provider's error interstitial — the HTTP request itself succeeded even though the content is a lie. Skip the check and &lt;code&gt;feed_rs::parser::parse&lt;/code&gt; gets handed an HTML error page instead of a feed, fails to parse it, and the caller sees &lt;code&gt;FetchError::Parse&lt;/code&gt; for what was actually a request-level failure. Checking status before reading the body is what keeps &lt;code&gt;Request&lt;/code&gt; meaning "didn't get a real feed body at all" — whether the cause is DNS, a timeout, or a 404 — and &lt;code&gt;Parse&lt;/code&gt; meaning "got a body and it wasn't a feed."&lt;/p&gt;

&lt;p&gt;&lt;code&gt;feed_rs::parser::parse&lt;/code&gt; takes anything implementing &lt;code&gt;std::io::Read&lt;/code&gt; (a byte slice qualifies) and returns entries regardless of whether the source was RSS or Atom. Gazette's four sample feeds are a mix of both — &lt;em&gt;This Week in GNOME&lt;/em&gt; and &lt;em&gt;From the Architect&lt;/em&gt; are Atom, &lt;em&gt;LWN&lt;/em&gt; and the Hacker News mirror are RSS — and &lt;code&gt;fetch_feed&lt;/code&gt; doesn't need to know or care which. That format detection is &lt;code&gt;feed-rs&lt;/code&gt;'s job, done once, before any of Gazette's code sees an entry.&lt;/p&gt;

&lt;p&gt;One thing worth knowing, not fixing here: &lt;code&gt;reqwest::get&lt;/code&gt; is a convenience function that builds a fresh &lt;code&gt;Client&lt;/code&gt; — and with it, a fresh connection pool and TLS session — on every call. Fine for a post about the boundary between two executors, wasteful in code that fetches the same handful of feeds over and over. Building a &lt;code&gt;Client&lt;/code&gt; once and sharing it alongside the Tokio handle is a refinement for a later post, once persistence is in the picture.&lt;/p&gt;




&lt;h2&gt;
  
  
  Crossing back
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;feed-selected&lt;/code&gt; handler is where the two executors actually meet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="nf"&gt;.connect_closure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"feed-selected"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nn"&gt;glib&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nd"&gt;closure_local!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;move&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="n"&gt;window&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;GazetteWindow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;feed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Feed&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nd"&gt;eprintln!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"feed selected: {} ({})"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;feed&lt;/span&gt;&lt;span class="nf"&gt;.name&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;feed&lt;/span&gt;&lt;span class="nf"&gt;.uri&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

        &lt;span class="c1"&gt;// The GLib executor owns `feed` and `window`; the Tokio&lt;/span&gt;
        &lt;span class="c1"&gt;// executor (below) only ever sees the URI string it needs&lt;/span&gt;
        &lt;span class="c1"&gt;// to fetch. Neither side touches the other's objects&lt;/span&gt;
        &lt;span class="c1"&gt;// directly — the `.await` on the join handle is the only&lt;/span&gt;
        &lt;span class="c1"&gt;// crossing.&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;tokio_handle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;window&lt;/span&gt;
            &lt;span class="nf"&gt;.application&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="py"&gt;.and_downcast&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;crate&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;application&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;GazetteApplication&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="nf"&gt;.expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"window has a GazetteApplication"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="nf"&gt;.tokio_handle&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;uri&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;feed&lt;/span&gt;&lt;span class="nf"&gt;.uri&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="nn"&gt;glib&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;MainContext&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;default&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.spawn_local&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;move&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tokio_handle&lt;/span&gt;
                &lt;span class="nf"&gt;.spawn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;move&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;crate&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;fetch_feed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
                &lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

            &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;feed&lt;/span&gt;&lt;span class="nf"&gt;.set_items&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;Err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fetch_err&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="nd"&gt;eprintln!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"fetch failed for {}: {fetch_err}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;feed&lt;/span&gt;&lt;span class="nf"&gt;.name&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;
                &lt;span class="nf"&gt;Err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;join_err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="nd"&gt;eprintln!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                        &lt;span class="s"&gt;"fetch task for {} did not complete: {join_err}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                        &lt;span class="n"&gt;feed&lt;/span&gt;&lt;span class="nf"&gt;.name&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                    &lt;span class="p"&gt;);&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read it from the outside in. &lt;code&gt;glib::MainContext::default().spawn_local(async move { ... })&lt;/code&gt; schedules the outer future on the GLib main context — the same executor that owns every widget — running it cooperatively alongside everything else, exactly like a signal handler. Because it's confined to the main context, it's allowed to hold &lt;code&gt;feed&lt;/code&gt; and eventually call &lt;code&gt;feed.set_items(...)&lt;/code&gt;. That's still executor-one territory.&lt;/p&gt;

&lt;p&gt;Inside it, &lt;code&gt;tokio_handle.spawn(async move { crate::fetch::fetch_feed(&amp;amp;uri).await })&lt;/code&gt; hands the actual work to executor two. &lt;code&gt;Handle::spawn&lt;/code&gt; returns immediately with a &lt;code&gt;JoinHandle&lt;/code&gt;; it doesn't block the caller, which is the entire point of calling it from the main thread. &lt;code&gt;.await&lt;/code&gt;ing that &lt;code&gt;JoinHandle&lt;/code&gt; is where the outer future pauses: control genuinely returns to the GLib main loop, which goes on painting, handling input, and dispatching other signals while Tokio's worker threads do the fetch. When the Tokio task finishes, the main loop resumes this specific future exactly where it left off, back on the main thread, with &lt;code&gt;result&lt;/code&gt; now holding a value.&lt;/p&gt;

&lt;p&gt;Why &lt;code&gt;Ok(Ok(items))&lt;/code&gt; and not just &lt;code&gt;Ok(items)&lt;/code&gt;? Two different things can go wrong, and they're wrapped separately. The outer &lt;code&gt;Result&lt;/code&gt; comes from &lt;code&gt;JoinHandle&lt;/code&gt; itself — its &lt;code&gt;Err&lt;/code&gt; means the spawned task panicked or was cancelled, a Tokio-level failure that has nothing to do with feeds. The inner &lt;code&gt;Result&lt;/code&gt; is &lt;code&gt;fetch_feed&lt;/code&gt;'s own &lt;code&gt;Result&amp;lt;Vec&amp;lt;FeedItem&amp;gt;, FetchError&amp;gt;&lt;/code&gt;, a normal, expected outcome of trying to fetch something over a network that might be down. Matching both layers explicitly is what forces you to decide what each one means, rather than letting &lt;code&gt;?&lt;/code&gt; collapse them into a single generic failure.&lt;/p&gt;

&lt;p&gt;And a specific footgun: &lt;strong&gt;&lt;code&gt;tokio::spawn(...)&lt;/code&gt;, the free function, panics here — &lt;code&gt;tokio_handle.spawn(...)&lt;/code&gt; doesn't.&lt;/strong&gt; The free function only works from &lt;em&gt;inside&lt;/em&gt; a future that's already running on a Tokio runtime. Called from the GLib main thread, which isn't inside any Tokio runtime, it panics; the exact wording has changed across Tokio versions, but it always amounts to "no runtime found in this context." &lt;code&gt;Handle::spawn&lt;/code&gt;, called on the &lt;code&gt;Handle&lt;/code&gt; cloned out in &lt;code&gt;main()&lt;/code&gt;, is explicitly designed to work from any thread, runtime or not — that's the entire reason &lt;code&gt;GazetteApplication&lt;/code&gt; stores a &lt;code&gt;Handle&lt;/code&gt; instead of a &lt;code&gt;Runtime&lt;/code&gt;. If you ever see that panic, you've reached for the free function where you needed the handle.&lt;/p&gt;

&lt;p&gt;Run the app now. Click &lt;em&gt;Hacker News&lt;/em&gt;: after a brief pause — a real network round trip, with the window still live under your cursor — real headlines appear on stderr. Click &lt;em&gt;LWN.net Headlines&lt;/em&gt; before the first fetch has printed anything, and both proceed independently.&lt;/p&gt;




&lt;h2&gt;
  
  
  Feed remembers what it fetched
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;set_items&lt;/code&gt; is doing real work in that handler, and &lt;code&gt;Feed&lt;/code&gt; needs somewhere to put what it's given:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="k"&gt;crate&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;FeedItem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nd"&gt;#[derive(Debug,&lt;/span&gt; &lt;span class="nd"&gt;Default,&lt;/span&gt; &lt;span class="nd"&gt;Properties)]&lt;/span&gt;
&lt;span class="nd"&gt;#[properties(wrapper_type&lt;/span&gt; &lt;span class="nd"&gt;=&lt;/span&gt; &lt;span class="nd"&gt;super::Feed)]&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;Feed&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nd"&gt;#[property(get,&lt;/span&gt; &lt;span class="nd"&gt;set)]&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;RefCell&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nd"&gt;#[property(get,&lt;/span&gt; &lt;span class="nd"&gt;set)]&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;RefCell&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nd"&gt;#[property(get,&lt;/span&gt; &lt;span class="nd"&gt;set)]&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;unread_count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Cell&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;u32&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

    &lt;span class="c1"&gt;// Plain data, not a property: `Vec&amp;lt;FeedItem&amp;gt;` has no `glib::Value`&lt;/span&gt;
    &lt;span class="c1"&gt;// mapping, and nothing outside this module needs to bind to it&lt;/span&gt;
    &lt;span class="c1"&gt;// directly. `items()` and `set_items()` are the accessors.&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;RefCell&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;FeedItem&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;impl&lt;/span&gt; &lt;span class="n"&gt;Feed&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;FeedItem&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="nf"&gt;.imp&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="py"&gt;.items&lt;/span&gt;&lt;span class="nf"&gt;.borrow&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.clone&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="cd"&gt;/// Replaces this feed's items and emits `items-updated`. The count&lt;/span&gt;
    &lt;span class="cd"&gt;/// crossing over from a real fetch is what makes `unread-count` real&lt;/span&gt;
    &lt;span class="cd"&gt;/// too, rather than the placeholder zero every sample feed starts with.&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;set_items&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;FeedItem&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="nf"&gt;.set_unread_count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="nf"&gt;.len&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;u32&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="nf"&gt;.imp&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="py"&gt;.items&lt;/span&gt;&lt;span class="nf"&gt;.replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.emit_by_name&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"items-updated"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;[]);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;items&lt;/code&gt; isn't a &lt;code&gt;#[property]&lt;/code&gt;: a &lt;code&gt;Vec&amp;lt;FeedItem&amp;gt;&lt;/code&gt; has no &lt;code&gt;glib::Value&lt;/code&gt; representation the way a &lt;code&gt;String&lt;/code&gt; or a &lt;code&gt;u32&lt;/code&gt; does, and nothing outside &lt;code&gt;Feed&lt;/code&gt; needs to bind a widget to the raw list. It's a &lt;code&gt;RefCell&lt;/code&gt; for the same reason &lt;code&gt;selected_feed&lt;/code&gt; was one back in Part 5 — a value that changes over the object's life, read from one place (&lt;code&gt;items()&lt;/code&gt;), written from one place (&lt;code&gt;set_items()&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;items-updated&lt;/code&gt; signal was declared back in Part 2, on a &lt;code&gt;Feed&lt;/code&gt; that never actually got new items, then Part 5 dropped it entirely to avoid emitting ceremony for nothing. It comes back here on the same &lt;code&gt;impl&lt;/code&gt; block that already derives &lt;code&gt;Feed&lt;/code&gt;'s properties — with one change from Part 2's sketch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="nd"&gt;#[glib::derived_properties]&lt;/span&gt;
&lt;span class="k"&gt;impl&lt;/span&gt; &lt;span class="n"&gt;ObjectImpl&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;Feed&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;signals&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;'static&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Signal&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;SIGNALS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;OnceLock&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Signal&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;OnceLock&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="n"&gt;SIGNALS&lt;/span&gt;&lt;span class="nf"&gt;.get_or_init&lt;/span&gt;&lt;span class="p"&gt;(||&lt;/span&gt; &lt;span class="nd"&gt;vec!&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nn"&gt;Signal&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"items-updated"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.build&lt;/span&gt;&lt;span class="p"&gt;()])&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Part 2's version carried a &lt;code&gt;u32&lt;/code&gt; payload — the new item count, passed directly on the signal. This one carries nothing. &lt;code&gt;Feed&lt;/code&gt; has an &lt;code&gt;unread-count&lt;/code&gt; property now, so a listener that wants the count can just read &lt;code&gt;feed.unread_count()&lt;/code&gt; instead of catching it off the emission. A signal that announces &lt;em&gt;something changed&lt;/em&gt; and lets listeners go look is easier to evolve than one that ships a payload every listener has to keep accepting — that's the shape the rest of this post builds on.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;#[glib::derived_properties]&lt;/code&gt; has to stay on this &lt;code&gt;impl&lt;/code&gt;, even though &lt;code&gt;signals()&lt;/code&gt; has nothing to do with properties: it's what generates &lt;code&gt;properties()&lt;/code&gt;, &lt;code&gt;property()&lt;/code&gt;, and &lt;code&gt;set_property()&lt;/code&gt; from the &lt;code&gt;#[property]&lt;/code&gt; fields on the struct above. Drop it while adding &lt;code&gt;signals()&lt;/code&gt; and every &lt;code&gt;#[property]&lt;/code&gt; accessor &lt;code&gt;Feed&lt;/code&gt; already has — &lt;code&gt;name()&lt;/code&gt;, &lt;code&gt;uri()&lt;/code&gt;, &lt;code&gt;set_unread_count()&lt;/code&gt; — silently stops working.&lt;/p&gt;

&lt;p&gt;What's different now is that &lt;code&gt;set_items&lt;/code&gt; is the only thing that ever emits &lt;code&gt;items-updated&lt;/code&gt;, and it's only ever called with data that actually came off the network. &lt;code&gt;set_unread_count&lt;/code&gt; runs first, before the emit, and the count that shows up in the sidebar is the number of items this fetch found.&lt;/p&gt;

&lt;p&gt;That's a narrower claim than "unread," on purpose. Every fetch sets &lt;code&gt;unread-count&lt;/code&gt; to the &lt;em&gt;total&lt;/em&gt; item count, not the count of items the user hasn't seen. Refetch a feed after reading everything in it and the badge goes straight back up to the full number. &lt;code&gt;unread_count&lt;/code&gt; is standing in for a property Gazette doesn't have the machinery to compute correctly yet — real read-tracking needs per-item state, which doesn't exist until this series adds persistence. Until then, read the name as aspirational, not as a bug you need to chase.&lt;/p&gt;




&lt;h2&gt;
  
  
  A consumer for items-updated
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;set_items&lt;/code&gt; emits; something has to be listening. Each sample feed gets a logging handler when the store is built, in &lt;code&gt;constructed()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;gio&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;ListStore&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;new&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Feed&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;SAMPLE_FEEDS&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;feed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;Feed&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// Reacting to items-updated is a separate job from fetching&lt;/span&gt;
    &lt;span class="c1"&gt;// them — the fetch (wired below, on feed-selected) never&lt;/span&gt;
    &lt;span class="c1"&gt;// logs directly, it just calls `feed.set_items(...)`.&lt;/span&gt;
    &lt;span class="n"&gt;feed&lt;/span&gt;&lt;span class="nf"&gt;.connect_closure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s"&gt;"items-updated"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nn"&gt;glib&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nd"&gt;closure_local!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;move&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="n"&gt;feed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Feed&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;feed&lt;/span&gt;&lt;span class="nf"&gt;.items&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
            &lt;span class="nd"&gt;eprintln!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"{}: {} items"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;feed&lt;/span&gt;&lt;span class="nf"&gt;.name&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="nf"&gt;.len&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
            &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="nf"&gt;.iter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.take&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nd"&gt;eprintln!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"  - {}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="py"&gt;.title&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="nf"&gt;.append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;feed&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice the fetch code in &lt;code&gt;feed-selected&lt;/code&gt; never calls &lt;code&gt;eprintln!&lt;/code&gt; on success. It just calls &lt;code&gt;feed.set_items(items)&lt;/code&gt; and stops. Printing what arrived is this closure's job, connected once per feed, entirely separate from the code that went and got the data. That's the same lesson Part 5 landed with the placeholder binding and the &lt;code&gt;feed-selected&lt;/code&gt; logger: fetching is one job, reacting to what was fetched is another, and the signal between them is what keeps neither one needing to know the other exists. A future persistence layer connects its own handler to &lt;code&gt;items-updated&lt;/code&gt; later without touching a line of &lt;code&gt;fetch.rs&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Sharp edges
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;RefCell&lt;/code&gt; borrow held across an &lt;code&gt;.await&lt;/code&gt; fails the same way a borrow held across a signal emit did in Part 5, just harder to spot. The failure shape: &lt;code&gt;let items = self.items.borrow_mut(); some_future.await;&lt;/code&gt; inside a &lt;code&gt;spawn_local&lt;/code&gt; block, where the code resumed after the &lt;code&gt;.await&lt;/code&gt; — or a handler running on a different turn of the main loop, if the borrow escapes further — tries to borrow the same cell. Signal handlers run synchronously, so a Part 5-style borrow panic happens right where you'd look for it. An &lt;code&gt;.await&lt;/code&gt; point hands control back to the main loop for an arbitrary amount of time: anything else the loop dispatches in between, including another handler that reaches for the same &lt;code&gt;RefCell&lt;/code&gt;, can trigger the panic somewhere that looks nothing like the code that caused it. Keep borrows scoped tightly, and never hold one across an &lt;code&gt;.await&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;What happens if you click the same feed five times in a row? Five independent fetches run concurrently, each racing to call &lt;code&gt;set_items&lt;/code&gt; on the same &lt;code&gt;Feed&lt;/code&gt; when it finishes — last one to land wins, and &lt;code&gt;items-updated&lt;/code&gt; fires five times for one selection. Harmless for a post about the executor boundary, but worth knowing before you go adding a refresh action, which makes hammering that shortcut the obvious next thing to try. A real fix would track the in-flight &lt;code&gt;JoinHandle&lt;/code&gt; on &lt;code&gt;Feed&lt;/code&gt; and abort or ignore a new request while one's outstanding — outside this post's scope, but that's where the fix would go.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;window.application()&lt;/code&gt; returns &lt;code&gt;None&lt;/code&gt; before the window has one, and &lt;code&gt;expect&lt;/code&gt; turns that into a panic. In practice this never fires here: &lt;code&gt;GazetteWindow::new&lt;/code&gt; always takes an application, and nothing runs &lt;code&gt;feed-selected&lt;/code&gt; before the window exists. The &lt;code&gt;expect&lt;/code&gt; documents an invariant rather than checking one — this window always has an application by the time it can receive input. If you ever construct a &lt;code&gt;GazetteWindow&lt;/code&gt; without one, a test most likely, this is where it breaks.&lt;/p&gt;




&lt;h2&gt;
  
  
  What we have so far
&lt;/h2&gt;

&lt;p&gt;Selecting a feed now does real work. A Tokio runtime, built once alongside the GTK application and never touched by it directly, does the actual fetching and parsing, and the only thing that ever crosses back onto the GLib main thread is a &lt;code&gt;Result&lt;/code&gt; full of plain data. &lt;code&gt;Feed::items-updated&lt;/code&gt; fires for real for the first time since Part 2 declared it, driven by whatever &lt;code&gt;feed-rs&lt;/code&gt; found at the far end of a real URL.&lt;/p&gt;

&lt;p&gt;Nothing about this displays anywhere yet — the items live in a &lt;code&gt;RefCell&lt;/code&gt; a logging handler reads, and the sidebar still shows nothing but a name and a count. The fetch mechanism this post built doesn't need to change at all to support that; it already hands off a &lt;code&gt;Vec&amp;lt;FeedItem&amp;gt;&lt;/code&gt; to whatever wants to render it.&lt;/p&gt;




&lt;h2&gt;
  
  
  What comes next
&lt;/h2&gt;

&lt;p&gt;That's the gap the next post closes: an article list for the selected feed, a content pane for the selected article, and the adaptive layout that collapses both into a single pane on a narrow window. The two-executor pattern from this post doesn't change to support any of it.&lt;/p&gt;

&lt;p&gt;Further out, &lt;code&gt;unread_count&lt;/code&gt; and &lt;code&gt;items&lt;/code&gt; want somewhere durable to live, so a refetch doesn't erase read state and a restart doesn't lose it either. That's the persistence layer this series has been setting up for since the &lt;code&gt;OnceCell&lt;/code&gt; and &lt;code&gt;RefCell&lt;/code&gt; conventions in Part 5 — a real place for &lt;code&gt;items-updated&lt;/code&gt; to write to, instead of a logging handler standing in for one.&lt;/p&gt;




&lt;p&gt;The source code at the end of this post lives on the &lt;code&gt;part-6&lt;/code&gt; branch of &lt;a href="https://github.com/fromthearchitect/gnome-rust-gazette/tree/part-6" rel="noopener noreferrer"&gt;fromthearchitect/gnome-rust-gazette&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>gnome</category>
      <category>rust</category>
      <category>gtk</category>
      <category>tokio</category>
    </item>
    <item>
      <title>Moments: The Photo App I Built to Win an Argument</title>
      <dc:creator>Justin - FromTheArchitect</dc:creator>
      <pubDate>Fri, 14 Aug 2026 08:17:33 +0000</pubDate>
      <link>https://dev.to/fromthearchitect/moments-the-photo-app-i-built-to-win-an-argument-1edm</link>
      <guid>https://dev.to/fromthearchitect/moments-the-photo-app-i-built-to-win-an-argument-1edm</guid>
      <description>&lt;p&gt;&lt;em&gt;A phone call with a mate turned into a wager: could AI actually build a real application from scratch? Five months, 898 commits and 40,000 lines of Rust later, here's the verdict — and the app.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  It started with a phone call
&lt;/h2&gt;

&lt;p&gt;Like most of my questionable decisions, this one started on the phone with a mate, talking about technology the way we always do. The conversation drifted, as every technical conversation eventually does now, into AI. And we ended up circling the same question everyone circles: is this stuff actually capable of building real software, or is it just very confident autocomplete?&lt;/p&gt;

&lt;p&gt;He was the sceptic. I was less sure. I'd been watching AI coding tools long enough to see signs they could do more than trivial hello-world demos — I'd even &lt;a href="https://dev.to/posts/ai_pair_programmer_post/"&gt;written about the right way to work with them&lt;/a&gt; more than a year earlier. But I don't think either of us honestly believed the answer would be a full-featured desktop application.&lt;/p&gt;

&lt;p&gt;So I did the thing you should never do on a phone call after 9pm: I made a declaration. I would definitively prove, one way or the other, whether AI could build a real application from scratch.&lt;/p&gt;

&lt;p&gt;Not from a single prompt. That's a stupid idea, and it's a strawman both camps love: the boosters because it makes a great demo, the sceptics because it makes a great failure. I mean built the way software actually gets built: architecture, iteration, review, tests, releases. Using the pair programming approach I'd already laid out on this blog — human as navigator, AI as driver, with the conversation between them doing the real work.&lt;/p&gt;

&lt;p&gt;If it failed, I'd write that post. It would have been an easier post to write, honestly. This is the other one.&lt;/p&gt;




&lt;h2&gt;
  
  
  Picking a fight worth having
&lt;/h2&gt;

&lt;p&gt;If you're going to test whether AI can build real software, the project has to be real: something with real complexity, a demanding platform, and a reason to exist beyond the experiment.&lt;/p&gt;

&lt;p&gt;I've been an on-again, off-again Linux user for the best part of thirty years. The kernel was never the problem. The desktop was never really the problem either, especially lately. The thing that has always dragged me back to commercial operating systems is the application ecosystem. It's better today than it has ever been, thanks to an enormous number of dedicated open source developers. But there are still categories where the open source options simply don't compete with their commercial rivals. Photos. Email. The apps your family actually touches.&lt;/p&gt;

&lt;p&gt;And that second part matters, because this wasn't abstract for me. I've been trying to move my family away from Apple Photos, and self-hosted &lt;a href="https://immich.app/" rel="noopener noreferrer"&gt;Immich&lt;/a&gt; is a brilliant back end for that. But my family has spent too many years inside the Apple ecosystem to accept a web interface for something as personal as their photo library. They want a real application. Native, fast, polished. The kind of thing the Linux desktop is chronically short of.&lt;/p&gt;

&lt;p&gt;So that was the wager, fully formed: a modern GTK4/libadwaita photo management application for the GNOME desktop, written in Rust. (All the cool kids use Rust. It's been a long time since I was a kid.)&lt;/p&gt;

&lt;p&gt;There was one more reason this experiment appealed to me, and it's the one I suspect resonates with most working engineers. My background is software engineering. I've built some large, complex, performance-critical systems for companies big and small. Building software has never been the hard part. &lt;em&gt;Finding the time&lt;/em&gt; has. A career plus a family leaves you time-poor in exactly the way that kills side projects, and it's why I'd never seriously committed to a sizable open source project before. If AI could compress the gap between the architecture in my head and working code on disk, the economics of contributing to open source change completely — not just for me, for every experienced engineer who's been sitting on the sidelines for the same reason.&lt;/p&gt;




&lt;h2&gt;
  
  
  What it was actually like
&lt;/h2&gt;

&lt;p&gt;Five months later I can tell you the answer to the wager, but the honest version of this story is the middle, not the ending. Because the first few weeks were not smooth.&lt;/p&gt;

&lt;p&gt;The early problems were all variations on the same theme: an AI will cheerfully solve the problem in front of it with no memory of how it solved the same problem last Tuesday. I'd find duplicate implementations of the same logic in different corners of the codebase. I'd find three different patterns for the same category of problem, each locally reasonable, collectively a mess. I had to learn the right size to chunk features — too big and the AI loses the thread, too small and you spend more time briefing than you save.&lt;/p&gt;

&lt;p&gt;But the biggest lesson was about architecture, and it's the one I'd put on a poster. &lt;strong&gt;Claude's instinct is to reach the outcome in front of it as fast as possible.&lt;/strong&gt; It is genuinely good at that. What it does not do — unless you make it — is weigh that outcome against the long-term shape of the system. Left unsupervised, you get software that works today and calcifies by next month. Which, now that I write it down, also describes plenty of human teams I've worked with.&lt;/p&gt;

&lt;p&gt;So the collaboration settled into a division of labour that will look very familiar to anyone who read my pair programming post:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Mine:&lt;/strong&gt; application architecture, design, and feature management. Every structural decision — the backend abstraction, the sync model, the storage layout — came from me. I was the navigator, holding the long-term picture.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Claude's:&lt;/strong&gt; the code. The overwhelming majority of the actual Rust was written by the AI, driving under direction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ours:&lt;/strong&gt; the review loop. Every change went through a pull request, reviewed and audited before merge — the same discipline I'd demand on any professional team. No PR process, no experiment; it's the review loop that makes the whole thing defensible.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Part way through I added a technique I now consider essential: &lt;strong&gt;adversarial review&lt;/strong&gt;. Don't let the AI that wrote the code be the only AI that reads it. A second model — or even the same model under a deliberately hostile prompt — catches code smells, duplication and quiet regressions far earlier than a friendly one does. The author is always too fond of its own work. That turns out to be true of machines too.&lt;/p&gt;

&lt;p&gt;Somewhere in those months, the experiment stopped feeling like an experiment. That's the part I didn't expect. Within days it was apparent that working this way wasn't just &lt;em&gt;possible&lt;/em&gt; — it was &lt;em&gt;preferred&lt;/em&gt;. I wasn't tolerating the AI to prove a point. I was reaching for it because the loop of architect–direct–review–refine was producing better software, faster, than I would have produced alone in the hours I actually have.&lt;/p&gt;




&lt;h2&gt;
  
  
  The verdict
&lt;/h2&gt;

&lt;p&gt;Here's what the wager produced:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;~40,000 lines of Rust&lt;/strong&gt;, GTK4 and libadwaita&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;898 commits&lt;/strong&gt; over five months, first commit 22 March 2026&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every change human-reviewed&lt;/strong&gt; before merge, through a PR workflow&lt;/li&gt;
&lt;li&gt;Releases through &lt;strong&gt;v0.4.1&lt;/strong&gt;, shipped as Flatpak bundles with checksums, and GPG signatures on signed releases&lt;/li&gt;
&lt;li&gt;Built almost entirely in evenings and spare hours: the time budget of a person with a job and a family, not a funded team&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now overlay the cost lens, because this is where the wager stops being a curiosity and starts being an economic argument. The entire AI side of this project ran on a Claude Code subscription — I started on the Pro plan and very quickly determined it wasn't enough, so most of the project was built on the Max 5x tier, at roughly AU$150 a month. During heavy development I'd regularly burn through 80–90% of my weekly token allocation, but I never went over it. So call it $150 a month, all in, for a coding partner that helped produce a 40,000-line application. That's not cheap as subscriptions go, and I won't pretend it's nothing — but weighed against what it unlocked, I found it an easy cost to justify. For me, the constraint was never the money. It was only ever the time. I'll admit I spent more evenings on this than my family would have liked while it was all coming together. Now that we have a functioning app, it's consuming far less of them.&lt;/p&gt;

&lt;p&gt;And here is the verdict, as plainly as I can put it: &lt;strong&gt;AI can absolutely build a real application — but not alone, and that's precisely the point.&lt;/strong&gt; The single-prompt fantasy is still a fantasy. What is real, right now, is an experienced engineer setting the architecture and standards, an AI doing the driving, and a review process keeping everyone honest. That combination settled the phone argument — and it removed the constraint that has kept me, and I suspect a great many engineers like me, out of open source for years.&lt;/p&gt;

&lt;p&gt;I called my mate to concede that neither of us had been right. He's yet to install a Linux desktop, so I'm calling it a draw.&lt;/p&gt;




&lt;h2&gt;
  
  
  Meet Moments
&lt;/h2&gt;

&lt;p&gt;Enough about the wager — here's the app.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/justinf555/Moments" rel="noopener noreferrer"&gt;Moments&lt;/a&gt;&lt;/strong&gt; is a photo management application for the GNOME desktop. It's built for exactly the situation my family is in: your photos live either on your own disk or on a self-hosted Immich server, and you want a fast, native way to actually browse them.&lt;/p&gt;

&lt;p&gt;It talks to a local folder or to an Immich server. The Immich backend caches metadata and thumbnails locally in SQLite, so browsing, searching and anything you've already opened works fully offline; a photo you've never viewed before will fetch its full-resolution original on demand, then keep it in a local cache for next time. The grid is keyset-paginated with six zoom levels, and it stays smooth through large libraries.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdjs50zxss9a02oy98bbm.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdjs50zxss9a02oy98bbm.jpg" alt="The Moments photo grid, showing a synced Immich library with sidebar navigation and albums" width="800" height="469"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Beyond that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;RAW and modern formats&lt;/strong&gt; — CR2, NEF, ARW, DNG and friends, alongside JPEG, PNG, WebP, HEIC and TIFF&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Video&lt;/strong&gt; — import and playback via GStreamer&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Albums, People and Favourites&lt;/strong&gt; — including face data synced from Immich&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;EXIF metadata&lt;/strong&gt; — camera, lens, exposure and GPS in the detail panel&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Non-destructive rotate, flip and crop&lt;/strong&gt;, with adjustments and filters behind experimental flags&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's GPL-3.0-or-later, and it's a real daily-driver application — my family's photo library runs on it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Installing it&lt;/strong&gt; takes a download and two commands. Every release ships a single-file Flatpak bundle (with checksums, and GPG signatures on signed releases):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;flatpak &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--user&lt;/span&gt; moments-&amp;lt;version&amp;gt;-x86_64.flatpak
flatpak run io.github.justinf555.Moments
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Grab the latest bundle from the &lt;a href="https://github.com/justinf555/Moments/releases/latest" rel="noopener noreferrer"&gt;releases page&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Flathub question
&lt;/h2&gt;

&lt;p&gt;The obvious question is why those two commands aren't just &lt;code&gt;flatpak install flathub io.github.justinf555.Moments&lt;/code&gt;. That deserves a straight answer, because it's part of this story too.&lt;/p&gt;

&lt;p&gt;I submitted Moments to Flathub on 27 March 2026, five days after the project's first commit — early, deliberately, so the review could run alongside development. The submission worked through the normal review cycle: manifest fixes, portal permissions, test builds passing on both architectures.&lt;/p&gt;

&lt;p&gt;Then, at the end of May, &lt;a href="https://docs.flathub.org/docs/for-app-authors/requirements" rel="noopener noreferrer"&gt;Flathub changed its inclusion policy&lt;/a&gt;. The new rule is a blanket one: &lt;em&gt;"Applications containing AI-generated or AI-assisted code, documentation, or any other content are not allowed."&lt;/em&gt; Not AI-generated slop with no human behind it — which the previous policy already prohibited, rightly — but AI-assisted anything. Two days after the policy landed, my submission was closed. The &lt;a href="https://github.com/flathub/flathub/pull/8227" rel="noopener noreferrer"&gt;full exchange is public&lt;/a&gt;, and I'd encourage you to read it and draw your own conclusions rather than take my summary of it. Read closely and you'll notice I described the AI's share of the codebase as "parts of the app" in that thread — more of it than I was comfortable admitting mid-argument, if I'm honest. This post is the fuller account.&lt;/p&gt;

&lt;p&gt;I withdrew the submission, and I said my piece on the way out, which I'll stand behind here rather than repeat at length: the old policy drew the correct line — AI-generated code with no human behind it, not allowed; AI-assisted code that a human reviewed, tested and stands behind, allowed. The new policy erases that line. It stops judging software by whether it's good, tested and maintained, and starts judging it by how it was typed.&lt;/p&gt;

&lt;p&gt;I understand where the policy comes from. Maintainers are drowning in low-effort AI submissions, and I have real sympathy for that — I wrote &lt;a href="https://dev.to/posts/why-open-source-doesnt-embrace-ai/"&gt;a whole post about the open source community's fraught relationship with AI&lt;/a&gt; before I was ever personally on the receiving end of it. Flathub's repo, Flathub's rules. But I think the blanket version of the rule is a mistake, and I think this app is a concrete counterexample: working GPL software, architected and reviewed by a human, in a category the Linux desktop has needed for decades. The policy does allow exceptions for "mature, well-maintained projects", so perhaps Moments and Flathub will meet again down the track. The door's open on my side.&lt;/p&gt;

&lt;p&gt;In the meantime, the Flatpak bundles work everywhere Flatpak does, and updates are one download away.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I'd ask of you
&lt;/h2&gt;

&lt;p&gt;Three things, in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Install it.&lt;/strong&gt; Point it at a folder of photos or an Immich server and see what you think. It's &lt;a href="https://github.com/justinf555/Moments/releases/latest" rel="noopener noreferrer"&gt;a download and two commands&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Star the repo.&lt;/strong&gt; &lt;a href="https://github.com/justinf555/Moments" rel="noopener noreferrer"&gt;github.com/justinf555/Moments&lt;/a&gt; — discovery is the thing losing Flathub actually cost this project, and a star is the closest substitute GitHub offers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tell me what's broken or missing.&lt;/strong&gt; &lt;a href="https://github.com/justinf555/Moments/issues" rel="noopener noreferrer"&gt;Issues&lt;/a&gt; for bugs and feature requests, &lt;a href="https://github.com/justinf555/Moments/discussions" rel="noopener noreferrer"&gt;Discussions&lt;/a&gt; for everything else. This is the part of open source no AI can do: real people, with real photo libraries, telling you what actually matters.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And if you're an experienced engineer who's been sitting on an idea for years because you couldn't find the time — that's the real takeaway of the wager. The time problem is the one that just changed. Go and settle an argument of your own.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>opensource</category>
      <category>rust</category>
      <category>pairprogramming</category>
    </item>
    <item>
      <title>Building GNOME Apps with Rust, Part 5: State and Signals</title>
      <dc:creator>Justin - FromTheArchitect</dc:creator>
      <pubDate>Mon, 10 Aug 2026 09:39:54 +0000</pubDate>
      <link>https://dev.to/fromthearchitect/building-gnome-apps-with-rust-part-5-state-and-signals-4pb9</link>
      <guid>https://dev.to/fromthearchitect/building-gnome-apps-with-rust-part-5-state-and-signals-4pb9</guid>
      <description>&lt;p&gt;&lt;em&gt;This is Part 5 of a series taking a GNOME app from an empty directory to GNOME Circle. &lt;a href="https://dev.to/fromthearchitect/building-gnome-apps-with-rust-part-4-blueprint-5dce"&gt;Part 4&lt;/a&gt; replaced our XML templates with Blueprint and grew the window into the real Gazette layout — a sidebar, a content pane, and three typed widget handles waiting for behaviour. This is the post where they get some.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If the GObject machinery in here feels unfamiliar — &lt;code&gt;mod imp&lt;/code&gt;, properties, signals — &lt;a href="https://dev.to/fromthearchitect/building-gnome-apps-with-rust-part-2-gobject-in-rust-the-type-system-explained-310n"&gt;Part 2&lt;/a&gt; is the reference. This is where those patterns stop being theoretical.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The click that goes nowhere
&lt;/h2&gt;

&lt;p&gt;Right now, clicking a sidebar row does nothing. GTK draws a selection highlight, the row picks up its hover state, and that's it — our code never sees the click. The whole point of the sidebar is that it drives the content pane, and we haven't wired that yet.&lt;/p&gt;

&lt;p&gt;By the end of this post, selecting a row drives everything to its right. The shape I keep coming back to in GTK apps is a chain — model to list to selection to signal to content — and getting those links in the right order is half the work. The other half is interior mutability: where state lives, when &lt;code&gt;RefCell&lt;/code&gt; earns its place, when &lt;code&gt;OnceCell&lt;/code&gt; does, and why the &lt;code&gt;Rc&amp;lt;RefCell&amp;lt;T&amp;gt;&amp;gt;&lt;/code&gt; reflex most of us carry over from non-GUI Rust almost never belongs in a &lt;code&gt;mod imp&lt;/code&gt;-shaped app. I'll take each one where it comes up rather than in the abstract.&lt;/p&gt;

&lt;p&gt;Concretely, we'll build a real &lt;code&gt;Feed&lt;/code&gt; GObject with &lt;code&gt;name&lt;/code&gt; and &lt;code&gt;uri&lt;/code&gt; properties, swap that frozen sidebar row for a &lt;code&gt;gio::ListStore&amp;lt;Feed&amp;gt;&lt;/code&gt; seeded with four real feeds, define a custom &lt;code&gt;feed-selected(Feed)&lt;/code&gt; signal on the window, and make the content pane react — feed name as the title, URL as the description, the empty-state placeholder showing only when nothing's selected. That &lt;code&gt;feed-selected&lt;/code&gt; signal is the seam Part 6 plugs Tokio into: the synchronous handler we write here gets joined by an asynchronous fetch handler that lives entirely in another file, and designing for that boundary now is the quieter lesson of the post.&lt;/p&gt;




&lt;h2&gt;
  
  
  Feed, for real
&lt;/h2&gt;

&lt;p&gt;Part 2 sketched a &lt;code&gt;Feed&lt;/code&gt; GObject — a title, an &lt;code&gt;items-updated&lt;/code&gt; signal, the whole &lt;code&gt;mod imp&lt;/code&gt; apparatus. None of it landed in source. We got away with it because Parts 3 through 4 only needed widgets, not data. That ends here. Before we can have a sidebar that &lt;em&gt;means&lt;/em&gt; something, we need a model for what the sidebar contains.&lt;/p&gt;

&lt;p&gt;Create &lt;code&gt;src/feed.rs&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;glib&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;glib&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Properties&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;prelude&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;subclass&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;prelude&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;std&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;cell&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;RefCell&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;mod&lt;/span&gt; &lt;span class="n"&gt;imp&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;#[derive(Debug,&lt;/span&gt; &lt;span class="nd"&gt;Default,&lt;/span&gt; &lt;span class="nd"&gt;Properties)]&lt;/span&gt;
    &lt;span class="nd"&gt;#[properties(wrapper_type&lt;/span&gt; &lt;span class="nd"&gt;=&lt;/span&gt; &lt;span class="nd"&gt;super::Feed)]&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;Feed&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nd"&gt;#[property(get,&lt;/span&gt; &lt;span class="nd"&gt;set)]&lt;/span&gt;
        &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;RefCell&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nd"&gt;#[property(get,&lt;/span&gt; &lt;span class="nd"&gt;set)]&lt;/span&gt;
        &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;RefCell&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;#[glib::object_subclass]&lt;/span&gt;
    &lt;span class="k"&gt;impl&lt;/span&gt; &lt;span class="n"&gt;ObjectSubclass&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;Feed&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;NAME&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;'static&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"GazetteFeed"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Feed&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;#[glib::derived_properties]&lt;/span&gt;
    &lt;span class="k"&gt;impl&lt;/span&gt; &lt;span class="n"&gt;ObjectImpl&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;Feed&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nn"&gt;glib&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nd"&gt;wrapper!&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="nf"&gt;Feed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ObjectSubclass&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nn"&gt;imp&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Feed&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;impl&lt;/span&gt; &lt;span class="n"&gt;Feed&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;Self&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nn"&gt;glib&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="nf"&gt;.property&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="nf"&gt;.property&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"uri"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="nf"&gt;.build&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things stand out against that Part 2 sketch. I left the signals out: &lt;code&gt;items-updated&lt;/code&gt; was the right instinct — feeds change when fresh content arrives — but with no fetching code yet, emitting it would be ceremony for nothing, so it comes back in Part 6 alongside the Tokio fetcher that gives it something to announce. The properties use &lt;code&gt;#[derive(Properties)]&lt;/code&gt; paired with &lt;code&gt;#[glib::derived_properties]&lt;/code&gt;, which is the modern declaration form: the macro generates the &lt;code&gt;properties()&lt;/code&gt;, &lt;code&gt;set_property()&lt;/code&gt;, and &lt;code&gt;property()&lt;/code&gt; boilerplate from the struct fields, where the older tutorials you'll still find hand-roll &lt;code&gt;glib::ParamSpecString::builder("name").build()&lt;/code&gt;. Identical runtime behaviour, dramatically less typing, one fewer place to fat-finger a property name. And each field sits in a &lt;code&gt;RefCell&lt;/code&gt; because properties have to be settable through GObject's runtime API — that demands interior mutability, and &lt;code&gt;RefCell&lt;/code&gt; is the simplest answer for fields written occasionally and read everywhere.&lt;/p&gt;

&lt;p&gt;One thing about that &lt;code&gt;NAME&lt;/code&gt; constant: it's the GObject type name registered with the runtime. Prefix it with the application — &lt;code&gt;GazetteFeed&lt;/code&gt;, not just &lt;code&gt;Feed&lt;/code&gt; — so it doesn't collide with another library's &lt;code&gt;Feed&lt;/code&gt; type. Names are global; collisions abort the process at type registration. Once your app loads a &lt;code&gt;Feed&lt;/code&gt; type, no other library in the same process can.&lt;/p&gt;

&lt;p&gt;Wire the module into &lt;code&gt;src/main.rs&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;mod&lt;/span&gt; &lt;span class="n"&gt;application&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;mod&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;mod&lt;/span&gt; &lt;span class="n"&gt;feed&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;mod&lt;/span&gt; &lt;span class="n"&gt;window&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;application&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;GazetteApplication&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;feed&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Feed&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;window&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;GazetteWindow&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Feed&lt;/code&gt; is now a real type the rest of the codebase can build on.&lt;/p&gt;




&lt;h2&gt;
  
  
  A list bound to a model
&lt;/h2&gt;

&lt;p&gt;The sidebar in &lt;code&gt;window.blp&lt;/code&gt; currently has one hard-coded row:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ListBox feed_list {
  selection-mode: single;
  styles ["navigation-sidebar"]

  ListBoxRow {
    child: Label {
      label: _("All Articles");
      halign: start;
      margin-start: 12;
      margin-end: 12;
      margin-top: 6;
      margin-bottom: 6;
    };
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That row is frozen in the markup. It can't grow, shrink, or change without touching the &lt;code&gt;.blp&lt;/code&gt; file. Real apps don't work that way — feeds get added, removed, renamed. The markup needs to describe the &lt;em&gt;shape&lt;/em&gt; of a row, and the runtime needs to produce one row per item in some collection. GTK has a name for this pattern: model/view binding.&lt;/p&gt;

&lt;p&gt;Strip the inner &lt;code&gt;ListBoxRow&lt;/code&gt; out. The new sidebar block:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;content: ScrolledWindow {
  child: ListBox feed_list {
    selection-mode: single;
    styles ["navigation-sidebar"]
  };
};
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Empty &lt;code&gt;ListBox&lt;/code&gt;. The rows come from Rust now.&lt;/p&gt;

&lt;p&gt;In &lt;code&gt;src/window.rs&lt;/code&gt;, add the imports we'll need and grow the &lt;code&gt;imp&lt;/code&gt; struct:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;std&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;cell&lt;/span&gt;&lt;span class="p"&gt;::{&lt;/span&gt;&lt;span class="n"&gt;OnceCell&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;RefCell&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;adw&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;subclass&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;prelude&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;prelude&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::{&lt;/span&gt;&lt;span class="n"&gt;gio&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;glib&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="k"&gt;crate&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Feed&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;SAMPLE_FEEDS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"This Week in GNOME"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"https://thisweek.gnome.org/index.xml"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"LWN.net Headlines"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="s"&gt;"https://lwn.net/headlines/newrss"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Hacker News"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="s"&gt;"https://hnrss.org/frontpage"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"From the Architect"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"https://fromthearchitect.dev/index.xml"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="k"&gt;mod&lt;/span&gt; &lt;span class="n"&gt;imp&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;#[derive(Debug,&lt;/span&gt; &lt;span class="nd"&gt;Default,&lt;/span&gt; &lt;span class="nd"&gt;gtk::CompositeTemplate)]&lt;/span&gt;
    &lt;span class="nd"&gt;#[template(resource&lt;/span&gt; &lt;span class="nd"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"/io/github/fromthearchitect/gazette/window.ui"&lt;/span&gt;&lt;span class="nd"&gt;)]&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;GazetteWindow&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nd"&gt;#[template_child]&lt;/span&gt;
        &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;split_view&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;TemplateChild&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nn"&gt;adw&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;NavigationSplitView&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nd"&gt;#[template_child]&lt;/span&gt;
        &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;feed_list&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;TemplateChild&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;ListBox&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nd"&gt;#[template_child]&lt;/span&gt;
        &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;placeholder&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;TemplateChild&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nn"&gt;adw&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;StatusPage&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

        &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;feeds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;OnceCell&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nn"&gt;gio&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;ListStore&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;selected_feed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;RefCell&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Feed&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// ... ObjectSubclass impl unchanged&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two new fields. In the window's &lt;code&gt;ObjectImpl::constructed&lt;/code&gt;, populate the store and bind it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;impl&lt;/span&gt; &lt;span class="n"&gt;ObjectImpl&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;GazetteWindow&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;constructed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="nf"&gt;.parent_constructed&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;gio&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;ListStore&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;new&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Feed&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;SAMPLE_FEEDS&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="nf"&gt;.append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nn"&gt;Feed&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.feeds&lt;/span&gt;&lt;span class="nf"&gt;.set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="nf"&gt;.clone&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="nf"&gt;.expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"feeds set once"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.feed_list&lt;/span&gt;&lt;span class="nf"&gt;.bind_model&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;feed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="py"&gt;.downcast_ref&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Feed&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"item is a Feed"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;Label&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                &lt;span class="nf"&gt;.label&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;feed&lt;/span&gt;&lt;span class="nf"&gt;.name&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
                &lt;span class="nf"&gt;.halign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;Align&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Start&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="nf"&gt;.margin_start&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="nf"&gt;.margin_end&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="nf"&gt;.margin_top&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="nf"&gt;.margin_bottom&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="nf"&gt;.build&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                &lt;span class="nf"&gt;.upcast&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;bind_model&lt;/code&gt; takes the store and a closure, and it accepts our store because &lt;code&gt;gio::ListStore&lt;/code&gt; implements &lt;code&gt;gio::ListModel&lt;/code&gt; — it binds against that trait, not the concrete type. That indirection is the part I actually care about: it's what lets me wrap the store in a sort or filter model &lt;em&gt;later&lt;/em&gt; without rewriting this call, which is exactly the refactor Gazette grows into once feeds need ordering. The mechanics from there are unremarkable — &lt;code&gt;gtk::ListBox&lt;/code&gt; runs the closure once per item at bind time, then again whenever the model's &lt;code&gt;items-changed&lt;/code&gt; signal reports an add or change, and because the closure returns a bare &lt;code&gt;gtk::Widget&lt;/code&gt;, &lt;code&gt;ListBox&lt;/code&gt; wraps it in a &lt;code&gt;GtkListBoxRow&lt;/code&gt; so we never construct one ourselves.&lt;/p&gt;

&lt;p&gt;Run the app. The sidebar now shows four feeds — &lt;em&gt;This Week in GNOME&lt;/em&gt;, &lt;em&gt;LWN.net Headlines&lt;/em&gt;, &lt;em&gt;Hacker News&lt;/em&gt;, &lt;em&gt;From the Architect&lt;/em&gt; — and you can click between them. Selection still doesn't &lt;em&gt;do&lt;/em&gt; anything, but the rows are real.&lt;/p&gt;

&lt;p&gt;One trap worth flagging — and a clarification about what that generic actually buys you. &lt;code&gt;gio::ListStore::new::&amp;lt;Feed&amp;gt;()&lt;/code&gt; parameterises the store on the Rust side, and that's worth doing: it keeps the typed API honest and documents what the store holds. But it's a Rust-side convenience, not a hard runtime guarantee — at the GObject level the store holds plain &lt;code&gt;glib::Object&lt;/code&gt;. So items coming back &lt;em&gt;out&lt;/em&gt; of it — through &lt;code&gt;item()&lt;/code&gt;, or through the &lt;code&gt;ListModel&lt;/code&gt; interface a sort or filter model would hand you — still arrive as &lt;code&gt;glib::Object&lt;/code&gt; and need the explicit &lt;code&gt;and_downcast::&amp;lt;Feed&amp;gt;()&lt;/code&gt; you'll see in the selection handler shortly. Parameterise with your concrete type, but treat it as intent and documentation, not as enforcement at the model boundary.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where state lives
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;imp&lt;/code&gt; struct now carries two things that aren't template children: &lt;code&gt;feeds&lt;/code&gt; and &lt;code&gt;selected_feed&lt;/code&gt;. This is the right place for them. &lt;strong&gt;State on a GObject lives in the inner &lt;code&gt;imp&lt;/code&gt; struct, never on the outer wrapper.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The reason is structural. &lt;code&gt;GazetteWindow&lt;/code&gt; — the outer type — is a thin handle. &lt;code&gt;glib::wrapper!&lt;/code&gt; makes it a transparent newtype around a refcounted pointer. Add a field to the outer type and you've added a field that won't be shared between clones of the handle, won't be visible across closures, and won't survive being passed back through GTK's C API. None of those failure modes is loud; the code compiles, the field exists, you set it, and then a second handle to the "same" window can't see what you wrote.&lt;/p&gt;

&lt;p&gt;The inner &lt;code&gt;imp::GazetteWindow&lt;/code&gt;, by contrast, is the actual heap-allocated object. There's exactly one of it per window, all handles point to it, and any code holding a handle can reach into it via &lt;code&gt;obj.imp()&lt;/code&gt;. State you want every part of the app to agree on goes there.&lt;/p&gt;

&lt;p&gt;This is one of the framework's rules rather than something you can derive from first principles, and I learned it the slow way — by adding a field to the outer type, setting it, and then losing an afternoon to state that silently wasn't shared between two handles to the "same" window. Memorise it — &lt;em&gt;state lives in &lt;code&gt;imp&lt;/code&gt;&lt;/em&gt; — and a surprising number of the architectural questions you might have about a GTK Rust app stop being questions.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which cell?
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;feeds: OnceCell&amp;lt;gio::ListStore&amp;gt;&lt;/code&gt; and &lt;code&gt;selected_feed: RefCell&amp;lt;Option&amp;lt;Feed&amp;gt;&amp;gt;&lt;/code&gt; — two different cells for two different lifetimes, and the difference is the whole point. The &lt;code&gt;feeds&lt;/code&gt; store is built once in &lt;code&gt;constructed()&lt;/code&gt; and read forever after, never replaced; that's exactly what &lt;code&gt;OnceCell&lt;/code&gt; is for, and its &lt;code&gt;set&lt;/code&gt; returning &lt;code&gt;Err&lt;/code&gt; when it's already been set means the code asserts that contract for me instead of trusting me to remember it. The &lt;code&gt;selected_feed&lt;/code&gt; changes every time the user picks a different row, so it needs the ongoing mutability &lt;code&gt;RefCell&lt;/code&gt; gives. (A third category needs no cell at all: values the GObject machinery sets once at construction — template children, derived properties — that your own code never touches again.)&lt;/p&gt;

&lt;p&gt;&lt;code&gt;RefCell&lt;/code&gt; adds a runtime borrow check: &lt;code&gt;borrow()&lt;/code&gt; and &lt;code&gt;borrow_mut()&lt;/code&gt; panic if you take incompatible borrows at once. In this domain that's exactly what I want — GTK callbacks run on the main thread one at a time, so the panic only fires when &lt;em&gt;my&lt;/em&gt; code has gone re-entrant in a way I didn't notice, and the first time it happened to me it pointed straight at a bug class I'd otherwise have spent an afternoon bisecting.&lt;/p&gt;

&lt;p&gt;Notice the &lt;code&gt;gio::ListStore&lt;/code&gt; itself isn't wrapped in &lt;code&gt;RefCell&lt;/code&gt;, even though we mutate it constantly with &lt;code&gt;store.append(...)&lt;/code&gt;. &lt;code&gt;ListStore&lt;/code&gt; is a GObject, and GObjects do their own reference counting and stay mutable through their GObject API without needing Rust-side cells. Wrapping it would compile but be redundant — you'd be borrow-checking access to a pointer.&lt;/p&gt;

&lt;p&gt;My rule of thumb, after enough of these: template children and properties are naked fields with the right macro attribute; anything that owns a GObject — a &lt;code&gt;gio::ListStore&lt;/code&gt;, another widget — is a naked field or an &lt;code&gt;OnceCell&lt;/code&gt;, never a &lt;code&gt;RefCell&lt;/code&gt;; a Rust value that genuinely mutates gets a &lt;code&gt;RefCell&lt;/code&gt;, or a &lt;code&gt;Cell&lt;/code&gt; if it's &lt;code&gt;Copy&lt;/code&gt;; and a value set once and read everywhere gets a &lt;code&gt;OnceCell&lt;/code&gt;. Once that split is reflexive, most of the "which wrapper goes here?" questions stop being questions.&lt;/p&gt;




&lt;h2&gt;
  
  
  Wiring selection, and the &lt;code&gt;clone!&lt;/code&gt; macro
&lt;/h2&gt;

&lt;p&gt;The rows render but selecting them does nothing. Wire selection up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;obj&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="nf"&gt;.obj&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.feed_list&lt;/span&gt;&lt;span class="nf"&gt;.connect_row_selected&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;glib&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nd"&gt;clone!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nd"&gt;#[weak]&lt;/span&gt; &lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;move&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;feed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;
            &lt;span class="nf"&gt;.and_then&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="nf"&gt;.index&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.try_into&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.ok&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
            &lt;span class="nf"&gt;.and_then&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;u32&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="nf"&gt;.imp&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="py"&gt;.feeds&lt;/span&gt;&lt;span class="nf"&gt;.get&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.unwrap&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.item&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="py"&gt;.and_downcast&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Feed&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="nf"&gt;.imp&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="py"&gt;.selected_feed&lt;/span&gt;&lt;span class="nf"&gt;.replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;feed&lt;/span&gt;&lt;span class="nf"&gt;.clone&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;feed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;feed&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="py"&gt;.emit_by_name&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"feed-selected"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;feed&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(We define the &lt;code&gt;feed-selected&lt;/code&gt; signal in the next section. Pretend it exists for a moment.)&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;glib::clone!&lt;/code&gt; macro is doing a lot of work. Without it, the equivalent is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;obj_weak&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="nf"&gt;.downgrade&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.feed_list&lt;/span&gt;&lt;span class="nf"&gt;.connect_row_selected&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;move&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;obj_weak&lt;/span&gt;&lt;span class="nf"&gt;.upgrade&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="c1"&gt;// ... rest of the closure ...&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The macro hides the &lt;code&gt;downgrade()&lt;/code&gt; / &lt;code&gt;upgrade()&lt;/code&gt; dance. The &lt;code&gt;#[weak]&lt;/code&gt; attribute means the closure holds a &lt;em&gt;weak&lt;/em&gt; reference to &lt;code&gt;obj&lt;/code&gt;: it doesn't keep the window alive. When the closure runs, the macro tries to upgrade the weak reference; if the window is gone, the closure returns early.&lt;/p&gt;

&lt;p&gt;The alternative is &lt;code&gt;#[strong]&lt;/code&gt;, which captures by clone, incrementing the refcount. You almost never want that for a closure connected to one of the window's own widgets, because it builds a reference cycle:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The window owns the widget.&lt;/li&gt;
&lt;li&gt;The widget owns the signal handler (the closure).&lt;/li&gt;
&lt;li&gt;The closure now strongly owns the window.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The window can't drop because the widget's closure points to it; the widget can't drop because the window owns it; the closure can't drop because the widget holds it. Your app appears to leak windows on close. &lt;code&gt;#[weak]&lt;/code&gt; breaks the cycle — the closure points weakly back at the window, and when the window's last strong reference is released, the chain unwinds cleanly.&lt;/p&gt;

&lt;p&gt;This is the most common GTK Rust memory leak I run into, and it hides well: most apps have one window for their whole lifetime, so nothing looks wrong until you write a multi-window app — or, in my case, a test that spun windows up and tore them down in a loop, where the slowly climbing memory finally gave it away. The fix is always the same: weak the captures into widget-connected closures.&lt;/p&gt;

&lt;p&gt;(One bit of translation if you're reading older code: the &lt;code&gt;clone!(@weak obj =&amp;gt; move |...| {...})&lt;/code&gt; syntax was deprecated in &lt;code&gt;glib&lt;/code&gt; 0.20. The &lt;code&gt;#[weak]&lt;/code&gt; attribute form is the current spelling. They behave identically.)&lt;/p&gt;




&lt;h2&gt;
  
  
  A custom signal on &lt;code&gt;GazetteWindow&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;The selection handler emits &lt;code&gt;feed-selected&lt;/code&gt;, but we haven't defined it. Adding a custom signal is the same pattern as Part 2's &lt;code&gt;Feed::items-updated&lt;/code&gt;, plugged into the window instead of the model.&lt;/p&gt;

&lt;p&gt;In &lt;code&gt;mod imp&lt;/code&gt; in &lt;code&gt;src/window.rs&lt;/code&gt;, give &lt;code&gt;ObjectImpl&lt;/code&gt; a &lt;code&gt;signals()&lt;/code&gt; method:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;std&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;sync&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;OnceLock&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;glib&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;subclass&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Signal&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;impl&lt;/span&gt; &lt;span class="n"&gt;ObjectImpl&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;GazetteWindow&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;signals&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;'static&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Signal&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;SIGNALS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;OnceLock&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Signal&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;OnceLock&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="n"&gt;SIGNALS&lt;/span&gt;&lt;span class="nf"&gt;.get_or_init&lt;/span&gt;&lt;span class="p"&gt;(||&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nd"&gt;vec!&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nn"&gt;Signal&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"feed-selected"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="nf"&gt;.param_types&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nn"&gt;Feed&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;static_type&lt;/span&gt;&lt;span class="p"&gt;()])&lt;/span&gt;
                &lt;span class="nf"&gt;.build&lt;/span&gt;&lt;span class="p"&gt;()]&lt;/span&gt;
        &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;constructed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="nf"&gt;.parent_constructed&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="c1"&gt;// ... store setup, bind_model, connect_row_selected ...&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;signals()&lt;/code&gt; is a static method GObject calls once, when the type is first registered, to learn what signals it defines. The &lt;code&gt;OnceLock&lt;/code&gt; cache is conventional — GObject doesn't require it, but it means the &lt;code&gt;vec!&lt;/code&gt; allocation only runs once. (Note this is &lt;code&gt;std::sync::OnceLock&lt;/code&gt;, the thread-safe one: a &lt;code&gt;static&lt;/code&gt; has to be &lt;code&gt;Sync&lt;/code&gt;, so the single-threaded &lt;code&gt;std::cell::OnceCell&lt;/code&gt; we reached for in the imp fields wouldn't compile here — same family, different guarantees.) Our signal is named &lt;code&gt;"feed-selected"&lt;/code&gt; and carries one parameter, a &lt;code&gt;Feed&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That's enough to make the &lt;code&gt;emit_by_name&lt;/code&gt; call in the selection handler legal. But a signal with no listeners does nothing — so let's give it a consumer. The handler decides what to do when a feed is selected: update the placeholder.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;obj&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="nf"&gt;.obj&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="nf"&gt;.connect_closure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"feed-selected"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nn"&gt;glib&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nd"&gt;closure_local!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nd"&gt;#[weak]&lt;/span&gt; &lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;move&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="n"&gt;_window&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;GazetteWindow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;feed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Feed&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;imp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="nf"&gt;.imp&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
            &lt;span class="n"&gt;imp&lt;/span&gt;&lt;span class="py"&gt;.placeholder&lt;/span&gt;&lt;span class="nf"&gt;.set_icon_name&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"rss-symbolic"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
            &lt;span class="n"&gt;imp&lt;/span&gt;&lt;span class="py"&gt;.placeholder&lt;/span&gt;&lt;span class="nf"&gt;.set_title&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;feed&lt;/span&gt;&lt;span class="nf"&gt;.name&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
            &lt;span class="n"&gt;imp&lt;/span&gt;&lt;span class="py"&gt;.placeholder&lt;/span&gt;&lt;span class="nf"&gt;.set_description&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;feed&lt;/span&gt;&lt;span class="nf"&gt;.uri&lt;/span&gt;&lt;span class="p"&gt;()));&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few new pieces:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;connect_closure&lt;/code&gt;&lt;/strong&gt; is the lower-level form of signal connection. We need it because the signal is custom — there's no auto-generated &lt;code&gt;connect_feed_selected&lt;/code&gt; method, so we connect by name.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;glib::closure_local!&lt;/code&gt;&lt;/strong&gt; wraps the Rust closure in a &lt;code&gt;glib::Closure&lt;/code&gt; the GObject system can store. It's the bridge between Rust closures and GObject's signal machinery, and it accepts the same &lt;code&gt;#[weak]&lt;/code&gt; / &lt;code&gt;#[strong]&lt;/code&gt; attributes as &lt;code&gt;clone!&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;false&lt;/code&gt;&lt;/strong&gt; is the &lt;code&gt;after&lt;/code&gt; flag — whether this handler runs before or after the &lt;em&gt;other&lt;/em&gt; handlers connected to the signal. It's easy to read it as "after the default handler," but that's a separate mechanism (the handler baked into the type definition); &lt;code&gt;after&lt;/code&gt; only orders you against the other external connections. With a single handler it makes no difference; by convention, pass &lt;code&gt;false&lt;/code&gt; unless you specifically need to run last.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two handlers now exist on &lt;code&gt;feed-selected&lt;/code&gt;: the implicit one in &lt;code&gt;connect_row_selected&lt;/code&gt; that &lt;em&gt;emits&lt;/em&gt; it, and the explicit one in &lt;code&gt;connect_closure&lt;/code&gt; that &lt;em&gt;consumes&lt;/em&gt; it. Neither side names the other; the signal is all they share.&lt;/p&gt;

&lt;p&gt;Run the app. Click &lt;em&gt;This Week in GNOME&lt;/em&gt;; the content pane updates to show the feed name as the title and the URL as the description. Click &lt;em&gt;Hacker News&lt;/em&gt;; it switches. Selection has meaning now.&lt;/p&gt;




&lt;h2&gt;
  
  
  When &lt;code&gt;Rc&amp;lt;RefCell&amp;lt;T&amp;gt;&amp;gt;&lt;/code&gt; isn't the answer
&lt;/h2&gt;

&lt;p&gt;The instinct, especially coming from non-GUI Rust, is to reach for &lt;code&gt;Rc&amp;lt;RefCell&amp;lt;T&amp;gt;&amp;gt;&lt;/code&gt; whenever two closures need to share mutable state — it's the canonical answer to "shared mutable state without locks," and my first couple of GTK apps were quietly littered with it.&lt;/p&gt;

&lt;p&gt;Suppose both &lt;code&gt;connect_row_selected&lt;/code&gt; and the &lt;code&gt;feed-selected&lt;/code&gt; handler wanted to read the currently-selected &lt;code&gt;Feed&lt;/code&gt;. The wrong instinct produces this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Don't do this.&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;selected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;std&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;rc&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;Rc&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;std&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;cell&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;RefCell&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;None&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Feed&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;selected_clone&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;selected&lt;/span&gt;&lt;span class="nf"&gt;.clone&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.feed_list&lt;/span&gt;&lt;span class="nf"&gt;.connect_row_selected&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;move&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;selected_clone&lt;/span&gt;&lt;span class="nf"&gt;.borrow_mut&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="cm"&gt;/* derive feed from row */&lt;/span&gt; &lt;span class="nb"&gt;None&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;selected_clone&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;selected&lt;/span&gt;&lt;span class="nf"&gt;.clone&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="nf"&gt;.connect_closure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"feed-selected"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nn"&gt;glib&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nd"&gt;closure_local!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;move&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="n"&gt;_window&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;GazetteWindow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_feed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Feed&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;_current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;selected_clone&lt;/span&gt;&lt;span class="nf"&gt;.borrow&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="c1"&gt;// ...&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This compiles, runs, and is reasonable Rust outside the GTK context. Inside it, the state belongs in &lt;code&gt;imp&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.feed_list&lt;/span&gt;&lt;span class="nf"&gt;.connect_row_selected&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;glib&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nd"&gt;clone!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nd"&gt;#[weak]&lt;/span&gt; &lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;move&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;feed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="cm"&gt;/* derive feed from row */&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="nf"&gt;.imp&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="py"&gt;.selected_feed&lt;/span&gt;&lt;span class="nf"&gt;.replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;feed&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both closures already reach the same &lt;code&gt;selected_feed&lt;/code&gt; through &lt;code&gt;obj.imp()&lt;/code&gt;. There's no need for an additional &lt;code&gt;Rc&lt;/code&gt; — GObject's reference counting &lt;em&gt;is&lt;/em&gt; the &lt;code&gt;Rc&lt;/code&gt;. There's no need for a separate &lt;code&gt;RefCell&lt;/code&gt; — the field is already in one on the imp struct.&lt;/p&gt;

&lt;p&gt;The rule of thumb: &lt;strong&gt;any time you find yourself typing &lt;code&gt;Rc&amp;lt;RefCell&amp;lt;T&amp;gt;&amp;gt;&lt;/code&gt; inside an &lt;code&gt;imp&lt;/code&gt; block, stop.&lt;/strong&gt; The state you're trying to share lives on the imp; closures reach it through &lt;code&gt;obj.imp()&lt;/code&gt;; the GObject lifecycle handles the sharing. It can be appropriate at the &lt;em&gt;edges&lt;/em&gt; of a system — prototyping a small example without a wrapping GObject — but inside a real GObject-based architecture, it's a smell.&lt;/p&gt;




&lt;h2&gt;
  
  
  Binding the view to state
&lt;/h2&gt;

&lt;p&gt;The placeholder updates imperatively inside the &lt;code&gt;feed-selected&lt;/code&gt; handler — three &lt;code&gt;set_*&lt;/code&gt; calls per selection. &lt;code&gt;bind_property&lt;/code&gt; promises to replace that with a declarative link, and the tempting move is to drop it straight into the handler:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Tempting, and wrong.&lt;/span&gt;
&lt;span class="n"&gt;feed&lt;/span&gt;&lt;span class="nf"&gt;.bind_property&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;*&lt;/span&gt;&lt;span class="n"&gt;imp&lt;/span&gt;&lt;span class="py"&gt;.placeholder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;.sync_create&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nf"&gt;.build&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This compiles and looks right — select a feed, the title updates — and I shipped exactly this shape once and didn't notice for weeks. The catch is that the handler runs &lt;em&gt;once per selection&lt;/em&gt;, so every click builds a fresh &lt;code&gt;glib::Binding&lt;/code&gt; and nothing tears down the previous one. Click between four feeds and the placeholder's &lt;code&gt;title&lt;/code&gt; now has four live bindings pointing at it, each holding a reference to a feed. They stay quiet only because a feed's &lt;code&gt;name&lt;/code&gt; never changes after creation — and the moment it does, which is exactly what Part 6's fetcher will make happen, every stale binding fires and an &lt;em&gt;unselected&lt;/em&gt; feed can overwrite the &lt;em&gt;selected&lt;/em&gt; one's title. A binding built in a per-event handler is a leak with a delay on it.&lt;/p&gt;

&lt;p&gt;The fix isn't to manage the binding's lifetime by hand. It's to notice what the placeholder actually is: a pure function of &lt;em&gt;which feed is selected&lt;/em&gt;. That's state, and we have a rule for state — it lives in &lt;code&gt;imp&lt;/code&gt;. Promote &lt;code&gt;selected_feed&lt;/code&gt; from a plain field to a GObject property, and the placeholder can bind to it &lt;strong&gt;once&lt;/strong&gt;, for the life of the window.&lt;/p&gt;

&lt;p&gt;Add the &lt;code&gt;Properties&lt;/code&gt; derive to the window's imp struct and annotate the field:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="nd"&gt;#[derive(Debug,&lt;/span&gt; &lt;span class="nd"&gt;Default,&lt;/span&gt; &lt;span class="nd"&gt;gtk::CompositeTemplate,&lt;/span&gt; &lt;span class="nd"&gt;glib::Properties)]&lt;/span&gt;
&lt;span class="nd"&gt;#[properties(wrapper_type&lt;/span&gt; &lt;span class="nd"&gt;=&lt;/span&gt; &lt;span class="nd"&gt;super::GazetteWindow)]&lt;/span&gt;
&lt;span class="nd"&gt;#[template(resource&lt;/span&gt; &lt;span class="nd"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"/io/github/fromthearchitect/gazette/window.ui"&lt;/span&gt;&lt;span class="nd"&gt;)]&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;GazetteWindow&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nd"&gt;#[template_child]&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;split_view&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;TemplateChild&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nn"&gt;adw&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;NavigationSplitView&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nd"&gt;#[template_child]&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;feed_list&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;TemplateChild&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;ListBox&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nd"&gt;#[template_child]&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;placeholder&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;TemplateChild&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nn"&gt;adw&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;StatusPage&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;feeds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;OnceCell&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nn"&gt;gio&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;ListStore&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nd"&gt;#[property(get,&lt;/span&gt; &lt;span class="nd"&gt;set,&lt;/span&gt; &lt;span class="nd"&gt;nullable)]&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;selected_feed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;RefCell&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Feed&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add &lt;code&gt;#[glib::derived_properties]&lt;/code&gt; above the window's &lt;code&gt;impl ObjectImpl&lt;/code&gt; block — the same pairing &lt;code&gt;Feed&lt;/code&gt; already uses. &lt;code&gt;nullable&lt;/code&gt; is what lets the property hold &lt;code&gt;None&lt;/code&gt;: nothing is selected at startup, and the user can clear the selection later.&lt;/p&gt;

&lt;p&gt;The selection handler now &lt;em&gt;sets the property&lt;/em&gt; instead of replacing the cell directly. Going through the generated setter is what emits &lt;code&gt;notify::selected-feed&lt;/code&gt;, and that notification drives the binding:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.feed_list&lt;/span&gt;&lt;span class="nf"&gt;.connect_row_selected&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;glib&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nd"&gt;clone!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nd"&gt;#[weak]&lt;/span&gt; &lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;move&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;feed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;
            &lt;span class="nf"&gt;.and_then&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="nf"&gt;.index&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.try_into&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.ok&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
            &lt;span class="nf"&gt;.and_then&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;u32&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="nf"&gt;.imp&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="py"&gt;.feeds&lt;/span&gt;&lt;span class="nf"&gt;.get&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.unwrap&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.item&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="py"&gt;.and_downcast&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Feed&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="nf"&gt;.set_selected_feed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;feed&lt;/span&gt;&lt;span class="nf"&gt;.as_ref&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;feed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;feed&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="py"&gt;.emit_by_name&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"feed-selected"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;feed&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then bind the placeholder to the property once, in &lt;code&gt;constructed&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="nf"&gt;.bind_property&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"selected-feed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;*&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.placeholder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;.transform_to&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;feed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Feed&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;feed&lt;/span&gt;&lt;span class="nf"&gt;.map_or_else&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="s"&gt;"No Feed Selected"&lt;/span&gt;&lt;span class="nf"&gt;.to_string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="n"&gt;feed&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;feed&lt;/span&gt;&lt;span class="nf"&gt;.name&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="nf"&gt;.sync_create&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nf"&gt;.build&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="nf"&gt;.bind_property&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"selected-feed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;*&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.placeholder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;.transform_to&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;feed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Feed&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;feed&lt;/span&gt;&lt;span class="nf"&gt;.map_or_else&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="s"&gt;"Select a feed from the sidebar to see its articles."&lt;/span&gt;&lt;span class="nf"&gt;.to_string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="n"&gt;feed&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;feed&lt;/span&gt;&lt;span class="nf"&gt;.uri&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="nf"&gt;.sync_create&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nf"&gt;.build&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;transform_to&lt;/code&gt; runs whenever the source changes, mapping the selected &lt;code&gt;Feed&lt;/code&gt; to a string for the target. The &lt;code&gt;None&lt;/code&gt; arm matters as much as the &lt;code&gt;Some&lt;/code&gt; arm: when the selection clears, the binding restores the empty-state text — the placeholder handles deselection for free, with no code in the handler. &lt;code&gt;sync_create()&lt;/code&gt; copies the current value (&lt;code&gt;None&lt;/code&gt; at startup) the moment the binding is built, which is why the placeholder reads "No Feed Selected" before anything is clicked.&lt;/p&gt;

&lt;p&gt;With the placeholder now driven by the property, the &lt;code&gt;feed-selected&lt;/code&gt; &lt;em&gt;placeholder&lt;/em&gt; handler has nothing left to do — delete it. And that deletion sharpens the point about routing through a signal. Reflecting state in the UI — the title just following the selection — is a binding's job: declarative, set up once. &lt;em&gt;Acting&lt;/em&gt; on a change — fetching the feed, logging, anything with a side effect — is a signal's job, and &lt;code&gt;feed-selected&lt;/code&gt; stays exactly for that. The placeholder was never really a signal consumer; it was a view of state, and now it's wired that way.&lt;/p&gt;

&lt;p&gt;So what &lt;em&gt;does&lt;/em&gt; listen to &lt;code&gt;feed-selected&lt;/code&gt; now? For the moment, a logger — proof the seam works:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="nf"&gt;.connect_closure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"feed-selected"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nn"&gt;glib&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nd"&gt;closure_local!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;move&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="n"&gt;_window&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;GazetteWindow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;feed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Feed&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nd"&gt;eprintln!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"feed selected: {} ({})"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;feed&lt;/span&gt;&lt;span class="nf"&gt;.name&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;feed&lt;/span&gt;&lt;span class="nf"&gt;.uri&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run, click feeds, watch the terminal. Two independent things now happen on every selection: the placeholder updates through the property binding, and the log prints through the signal handler — and neither is wired to the other. Part 6 connects a second handler to the same signal, an async fetcher that reads the feed's URL, and it slots in without the binding or this logger ever knowing it arrived.&lt;/p&gt;




&lt;h2&gt;
  
  
  Sharp edges
&lt;/h2&gt;

&lt;p&gt;A few things that bite, all on the GObject-runtime seam where Rust's compile-time guarantees stop and GObject's runtime conventions begin.&lt;/p&gt;

&lt;h3&gt;
  
  
  Signal names aren't checked
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;emit_by_name::&amp;lt;()&amp;gt;("feed-selected", ...)&lt;/code&gt; compiles fine if you typo the name. It fails at runtime when the type system can't find a signal by that exact string, and panics with a message about a name lookup miss. There's no compile-time check. The remedies: care, integration tests that exercise every signal path, or &lt;code&gt;const&lt;/code&gt;-ing the name in one place and using it in both &lt;code&gt;signals()&lt;/code&gt; and the emit sites.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;connect_row_selected&lt;/code&gt; fires with &lt;code&gt;None&lt;/code&gt; on deselection
&lt;/h3&gt;

&lt;p&gt;When the selection clears — programmatically, or because something else takes focus — &lt;code&gt;row&lt;/code&gt; is &lt;code&gt;None&lt;/code&gt;. Code that always assumes a feed exists goes quietly wrong. Branch on &lt;code&gt;row&lt;/code&gt; early; the &lt;code&gt;Option&lt;/code&gt; is part of the API for a reason.&lt;/p&gt;

&lt;h3&gt;
  
  
  Borrow panics inside signal handlers
&lt;/h3&gt;

&lt;p&gt;If you hold a &lt;code&gt;RefCell&lt;/code&gt; borrow across a signal emission and a handler for that signal tries to borrow the same cell, you panic — signal handlers run synchronously, so the emit doesn't return until they're done. The shape that triggers it: &lt;code&gt;let mut state = self.thing.borrow_mut();&lt;/code&gt; followed immediately by &lt;code&gt;obj.emit_by_name::&amp;lt;()&amp;gt;(...)&lt;/code&gt;, still inside that borrow. The fix is to scope the borrow tightly so it ends before you emit. (The derived property setters are safe here: each scopes its own &lt;code&gt;borrow_mut&lt;/code&gt;, writes, and returns &lt;em&gt;before&lt;/em&gt; GObject emits &lt;code&gt;notify&lt;/code&gt;.)&lt;/p&gt;

&lt;h3&gt;
  
  
  Kebab-case at the GObject level, snake_case in Rust
&lt;/h3&gt;

&lt;p&gt;A struct field &lt;code&gt;selected_feed&lt;/code&gt; becomes the property &lt;code&gt;selected-feed&lt;/code&gt; in &lt;code&gt;bind_property&lt;/code&gt; and friends, with accessors &lt;code&gt;selected_feed()&lt;/code&gt; / &lt;code&gt;set_selected_feed(...)&lt;/code&gt; on the Rust side. The derive macro does the conversion. Mistype the kebab-case form — &lt;code&gt;bind_property("selected_feed", ...)&lt;/code&gt; — and the binding fails to find a property by that name. Not silently: GLib prints a &lt;code&gt;g_warning&lt;/code&gt; to stderr and the binding does nothing.&lt;/p&gt;




&lt;h2&gt;
  
  
  What we have so far
&lt;/h2&gt;

&lt;p&gt;Selecting a feed does something. The structure model → list → selection → signal → content is in place, and four real RSS feeds populate the sidebar at startup. The state we added — &lt;code&gt;feeds: OnceCell&amp;lt;gio::ListStore&amp;gt;&lt;/code&gt;, &lt;code&gt;selected_feed&lt;/code&gt; as a nullable property — lives in &lt;code&gt;imp&lt;/code&gt;, where the GObject machinery already manages sharing across closures and lifetimes. &lt;code&gt;Rc&amp;lt;RefCell&amp;lt;T&amp;gt;&amp;gt;&lt;/code&gt; doesn't appear anywhere in this codebase, and probably won't.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;feed-selected&lt;/code&gt; signal is a deliberate seam. The placeholder doesn't even use it — selection state flows through a property binding instead, because reflecting state and reacting to events are different jobs. The signal is for reacting: right now a single logging handler listens.&lt;/p&gt;




&lt;h2&gt;
  
  
  What comes next
&lt;/h2&gt;

&lt;p&gt;That logging handler is a placeholder for the real one. Part 6 — &lt;strong&gt;Fetching Feeds&lt;/strong&gt; — connects an asynchronous fetcher to &lt;code&gt;feed-selected&lt;/code&gt;: it hits each feed's URL, parses the RSS or Atom response, and emits the &lt;code&gt;items-updated&lt;/code&gt; signal we deferred back at the top of this post as articles arrive.&lt;/p&gt;

&lt;p&gt;That fetcher needs a runtime GTK doesn't have. Part 6 explains why GTK is single-threaded around a single main loop, why blocking that loop freezes the window, and how the two-executor pattern lets Tokio do network work without ever touching a widget on the wrong thread. The seam we built here is what it plugs into.&lt;/p&gt;




&lt;p&gt;The source code at the end of this post lives on the &lt;code&gt;part-5&lt;/code&gt; branch of &lt;a href="https://github.com/fromthearchitect/gnome-rust-gazette/tree/part-5" rel="noopener noreferrer"&gt;fromthearchitect/gnome-rust-gazette&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>gnome</category>
      <category>rust</category>
      <category>gtk</category>
      <category>gobject</category>
    </item>
    <item>
      <title>Four Ways the Outbox Bit Me</title>
      <dc:creator>Justin - FromTheArchitect</dc:creator>
      <pubDate>Tue, 02 Jun 2026 22:00:00 +0000</pubDate>
      <link>https://dev.to/fromthearchitect/four-ways-the-outbox-bit-me-3phe</link>
      <guid>https://dev.to/fromthearchitect/four-ways-the-outbox-bit-me-3phe</guid>
      <description>&lt;p&gt;In &lt;a href="https://fromthearchitect.dev/posts/recording-mutations-not-events/" rel="noopener noreferrer"&gt;the previous post&lt;/a&gt; I described an architecture where the &lt;a href="https://github.com/justinf555/Moments" rel="noopener noreferrer"&gt;Moments&lt;/a&gt; library doesn't know that sync exists. Services record &lt;code&gt;Mutation&lt;/code&gt; values into a one-method trait; one implementation writes to an outbox table, another does nothing. The library code is identical regardless of which backend it's running under, and a third backend could be added without touching the library at all. The architecture is sound. I still believe in it.&lt;/p&gt;

&lt;p&gt;The first six weeks of running it were a different story. Four bugs in particular are worth describing, because each one tells you something the diagrams don't. Two are about the &lt;em&gt;abstraction&lt;/em&gt; — assumptions the trait quietly makes about how the world works, and what breaks when the world doesn't cooperate. Two are about the &lt;em&gt;substrate&lt;/em&gt; — the boring queue-and-retry machinery underneath the trait, which has its own opinions about how things should be done. Clean architecture doesn't relieve you of the substrate's problems. It just gives you one place to deal with them.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The pull-delete loop
&lt;/h2&gt;

&lt;p&gt;The first bug was the most embarrassing one, and the cleanest illustration of a hidden assumption in the design.&lt;/p&gt;

&lt;p&gt;The symptom, as a user would see it: trash a photo on your phone via the Immich mobile app. Watch it disappear from the desktop a few seconds later when the next pull cycle arrives. Watch it &lt;em&gt;reappear&lt;/em&gt; a few seconds after that. Watch it disappear again. The asset ping-pongs across the network, never settling, every thirty seconds, forever — or at least until you notice and start digging.&lt;/p&gt;

&lt;p&gt;The mechanics: the pull-side handler for &lt;code&gt;AssetDeleteV1&lt;/code&gt; called &lt;code&gt;MediaService::delete_permanently&lt;/code&gt;, which dutifully recorded an &lt;code&gt;AssetDeleted&lt;/code&gt; mutation into the outbox, which the push manager dutifully tried to send back to the server, which had just told us the asset was gone. Server says delete → local deletes → outbox records delete → push tries to delete on server → server returns 404, push gives up. So far so harmless. But the &lt;em&gt;next&lt;/em&gt; pull cycle then re-emits the original delete (because the server is still asserting "this asset has been deleted, here's the event"), and we go around again.&lt;/p&gt;

&lt;p&gt;The recorder-based design has a hidden assumption baked into it: mutations have a single source, and that source is the user sitting at the desktop. The whole point of recording mutations is to push them to the server. When the &lt;em&gt;server&lt;/em&gt; starts producing mutations — and in a two-way sync, of course it does — the recorder happily reports those back to the server too, and the system feeds on its own output.&lt;/p&gt;

&lt;p&gt;The fix (commit &lt;a href="https://github.com/justinf555/Moments/commit/f097174" rel="noopener noreferrer"&gt;&lt;code&gt;f097174&lt;/code&gt;&lt;/a&gt;) was a &lt;code&gt;Library::delete_permanently_from_sync&lt;/code&gt; variant that takes the same code path &lt;em&gt;except&lt;/em&gt; it skips the recorder. The &lt;code&gt;_from_sync&lt;/code&gt; naming convention now appears wherever pull-side handlers mutate the library — there's a &lt;code&gt;_from_sync&lt;/code&gt; for trash, for restore, for the album mutations, and so on. Each one is a tiny duplicate of its normal counterpart, with the recorder call removed. It feels redundant, and it is, but the redundancy is the whole point: pull is the local equivalent of "don't echo back what you were just told", and the duplication makes the don't-echo explicit at every call site.&lt;/p&gt;

&lt;p&gt;There were two alternatives I considered. One was a thread-local "I am inside the sync handler" flag that the recorder would check; that's a kind of magic that hides the directional question rather than answering it. The other was a parameter on every service method indicating origin; that pushes the question into every signature instead of into a single naming convention. The &lt;code&gt;_from_sync&lt;/code&gt; approach won because it makes the asymmetry visible exactly where it matters and nowhere else.&lt;/p&gt;

&lt;p&gt;The architectural lesson, stated generally: a recorder-based design implicitly assumes mutations have a single source. The moment a second source appears, you have to mark it in the code, or you get an infinite loop with a polite retry interval.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Retry, backoff, and the death of multi-id rows
&lt;/h2&gt;

&lt;p&gt;The second bug is one of substrate, not abstraction. It would have bitten any outbox-based design, regardless of how the recorder was shaped.&lt;/p&gt;

&lt;p&gt;The first version of the outbox stored multi-id mutations as a single row. &lt;code&gt;AssetTrashed { ids: [a, b, c] }&lt;/code&gt; produced one outbox row with a JSON-encoded array of ids. It was simpler. The mutation type kept its natural shape — "the user trashed three photos" is a single mental event, after all — and the database had three fewer rows to deal with.&lt;/p&gt;

&lt;p&gt;It was also wrong, in three independent ways.&lt;/p&gt;

&lt;p&gt;The first was &lt;em&gt;partial progress&lt;/em&gt;. The push call for that row was, internally, a loop over the ids making per-asset API calls. If the server accepted three of five ids and then the connection dropped, there was no way to record the partial success. The next retry would re-send all five, and the server's behaviour on the already-trashed ones was implementation-defined enough that I didn't trust it.&lt;/p&gt;

&lt;p&gt;The second was &lt;em&gt;blast radius&lt;/em&gt;. If a single id in the batch was the cause of a permanent failure — say, the user trashed an asset, then permanently deleted it elsewhere, then triggered a sync — the batch would fail forever on that one bad asset. The other four perfectly good operations were dragged into the dead-letter queue alongside it.&lt;/p&gt;

&lt;p&gt;The third was &lt;em&gt;per-batch backoff&lt;/em&gt;. Exponential backoff on the batch row meant one bad asset could starve the entire trash pipeline behind it. Asset X fails three times, the row sleeps for ten minutes, all five operations wait. Asset X fails again, the row sleeps for an hour. Operations Y and Z, which would have succeeded immediately, are blocked on Asset X's bad behaviour.&lt;/p&gt;

&lt;p&gt;The fix (commit &lt;a href="https://github.com/justinf555/Moments/commit/77d9647" rel="noopener noreferrer"&gt;&lt;code&gt;77d9647&lt;/code&gt;&lt;/a&gt;) rewrote &lt;code&gt;Mutation::to_outbox_rows()&lt;/code&gt; to produce one row per entity, and added per-row retry/backoff machinery: &lt;code&gt;attempts&lt;/code&gt; and &lt;code&gt;next_attempt_at&lt;/code&gt; columns, exponential backoff capped at one hour, a &lt;code&gt;DeadLetter&lt;/code&gt; status after ten consecutive failures so a permanently bad row stops being retried. &lt;code&gt;AssetTrashed { ids: [a, b, c] }&lt;/code&gt; now produces three rows. Each one is independently retryable, independently dead-letterable, independently backed off.&lt;/p&gt;

&lt;p&gt;I want to make this lesson explicit because it took me three iterations to internalise: &lt;strong&gt;the unit of retry should be the unit of failure&lt;/strong&gt;. If a single id can fail independently, each id needs its own row. The temptation to compact the outbox is real — fewer rows feels cleaner, the JSON arrays are tidier in the schema browser — but the cost shows up under exactly the conditions you can't easily reproduce in development.&lt;/p&gt;

&lt;p&gt;This isn't a recorder bug. It's a queue-design bug, and would have happened with a channel, a Kafka topic, or a hand-rolled WAL. But it's still a bug the &lt;em&gt;architecture&lt;/em&gt; made me responsible for, because the trait that the architecture promises is only as good as the queue underneath it.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Identity across the round-trip
&lt;/h2&gt;

&lt;p&gt;The third bug was the one that taught me the difference between decoupling and ignoring.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;MediaId&lt;/code&gt; and &lt;code&gt;AlbumId&lt;/code&gt; are local UUIDs that Moments generates when an asset or album is first created on the desktop. Immich also generates UUIDs for assets and albums on its end — those land in a column called &lt;code&gt;external_id&lt;/code&gt;. The two are not the same. The local id is the row's primary key forever; the external id is stamped onto the row once the push succeeds and the pull cycle confirms the asset is now visible server-side.&lt;/p&gt;

&lt;p&gt;So far so reasonable. Now consider the lifecycle of a freshly imported photo. The user drops a JPEG into the import folder. &lt;code&gt;MediaService::import&lt;/code&gt; generates a fresh &lt;code&gt;MediaId&lt;/code&gt;, writes a row, records an &lt;code&gt;AssetImported&lt;/code&gt; mutation. The push manager picks up the outbox row, uploads the file, receives the Immich asset id from the response, stamps it into &lt;code&gt;media.external_id&lt;/code&gt;. Beautiful. Then the next pull cycle, dutifully fetching everything the server knows about, streams an &lt;code&gt;AssetV1&lt;/code&gt; event for that same asset.&lt;/p&gt;

&lt;p&gt;The original handler for &lt;code&gt;AssetV1&lt;/code&gt; did &lt;code&gt;INSERT OR REPLACE INTO media (id, external_id, …)&lt;/code&gt;. The &lt;code&gt;id&lt;/code&gt; it inserted was a freshly-generated local UUID — because that's how the pull handler always created assets. The pull handler had no way to know that &lt;em&gt;this&lt;/em&gt; asset already existed locally under a different local id, because the only thing connecting them was the &lt;code&gt;external_id&lt;/code&gt; column it was about to overwrite.&lt;/p&gt;

&lt;p&gt;The result: a brand-new row, with a brand-new &lt;code&gt;MediaId&lt;/code&gt;, replacing the original one. Every album-membership row pointing at the original &lt;code&gt;MediaId&lt;/code&gt; now dangled. The asset appeared in the timeline grid but not in any of the albums the user had just added it to. Worse, the original row's on-disk thumbnail and cached EXIF data were now associated with an id that no longer existed in &lt;code&gt;media&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Commits &lt;a href="https://github.com/justinf555/Moments/commit/d43859f" rel="noopener noreferrer"&gt;&lt;code&gt;d43859f&lt;/code&gt;&lt;/a&gt; and &lt;a href="https://github.com/justinf555/Moments/commit/1692881" rel="noopener noreferrer"&gt;&lt;code&gt;1692881&lt;/code&gt;&lt;/a&gt; fixed this by making &lt;code&gt;MediaId&lt;/code&gt; and &lt;code&gt;AlbumId&lt;/code&gt; &lt;em&gt;round-trip stable&lt;/em&gt;. On inbound sync, every handler looks up the existing row by &lt;code&gt;external_id&lt;/code&gt; first. If a row already exists with that external id, the handler reuses its &lt;code&gt;MediaId&lt;/code&gt; and updates fields in place, never minting a new one. The local id becomes the durable identity; the external id is the bridge that lets inbound sync find it again.&lt;/p&gt;

&lt;p&gt;The general lesson — and this one took me by surprise — is that "an outbox decouples local mutations from remote sync" is technically true and architecturally meaningless if the &lt;em&gt;identity&lt;/em&gt; of an entity doesn't survive the round-trip. The decoupling is between the two &lt;em&gt;flows&lt;/em&gt; of operations, not between the two &lt;em&gt;namespaces&lt;/em&gt; of identifiers. Those namespaces still have to be reconciled somewhere, and the schema is the natural place. You need a column, or a constraint, or a lookup convention that lets inbound sync recognise "this thing already exists" rather than treating every server-side row as a first-time event.&lt;/p&gt;

&lt;p&gt;In retrospect, the lesson is obvious. In practice, it costs you a weekend the first time, because the bug is invisible in any test where the asset is created locally &lt;em&gt;or&lt;/em&gt; fetched from the server but never both. The minimum reproducer is a real round-trip, and you don't usually write tests that span import → push → pull on the same asset.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Reset semantics, or: what does the server mean by "start over"
&lt;/h2&gt;

&lt;p&gt;The fourth bug is the one that taught me sync protocols are not what they appear to be.&lt;/p&gt;

&lt;p&gt;Immich's sync protocol can send a &lt;code&gt;SyncResetV1&lt;/code&gt; message when the last checkpoint is more than thirty days stale. The semantics, in the spec, are roughly: "your understanding of my state is too old to be incrementally caught up. Treat what follows as a complete re-emission of everything I know about."&lt;/p&gt;

&lt;p&gt;The naive interpretation is "wipe the local cache and re-fetch everything." That interpretation is catastrophic for an offline-first store. The user has locally-imported, not-yet-pushed assets sitting in their library. Those have no &lt;code&gt;external_id&lt;/code&gt; because the server has never heard of them. A blind wipe would delete the user's recent imports the moment the sync handshake decided their checkpoint was stale — and "stale" in this protocol can mean as little as a month of laptop-closed time.&lt;/p&gt;

&lt;p&gt;The first version of the reset handler tried to be careful. It built a &lt;code&gt;HashSet&amp;lt;MediaId&amp;gt;&lt;/code&gt; of every local asset at the start of the reset cycle, removed each id as the stream re-emitted it, and at end-of-stream deleted whatever was left over. That at least filtered to "things the server should have known about". It still had three latent bugs, and I'll only name them briefly because the full catalogue is in the design doc.&lt;/p&gt;

&lt;p&gt;First, namespace confusion. &lt;code&gt;media.id&lt;/code&gt; is the local UUID; the stream's &lt;code&gt;entity_id&lt;/code&gt; is the Immich UUID. The set was being keyed on one and reduced by the other, so the set never actually shrank — every reset cycle wiped the entire library. Second, locally-imported rows (&lt;code&gt;external_id IS NULL&lt;/code&gt;) shouldn't have been candidates at all, but the set didn't filter them out. Third, albums and &lt;code&gt;asset_faces&lt;/code&gt; had no orphan tracking, so they leaked across resets entirely.&lt;/p&gt;

&lt;p&gt;Commits &lt;a href="https://github.com/justinf555/Moments/commit/e54e037" rel="noopener noreferrer"&gt;&lt;code&gt;e54e037&lt;/code&gt;&lt;/a&gt; and &lt;a href="https://github.com/justinf555/Moments/commit/3e24d9f" rel="noopener noreferrer"&gt;&lt;code&gt;3e24d9f&lt;/code&gt;&lt;/a&gt; rewrote the whole thing into a heartbeat-based reconciliation, which is the kind of state-machine design that's interesting on its own merits. On &lt;code&gt;SyncResetV1&lt;/code&gt; the reset handler captures the current timestamp as a "checkpoint" and clears the per-entity-type ack cursors. Nothing gets deleted yet. Every entity-handler — &lt;code&gt;AssetV1&lt;/code&gt;, &lt;code&gt;AlbumV1&lt;/code&gt;, &lt;code&gt;PersonV1&lt;/code&gt;, &lt;code&gt;AssetFaceV1&lt;/code&gt; — has an &lt;code&gt;UPDATE table SET last_seen_at = now() WHERE id = ?&lt;/code&gt; baked into it. Any locally-driven server interaction (a push completion, a favorite round-trip) bumps the same column.&lt;/p&gt;

&lt;p&gt;On &lt;code&gt;SyncCompleteV1&lt;/code&gt;, four targeted sweeps run — on &lt;code&gt;media&lt;/code&gt;, &lt;code&gt;albums&lt;/code&gt;, &lt;code&gt;people&lt;/code&gt;, and &lt;code&gt;asset_faces&lt;/code&gt; — each filtering rows where &lt;code&gt;last_seen_at &amp;lt; checkpoint&lt;/code&gt;. The &lt;code&gt;media&lt;/code&gt; and &lt;code&gt;albums&lt;/code&gt; sweeps add &lt;code&gt;external_id IS NOT NULL&lt;/code&gt; as a guard, which is the single line of defence between a reset cycle and the user's local-only imports. &lt;code&gt;people&lt;/code&gt; and &lt;code&gt;asset_faces&lt;/code&gt; don't need that guard: nothing local creates them — they only exist once the server emits them. The whole scheme is crash-safe by accident: partial heartbeats persist across a process restart, so an interrupted reset cycle finishes correctly on the next pull rather than starting over.&lt;/p&gt;

&lt;p&gt;The lesson is broader than reset. "Reset" in a sync protocol is not "delete everything." It's "re-establish ground truth without losing what only you know about." The set of things only-you-know-about is — for an offline-first store — every row without an &lt;code&gt;external_id&lt;/code&gt; plus every locally-mutated field that hasn't yet been pushed. If your reset handler doesn't have a name for that set, your reset handler is going to delete it.&lt;/p&gt;

&lt;p&gt;This is the bug that has the least to do with the recorder trait itself. It belongs to the broader category of "things you only discover once you have the architecture, the outbox, and a real round-trip running long enough to encounter a thirty-day boundary." But it earned its place on this list because it follows the same shape as the others: the architecture didn't cause it, the architecture didn't prevent it, but the architecture is what gave us one well-defined place to fix it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the four have in common
&lt;/h2&gt;

&lt;p&gt;The bugs span the two categories the intro set out — abstraction (1 and 3) and substrate (2 and 4) — and what they share, across both, is that none of them required changing the architecture. Bug 1 added a &lt;code&gt;_from_sync&lt;/code&gt; variant alongside the user-facing method, not a new trait or a new flag on the recorder. Bug 2 rewrote &lt;code&gt;to_outbox_rows()&lt;/code&gt; and added retry columns to the schema; the recorder trait was untouched. Bug 3 changed how inbound handlers look up existing rows; nothing above the schema cared. Bug 4 added a single column (&lt;code&gt;last_seen_at&lt;/code&gt;) and four sweep queries; the trait, the enum, the outbox table layout, the library services — all unchanged.&lt;/p&gt;

&lt;p&gt;That's the dividend the architecture actually pays. The bugs are real, expensive, and embarrassing in their own ways. None of them propagated upward. The library still doesn't know sync exists. It just describes what it changed, and lets the substrate — and the people maintaining it — pay the round-trip's price.&lt;/p&gt;

</description>
      <category>rust</category>
      <category>architecture</category>
      <category>sync</category>
      <category>outbox</category>
    </item>
    <item>
      <title>Why Open Source Doesn't Embrace AI</title>
      <dc:creator>Justin - FromTheArchitect</dc:creator>
      <pubDate>Wed, 27 May 2026 12:19:50 +0000</pubDate>
      <link>https://dev.to/fromthearchitect/why-open-source-doesnt-embrace-ai-2n0</link>
      <guid>https://dev.to/fromthearchitect/why-open-source-doesnt-embrace-ai-2n0</guid>
      <description>&lt;p&gt;&lt;em&gt;A week ago I would have told you the open source community rejected AI for four obvious reasons. Then I tried defending each one properly. Most of them collapse under scrutiny — and what's left is a much more tractable conversation.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The conventional answer
&lt;/h2&gt;

&lt;p&gt;Every maintainer I know has roughly the same three or four complaints about AI tooling.&lt;/p&gt;

&lt;p&gt;First, nobody can tell where the training data came from, which sits badly with a culture obsessed with provenance. Second, maintainers are drowning in AI-generated PRs and CVE reports — Daniel Stenberg's posts on the curl side of this are now the canonical example. Third, AI shortcuts the old apprenticeship loop where contributors learned a codebase by earning review trust over months. And fourth, the power asymmetry: frontier models need hyperscaler capital, which sits uneasily inside a movement built on "you can read, modify, and redistribute the thing."&lt;/p&gt;

&lt;p&gt;This is the standard pitch. Put it in front of any contributor at FOSDEM and you'd get nods all the way down. It's also the position I'd have given you a week ago, before I tried to defend it in detail and realised most of it doesn't actually hold up.&lt;/p&gt;

&lt;h2&gt;
  
  
  The win-win argument
&lt;/h2&gt;

&lt;p&gt;The most common pushback I hear from developers is straightforward. If AI is trained on open source, and then used to write more open source, surely that's a win for everyone. The argument, basically, is that open source taught the model, and the model now helps people ship more open source.&lt;/p&gt;

&lt;p&gt;The first-order answer is that the value doesn't circulate. Code goes &lt;em&gt;in&lt;/em&gt; to the model for free, gets laundered through training, and comes back out behind a paywall rented from a private company. The contributor whose code shaped GPT-5 or Claude or Copilot doesn't get a cut, doesn't get attribution, and pays the same subscription fee as everyone else. Compare that to how open source normally compounds: I fix a bug in libcurl, the fix lives in libcurl, every downstream user benefits, the project is permanently better. With LLMs, the upstream project gets nothing.&lt;/p&gt;

&lt;p&gt;That answer is satisfying. It's also where the argument starts to wobble.&lt;/p&gt;

&lt;h2&gt;
  
  
  The RHEL problem
&lt;/h2&gt;

&lt;p&gt;The community's case against AI extraction has to reckon with the fact that it has cheerfully tolerated extraction at far larger scale for decades.&lt;/p&gt;

&lt;p&gt;Red Hat ships kernel patches upstream, but Satellite, Insights, and Ansible Automation Platform are proprietary. They made billions off a kernel they didn't write, restricted source distribution to paying customers in 2023, and remain consistently in the top tier of corporate contributors to Linux. The community grumbled and kept using RHEL. Cloud provider extraction hit Elasticsearch and MongoDB hard enough that both re-licensed in self-defence — Elastic directly aimed at AWS, MongoDB preemptively against the whole cloud category — and we still all use AWS. Amazon Linux 2023 is a downstream of Fedora. They keep the orchestration plane closed. Nobody is staging a boycott.&lt;/p&gt;

&lt;p&gt;If RHEL gets a pass for closed-source Satellite while making billions off a kernel they didn't write, the AI critique can't just be "they made money off the commons." That proves too much. It rules out the entire managed-services industry that open source has come to depend on.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Valve actually shows
&lt;/h2&gt;

&lt;p&gt;Valve is probably the strongest counter-example the anti-AI position has. The Steam Deck ships KDE Plasma in desktop mode, so Valve's Steam Deck work funds a consultancy — Techpaladin since 2025, Blue Systems before that — which in turn employs a substantial slice of the KDE Plasma development team, including Nate Graham and David Edmundson. Valve also funds Mesa, kernel graphics, Wine, gamescope, and PipeWire work through similar arrangements. KDE Plasma on Wayland in 2026 is dramatically better than it was in 2022, and a non-trivial chunk of that is Valve money.&lt;/p&gt;

&lt;p&gt;But the reason it works matters, because it doesn't generalise the way the community sometimes wants it to.&lt;/p&gt;

&lt;p&gt;Valve is &lt;strong&gt;redistributing&lt;/strong&gt; GPL'd binaries on every Deck they ship. The licence compels everything that follows. They have to provide source, they have to comply with copyleft, and funding upstream is partly enlightened self-interest because the alternative is forking the world and maintaining it forever. That's not moral generosity, it's licence physics. The Linux kernel made them play by Linux's rules.&lt;/p&gt;

&lt;p&gt;AI vendors don't redistribute the code. They train on it. Whether that triggers any licence at all is the entire legal question — and so far courts have leaned toward "no, it doesn't." Which means the Valve comparison isn't actually telling us what we want it to tell us. It's telling us that &lt;em&gt;when distribution triggers copyleft, copyleft works&lt;/em&gt;. It says nothing about what happens when distribution doesn't enter the picture.&lt;/p&gt;

&lt;h2&gt;
  
  
  Inspiration is not copying
&lt;/h2&gt;

&lt;p&gt;This is the part of the argument I've moved most on, and the part the strong anti-AI position has the most trouble with.&lt;/p&gt;

&lt;p&gt;If copyright treated "learning from code" as infringement, the software industry would collapse under its own weight. Every senior engineer I know has spent years reading other people's code and unconsciously carrying patterns forward. I learned how to structure a GTK app by reading Builder's plugin code. Half of what I know about production C came from reading curl. None of that gave libcurl or Builder a claim on what I write next. The uncomfortable part is that LLMs industrialise that process at a scale humans never could — and a lot of the community's gut reaction is to that scale shift, not to the legal question underneath it.&lt;/p&gt;

&lt;p&gt;The honest legal question isn't "is training infringement." US courts so far appear sympathetic to transformative-use arguments around training &lt;em&gt;when the training data was lawfully acquired&lt;/em&gt; — Anthropic's $1.5B settlement in &lt;em&gt;Bartz v. Anthropic&lt;/em&gt; last year over books obtained from piracy sites is the live counter-example, and it carves the question into two pieces. Training on lawfully-licensed text looks defensible; how the corpus was assembled is its own front. And the EU is heading somewhere different again. The remaining interesting question is "is the &lt;em&gt;output&lt;/em&gt; infringement when it reproduces training data verbatim." That's at least a problem courts can reason about.&lt;/p&gt;

&lt;p&gt;And once you separate "training happened" from "specific expression was reproduced," software starts looking awkwardly unlike most of the creative media copyright was designed around. There are only so many ways to write code.&lt;/p&gt;

&lt;p&gt;Copyright law already has a concept for this — it's called &lt;strong&gt;merger&lt;/strong&gt;. If there are only a handful of sensible ways to express something, copyright protection gets very thin. Nobody owns the canonical &lt;code&gt;for&lt;/code&gt; loop. Nobody owns the standard shape of a quicksort or a binary search. There's a related doctrine called &lt;strong&gt;scènes à faire&lt;/strong&gt; for stock elements that are unavoidable in a genre. &lt;em&gt;Google v. Oracle&lt;/em&gt; (2021) touched adjacent territory — the Court treated Java's declaring code as highly functional and ultimately ruled Google's use fair, with functional constraint as one of the factors that pulled the analysis in Google's direction.&lt;/p&gt;

&lt;p&gt;Apply this to the AI debate and a lot of the "look, the model emitted GPL code!" claims get weaker on inspection. Train on enough code and exact matches alone stop telling you very much — most non-trivial codebases share enough surface patterns that something is going to collide. The real legal test isn't "does the output match." It's "does the output match in ways that have no functional justification."&lt;/p&gt;

&lt;p&gt;Strong evidence of copying: idiosyncratic variable names, distinctive comment style, unusual algorithmic choices, reproduced bugs, copyright headers emitted verbatim, an author's specific tics. The famous Copilot examples — the Quake III fast inverse square root with the &lt;code&gt;// what the fuck?&lt;/code&gt; comment intact, full GPL licence headers — are real and damning. They have no merger defence.&lt;/p&gt;

&lt;p&gt;Weak evidence of copying: a quicksort that looks like every other quicksort, a hash function that matches because there are six reasonable ways to write one, boilerplate that converges because boilerplate converges. A court applying merger doctrine would throw most of these out without breaking a sweat.&lt;/p&gt;

&lt;p&gt;So the actual remaining infringement concern isn't "regurgitation happens." It's &lt;em&gt;targeted regurgitation of idiosyncratic, non-functionally-constrained expression&lt;/em&gt;. Exact-match suppression is tractable, and some vendors are already doing it. Detecting transformed or partially memorised output is much harder in practice, and that's where the real engineering work sits. But it's still engineering work, not a philosophical impasse.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually remains
&lt;/h2&gt;

&lt;p&gt;Once you get past the slogans, the objections narrow pretty quickly. None of what remains is wrong. None of it is what the discourse usually sounds like.&lt;/p&gt;

&lt;p&gt;The first is auditability. Open source culture runs on receipts — git blame, mailing list archives, CVE databases, sign-off lines — the whole apparatus is built around being able to trace value to its source. RHEL's contribution to the commons is auditable down to the patch. Valve's is auditable down to the developer. AI vendors offer no equivalent. The training corpus is undisclosed, the weights are closed, and there's no way to compute what fraction of a model's capability came from your project versus someone else's. The objection isn't really "you took our code." It's "you took our code and we have no way to audit what happened next." That's a process complaint, but it's a legitimate one, and it could in principle be answered.&lt;/p&gt;

&lt;p&gt;The second is targeted regurgitation — the case where models emit idiosyncratic chunks of training data verbatim, not statistical convergence on standard patterns but actual reproduction of distinctive expression. This is a real infringement concern, but it's an engineering one. Exact-match detection is solved. Semantically equivalent memorisation isn't. The work is real, but the problem is bounded.&lt;/p&gt;

&lt;p&gt;The third is harder, because it doesn't have a copyright answer at all. A human inspired by reading Linux gets a modest productivity boost on their next project — they still have to write it. An LLM trained on Linux gives every developer using it a substantial productivity boost, sold by subscription, to millions of users. The legal status is the same as the inspired human. The economic transfer is wildly different. Copyright was never designed to police that gap, because until now the gap didn't exist — inspiration was bounded by human cognitive throughput. This is a real concern, but it's a &lt;em&gt;policy&lt;/em&gt; question about whether we want a new tax or licence regime for industrial-scale inspiration. Conflating it with copyright, the way most of the community rhetoric does, is sloppy — copyright doesn't cover what we're actually upset about.&lt;/p&gt;

&lt;p&gt;Three things, then. Publish training corpora. Suppress idiosyncratic regurgitation. Have a serious conversation about whether productivity transfers at this scale need a policy response. None of it requires rejecting AI.&lt;/p&gt;

&lt;h2&gt;
  
  
  The reframe
&lt;/h2&gt;

&lt;p&gt;Which brings me to the position I've actually landed on, which is that the community has been judging AI vendors by the wrong standard.&lt;/p&gt;

&lt;p&gt;In the open source communities I'm part of — GNOME, Rust crates ecosystem, the desktop Linux world — AI-assisted development is already flowing back into public codebases at substantial scale. Every Rust crate, every GTK widget, every kernel patch that gets shipped faster because someone had a competent pair programmer in their editor is contribution to the commons. It just doesn't show up on the AI vendor's ledger. If you measure contribution by &lt;em&gt;what ends up in the commons&lt;/em&gt; rather than &lt;em&gt;what the model vendor open-sources&lt;/em&gt;, the ledger might already be net positive. We're measuring the wrong column.&lt;/p&gt;

&lt;p&gt;The community's current position is, I think, partly a category error. It's judging AI vendors by the standard we apply to &lt;strong&gt;forges&lt;/strong&gt; — GitHub, GitLab, SourceHut — when the closer analogue is &lt;strong&gt;compilers and IDEs&lt;/strong&gt;. Nobody demanded GCC's optimisation heuristics be reproducible from public data, or that JetBrains open-source IntelliJ before you were allowed to write Linux patches in it. The analogy isn't perfect — compilers don't ingest copyrighted corpora the way foundation models do — but culturally we treated those things as productivity tools rather than infrastructure providers. We judged them by what their users produced.&lt;/p&gt;

&lt;p&gt;There's a coherent version of the pro-AI argument that says: AI vendors are toolmakers, not platform owners. Judge them by what their users produce. And the produce here is overwhelmingly more open source, faster.&lt;/p&gt;

&lt;p&gt;Most contributors I know already use these tools. The real issue is what would let maintainers admit that publicly without feeling like they're conceding the entire argument — and the answer, when you work it out, turns out to be a fairly short list. Receipts, regurgitation suppression, and an honest policy conversation about productivity transfer. That's all of it.&lt;/p&gt;

&lt;p&gt;Compared to where the discourse usually sits, that's a much shorter list than the rhetoric suggests.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>opensource</category>
      <category>licensing</category>
      <category>commons</category>
    </item>
    <item>
      <title>How My Library Code Has No Idea It Has a Sync Backend</title>
      <dc:creator>Justin - FromTheArchitect</dc:creator>
      <pubDate>Tue, 19 May 2026 22:00:00 +0000</pubDate>
      <link>https://dev.to/fromthearchitect/how-my-library-code-has-no-idea-it-has-a-sync-backend-lh0</link>
      <guid>https://dev.to/fromthearchitect/how-my-library-code-has-no-idea-it-has-a-sync-backend-lh0</guid>
      <description>&lt;p&gt;Half of the library code in &lt;a href="https://github.com/justinf555/Moments" rel="noopener noreferrer"&gt;Moments&lt;/a&gt;, my photo manager, calls a one-method trait after every database write. That trait has two implementations. One of them returns &lt;code&gt;Ok(())&lt;/code&gt; and does nothing else. From inside the library, there is no way to tell which one is wired up.&lt;/p&gt;

&lt;p&gt;That is the entire architecture of how Moments syncs to Immich — and the entire reason the local backend, which has no sync at all, is &lt;em&gt;the same program&lt;/em&gt; as the Immich one.&lt;/p&gt;

&lt;p&gt;This post is about that trait, the enum it consumes, the outbox table underneath it, and the architectural property they buy together: a library that has no idea it has a sync backend, and a sync backend that's purely additive to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The offline-first problem
&lt;/h2&gt;

&lt;p&gt;Moments has two backends. The local backend stores photos on disk and indexes them in SQLite. The Immich backend talks to a self-hosted &lt;a href="https://immich.app" rel="noopener noreferrer"&gt;Immich&lt;/a&gt; server but caches everything in the &lt;em&gt;same&lt;/em&gt; SQLite schema — the UI reads from the local DB either way, and an Immich library works fully offline after initial sync.&lt;/p&gt;

&lt;p&gt;That means local writes have two possible destinations. Always the local DB. Sometimes — if the backend happens to be Immich — also a remote server, eventually, when the network cooperates. "Trash these three assets" should commit locally, return immediately, and arrange for the server to be told later. "Create this album" should be visible in the UI the moment the row hits the database, regardless of whether the push call has happened, will happen, or will fail and retry six times.&lt;/p&gt;

&lt;p&gt;The naive answer is to give every service a reference to the sync layer. &lt;code&gt;MediaService::trash&lt;/code&gt; calls &lt;code&gt;sync.enqueue_trash(ids)&lt;/code&gt; after writing to the DB. &lt;code&gt;AlbumService::create&lt;/code&gt; calls &lt;code&gt;sync.enqueue_album_created(album)&lt;/code&gt;. It works for the Immich backend. It also pollutes the local backend, which has no sync, with either a second constructor for every service or an &lt;code&gt;Option&amp;lt;SyncHandle&amp;gt;&lt;/code&gt; carried everywhere it's irrelevant. Every service in the library starts to know that there is such a thing as sync.&lt;/p&gt;

&lt;p&gt;What I wanted instead was: services produce a value describing what they changed, and someone else decides whether to do anything about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The vocabulary
&lt;/h2&gt;

&lt;p&gt;The value is &lt;code&gt;Mutation&lt;/code&gt;, in &lt;code&gt;src/library/mutation.rs&lt;/code&gt;. It is an enum of state changes the library has just committed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="nd"&gt;#[derive(Debug,&lt;/span&gt; &lt;span class="nd"&gt;Clone)]&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;enum&lt;/span&gt; &lt;span class="n"&gt;Mutation&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;AssetTrashed&lt;/span&gt;       &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;MediaId&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;AssetFavorited&lt;/span&gt;     &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;MediaId&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;favorite&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;AssetDeleted&lt;/span&gt;       &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;MediaId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;AlbumCreated&lt;/span&gt;       &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;AlbumId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;AlbumMediaAdded&lt;/span&gt;    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;album_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;AlbumId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;media_ids&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;MediaId&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;AssetEditsApplied&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;MediaId&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="c1"&gt;// …&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Eighteen variants in total — asset lifecycle, album lifecycle, edit application, stacks, tags, people. The shape matters more than the count: every variant names a state change the database has just committed.&lt;/p&gt;

&lt;p&gt;The discipline that keeps this enum small is what it deliberately &lt;em&gt;isn't&lt;/em&gt;. There are no &lt;code&gt;*Requested&lt;/code&gt; variants — UI intent isn't a mutation, it's a method call on a service. There are no &lt;code&gt;*Result&lt;/code&gt; variants — results are return values. There are no UI hint variants like &lt;code&gt;ThumbnailReady&lt;/code&gt; — those are events on a specific service's event emitter and don't belong here.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;Mutation&lt;/code&gt; is one thing: a state change the database has just committed, that &lt;em&gt;might&lt;/em&gt; need to leave the machine. Nothing more.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trait
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;MutationRecorder&lt;/code&gt; is in &lt;code&gt;src/library/recorder.rs&lt;/code&gt;. It is the entire interface between the library and whatever wants to know what changed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="nd"&gt;#[async_trait]&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;trait&lt;/span&gt; &lt;span class="n"&gt;MutationRecorder&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Send&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nb"&gt;Sync&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;record&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mutation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;Mutation&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;LibraryError&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is it. One method, async, takes a borrowed &lt;code&gt;Mutation&lt;/code&gt;, returns an error if recording itself failed. Services hold an &lt;code&gt;Arc&amp;lt;dyn MutationRecorder&amp;gt;&lt;/code&gt; and call it after a successful write:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;trash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;MediaId&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;LibraryError&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.repo&lt;/span&gt;&lt;span class="nf"&gt;.trash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.recorder&lt;/span&gt;&lt;span class="nf"&gt;.record&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nn"&gt;Mutation&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;AssetTrashed&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;ids&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(())&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A service has no idea what the recorder &lt;em&gt;does&lt;/em&gt; with the mutation. It might write a row to a queue table. It might publish to a message broker. It might do absolutely nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two strategies, one of which is empty
&lt;/h2&gt;

&lt;p&gt;There are exactly two implementations, both in &lt;code&gt;src/sync/outbox/mod.rs&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The first is the entire point of the design:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;NoOpRecorder&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nd"&gt;#[async_trait]&lt;/span&gt;
&lt;span class="k"&gt;impl&lt;/span&gt; &lt;span class="n"&gt;MutationRecorder&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;NoOpRecorder&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;record&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_mutation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;Mutation&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;LibraryError&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(())&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The local backend takes &lt;code&gt;NoOpRecorder&lt;/code&gt;. Every service in the library still calls &lt;code&gt;recorder.record(...)&lt;/code&gt; after every mutation. Those calls do nothing.&lt;/p&gt;

&lt;p&gt;The code path is identical to the Immich one. No &lt;code&gt;if backend == local&lt;/code&gt; anywhere in the services. No second constructor. Just a vtable dispatch and an &lt;code&gt;Ok(())&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The second is the implementation that does the actual work:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;QueueWriterOutbox&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;OutboxRepository&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;#[async_trait]&lt;/span&gt;
&lt;span class="k"&gt;impl&lt;/span&gt; &lt;span class="n"&gt;MutationRecorder&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;QueueWriterOutbox&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;record&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mutation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;Mutation&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;LibraryError&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;mutation&lt;/span&gt;&lt;span class="nf"&gt;.to_outbox_rows&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.repo&lt;/span&gt;&lt;span class="nf"&gt;.insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(())&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;QueueWriterOutbox&lt;/code&gt; is used by the Immich backend. It serialises the mutation into one or more rows in a &lt;code&gt;sync_outbox&lt;/code&gt; table — multi-id mutations like &lt;code&gt;AssetTrashed { ids: [a, b, c] }&lt;/code&gt; fan out to three rows so retries can be per-entity rather than per-batch. The schema is unsurprising: &lt;code&gt;id&lt;/code&gt;, &lt;code&gt;entity_type&lt;/code&gt;, &lt;code&gt;entity_id&lt;/code&gt;, &lt;code&gt;action&lt;/code&gt;, &lt;code&gt;payload&lt;/code&gt; (JSON), &lt;code&gt;created_at&lt;/code&gt;, plus &lt;code&gt;status&lt;/code&gt;, &lt;code&gt;attempts&lt;/code&gt;, &lt;code&gt;next_attempt_at&lt;/code&gt;, and &lt;code&gt;last_error&lt;/code&gt; for retry bookkeeping. This is the &lt;a href="https://microservices.io/patterns/data/transactional-outbox.html" rel="noopener noreferrer"&gt;transactional outbox pattern&lt;/a&gt;, more commonly seen in service-to-service messaging, applied here to client-server sync — with one small adaptation to the trait signature I'll come to in the next section.&lt;/p&gt;

&lt;p&gt;The mapping from &lt;code&gt;Mutation&lt;/code&gt; to &lt;code&gt;OutboxRow&lt;/code&gt; lives in &lt;code&gt;src/sync/outbox/mutation.rs&lt;/code&gt; and is genuinely boring — a match on the variant producing an &lt;code&gt;entity_type&lt;/code&gt; string, an &lt;code&gt;entity_id&lt;/code&gt;, an action verb, and an optional JSON payload. It is the kind of code that should be boring; the interesting part has already happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  My favourite test in this codebase
&lt;/h2&gt;

&lt;p&gt;My favourite test in Moments doesn't assert on database state. It doesn't assert on side effects. It asserts on a sequence of &lt;code&gt;Mutation&lt;/code&gt;s the library produced — on what the library &lt;em&gt;said it did&lt;/em&gt;, in its own vocabulary, in order. That test works because the third implementation of &lt;code&gt;MutationRecorder&lt;/code&gt; lives in &lt;code&gt;src/library/recorder.rs&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="nd"&gt;#[derive(Default)]&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;crate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;CapturingRecorder&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;recorded&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Mutex&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Mutation&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;impl&lt;/span&gt; &lt;span class="n"&gt;CapturingRecorder&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;crate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;snapshot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Mutation&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.recorded&lt;/span&gt;&lt;span class="nf"&gt;.lock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.unwrap&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.clone&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;#[async_trait]&lt;/span&gt;
&lt;span class="k"&gt;impl&lt;/span&gt; &lt;span class="n"&gt;MutationRecorder&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;CapturingRecorder&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;record&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mutation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;Mutation&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;LibraryError&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.recorded&lt;/span&gt;&lt;span class="nf"&gt;.lock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.unwrap&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mutation&lt;/span&gt;&lt;span class="nf"&gt;.clone&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
        &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(())&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Service tests construct a &lt;code&gt;Library&lt;/code&gt; with &lt;code&gt;Arc::new(CapturingRecorder::default())&lt;/code&gt; and then assert on the recorded sequence after exercising a method. &lt;em&gt;"When I trash three assets, then create an album from two of them, the recorder should see &lt;code&gt;AssetTrashed&lt;/code&gt; followed by &lt;code&gt;AlbumCreated&lt;/code&gt; followed by &lt;code&gt;AlbumMediaAdded&lt;/code&gt;."&lt;/em&gt; That assertion is a stronger property than "the database ends up in the right state", because it pins down the &lt;em&gt;intent surface&lt;/em&gt; the library exposes to anything that might eventually act on it — including providers that don't exist yet.&lt;/p&gt;

&lt;p&gt;This is what the trait actually buys: not two production strategies, but three perspectives on the same library code — a no-op that proves the local backend doesn't care, a queue writer that ships changes upstream, and a capturing fixture that pins down what services promise. None of them required a single change to the services themselves.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dependency injection: sync as someone else's problem
&lt;/h2&gt;

&lt;p&gt;The wiring happens in exactly one place. &lt;code&gt;Library::open&lt;/code&gt; takes the recorder as a parameter and threads it into every service:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;bundle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Bundle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;LocalStorageMode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Database&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;recorder&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Arc&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;dyn&lt;/span&gt; &lt;span class="n"&gt;MutationRecorder&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;resolver&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Arc&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;dyn&lt;/span&gt; &lt;span class="n"&gt;OriginalResolver&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;Self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;LibraryError&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="nf"&gt;.open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;bundle&lt;/span&gt;&lt;span class="py"&gt;.database&lt;/span&gt;&lt;span class="nf"&gt;.join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"moments.db"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;albums&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;AlbumService&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="nf"&gt;.clone&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nn"&gt;Arc&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;clone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;recorder&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;faces&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;FacesService&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="nf"&gt;.clone&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;bundle&lt;/span&gt;&lt;span class="py"&gt;.thumbnails&lt;/span&gt;&lt;span class="nf"&gt;.clone&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nn"&gt;Arc&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;clone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;recorder&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;editing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;EditingService&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="nf"&gt;.clone&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nn"&gt;Arc&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;clone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;recorder&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;media&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;MediaService&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="nf"&gt;.clone&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;bundle&lt;/span&gt;&lt;span class="py"&gt;.originals&lt;/span&gt;&lt;span class="nf"&gt;.clone&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                                    &lt;span class="nn"&gt;Arc&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;clone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;recorder&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;resolver&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// …&lt;/span&gt;

    &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;Self&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;albums&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;faces&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;editing&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;media&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="cm"&gt;/* … */&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;recorder&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The local backend's caller hands in &lt;code&gt;Arc::new(NoOpRecorder)&lt;/code&gt;. The Immich backend's caller hands in &lt;code&gt;Arc::new(QueueWriterOutbox::new(db.clone()))&lt;/code&gt;, and separately starts a background &lt;code&gt;PushManager&lt;/code&gt; that drains the same table.&lt;/p&gt;

&lt;p&gt;That single substitution — the choice of recorder at the call site — is the only thing in the system that knows whether sync is happening. Everything below it is identical. From the library's point of view, sync is &lt;em&gt;someone else's problem&lt;/em&gt;: it dutifully describes what it changed and trusts that someone, somewhere, may care.&lt;/p&gt;

&lt;h2&gt;
  
  
  The drain side
&lt;/h2&gt;

&lt;p&gt;For completeness: &lt;code&gt;src/sync/providers/immich/push.rs&lt;/code&gt; is the &lt;code&gt;PushManager&lt;/code&gt;. It is a long-running Tokio task that wakes up on an interval, reads up to a hundred pending rows from &lt;code&gt;sync_outbox&lt;/code&gt;, deserialises each back into an &lt;code&gt;OutboxMutation&lt;/code&gt;, makes the appropriate Immich API call, and marks the row done or failed. There is exponential backoff on failure, a &lt;code&gt;DeadLetter&lt;/code&gt; status after ten consecutive failures, and a &lt;code&gt;MAX_BACKOFF_SECS&lt;/code&gt; cap so attempt ten doesn't sleep for seventeen hours.&lt;/p&gt;

&lt;p&gt;It is the side of the system that has to know about HTTP, retries, idempotency, and Immich-specific endpoints. It is also the side that doesn't exist at all when you're running the local backend, because nobody constructed it.&lt;/p&gt;

&lt;p&gt;The pull side (&lt;code&gt;pull.rs&lt;/code&gt; + &lt;code&gt;handlers/&lt;/code&gt;) is the reverse direction — &lt;code&gt;POST /sync/stream&lt;/code&gt; streams server-side changes back into the local DB. It does &lt;em&gt;not&lt;/em&gt; go through the recorder: pull-driven changes are server-originated, and recording them would feed the server's own news straight back into the outbox heading the other way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Adapting the pattern
&lt;/h2&gt;

&lt;p&gt;The classical transactional outbox pattern — the one I borrowed the name and the table from — requires the business write and the outbox INSERT to share a single database transaction. That's the whole point of the pattern: if the entity write commits, the outbox row commits with it; if either fails, both roll back. Atomicity is what guarantees the consumer eventually sees every committed change.&lt;/p&gt;

&lt;p&gt;Moments needs that guarantee, non-negotiably. If &lt;code&gt;trash&lt;/code&gt; returns success to the caller, the outbox row must be there waiting for the push manager. Otherwise the local state silently drifts ahead of the server: the user trashes a photo, the desktop hides it, the server never hears about it, and the next pull cycle dutifully restores it. A sync layer that loses writes is worse than no sync layer at all — once the user catches it doing that, they stop trusting it for everything else.&lt;/p&gt;

&lt;p&gt;The adaptation is small but load-bearing. The trait signature I've shown so far is simplified for exposition; the real one takes a transaction handle, and the three implementations all thread it through. Services own the transaction lifecycle, and both the repository write and the recorder call happen inside the same transaction:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="nd"&gt;#[async_trait]&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;trait&lt;/span&gt; &lt;span class="n"&gt;MutationRecorder&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Send&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nb"&gt;Sync&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;record&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="nn"&gt;sqlx&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Transaction&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nv"&gt;'_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Sqlite&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;mutation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;Mutation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;LibraryError&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// in MediaService:&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;trash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;MediaId&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;LibraryError&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;tx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.db&lt;/span&gt;&lt;span class="nf"&gt;.begin&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.repo&lt;/span&gt;&lt;span class="nf"&gt;.trash_in_tx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.recorder&lt;/span&gt;
        &lt;span class="nf"&gt;.record&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nn"&gt;Mutation&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;AssetTrashed&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="nf"&gt;.to_vec&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="nf"&gt;.commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(())&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;QueueWriterOutbox::record&lt;/code&gt; uses the transaction handle to insert into &lt;code&gt;sync_outbox&lt;/code&gt; inside the same transaction the service is about to commit. If the insert fails, the &lt;code&gt;?&lt;/code&gt; propagates and &lt;code&gt;tx.commit()&lt;/code&gt; is never called; the business write rolls back with it. The local database and the outbox share a fate by construction — neither can be ahead of the other.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;NoOpRecorder&lt;/code&gt; ignores the transaction parameter entirely. The local backend pays for opening and committing a transaction the recorder didn't touch — a small, fixed cost on every mutation, in exchange for the library never having to know which recorder it's been given. That's the price of the abstraction, and it's the only price the abstraction asks the local backend to pay.&lt;/p&gt;

&lt;p&gt;An honest aside, because the timing is too good to leave out. I drafted this section assuming the code matched the design above. Then I went to check. It didn't. The actual service code commits the repository write and &lt;em&gt;then&lt;/em&gt; calls the recorder in a second, separate await — and at most call sites the recorder error is logged and swallowed rather than propagated. A recorder failure can silently leave the local state ahead of the outbox, which is exactly the failure mode this section claims is impossible by construction. I filed &lt;a href="https://github.com/justinf555/Moments/issues/655" rel="noopener noreferrer"&gt;the bug&lt;/a&gt; while finishing this paragraph.&lt;/p&gt;

&lt;p&gt;The design above is the design. The code is catching up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Adding a third backend
&lt;/h2&gt;

&lt;p&gt;The shape of the substitution generalises beyond Immich. Adding a Nextcloud backend tomorrow would touch zero lines in &lt;code&gt;src/library/&lt;/code&gt;. The services already produce &lt;code&gt;Mutation&lt;/code&gt;s. The outbox table is provider-agnostic. The new code lives entirely in &lt;code&gt;src/sync/providers/nextcloud/&lt;/code&gt;: an HTTP client, a push manager that drains the outbox into Nextcloud's API, and a pull manager that streams server-side changes back into the local DB. The wiring in &lt;code&gt;main&lt;/code&gt; swaps &lt;code&gt;QueueWriterOutbox&lt;/code&gt; in as the recorder, starts the Nextcloud managers, and is done.&lt;/p&gt;

&lt;p&gt;That doesn't make a new backend free — push and pull are still real work, with their own retry semantics and identity gotchas — but it does make the cost proportional to what's actually different. The 99% of the app that doesn't care which server it's talking to doesn't have to be told.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a trait, and not a channel
&lt;/h2&gt;

&lt;p&gt;The same job could be done with an &lt;code&gt;mpsc::UnboundedSender&amp;lt;Mutation&amp;gt;&lt;/code&gt; — services hold a sender and push mutations into it; a consumer task somewhere pulls them out. A channel even handles &lt;code&gt;NoOpRecorder&lt;/code&gt; naturally: open a channel and never spawn a consumer — the sends just buffer.&lt;/p&gt;

&lt;p&gt;But a channel ties the producer and the consumer to a specific lifecycle. Who closes the sender? When? What happens when the buffer fills? Is the consumer guaranteed to run, and if not, are the dropped messages a bug or a feature? The trait sidesteps all of this by making "what to do with a mutation" a synchronous decision at the point of production. &lt;code&gt;NoOpRecorder&lt;/code&gt; returns immediately. &lt;code&gt;QueueWriterOutbox&lt;/code&gt; writes a row and returns. &lt;code&gt;CapturingRecorder&lt;/code&gt; pushes onto a vec and returns. There is no pending state, no backpressure, no "what if the consumer crashed" failure mode lurking in the design. The service doesn't have to reason about &lt;em&gt;whether&lt;/em&gt; its mutation was received, because reception is part of the same &lt;code&gt;await&lt;/code&gt; as the rest of the operation.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;Box&amp;lt;dyn Fn(Mutation)&amp;gt;&lt;/code&gt; closure could work too, and would even be slightly cheaper. The reason the trait wins isn't performance; it's &lt;em&gt;naming&lt;/em&gt;. &lt;code&gt;NoOpRecorder&lt;/code&gt;, &lt;code&gt;QueueWriterOutbox&lt;/code&gt;, &lt;code&gt;CapturingRecorder&lt;/code&gt; — each one is a documented, importable type that says what role it plays. A closure is anonymous; it can do anything; you cannot grep for who's using it. The trait makes the contract a concrete thing that can be referred to in design documents and PR descriptions, and the production implementations a fixed, small set that anyone can enumerate.&lt;/p&gt;

&lt;p&gt;The verb "record" matters too. "Emit" or "publish" or "broadcast" would have invited fan-out — and fan-out is exactly the property &lt;code&gt;Mutation&lt;/code&gt; has been built to avoid. There is one recipient, statically chosen at &lt;code&gt;Library::open&lt;/code&gt; time. "Record" implies durability and report-after-the-fact, which is what services actually do: they write to the database, then they describe what they wrote. The naming makes the contract harder to violate.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell myself
&lt;/h2&gt;

&lt;p&gt;If I were starting the Immich backend again, I would write &lt;code&gt;MutationRecorder&lt;/code&gt; and &lt;code&gt;NoOpRecorder&lt;/code&gt; on day one, before there was anything that needed syncing, and have services call it from the start. The local backend would carry a &lt;code&gt;NoOpRecorder&lt;/code&gt; it would never need, and when the Immich backend arrived months later, &lt;em&gt;nothing in the services would change&lt;/em&gt;. That's the value the trait actually provides: not a separation of concerns at the API surface, but the freedom to add an entire concern later without rewriting what's already there.&lt;/p&gt;

&lt;p&gt;The library doesn't know sync exists. &lt;code&gt;grep -rn 'crate::sync' src/library/&lt;/code&gt; returns six matches: five are &lt;code&gt;#[cfg(test)]&lt;/code&gt; imports of &lt;code&gt;NoOpRecorder&lt;/code&gt; from test helpers, one a comment pointer. Production imports: zero. From the inside, the local backend and the Immich backend are the same program; the only difference is what an &lt;code&gt;Arc&amp;lt;dyn …&amp;gt;&lt;/code&gt; happens to do.&lt;/p&gt;

</description>
      <category>rust</category>
      <category>architecture</category>
      <category>gnome</category>
      <category>sync</category>
    </item>
    <item>
      <title>The Shape of Decoupling</title>
      <dc:creator>Justin - FromTheArchitect</dc:creator>
      <pubDate>Tue, 12 May 2026 07:01:30 +0000</pubDate>
      <link>https://dev.to/fromthearchitect/the-shape-of-decoupling-331i</link>
      <guid>https://dev.to/fromthearchitect/the-shape-of-decoupling-331i</guid>
      <description>&lt;p&gt;There is a moment in every codebase where someone draws a box on a whiteboard, labels it &lt;code&gt;EventBus&lt;/code&gt;, and draws arrows fanning out to every other box. The room nods. It looks like architecture. It looks like the &lt;em&gt;right&lt;/em&gt; architecture — loosely coupled, extensible, the textbook answer to "how do we let many components react to one thing happening?"&lt;/p&gt;

&lt;p&gt;That diagram is a trap. I drew it for &lt;a href="https://github.com/justinf555/Moments" rel="noopener noreferrer"&gt;Moments&lt;/a&gt;, my photo manager, in late March 2026. I had the design reviewed by an external UI architect. I shipped it in April across six phased PRs. And on the third of May I deleted the whole thing in a single commit titled, with some satisfaction:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;refactor: delete EventBus, AppEvent, library/commands, MediaClient v1 — closes #580&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is what I learned about why the bus was wrong, what replaced it, and the broader pattern: an event bus is decoupling-by-indirection, and the indirection itself is the cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the bus looked like
&lt;/h2&gt;

&lt;p&gt;The original design was, by the standards of the form, a good one. A single &lt;code&gt;EventBus&lt;/code&gt; lived on the GTK main thread. Background Tokio tasks pushed &lt;code&gt;AppEvent&lt;/code&gt;s into it via a &lt;code&gt;Send + Clone&lt;/code&gt; sender. &lt;code&gt;glib::idle_add_once&lt;/code&gt; drained the queue and fanned out to subscribers. Subscriptions were RAII-handle-based with re-entrancy-safe deferred drops. It had tests. It had a design doc. It worked.&lt;/p&gt;

&lt;p&gt;Here is the central type — the bit that everything else depended on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="nd"&gt;#[derive(Debug,&lt;/span&gt; &lt;span class="nd"&gt;Clone)]&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;enum&lt;/span&gt; &lt;span class="n"&gt;AppEvent&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;ThumbnailReady&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;media_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;MediaId&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;

    &lt;span class="c1"&gt;// Commands: UI intent → library command handler&lt;/span&gt;
    &lt;span class="n"&gt;TrashRequested&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;MediaId&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;FavoriteRequested&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;MediaId&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;AddToAlbumRequested&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;album_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;AlbumId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;MediaId&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;CreateAlbumRequested&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;MediaId&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;DeleteAlbumRequested&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;AlbumId&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;

    &lt;span class="c1"&gt;// Results: command handler → subscribers&lt;/span&gt;
    &lt;span class="n"&gt;MediaTrashed&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;MediaId&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;AlbumCreated&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;album&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Album&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By the time I deleted it, &lt;code&gt;AppEvent&lt;/code&gt; had twenty-one variants in a single file, split into "commands" (UI intent) and "results" (library outcomes). Buttons emitted &lt;code&gt;*Requested&lt;/code&gt; events. A &lt;code&gt;CommandHandler&lt;/code&gt; trait dispatched each one to a struct in &lt;code&gt;src/commands/&lt;/code&gt;. Library backends emitted &lt;code&gt;*Result&lt;/code&gt; events. Widgets subscribed to whichever results they cared about and patched their &lt;code&gt;ListStore&lt;/code&gt; models.&lt;/p&gt;

&lt;p&gt;It was internally consistent, and it was wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it started hurting
&lt;/h2&gt;

&lt;p&gt;There was no single bug big enough to justify deleting it. There was just the same small bug, in slightly different costumes, every week.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The enum became a junk drawer.&lt;/strong&gt; Every new feature added a request variant &lt;em&gt;and&lt;/em&gt; a result variant. The file grew. Understanding what could fire meant scanning the whole enum. Worse, dead variants accumulated: &lt;code&gt;refactor: remove dead AppEvent variants (#576)&lt;/code&gt; is a real commit, removing variants that had no subscribers anywhere. Nobody had noticed because the bus made the absence of subscribers invisible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Double-update bugs.&lt;/strong&gt; When the album-create button fires &lt;code&gt;CreateAlbumRequested&lt;/code&gt;, the command handler calls the library, gets back an &lt;code&gt;Album&lt;/code&gt;, and emits &lt;code&gt;AlbumCreated&lt;/code&gt;. The album grid widget — which initiated the action — also subscribes to &lt;code&gt;AlbumCreated&lt;/code&gt;, because how else would it learn? So the widget refreshes from the broadcast. But the widget's &lt;em&gt;own&lt;/em&gt; code path knew the result first and tried to insert the row optimistically. Now you have two writers fighting over the same &lt;code&gt;ListStore&lt;/code&gt;. The fix in any individual case is easy — guard with a flag, defer one path. The pattern of needing the fix everywhere is the smell.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Re-entrancy hazards.&lt;/strong&gt; One commit message that survives in the history reads &lt;code&gt;fix: defer navigate in ImportComplete handler to avoid bus re-entrancy&lt;/code&gt;. A subscriber to &lt;code&gt;ImportComplete&lt;/code&gt; navigated to a new view. Navigating triggered &lt;code&gt;unrealize&lt;/code&gt; on the old view. &lt;code&gt;unrealize&lt;/code&gt; dropped a &lt;code&gt;Subscription&lt;/code&gt;. Dropping a &lt;code&gt;Subscription&lt;/code&gt; mutated the bus's subscriber list — &lt;em&gt;while the bus was iterating it&lt;/em&gt;. The original design had anticipated this and added a "deferred removals" mechanism. That mechanism was a tax on every drop, paid forever, to fix a problem the bus itself created.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;O(n) no-op events.&lt;/strong&gt; During an Immich sync, the face-recognition service updates the &lt;code&gt;face_count&lt;/code&gt; on every person row. There can be hundreds. Each update fired a result event. Each event hit every subscriber, even though &lt;code&gt;face_count&lt;/code&gt; wasn't a property any GObject was bound to. The fix was to teach the service to &lt;em&gt;skip&lt;/em&gt; emission for &lt;code&gt;update_face_count&lt;/code&gt;. So now the service had to know which of its own state changes were "broadcast-worthy" — which is a leaky responsibility for a layer that shouldn't know who's listening at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You can't follow it at runtime.&lt;/strong&gt; Set a breakpoint on &lt;code&gt;bus.send()&lt;/code&gt;. Hit it. Where does control end up? Twelve subscribers across four widgets and two background services, in some order, on the next idle tick. Now do that for an interaction that fires three events. The bus had given me decoupling at the cost of being unable to read the program.&lt;/p&gt;

&lt;p&gt;The translation layer was the first thing to fall. Library events arrived as a separate &lt;code&gt;LibraryEvent&lt;/code&gt; enum and were translated into &lt;code&gt;AppEvent&lt;/code&gt; at the boundary. The translation was pure ceremony. It came out in &lt;code&gt;refactor: eliminate LibraryEvent → AppEvent translation loop (#520)&lt;/code&gt; — about three weeks after the bus shipped. That should have been the warning sign: the bus's main job had been routing events from one layer to another, and as soon as the layers started talking directly, the bus's reason for existing started to thin out.&lt;/p&gt;

&lt;h2&gt;
  
  
  What replaced it
&lt;/h2&gt;

&lt;p&gt;The replacement is, deliberately, almost boring. Each library service holds its own typed &lt;code&gt;EventEmitter&amp;lt;T&amp;gt;&lt;/code&gt; and emits a small enum specific to that service:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="nd"&gt;#[derive(Debug,&lt;/span&gt; &lt;span class="nd"&gt;Clone)]&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;enum&lt;/span&gt; &lt;span class="n"&gt;AlbumEvent&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;AlbumAdded&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AlbumId&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nf"&gt;AlbumUpdated&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AlbumId&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nf"&gt;AlbumRemoved&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AlbumId&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nf"&gt;AlbumMediaChanged&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AlbumId&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four variants. Owned by the album feature. Nothing about media, faces, sync, errors, or UI intent.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;EventEmitter&amp;lt;T&amp;gt;&lt;/code&gt; itself is a single file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;EventEmitter&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Clone&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;senders&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Arc&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Mutex&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nn"&gt;mpsc&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;UnboundedSender&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;impl&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Clone&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;EventEmitter&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nn"&gt;mpsc&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;UnboundedReceiver&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;mpsc&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;unbounded_channel&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.senders&lt;/span&gt;&lt;span class="nf"&gt;.lock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"poisoned"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;rx&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;senders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.senders&lt;/span&gt;&lt;span class="nf"&gt;.lock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"poisoned"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;senders&lt;/span&gt;&lt;span class="nf"&gt;.retain&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="nf"&gt;.send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="nf"&gt;.clone&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="nf"&gt;.is_ok&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A subscriber gets a fresh receiver. Dead receivers prune themselves on the next emit. Clones share the same subscriber set. That is the entire primitive.&lt;/p&gt;

&lt;p&gt;The service uses it in two places — once to expose subscription, once to emit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;AlbumService&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Arc&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Database&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;EventEmitter&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;AlbumEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;impl&lt;/span&gt; &lt;span class="n"&gt;AlbumService&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nn"&gt;mpsc&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;UnboundedReceiver&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;AlbumEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.events&lt;/span&gt;&lt;span class="nf"&gt;.subscribe&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;delete_album&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;AlbumId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.db&lt;/span&gt;&lt;span class="nf"&gt;.delete_album&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.events&lt;/span&gt;&lt;span class="nf"&gt;.emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;AlbumEvent&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;AlbumRemoved&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="nf"&gt;.clone&lt;/span&gt;&lt;span class="p"&gt;()));&lt;/span&gt;
        &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(())&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the GTK side, the client subscribes once at configure time and spawns a Tokio listener that dispatches model patches back to the main thread:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;configure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;library&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Arc&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Library&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tokio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nn"&gt;tokio&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;runtime&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Handle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                 &lt;span class="n"&gt;events_rx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nn"&gt;mpsc&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;UnboundedReceiver&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;AlbumEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;client_weak&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nn"&gt;glib&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;SendWeakRef&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;AlbumClientV2&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="nf"&gt;.downgrade&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.into&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;tokio&lt;/span&gt;&lt;span class="nf"&gt;.spawn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;Self&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;events_rx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;library&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;client_weak&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;rx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nn"&gt;mpsc&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;UnboundedReceiver&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;AlbumEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;library&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Arc&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Library&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;client_weak&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nn"&gt;glib&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;SendWeakRef&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;AlbumClientV2&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rx&lt;/span&gt;&lt;span class="nf"&gt;.recv&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nn"&gt;AlbumEvent&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;AlbumRemoved&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;weak&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client_weak&lt;/span&gt;&lt;span class="nf"&gt;.clone&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
                &lt;span class="nn"&gt;glib&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;idle_add_once&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;move&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;weak&lt;/span&gt;&lt;span class="nf"&gt;.upgrade&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                        &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="nf"&gt;.remove_from_models&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
                    &lt;span class="p"&gt;}&lt;/span&gt;
                &lt;span class="p"&gt;});&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="c1"&gt;// ...&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the whole architecture. There is no central registry. There is no &lt;code&gt;AppEvent&lt;/code&gt; enum. The album feature defines its events, owns its emitter, and hands receivers to whoever asks. A new feature adds a new service with its own emitter; nothing in the existing code has to change.&lt;/p&gt;

&lt;h2&gt;
  
  
  The double-update fix that was a design fix
&lt;/h2&gt;

&lt;p&gt;The most interesting thing about the new pattern isn't the channel itself. It's a small contract documented on every command method:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Client-initiated mutations don't emit events — the client patches its model directly in its callback.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;When &lt;code&gt;AlbumClientV2::create_album&lt;/code&gt; runs, it calls the service, gets back the new album, inserts it into its tracked models &lt;em&gt;itself&lt;/em&gt;, and that's that. The service does &lt;em&gt;not&lt;/em&gt; fire &lt;code&gt;AlbumAdded&lt;/code&gt; for client-initiated work. The bus-era double-update bug cannot exist, because the broadcast path and the initiator path are no longer the same path.&lt;/p&gt;

&lt;p&gt;The bus had pushed me toward "everyone reacts to everything uniformly", which sounds clean and is a lie — initiators always know more than subscribers. The new design lets the initiator use what it knows. Events are reserved for state changes the initiator &lt;em&gt;didn't&lt;/em&gt; cause: a remote sync arriving, a background scan completing, a peer process writing the database.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell myself in 2025
&lt;/h2&gt;

&lt;p&gt;Three things, in order of how useful they would have been.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An event bus is decoupling-by-indirection.&lt;/strong&gt; It feels like you've removed a dependency. You haven't. You've moved it into a giant enum that nobody owns and that everyone has to read to understand the program. The dependency is still there; you've just made it untyped, ungrep-able, and impossible to follow at runtime. A direct &lt;code&gt;mpsc::UnboundedSender&amp;lt;AlbumEvent&amp;gt;&lt;/code&gt; from the album service to the album client is &lt;em&gt;more&lt;/em&gt; coupled than a bus message of type &lt;code&gt;AppEvent::AlbumCreated&lt;/code&gt;, and that coupling is exactly what makes the program legible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fan-out problem is rarer than you think.&lt;/strong&gt; I had reached for a bus because "many subscribers might react to one event." In practice, almost every event in Moments has one or two subscribers, and the subscribers are statically known. A bus's variable, late-bound subscriber list buys you very little when the real subscriber set is "the album client and nothing else."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Architecture review can validate internal consistency but not problem fit.&lt;/strong&gt; The bus design was reviewed and approved. The reviewer was right that, &lt;em&gt;given the bus,&lt;/em&gt; the design was sound. They couldn't have told me — and I couldn't have asked — whether a bus was the right shape for the problem in the first place. That's a question only a few weeks of using it can answer, and in this case a few weeks were enough. Build the smallest version of the boring thing first. Earn the right to a bus.&lt;/p&gt;

&lt;p&gt;The deletion commit removed about 1,200 lines net. The replacement, including &lt;code&gt;EventEmitter&lt;/code&gt; and four service-specific event enums, is closer to 200. The bus had been doing what a thin wrapper around &lt;code&gt;mpsc&lt;/code&gt; does, dressed up as architecture.&lt;/p&gt;

&lt;p&gt;I don't regret building it — without the bus there'd be nothing concrete to compare against, and the lessons would have stayed in the realm of opinion. But if I were starting again, I would write &lt;code&gt;pub fn subscribe(&amp;amp;self) -&amp;gt; mpsc::UnboundedReceiver&amp;lt;AlbumEvent&amp;gt;&lt;/code&gt; first and not draw the diagram at all.&lt;/p&gt;

</description>
      <category>rust</category>
      <category>gnome</category>
      <category>eventdriven</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Building GNOME Apps with Rust, Part 4: Blueprint</title>
      <dc:creator>Justin - FromTheArchitect</dc:creator>
      <pubDate>Fri, 08 May 2026 13:10:53 +0000</pubDate>
      <link>https://dev.to/fromthearchitect/building-gnome-apps-with-rust-part-4-blueprint-5dce</link>
      <guid>https://dev.to/fromthearchitect/building-gnome-apps-with-rust-part-4-blueprint-5dce</guid>
      <description>&lt;p&gt;&lt;em&gt;This is Part 4 of a series taking a GNOME app from an empty directory to GNOME Circle. &lt;a href="https://dev.to/fromthearchitect/building-gnome-apps-with-rust-part-3-your-first-app-4hnd"&gt;Part 3&lt;/a&gt; walked through every file Builder generated for our &lt;code&gt;gazette&lt;/code&gt; project. Now we're going to start changing things.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If you're new to this stack and wondering why GTK and libadwaita are separate libraries, why GObject's type system feels like 1990s C, or why Flatpak ships its own runtime alongside your app, there's a &lt;a href="https://dev.to/fromthearchitect/building-gnome-apps-with-rust-bonus-the-stack-underneath-e0n"&gt;short companion piece on the history of the stack&lt;/a&gt;. Skim it for context or skip it for code.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The XML problem
&lt;/h2&gt;

&lt;p&gt;At the end of Part 3, we had a working app. A header bar, a hamburger menu, a "Hello, World!" label, and a &lt;code&gt;window.ui&lt;/code&gt; file that looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;?xml version="1.0" encoding="UTF-8"?&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;interface&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;requires&lt;/span&gt; &lt;span class="na"&gt;lib=&lt;/span&gt;&lt;span class="s"&gt;"gtk"&lt;/span&gt; &lt;span class="na"&gt;version=&lt;/span&gt;&lt;span class="s"&gt;"4.0"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;requires&lt;/span&gt; &lt;span class="na"&gt;lib=&lt;/span&gt;&lt;span class="s"&gt;"Adw"&lt;/span&gt; &lt;span class="na"&gt;version=&lt;/span&gt;&lt;span class="s"&gt;"1.0"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;template&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"GazetteWindow"&lt;/span&gt; &lt;span class="na"&gt;parent=&lt;/span&gt;&lt;span class="s"&gt;"AdwApplicationWindow"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;property&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"title"&lt;/span&gt; &lt;span class="na"&gt;translatable=&lt;/span&gt;&lt;span class="s"&gt;"yes"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Gazette&lt;span class="nt"&gt;&amp;lt;/property&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;property&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"default-width"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;800&lt;span class="nt"&gt;&amp;lt;/property&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;property&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"default-height"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;600&lt;span class="nt"&gt;&amp;lt;/property&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;property&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"content"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;object&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"AdwToolbarView"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;child&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"top"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;lt;object&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"AdwHeaderBar"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;child&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"end"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
              &lt;span class="nt"&gt;&amp;lt;object&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"GtkMenuButton"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
                &lt;span class="nt"&gt;&amp;lt;property&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"primary"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;True&lt;span class="nt"&gt;&amp;lt;/property&amp;gt;&lt;/span&gt;
                &lt;span class="nt"&gt;&amp;lt;property&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"icon-name"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;open-menu-symbolic&lt;span class="nt"&gt;&amp;lt;/property&amp;gt;&lt;/span&gt;
                &lt;span class="nt"&gt;&amp;lt;property&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"tooltip-text"&lt;/span&gt;
                          &lt;span class="na"&gt;translatable=&lt;/span&gt;&lt;span class="s"&gt;"yes"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Main Menu&lt;span class="nt"&gt;&amp;lt;/property&amp;gt;&lt;/span&gt;
                &lt;span class="nt"&gt;&amp;lt;property&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"menu-model"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;primary_menu&lt;span class="nt"&gt;&amp;lt;/property&amp;gt;&lt;/span&gt;
              &lt;span class="nt"&gt;&amp;lt;/object&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;/child&amp;gt;&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;lt;/object&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;/child&amp;gt;&lt;/span&gt;
        ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read that aloud. Notice how much of it is structural noise — &lt;code&gt;&amp;lt;object class="..."&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;property name="..."&amp;gt;&lt;/code&gt;, opening and closing tags wrapping single values. The actual &lt;em&gt;information&lt;/em&gt; — "there's a header bar with a menu button on the right" — is buried under a layer of XML scaffolding. And we haven't even built any UI yet.&lt;/p&gt;

&lt;p&gt;This isn't a fixable problem in XML. It's how XML works. So GNOME has a different answer.&lt;/p&gt;




&lt;h2&gt;
  
  
  Blueprint
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://gitlab.gnome.org/GNOME/blueprint-compiler" rel="noopener noreferrer"&gt;Blueprint&lt;/a&gt; is a markup language built for GTK. It compiles to the same &lt;code&gt;.ui&lt;/code&gt; XML GTK has always loaded — the runtime artefact is identical — but the file you actually write looks like real code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;using Gtk 4.0;
using Adw 1;

template $GazetteWindow : Adw.ApplicationWindow {
  title: _("Gazette");
  default-width: 800;
  default-height: 600;

  content: Adw.ToolbarView {
    [top]
    Adw.HeaderBar {
      [end]
      MenuButton {
        primary: true;
        icon-name: "open-menu-symbolic";
        tooltip-text: _("Main Menu");
        menu-model: primary_menu;
      }
    }

    content: Label label {
      label: _("Hello, World!");

      styles ["title-1"]
    };
  };
}

menu primary_menu {
  section {
    item {
      label: _("_Preferences");
      action: "app.preferences";
    }
    item {
      label: _("_Keyboard Shortcuts");
      action: "app.shortcuts";
    }
    item {
      label: _("_About Gazette");
      action: "app.about";
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same widget tree. Same template binding. Same translatable strings. About a third the line count, and you can actually scan the structure without your eyes glazing over.&lt;/p&gt;

&lt;p&gt;Two bits of syntax are worth knowing up front. The &lt;code&gt;$&lt;/code&gt; on &lt;code&gt;$GazetteWindow&lt;/code&gt; is there because it's a user-defined type — the compiler can't validate it against a &lt;code&gt;.gir&lt;/code&gt;, so the &lt;code&gt;$&lt;/code&gt; is your acknowledgement that you know what you're doing. Built-in types like &lt;code&gt;Adw.ApplicationWindow&lt;/code&gt; never need it. And &lt;code&gt;[top]&lt;/code&gt;, &lt;code&gt;[end]&lt;/code&gt; are child types, equivalent to &lt;code&gt;&amp;lt;child type="top"&amp;gt;&lt;/code&gt; in XML, placed immediately before the child they apply to. The rest — &lt;code&gt;using&lt;/code&gt; for namespace imports, &lt;code&gt;_("...")&lt;/code&gt; for translatable strings, &lt;code&gt;styles ["..."]&lt;/code&gt; for CSS classes — you can pick up by porting.&lt;/p&gt;




&lt;h2&gt;
  
  
  Wiring Blueprint into the build
&lt;/h2&gt;

&lt;p&gt;Blueprint is a separate compiler, not a built-in GTK feature. Before we change any UI, we need to teach Meson how to invoke it and update the GResource manifest to bundle the generated &lt;code&gt;.ui&lt;/code&gt; files instead of the original ones.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is &lt;code&gt;blueprint-compiler&lt;/code&gt; available?
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;blueprint-compiler&lt;/code&gt; ships in the GNOME 50 SDK (which we upgraded to at the end of Part 3), so inside the Flatpak build sandbox we don't need to install anything. If you're targeting an older runtime where it isn't included, you'd add it as a module in the Flatpak manifest:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"modules"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"blueprint-compiler"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"buildsystem"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"meson"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"cleanup"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"*"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"sources"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"git"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://gitlab.gnome.org/GNOME/blueprint-compiler.git"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="nl"&gt;"tag"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"v0.20.4"&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"gazette"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For us on GNOME 50, that block isn't necessary. Just confirming the binary exists is enough.&lt;/p&gt;

&lt;h3&gt;
  
  
  Updating &lt;code&gt;src/meson.build&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Open &lt;code&gt;src/meson.build&lt;/code&gt;. We need to add a &lt;code&gt;custom_target&lt;/code&gt; that runs &lt;code&gt;blueprint-compiler batch-compile&lt;/code&gt; over our &lt;code&gt;.blp&lt;/code&gt; files and produces matching &lt;code&gt;.ui&lt;/code&gt; files in the build directory. Then we point the existing &lt;code&gt;gnome.compile_resources&lt;/code&gt; call at those generated files via a dependency.&lt;/p&gt;

&lt;p&gt;Here's the relevant addition, sitting just above the existing &lt;code&gt;compile_resources&lt;/code&gt; call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight meson"&gt;&lt;code&gt;&lt;span class="n"&gt;blueprints&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;custom_target&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'blueprints'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;files&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;'window.blp'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;'shortcuts-dialog.blp'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;'.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;command&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nb"&gt;find_program&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'blueprint-compiler'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s"&gt;'batch-compile'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;'@OUTPUT@'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;'@CURRENT_SOURCE_DIR@'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;'@INPUT@'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;gnome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;compile_resources&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'gazette'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s"&gt;'gazette.gresource.xml'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;dependencies&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;blueprints&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;gresource_bundle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;install&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;install_dir&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;pkgdatadir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;dependencies: blueprints&lt;/code&gt; line is what ties the two together. Without it, Meson might try to compile resources before Blueprint has produced the &lt;code&gt;.ui&lt;/code&gt; files, and you'll get a mystifying "file not found" error mid-build.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;gazette.gresource.xml&lt;/code&gt; is unchanged
&lt;/h3&gt;

&lt;p&gt;The resource manifest still references &lt;code&gt;window.ui&lt;/code&gt; and &lt;code&gt;shortcuts-dialog.ui&lt;/code&gt;. It doesn't mention &lt;code&gt;.blp&lt;/code&gt; at all — and that's the part that catches people.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;?xml version="1.0" encoding="UTF-8"?&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;gresources&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;gresource&lt;/span&gt; &lt;span class="na"&gt;prefix=&lt;/span&gt;&lt;span class="s"&gt;"/io/github/fromthearchitect/gazette"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;file&lt;/span&gt; &lt;span class="na"&gt;preprocess=&lt;/span&gt;&lt;span class="s"&gt;"xml-stripblanks"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;window.ui&lt;span class="nt"&gt;&amp;lt;/file&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;file&lt;/span&gt; &lt;span class="na"&gt;preprocess=&lt;/span&gt;&lt;span class="s"&gt;"xml-stripblanks"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;shortcuts-dialog.ui&lt;span class="nt"&gt;&amp;lt;/file&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/gresource&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/gresources&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's because &lt;code&gt;gnome.compile_resources&lt;/code&gt; looks for the listed files first in the source directory, then in the build directory — and our &lt;code&gt;custom_target&lt;/code&gt; writes the generated &lt;code&gt;.ui&lt;/code&gt; files into the build directory at exactly the right relative path. The Rust code referencing &lt;code&gt;/io/github/fromthearchitect/gazette/window.ui&lt;/code&gt; doesn't know or care that the file went through a Blueprint compile step on the way in.&lt;/p&gt;

&lt;p&gt;This is the bit I want you to internalise: &lt;strong&gt;Blueprint is purely a build-time concern&lt;/strong&gt;. The runtime artefact is identical. If anything goes wrong at runtime, it's a GTK problem, not a Blueprint problem — and the error messages will reference XML structures because that's what GTK sees.&lt;/p&gt;

&lt;h3&gt;
  
  
  Updating &lt;code&gt;POTFILES.in&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Open &lt;code&gt;po/POTFILES.in&lt;/code&gt;. Builder's scaffold lists one source file alongside the desktop integration files:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="err"&gt;data/io.github.fromthearchitect.gazette.desktop.in&lt;/span&gt;
&lt;span class="err"&gt;data/io.github.fromthearchitect.gazette.metainfo.xml.in&lt;/span&gt;
&lt;span class="err"&gt;data/io.github.fromthearchitect.gazette.gschema.xml&lt;/span&gt;
&lt;span class="err"&gt;src/window.ui&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two changes. First, point at &lt;code&gt;window.blp&lt;/code&gt; instead of &lt;code&gt;window.ui&lt;/code&gt;. Second, add &lt;code&gt;shortcuts-dialog.blp&lt;/code&gt; — Builder doesn't list it, but the strings in there need extracting too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;data/io.github.fromthearchitect.gazette.desktop.in
data/io.github.fromthearchitect.gazette.metainfo.xml.in
data/io.github.fromthearchitect.gazette.gschema.xml
src/shortcuts-dialog.blp
src/window.blp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why point at the &lt;code&gt;.blp&lt;/code&gt; files rather than the generated &lt;code&gt;.ui&lt;/code&gt; files? Gettext extracts strings by parsing source files for known patterns — &lt;code&gt;_("...")&lt;/code&gt; in Blueprint, &lt;code&gt;translatable="yes"&lt;/code&gt; in XML, &lt;code&gt;gettext!()&lt;/code&gt; in Rust, etc. If &lt;code&gt;POTFILES.in&lt;/code&gt; lists the &lt;em&gt;generated&lt;/em&gt; &lt;code&gt;.ui&lt;/code&gt; files, extraction will work, but the line numbers in your &lt;code&gt;.po&lt;/code&gt; files will reference auto-generated paths in the build directory that change between builds. Pointing at the &lt;code&gt;.blp&lt;/code&gt; source means translators see meaningful filenames and stable line numbers.&lt;/p&gt;

&lt;p&gt;The Meson &lt;code&gt;i18n&lt;/code&gt; module's &lt;code&gt;xgettext&lt;/code&gt; invocation already scans for &lt;code&gt;_(...)&lt;/code&gt; and &lt;code&gt;C_(...)&lt;/code&gt; calls regardless of file type, so listing &lt;code&gt;.blp&lt;/code&gt; files works without any Blueprint-specific extractor.&lt;/p&gt;




&lt;h2&gt;
  
  
  Porting the existing UI files
&lt;/h2&gt;

&lt;p&gt;Now the actual conversion. Two files: &lt;code&gt;window.ui&lt;/code&gt; and &lt;code&gt;shortcuts-dialog.ui&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If you had dozens of files to convert, you'd run &lt;code&gt;blueprint-compiler port&lt;/code&gt; from the project root and it would scan the project, generate &lt;code&gt;.blp&lt;/code&gt; versions of every &lt;code&gt;.ui&lt;/code&gt; file, and update the references for you. We've got two files, so it's just as quick to do it by hand and learn the syntax in the process.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;window.blp&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Delete &lt;code&gt;src/window.ui&lt;/code&gt;. Create &lt;code&gt;src/window.blp&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;using Gtk 4.0;
using Adw 1;

template $GazetteWindow : Adw.ApplicationWindow {
  title: _("Gazette");
  default-width: 800;
  default-height: 600;

  content: Adw.ToolbarView {
    [top]
    Adw.HeaderBar {
      [end]
      MenuButton {
        primary: true;
        icon-name: "open-menu-symbolic";
        tooltip-text: _("Main Menu");
        menu-model: primary_menu;
      }
    }

    content: Label label {
      label: _("Hello, World!");

      styles ["title-1"]
    };
  };
}

menu primary_menu {
  section {
    item {
      label: _("_Preferences");
      action: "app.preferences";
    }
    item {
      label: _("_Keyboard Shortcuts");
      action: "app.shortcuts";
    }
    item {
      label: _("_About Gazette");
      action: "app.about";
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Compare against the original XML if you want — every property, child, and string carries across one-to-one.&lt;/p&gt;

&lt;p&gt;The one thing worth pointing out is &lt;code&gt;Label label&lt;/code&gt;. In Blueprint, when you want to give a widget an ID (so the Rust side can grab it as a &lt;code&gt;TemplateChild&lt;/code&gt;), you put the ID after the type, with no &lt;code&gt;id:&lt;/code&gt; keyword. It feels weird at first if you're coming from XML's &lt;code&gt;&amp;lt;object class="GtkLabel" id="label"&amp;gt;&lt;/code&gt;, but it's consistent with how the rest of the language treats names.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;shortcuts-dialog.blp&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Builder's template for &lt;code&gt;shortcuts-dialog.ui&lt;/code&gt; is built around &lt;code&gt;Gtk.ShortcutsWindow&lt;/code&gt;, which was deprecated in GTK 4.18; libadwaita 1.8 shipped a successor, &lt;code&gt;Adw.ShortcutsDialog&lt;/code&gt;. Since Part 3 put us on GNOME 50 (libadwaita 1.9), we'll port to the modern widget while we're already in the file.&lt;/p&gt;

&lt;p&gt;Delete &lt;code&gt;src/shortcuts-dialog.ui&lt;/code&gt; and create &lt;code&gt;src/shortcuts-dialog.blp&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;using Gtk 4.0;
using Adw 1;

Adw.ShortcutsDialog shortcuts_dialog {
  Adw.ShortcutsSection {
    title: C_("shortcut window", "General");

    Adw.ShortcutsItem {
      title: C_("shortcut window", "Show Shortcuts");
      action-name: "app.shortcuts";
    }

    Adw.ShortcutsItem {
      title: C_("shortcut window", "Quit");
      action-name: "app.quit";
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;C_("context", "string")&lt;/code&gt; is the Blueprint equivalent of XML's &lt;code&gt;&amp;lt;attribute name="label" translatable="yes" context="shortcut window"&amp;gt;&lt;/code&gt; — gettext context lets translators distinguish the same English word used in different UI contexts. Builder generates these for shortcut dialogs because "General" might translate differently depending on whether it's a settings category or a shortcut group.&lt;/p&gt;

&lt;p&gt;The other thing worth knowing about this file is the widget ID. &lt;code&gt;shortcuts_dialog&lt;/code&gt; is what &lt;code&gt;AdwApplication&lt;/code&gt; looks for: if a &lt;code&gt;shortcuts-dialog.ui&lt;/code&gt; resource exists at the application's resource base path and its root widget is an &lt;code&gt;AdwShortcutsDialog&lt;/code&gt; with that ID, the application class automatically installs an &lt;code&gt;app.shortcuts&lt;/code&gt; action that presents the dialog, plus a Ctrl+? accelerator. That's why Part 3's menu could reference &lt;code&gt;app.shortcuts&lt;/code&gt; even though we never defined it ourselves.&lt;/p&gt;

&lt;p&gt;The migration mapping, if you're updating an older project: &lt;code&gt;Gtk.ShortcutsWindow&lt;/code&gt; → &lt;code&gt;Adw.ShortcutsDialog&lt;/code&gt;, &lt;code&gt;Gtk.ShortcutsGroup&lt;/code&gt; → &lt;code&gt;Adw.ShortcutsSection&lt;/code&gt;, &lt;code&gt;Gtk.ShortcutsShortcut&lt;/code&gt; → &lt;code&gt;Adw.ShortcutsItem&lt;/code&gt;. The old "view → section → group → shortcut" hierarchy collapsed to "section → item". Two features didn't survive the trip: gesture shortcuts and per-shortcut icons. If you needed those, you stay on the deprecated widget. We don't, so we don't.&lt;/p&gt;

&lt;p&gt;&lt;a href="/images/gnome-rust-part-4/shortcuts-dialog.png" class="article-body-image-wrapper"&gt;&lt;img src="/images/gnome-rust-part-4/shortcuts-dialog.png" alt="Gazette's keyboard shortcuts dialog rendered as an Adw.ShortcutsDialog, showing the General section with Show Shortcuts and Quit entries"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Build it
&lt;/h3&gt;

&lt;p&gt;Hit &lt;strong&gt;Run&lt;/strong&gt;. If everything is wired correctly, the app builds and launches looking exactly like it did at the end of Part 3.&lt;/p&gt;

&lt;p&gt;If you get build errors, the most common causes are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Forgot the &lt;code&gt;dependencies: blueprints&lt;/code&gt; line&lt;/strong&gt; — error references &lt;code&gt;window.ui&lt;/code&gt; not found.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Typo in a Blueprint file&lt;/strong&gt; — the compiler error is good, but it points at line numbers in the &lt;code&gt;.blp&lt;/code&gt;, not the generated XML.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Old &lt;code&gt;.ui&lt;/code&gt; file still on disk&lt;/strong&gt; — if you copied the &lt;code&gt;.blp&lt;/code&gt; instead of replacing the &lt;code&gt;.ui&lt;/code&gt;, both exist and the resource bundle will pick up whichever Meson finds first.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A clean rebuild (Builder's &lt;strong&gt;Build → Clean Build Output&lt;/strong&gt;) clears up most of these.&lt;/p&gt;




&lt;h2&gt;
  
  
  Now we have a foundation. Let's actually use it.
&lt;/h2&gt;

&lt;p&gt;A "Hello, World!" label is fine for proving the pipeline works. It is not Gazette. Gazette needs a sidebar of feeds and an articles pane. On a phone, only one shows at a time.&lt;/p&gt;

&lt;p&gt;Libadwaita has a widget for this exact pattern: &lt;code&gt;Adw.NavigationSplitView&lt;/code&gt;. Side-by-side on a wide screen, single-pane navigation when collapsed — at least, once you wire up the breakpoint that tells it when to collapse. We'll get to that.&lt;/p&gt;

&lt;p&gt;Replace the contents of &lt;code&gt;window.blp&lt;/code&gt; with this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;using Gtk 4.0;
using Adw 1;

template $GazetteWindow : Adw.ApplicationWindow {
  title: _("Gazette");
  default-width: 1000;
  default-height: 700;

  Adw.Breakpoint {
    condition ("max-width: 600sp")

    setters {
      split_view.collapsed: true;
    }
  }

  content: Adw.NavigationSplitView split_view {
    sidebar: Adw.NavigationPage {
      title: _("Feeds");

      child: Adw.ToolbarView {
        [top]
        Adw.HeaderBar {}

        content: ScrolledWindow {
          child: ListBox feed_list {
            selection-mode: single;

            styles ["navigation-sidebar"]

            ListBoxRow {
              child: Label {
                label: _("All Articles");
                halign: start;
                margin-start: 12;
                margin-end: 12;
                margin-top: 6;
                margin-bottom: 6;
              };
            }
          };
        };
      };
    };

    content: Adw.NavigationPage {
      title: _("Articles");

      child: Adw.ToolbarView {
        [top]
        Adw.HeaderBar {
          [end]
          MenuButton {
            primary: true;
            icon-name: "open-menu-symbolic";
            tooltip-text: _("Main Menu");
            menu-model: primary_menu;
          }
        }

        content: Adw.StatusPage placeholder {
          icon-name: "rss-symbolic";
          title: _("No Feed Selected");
          description: _("Select a feed from the sidebar to see its articles.");
        };
      };
    };
  };
}

menu primary_menu {
  section {
    item {
      label: _("_Preferences");
      action: "app.preferences";
    }
    item {
      label: _("_Keyboard Shortcuts");
      action: "app.shortcuts";
    }
    item {
      label: _("_About Gazette");
      action: "app.about";
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The window is bigger by default — 1000x700 instead of 800x600 — to give two panes room to breathe. &lt;code&gt;Adw.NavigationSplitView&lt;/code&gt; replaces the single &lt;code&gt;Adw.ToolbarView&lt;/code&gt;, with a &lt;code&gt;sidebar&lt;/code&gt; and a &lt;code&gt;content&lt;/code&gt;, each holding an &lt;code&gt;Adw.NavigationPage&lt;/code&gt;. Each pane gets its own &lt;code&gt;Adw.ToolbarView&lt;/code&gt; and &lt;code&gt;Adw.HeaderBar&lt;/code&gt; — the libadwaita pattern is that every pane owns its own chrome, so when the layout collapses on narrow screens each one has a working header bar with title and back button. The sidebar's &lt;code&gt;ListBox&lt;/code&gt; carries the &lt;code&gt;navigation-sidebar&lt;/code&gt; style class for the standard look; the content pane shows an &lt;code&gt;Adw.StatusPage&lt;/code&gt; empty state using &lt;code&gt;rss-symbolic&lt;/code&gt; from &lt;code&gt;adwaita-icon-theme&lt;/code&gt;. The hamburger menu has moved to the content pane's header bar — that's where it lives in most GNOME apps with this layout (Files, Music, Lollypop, Apostrophe).&lt;/p&gt;

&lt;p&gt;The bit that catches people: &lt;code&gt;Adw.NavigationSplitView&lt;/code&gt; doesn't collapse on its own. Drop the window narrower without an &lt;code&gt;Adw.Breakpoint&lt;/code&gt; and it just stays side-by-side, ignoring you. The breakpoint is what flips &lt;code&gt;split_view.collapsed&lt;/code&gt; to &lt;code&gt;true&lt;/code&gt; when the window narrows below 600 scaled pixels — adaptive behaviour is a property you set, not something the widget figures out. The &lt;code&gt;sp&lt;/code&gt; unit (scaled pixels) means the threshold respects the user's text scaling factor, so the layout collapses at the same effective width regardless of how big they've made their fonts.&lt;/p&gt;

&lt;p&gt;Hit &lt;strong&gt;Run&lt;/strong&gt; again.&lt;/p&gt;

&lt;p&gt;&lt;a href="/images/gnome-rust-part-4/main-window-with-sidebar.png" class="article-body-image-wrapper"&gt;&lt;img src="/images/gnome-rust-part-4/main-window-with-sidebar.png" alt="Gazette running with the new layout: Feeds sidebar on the left with a single All Articles row, and an Articles pane on the right showing a No Feed Selected status page"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The hamburger menu wires up unchanged — same &lt;code&gt;app.shortcuts&lt;/code&gt;, &lt;code&gt;app.about&lt;/code&gt;, and (still unimplemented) &lt;code&gt;app.preferences&lt;/code&gt; actions Builder generated in Part 3:&lt;/p&gt;

&lt;p&gt;&lt;a href="/images/gnome-rust-part-4/menu-open.png" class="article-body-image-wrapper"&gt;&lt;img src="/images/gnome-rust-part-4/menu-open.png" alt="Gazette's primary menu open from the content pane's header bar, showing Preferences, Keyboard Shortcuts, and About Gazette entries"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And dragging the window narrower trips the breakpoint, collapsing the split view to single-pane navigation:&lt;/p&gt;

&lt;p&gt;&lt;a href="/images/gnome-rust-part-4/collapsed-window.png" class="article-body-image-wrapper"&gt;&lt;img src="/images/gnome-rust-part-4/collapsed-window.png" alt="Gazette in narrow width with the split view collapsed — only the Feeds sidebar is visible, with a single All Articles row"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The Rust side
&lt;/h2&gt;

&lt;p&gt;Open &lt;code&gt;src/window.rs&lt;/code&gt;. Right now it has one &lt;code&gt;TemplateChild&lt;/code&gt; for the label we just deleted:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="nd"&gt;#[derive(Debug,&lt;/span&gt; &lt;span class="nd"&gt;Default,&lt;/span&gt; &lt;span class="nd"&gt;gtk::CompositeTemplate)]&lt;/span&gt;
&lt;span class="nd"&gt;#[template(resource&lt;/span&gt; &lt;span class="nd"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"/io/github/fromthearchitect/gazette/window.ui"&lt;/span&gt;&lt;span class="nd"&gt;)]&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;GazetteWindow&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nd"&gt;#[template_child]&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;TemplateChild&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Label&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things to update. First, the &lt;code&gt;label&lt;/code&gt; field doesn't refer to anything any more — there's no &lt;code&gt;Label label&lt;/code&gt; in the new &lt;code&gt;window.blp&lt;/code&gt;. Second, we want typed handles to the new widgets so future posts can react to them.&lt;/p&gt;

&lt;p&gt;Replace the struct with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="nd"&gt;#[derive(Debug,&lt;/span&gt; &lt;span class="nd"&gt;Default,&lt;/span&gt; &lt;span class="nd"&gt;gtk::CompositeTemplate)]&lt;/span&gt;
&lt;span class="nd"&gt;#[template(resource&lt;/span&gt; &lt;span class="nd"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"/io/github/fromthearchitect/gazette/window.ui"&lt;/span&gt;&lt;span class="nd"&gt;)]&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;GazetteWindow&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nd"&gt;#[template_child]&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;split_view&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;TemplateChild&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nn"&gt;adw&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;NavigationSplitView&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nd"&gt;#[template_child]&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;feed_list&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;TemplateChild&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;ListBox&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nd"&gt;#[template_child]&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;placeholder&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;TemplateChild&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nn"&gt;adw&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;StatusPage&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note that the &lt;code&gt;#[template(resource = ...)]&lt;/code&gt; path is unchanged. We didn't rename the resource path when we switched to Blueprint — &lt;code&gt;window.ui&lt;/code&gt; is still what gets bundled into the gresource at &lt;code&gt;/io/github/fromthearchitect/gazette/window.ui&lt;/code&gt;, it's just generated from &lt;code&gt;window.blp&lt;/code&gt; now. Keeping the resource path stable is what lets the rest of the code be oblivious to the source format change.&lt;/p&gt;

&lt;p&gt;Build and run. Same window, three new typed handles available for when we need them.&lt;/p&gt;




&lt;h2&gt;
  
  
  Sharp edges
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Editor support
&lt;/h3&gt;

&lt;p&gt;The official Blueprint compiler ships an LSP server. In &lt;strong&gt;GNOME Builder&lt;/strong&gt;, support is built in — you get syntax highlighting, completion, and inline error markers automatically. In &lt;strong&gt;VS Code&lt;/strong&gt;, install the &lt;a href="https://marketplace.visualstudio.com/items?itemName=bodil.blueprint-gtk" rel="noopener noreferrer"&gt;Blueprint extension&lt;/a&gt;. In &lt;strong&gt;Neovim&lt;/strong&gt;, configure &lt;code&gt;nvim-lspconfig&lt;/code&gt; with &lt;code&gt;blueprint_ls&lt;/code&gt;. The LSP knows about every property and signal of every widget — if you mistype &lt;code&gt;defautl-width&lt;/code&gt;, you'll see the squiggle before you save.&lt;/p&gt;

&lt;h3&gt;
  
  
  Error message lineage
&lt;/h3&gt;

&lt;p&gt;When Blueprint fails to compile, the error references your &lt;code&gt;.blp&lt;/code&gt; file with helpful line and column numbers. When GTK fails to load a &lt;code&gt;.ui&lt;/code&gt; template at runtime — usually because you referenced a widget that doesn't exist, or the &lt;code&gt;parent&lt;/code&gt; doesn't match the Rust &lt;code&gt;ParentType&lt;/code&gt; — the error references the &lt;em&gt;generated&lt;/em&gt; &lt;code&gt;.ui&lt;/code&gt; file. The line numbers won't match your &lt;code&gt;.blp&lt;/code&gt;. That's fine, the structure does, but it catches everyone the first time.&lt;/p&gt;

&lt;h3&gt;
  
  
  Blueprint can't express everything
&lt;/h3&gt;

&lt;p&gt;Blueprint covers the GTK Builder XML format completely, but a handful of edge cases require dropping back to either raw XML or runtime Rust code. The most common one I hit is custom GtkBuilder-loaded scriptable types where a property takes a complex serialised value. If you need it, you can mix &lt;code&gt;.ui&lt;/code&gt; and &lt;code&gt;.blp&lt;/code&gt; files in the same gresource — just list the &lt;code&gt;.ui&lt;/code&gt; ones in the &lt;code&gt;gazette.gresource.xml&lt;/code&gt; manifest and skip them in the Blueprint custom target.&lt;/p&gt;

&lt;h3&gt;
  
  
  Don't commit the generated &lt;code&gt;.ui&lt;/code&gt; files
&lt;/h3&gt;

&lt;p&gt;The build directory holds the generated XML. Builder's default &lt;code&gt;.gitignore&lt;/code&gt; already excludes &lt;code&gt;_build/&lt;/code&gt;, but if you set up a different build layout, double-check. Committing generated &lt;code&gt;.ui&lt;/code&gt; files leads to merge conflicts every time someone tweaks a &lt;code&gt;.blp&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  What we have so far
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;window.blp&lt;/code&gt; and &lt;code&gt;shortcuts-dialog.blp&lt;/code&gt; instead of XML. A working sidebar + content layout that's adaptive on small screens. Three typed widget handles in Rust waiting for behaviour. The same single-instance app, the same gresource path, the same menu, the same translations — but the file you actually edit when you want to change the UI now reads like a tree, not a tag soup.&lt;/p&gt;




&lt;h2&gt;
  
  
  What comes next
&lt;/h2&gt;

&lt;p&gt;We have widgets. We don't have any logic. Clicking a row in the sidebar does nothing. Adding a feed isn't possible. There's no state.&lt;/p&gt;

&lt;p&gt;Part 5 is &lt;strong&gt;App Architecture Patterns&lt;/strong&gt; — how to organise mutable state in a Rust GTK app without losing your mind. The big shape of it: GTK widgets aren't &lt;code&gt;Send&lt;/code&gt;, &lt;code&gt;Rc&amp;lt;RefCell&amp;lt;T&amp;gt;&amp;gt;&lt;/code&gt; is everywhere, the &lt;code&gt;clone!&lt;/code&gt; macro keeps closures sane, and async work lives on Tokio while UI work lives on the GLib main loop. We'll set up the two-executor pattern and use it to wire the sidebar selection to the content pane via a real GObject signal.&lt;/p&gt;

&lt;p&gt;That's the post where the patterns from Part 2 stop being theoretical.&lt;/p&gt;




&lt;p&gt;The source code at the end of this post lives on the &lt;code&gt;part-4&lt;/code&gt; branch of &lt;a href="https://github.com/fromthearchitect/gnome-rust-gazette/tree/part-4" rel="noopener noreferrer"&gt;fromthearchitect/gnome-rust-gazette&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>gnome</category>
      <category>rust</category>
      <category>opensource</category>
      <category>linux</category>
    </item>
    <item>
      <title>Building GNOME Apps with Rust, Bonus: The Stack Underneath</title>
      <dc:creator>Justin - FromTheArchitect</dc:creator>
      <pubDate>Fri, 08 May 2026 13:06:41 +0000</pubDate>
      <link>https://dev.to/fromthearchitect/building-gnome-apps-with-rust-bonus-the-stack-underneath-e0n</link>
      <guid>https://dev.to/fromthearchitect/building-gnome-apps-with-rust-bonus-the-stack-underneath-e0n</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a bonus post in the series taking a GNOME app from an empty directory to GNOME Circle. It sits between &lt;a href="https://dev.to/fromthearchitect/building-gnome-apps-with-rust-part-3-your-first-app-4hnd"&gt;Part 3&lt;/a&gt; and &lt;a href="https://dev.to/fromthearchitect/building-gnome-apps-with-rust-part-4-blueprint-5dce"&gt;Part 4&lt;/a&gt; — read it if you want context, skip it if you want code.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;By the end of Part 3 you had a running GTK 4 + libadwaita app with five trait implementations, a &lt;code&gt;mod imp&lt;/code&gt; block, an &lt;code&gt;adw::Application&lt;/code&gt; parent class, a &lt;code&gt;meson.build&lt;/code&gt; next to your &lt;code&gt;Cargo.toml&lt;/code&gt;, and a Flatpak manifest pinning a runtime version. None of those decisions arrived in 2026 fully formed. Each is a fossil of a specific argument that took years to settle.&lt;/p&gt;

&lt;p&gt;This is the short version of those arguments. The platform you're writing against is shaped by five fights — over licence, components, design, toolkit independence, and distribution — and once you've heard the fights, the conventions stop feeling arbitrary.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. The licence fight (1996–2000)
&lt;/h2&gt;

&lt;p&gt;In 1996 the dominant Unix GUI toolkit was &lt;strong&gt;Motif&lt;/strong&gt;, owned by the Open Software Foundation and proprietary in a way that mattered: you couldn't ship free software against it without commercial licensing. Spencer Kimball and Peter Mattis, two Berkeley undergrads writing the &lt;strong&gt;GIMP&lt;/strong&gt; paint program, wrote their own toolkit instead and called it the &lt;em&gt;GIMP Toolkit&lt;/em&gt; — &lt;strong&gt;GTK&lt;/strong&gt;. Within a few years the toolkit had outgrown the paint program. It is now what most of the free desktop is built on.&lt;/p&gt;

&lt;p&gt;The same year, Trolltech released &lt;strong&gt;Qt 1.0&lt;/strong&gt; and Matthias Ettrich announced &lt;strong&gt;KDE&lt;/strong&gt; on top of it. Qt was technically excellent, but its licence wasn't one the Free Software Foundation recognised until 2000. Between 1996 and 2000, a substantial part of the GNU community considered KDE non-free regardless of source availability.&lt;/p&gt;

&lt;p&gt;In August 1997, Miguel de Icaza and Federico Mena announced &lt;strong&gt;GNOME&lt;/strong&gt; — explicitly framed as a free-software desktop that didn't depend on Qt. The technical justifications (component model, accessibility, language bindings) were in the announcement; the political one — &lt;em&gt;you should not have to choose between a working desktop and a free-software stack&lt;/em&gt; — is what gave the project momentum. Qt was relicensed under the GPL in 2000 and the original objection mostly evaporated, but by then GNOME existed, had momentum, and had a 1.0 release on the way. The schism was permanent.&lt;/p&gt;

&lt;p&gt;The through-line to your code: GNOME's founding pressure was &lt;em&gt;licence purity&lt;/em&gt;, not technical superiority. Builder defaults to GPL-3.0-or-later when it scaffolds a project. Flathub's review process flags proprietary dependencies. Publishing through Flatpak rather than as a closed binary is implicit in every tutorial you'll read. None of this is convention; it's the residue of an argument from 1996.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. The component dream that didn't pan out (1999–2008)
&lt;/h2&gt;

&lt;p&gt;GNOME 1.0 shipped in 1999 against GTK+ 1.2. The project's name was the &lt;em&gt;GNU Network Object Model Environment&lt;/em&gt;, and the &lt;em&gt;network object model&lt;/em&gt; part was load-bearing. GNOME wasn't supposed to be a window manager and a panel. It was supposed to be a full component architecture: applications built out of embeddable, scriptable, network-transparent objects living inside each other's windows. The technology underneath was &lt;strong&gt;CORBA&lt;/strong&gt;, and GNOME shipped its own implementation (&lt;strong&gt;ORBit&lt;/strong&gt;) plus a higher-level component framework (&lt;strong&gt;Bonobo&lt;/strong&gt;). The dream was that you'd embed a &lt;strong&gt;GNumeric&lt;/strong&gt; spreadsheet inside an &lt;strong&gt;AbiWord&lt;/strong&gt; document and edit it in place.&lt;/p&gt;

&lt;p&gt;This wasn't a GNOME-specific bet. The whole industry was on it: Microsoft's COM and OLE, Sun's Java RMI, a decade of conference talks predicting that desktop apps would be rebuilt as orchestrations of small composable objects messaging each other. It didn't pan out. Components-via-IPC was the wrong abstraction at the application layer, and the lesson got learned the expensive way by everyone who'd bet on it.&lt;/p&gt;

&lt;p&gt;The technology survived in a different shape. &lt;strong&gt;GLib 2.0&lt;/strong&gt; had shipped in 2002, and one of its contributions was to formalise &lt;strong&gt;GObject&lt;/strong&gt; as a standalone type system — separable from GTK, and from the Bonobo component dream above it. That is the type system you're working against in Part 2's &lt;code&gt;Feed&lt;/code&gt; and Part 3's &lt;code&gt;GazetteWindow&lt;/code&gt;. The shape of it — inner/outer split, vtables, manual reference counting, registration macros — is a 1990s C solution to the object-oriented programming problem. It feels strange in Rust because it predates Rust by fifteen years. &lt;strong&gt;D-Bus&lt;/strong&gt; survived for the same reason: deliberately small where CORBA was deliberately general, and it's how your single-instance behaviour works. &lt;strong&gt;GObject Introspection&lt;/strong&gt; (2008) ships machine-readable type metadata alongside every GObject library, and it's the only reason the Rust bindings can be auto-generated and stay current.&lt;/p&gt;

&lt;p&gt;A lot of GNOME's strangeness in 2026 is the residue of a 1990s bet that didn't pay off. The bet itself is gone. The infrastructure built to support it survived, got refactored, and is now the foundation everything else stands on.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. The GNOME 3 reset (2011)
&lt;/h2&gt;

&lt;p&gt;GNOME 2 had been the standard Linux desktop for a decade by 2010 — a panel, a menu, a taskbar, icons on the desktop. Boring, in the best sense.&lt;/p&gt;

&lt;p&gt;April 2011 broke that. &lt;strong&gt;GNOME 3.0&lt;/strong&gt; shipped with a complete reset of the user-visible desktop: a full-screen Activities overview, dynamic workspaces, no taskbar, no minimise button, no system tray, no desktop icons. The argument from the design team was that those affordances were artefacts of a 1990s desktop that no longer matched how people actually used computers. The revolt was real and loud — whole distributions refused to ship it, two forks (&lt;strong&gt;MATE&lt;/strong&gt; preserving GNOME 2 verbatim, &lt;strong&gt;Cinnamon&lt;/strong&gt; fixing the new shell) emerged within a year, and Linus Torvalds publicly switched to Xfce.&lt;/p&gt;

&lt;p&gt;Here's the part that's harder to write in 2026 than in 2012: the design has aged well. The Activities overview, dynamic workspaces, the deliberate absence of customisation knobs, the bias toward defending users from a thousand small decisions — these were unpopular in 2011 because they were unfamiliar. macOS, Windows 11, ChromeOS, every mobile OS — all the design idioms now look more like GNOME 3 than they look like GNOME 2.&lt;/p&gt;

&lt;p&gt;Every libadwaita widget you reach for is a descendant of that design ethos. &lt;code&gt;Adw.ApplicationWindow&lt;/code&gt; instead of &lt;code&gt;Gtk.Window&lt;/code&gt;, &lt;code&gt;Adw.ToolbarView&lt;/code&gt; containing the header bar, the absence of a system-tray API, the deliberately limited theming surface — these aren't arbitrary library decisions. They are the same opinionated-design argument from 2011, restated as code. Building Gazette to libadwaita conventions is, structurally, accepting GNOME 3's premise. Fifteen years on, the premise has stopped being controversial.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. GTK 4 and the libadwaita split (2020–2021)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;GTK 4.0&lt;/strong&gt; shipped in December 2020 after roughly four years of development. The whole rendering pipeline had been rewritten — widgets emit declarative render nodes that GSK (the new GTK Scene Kit) compiles into GPU-accelerated draw operations. That's why your Flatpak manifest from Part 3 has &lt;code&gt;--device=dri&lt;/code&gt; even though you're shipping an RSS reader. It's not for graphics; it's because GSK exists, and GSK wants the GPU.&lt;/p&gt;

&lt;p&gt;The other GTK 4 change is harder to see but more consequential. The toolkit was deliberately positioned this time &lt;em&gt;not&lt;/em&gt; to be the GNOME-only library any more. GTK 3 had dragged the GNOME aesthetic along with it wherever it went. GTK 4 strips the GNOME-specific visual identity out and leaves a place where it can be added back, optionally, as a separate library.&lt;/p&gt;

&lt;p&gt;That library is &lt;strong&gt;libadwaita&lt;/strong&gt;, first released in December 2021. Its release announcement put the bargain plainly: GTK should grow independently of GNOME at its own pace; other GTK consumers — &lt;strong&gt;Inkscape&lt;/strong&gt;, &lt;strong&gt;elementary OS&lt;/strong&gt; — should be first-class citizens rather than second-class baggage; libadwaita itself should be &lt;em&gt;another library you can choose to link against if you want to make your application fit well into GNOME&lt;/em&gt;. The Adwaita stylesheet — GTK 3's default look — moved out of GTK into libadwaita, and GTK 4 kept a renamed neutral copy.&lt;/p&gt;

&lt;p&gt;Each &lt;code&gt;adw::&lt;/code&gt; import in &lt;code&gt;application.rs&lt;/code&gt; and &lt;code&gt;window.rs&lt;/code&gt; is a deliberate vote to be a GNOME app rather than a generic GTK one. Replace every &lt;code&gt;adw::&lt;/code&gt; symbol with its &lt;code&gt;gtk::&lt;/code&gt; equivalent and Gazette would still compile, still run, and still display a working window. It would just stop being a GNOME application. The line between the two is exactly the line GTK and libadwaita's architects drew in 2020 and 2021, restated in your &lt;code&gt;use&lt;/code&gt; statements.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Flatpak and the runtime model (2015–)
&lt;/h2&gt;

&lt;p&gt;Before Flatpak, a GNOME application's distribution problem was N × M × K. Forty-odd Linux distributions, each shipping a different GTK version, each on a different cadence, often two GNOME releases behind. When a user reported a bug you'd fixed eighteen months earlier, the answer was almost always &lt;em&gt;which distro, which version, please file a bug with them&lt;/em&gt;. That isn't a release model. It's an apology.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Flatpak&lt;/strong&gt; (originally &lt;strong&gt;xdg-app&lt;/strong&gt;, started 2015 by Alex Larsson at Red Hat) ignores the host distribution's library versions entirely. Bundle the application with the runtime it expects, run it in a sandbox via &lt;strong&gt;bubblewrap&lt;/strong&gt;, mediate everything else through &lt;strong&gt;portals&lt;/strong&gt;. If your app needs &lt;code&gt;org.gnome.Platform&lt;/code&gt; 50, Flatpak ships it alongside your app and the user's Debian or Fedora version is no longer your problem.&lt;/p&gt;

&lt;p&gt;The runtime/SDK distinction in Part 3 is the literal embodiment of this model. &lt;code&gt;org.gnome.Platform&lt;/code&gt; is what users get; &lt;code&gt;org.gnome.Sdk&lt;/code&gt; is what you build against. Updating your manifest from &lt;code&gt;"runtime-version": "48"&lt;/code&gt; to &lt;code&gt;"50"&lt;/code&gt; isn't a build-system quirk. It's the act of saying &lt;em&gt;I have tested this app against GNOME 50, here is what I'm shipping&lt;/em&gt;. The Flatpak manifest pinning a version, the &lt;code&gt;--socket=wayland&lt;/code&gt; line, the &lt;code&gt;--device=dri&lt;/code&gt; line — every one of those is a public commitment to a specific platform contract.&lt;/p&gt;




&lt;h2&gt;
  
  
  So
&lt;/h2&gt;

&lt;p&gt;Each shape in your Part 3 project has a date and a reason now. The five trait implementations: GObject, formalised in 2002 because the 1990s component dream needed a separable type system. The &lt;code&gt;mod imp&lt;/code&gt; block: that same 1990s C inheritance pattern restated as a Rust idiom. The &lt;code&gt;adw::Application&lt;/code&gt; parent class: the 2020–2021 split between a deliberately neutral GTK 4 and a deliberately opinionated libadwaita, drawn in &lt;code&gt;use&lt;/code&gt; statements. The &lt;code&gt;meson.build&lt;/code&gt; next to &lt;code&gt;Cargo.toml&lt;/code&gt;: the 2017–2019 migration that replaced autotools across the project. The Flatpak manifest: the 2015-onward distribution model that made &lt;em&gt;same app, all distributions&lt;/em&gt; a viable promise.&lt;/p&gt;

&lt;p&gt;History over. &lt;a href="https://dev.to/fromthearchitect/building-gnome-apps-with-rust-part-4-blueprint-5dce"&gt;Part 4&lt;/a&gt; is where the code resumes.&lt;/p&gt;

</description>
      <category>gnome</category>
      <category>rust</category>
      <category>opensource</category>
      <category>linux</category>
    </item>
    <item>
      <title>Building GNOME Apps with Rust, Part 3: Your First App</title>
      <dc:creator>Justin - FromTheArchitect</dc:creator>
      <pubDate>Mon, 04 May 2026 13:43:25 +0000</pubDate>
      <link>https://dev.to/fromthearchitect/building-gnome-apps-with-rust-part-3-your-first-app-4hnd</link>
      <guid>https://dev.to/fromthearchitect/building-gnome-apps-with-rust-part-3-your-first-app-4hnd</guid>
      <description>&lt;p&gt;&lt;em&gt;This is Part 3 of a series that takes a GNOME application from an empty directory to acceptance into GNOME Circle. &lt;a href="https://dev.to/posts/gnome-rust-part-2-gobject/"&gt;Part 2&lt;/a&gt; covered GObject's type system — properties, signals, and the inner/outer type pattern. Now we'll use everything we learned to build a real application.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  From theory to a running window
&lt;/h2&gt;

&lt;p&gt;In Part 2 we built a GObject subclass by hand — a &lt;code&gt;Feed&lt;/code&gt; model with properties and signals, no GTK in sight. That was deliberate. Understanding GObject's inner/outer type split, the &lt;code&gt;ObjectSubclass&lt;/code&gt; trait, and the &lt;code&gt;mod imp&lt;/code&gt; pattern is the foundation that everything else rests on.&lt;/p&gt;

&lt;p&gt;Now we're going to see those same patterns in context. By the end of this post, you'll have a running GTK4 + libadwaita application with a header bar, a menu, keyboard shortcuts, and a properly structured project that's ready for Flatpak packaging. And you won't have written most of it by hand — GNOME Builder will generate it for you. Our job is to understand what it generated and why.&lt;/p&gt;




&lt;h2&gt;
  
  
  Creating the project in GNOME Builder
&lt;/h2&gt;

&lt;p&gt;Open GNOME Builder and click &lt;strong&gt;Create New Project&lt;/strong&gt;. Fill in the dialog like this:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3b2ccw5gvp86ahuh5p5n.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3b2ccw5gvp86ahuh5p5n.png" alt="GNOME Builder's Create New Project dialog, configured for Gazette" width="800" height="648"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Project Name&lt;/strong&gt;: &lt;code&gt;gazette&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Application ID&lt;/strong&gt;: &lt;code&gt;io.github.fromthearchitect.gazette&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Location&lt;/strong&gt;: wherever you keep your projects&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Language&lt;/strong&gt;: Rust&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;License&lt;/strong&gt;: GPL-3.0-or-later&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Version Control&lt;/strong&gt;: enabled&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Template&lt;/strong&gt;: GNOME Application&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Click &lt;strong&gt;Create Project&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  A note on application IDs
&lt;/h3&gt;

&lt;p&gt;The application ID is a reverse domain name that uniquely identifies your app across the entire Linux desktop. It's used for the D-Bus service name, the GSettings schema path, the desktop file name, the icon name, and the Flatpak bundle ID. Everything keys off this single string.&lt;/p&gt;

&lt;p&gt;The convention is to use a domain you control. If you're publishing to a GitHub organisation, &lt;code&gt;io.github.yourorg.yourapp&lt;/code&gt; is the standard pattern. If you own a domain, use it: &lt;code&gt;dev.fromthearchitect.gazette&lt;/code&gt; would also work. The important thing is that this ID is globally unique and stable — changing it later means renaming dozens of files and updating every reference.&lt;/p&gt;




&lt;h2&gt;
  
  
  Hit Run
&lt;/h2&gt;

&lt;p&gt;Before we look at any code, hit the &lt;strong&gt;Run&lt;/strong&gt; button (the play icon in the header bar) or press &lt;strong&gt;Ctrl+F5&lt;/strong&gt;. Builder will configure the Flatpak environment, download the SDK if needed, compile the Rust code, bundle the resources, and launch the application.&lt;/p&gt;

&lt;p&gt;The first build takes a while — Cargo is downloading and compiling every dependency inside the Flatpak sandbox. Subsequent builds are much faster thanks to caching.&lt;/p&gt;

&lt;p&gt;When it finishes, you'll see this:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5fkj0mksiaivvlx2fmj7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5fkj0mksiaivvlx2fmj7.png" alt="The Gazette application running — an empty window with a header bar showing " width="800" height="628"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A window. A header bar with a title. A hamburger menu with Preferences, Keyboard Shortcuts, and About entries. A "Hello, World!" label in the centre. That's your application.&lt;/p&gt;

&lt;p&gt;It doesn't look like much yet, but there's a surprising amount happening behind the scenes. Let's look at what Builder actually created.&lt;/p&gt;




&lt;h2&gt;
  
  
  The project structure
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjde9ishi4jszifeayrcx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjde9ishi4jszifeayrcx.png" alt="GNOME Builder showing the Gazette project file tree" width="800" height="618"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here's what Builder generated:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;gazette/
├── Cargo.toml
├── COPYING
├── meson.build
├── io.github.fromthearchitect.gazette.json
├── data/
│   ├── meson.build
│   ├── icons/
│   │   ├── meson.build
│   │   ├── hicolor/scalable/apps/
│   │   │   └── io.github.fromthearchitect.gazette.svg
│   │   └── hicolor/symbolic/apps/
│   │       └── io.github.fromthearchitect.gazette-symbolic.svg
│   ├── io.github.fromthearchitect.gazette.desktop.in
│   ├── io.github.fromthearchitect.gazette.gschema.xml
│   ├── io.github.fromthearchitect.gazette.metainfo.xml.in
│   └── io.github.fromthearchitect.gazette.service.in
├── po/
│   ├── LINGUAS
│   ├── POTFILES.in
│   └── meson.build
└── src/
    ├── meson.build
    ├── main.rs
    ├── application.rs
    ├── config.rs.in
    ├── window.rs
    ├── window.ui
    ├── shortcuts-dialog.ui
    └── gazette.gresource.xml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's a lot of files for "Hello, World!" — and every single one of them is there for a reason. Here's what's in each group.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Rust source code
&lt;/h2&gt;

&lt;h3&gt;
  
  
  main.rs — the entry point
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;mod&lt;/span&gt; &lt;span class="n"&gt;application&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;mod&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;mod&lt;/span&gt; &lt;span class="n"&gt;window&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;application&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;GazetteApplication&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;window&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;GazetteWindow&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;config&lt;/span&gt;&lt;span class="p"&gt;::{&lt;/span&gt;&lt;span class="n"&gt;GETTEXT_PACKAGE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;LOCALEDIR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;PKGDATADIR&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;gettextrs&lt;/span&gt;&lt;span class="p"&gt;::{&lt;/span&gt;&lt;span class="n"&gt;bind_textdomain_codeset&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bindtextdomain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;textdomain&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::{&lt;/span&gt;&lt;span class="n"&gt;gio&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;glib&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;prelude&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nn"&gt;glib&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;ExitCode&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Set up gettext translations&lt;/span&gt;
    &lt;span class="nf"&gt;bindtextdomain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;GETTEXT_PACKAGE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;LOCALEDIR&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;.expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Unable to bind the text domain"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;bind_textdomain_codeset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;GETTEXT_PACKAGE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"UTF-8"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;.expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Unable to set the text domain encoding"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;textdomain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;GETTEXT_PACKAGE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;.expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Unable to switch to the text domain"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// Load resources&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;resources&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;gio&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;Resource&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;PKGDATADIR&lt;/span&gt;&lt;span class="nf"&gt;.to_owned&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"/gazette.gresource"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Could not load resources"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nn"&gt;gio&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;resources_register&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;resources&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;GazetteApplication&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s"&gt;"io.github.fromthearchitect.gazette"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nn"&gt;gio&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;ApplicationFlags&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;empty&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="nf"&gt;.run&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things happen here before the application starts:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Gettext initialisation.&lt;/strong&gt; This wires up the translation system so that strings marked as translatable in the UI files and source code can be looked up in the user's language. Even if you never translate your app, the plumbing needs to be in place.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Resource loading.&lt;/strong&gt; The compiled GResource bundle (containing the UI templates, icons, and other assets) is loaded from disk and registered globally. After this call, any code can access bundled files by their resource path — for example, &lt;code&gt;/io/github/fromthearchitect/gazette/window.ui&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Application creation and run.&lt;/strong&gt; &lt;code&gt;GazetteApplication::new()&lt;/code&gt; creates our application object, and &lt;code&gt;app.run()&lt;/code&gt; hands control to the GLib main loop. The main loop processes events — user input, window management, D-Bus messages — until the application quits.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The &lt;code&gt;config&lt;/code&gt; module isn't a regular Rust file — it's generated at build time by Meson from a template. More on that shortly.&lt;/p&gt;

&lt;h3&gt;
  
  
  application.rs — the GtkApplication subclass
&lt;/h3&gt;

&lt;p&gt;This is where you'll recognise the GObject patterns from Part 2:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;gettextrs&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;gettext&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;adw&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;prelude&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;adw&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;subclass&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;prelude&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::{&lt;/span&gt;&lt;span class="n"&gt;gio&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;glib&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="k"&gt;crate&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;config&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;VERSION&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="k"&gt;crate&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;GazetteWindow&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;mod&lt;/span&gt; &lt;span class="n"&gt;imp&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;#[derive(Debug,&lt;/span&gt; &lt;span class="nd"&gt;Default)]&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;GazetteApplication&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

    &lt;span class="nd"&gt;#[glib::object_subclass]&lt;/span&gt;
    &lt;span class="k"&gt;impl&lt;/span&gt; &lt;span class="n"&gt;ObjectSubclass&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;GazetteApplication&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;NAME&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;'static&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"GazetteApplication"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;GazetteApplication&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;ParentType&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;adw&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Application&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;impl&lt;/span&gt; &lt;span class="n"&gt;ObjectImpl&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;GazetteApplication&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;constructed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="nf"&gt;.parent_constructed&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
            &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;obj&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="nf"&gt;.obj&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
            &lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="nf"&gt;.setup_gactions&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
            &lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="nf"&gt;.set_accels_for_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"app.quit"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"&amp;lt;control&amp;gt;q"&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;impl&lt;/span&gt; &lt;span class="n"&gt;ApplicationImpl&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;GazetteApplication&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;activate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;application&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="nf"&gt;.obj&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
            &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;window&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;application&lt;/span&gt;&lt;span class="nf"&gt;.active_window&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.unwrap_or_else&lt;/span&gt;&lt;span class="p"&gt;(||&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;window&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;GazetteWindow&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;*&lt;/span&gt;&lt;span class="n"&gt;application&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
                &lt;span class="n"&gt;window&lt;/span&gt;&lt;span class="nf"&gt;.upcast&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="p"&gt;});&lt;/span&gt;
            &lt;span class="n"&gt;window&lt;/span&gt;&lt;span class="nf"&gt;.present&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;impl&lt;/span&gt; &lt;span class="n"&gt;GtkApplicationImpl&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;GazetteApplication&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="k"&gt;impl&lt;/span&gt; &lt;span class="n"&gt;AdwApplicationImpl&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;GazetteApplication&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nn"&gt;glib&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nd"&gt;wrapper!&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="nf"&gt;GazetteApplication&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ObjectSubclass&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nn"&gt;imp&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;GazetteApplication&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;extends&lt;/span&gt; &lt;span class="nn"&gt;gio&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Application&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Application&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nn"&gt;adw&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Application&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;implements&lt;/span&gt; &lt;span class="nn"&gt;gio&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;ActionGroup&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nn"&gt;gio&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;ActionMap&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same pattern as Part 2's &lt;code&gt;Feed&lt;/code&gt;: an inner type in &lt;code&gt;mod imp&lt;/code&gt; that holds the state, &lt;code&gt;ObjectSubclass&lt;/code&gt; to register it with GObject, and a &lt;code&gt;glib::wrapper!&lt;/code&gt; macro to create the outer type. The difference is what we're subclassing — &lt;code&gt;adw::Application&lt;/code&gt; instead of &lt;code&gt;glib::Object&lt;/code&gt; — and the trait implementations that come with it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;ObjectImpl::constructed&lt;/code&gt;&lt;/strong&gt; is called once when the application is first created. It's the place to set up actions and keyboard shortcuts. This is analogous to a constructor, but in GObject-land, construction happens in phases — &lt;code&gt;constructed&lt;/code&gt; runs after all properties have been set.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;ApplicationImpl::activate&lt;/code&gt;&lt;/strong&gt; is the most important callback. It's called when the application is launched (or when the user tries to open a second instance). The pattern here is standard: check if there's already a window, create one if there isn't, and present it. This is how GNOME apps implement single-instance behaviour — the platform enforces one running instance per application ID, and subsequent launches just activate the existing one.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;impl GtkApplicationImpl&lt;/code&gt; and &lt;code&gt;impl AdwApplicationImpl&lt;/code&gt; lines are empty but required. They tell the GObject type system that we're implementing the full trait chain from &lt;code&gt;gio::Application&lt;/code&gt; through &lt;code&gt;gtk::Application&lt;/code&gt; to &lt;code&gt;adw::Application&lt;/code&gt;. If you leave one out, you'll get a compile error.&lt;/p&gt;

&lt;p&gt;Further down in the file, there's the actions setup and the About dialog:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;impl&lt;/span&gt; &lt;span class="n"&gt;GazetteApplication&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;application_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;flags&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nn"&gt;gio&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;ApplicationFlags&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;Self&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nn"&gt;glib&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="nf"&gt;.property&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"application-id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;application_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="nf"&gt;.property&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"flags"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;flags&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="nf"&gt;.property&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"resource-base-path"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                       &lt;span class="s"&gt;"/io/github/fromthearchitect/gazette"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="nf"&gt;.build&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;setup_gactions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;quit_action&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;gio&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;ActionEntry&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"quit"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="nf"&gt;.activate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;move&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;Self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="nf"&gt;.quit&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
            &lt;span class="nf"&gt;.build&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;about_action&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;gio&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;ActionEntry&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"about"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="nf"&gt;.activate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;move&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;Self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="nf"&gt;.show_about&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
            &lt;span class="nf"&gt;.build&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="nf"&gt;.add_action_entries&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;quit_action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;about_action&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;show_about&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;window&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="nf"&gt;.active_window&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.unwrap&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;about&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;adw&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;AboutDialog&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="nf"&gt;.application_name&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Gazette"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="nf"&gt;.application_icon&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"io.github.fromthearchitect.gazette"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="nf"&gt;.developer_name&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Unknown"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="nf"&gt;.version&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;VERSION&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="nf"&gt;.developers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;vec!&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"Unknown"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
            &lt;span class="nf"&gt;.translator_credits&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nf"&gt;gettext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"translator-credits"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="nf"&gt;.copyright&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"© 2026 Unknown"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="nf"&gt;.build&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="n"&gt;about&lt;/span&gt;&lt;span class="nf"&gt;.present&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;window&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice the placeholder values — &lt;code&gt;developer_name("Unknown")&lt;/code&gt;, &lt;code&gt;developers(vec!["Unknown"])&lt;/code&gt;, &lt;code&gt;copyright("© 2026 Unknown")&lt;/code&gt;. Builder generates these as placeholders. Replace them with your actual name before publishing your app.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GActions&lt;/strong&gt; are GNOME's action system. Instead of connecting button clicks directly to functions, you define named actions (&lt;code&gt;app.quit&lt;/code&gt;, &lt;code&gt;app.about&lt;/code&gt;) and connect UI elements to those names. The menu items in the UI template reference &lt;code&gt;app.about&lt;/code&gt; — the action system routes that to &lt;code&gt;show_about()&lt;/code&gt;. This decoupling means you can trigger the same action from a menu, a keyboard shortcut, a command-line argument, or a D-Bus message.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;resource-base-path&lt;/code&gt; property tells the application where to look for automatically loaded resources. When we set it to &lt;code&gt;/io/github/fromthearchitect/gazette&lt;/code&gt;, the application will automatically load the shortcuts dialog from that path without us doing anything extra.&lt;/p&gt;

&lt;h3&gt;
  
  
  window.rs — the application window
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;prelude&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;adw&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;subclass&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;prelude&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::{&lt;/span&gt;&lt;span class="n"&gt;gio&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;glib&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;mod&lt;/span&gt; &lt;span class="n"&gt;imp&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;#[derive(Debug,&lt;/span&gt; &lt;span class="nd"&gt;Default,&lt;/span&gt; &lt;span class="nd"&gt;gtk::CompositeTemplate)]&lt;/span&gt;
    &lt;span class="nd"&gt;#[template(resource&lt;/span&gt; &lt;span class="nd"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"/io/github/fromthearchitect/gazette/window.ui"&lt;/span&gt;&lt;span class="nd"&gt;)]&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;GazetteWindow&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nd"&gt;#[template_child]&lt;/span&gt;
        &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;TemplateChild&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Label&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;#[glib::object_subclass]&lt;/span&gt;
    &lt;span class="k"&gt;impl&lt;/span&gt; &lt;span class="n"&gt;ObjectSubclass&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;GazetteWindow&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;NAME&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;'static&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"GazetteWindow"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;GazetteWindow&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;ParentType&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;adw&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;ApplicationWindow&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;class_init&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;klass&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="k"&gt;Self&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;klass&lt;/span&gt;&lt;span class="nf"&gt;.bind_template&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;instance_init&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nn"&gt;glib&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;subclass&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;InitializingObject&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;Self&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="nf"&gt;.init_template&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;impl&lt;/span&gt; &lt;span class="n"&gt;ObjectImpl&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;GazetteWindow&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="k"&gt;impl&lt;/span&gt; &lt;span class="n"&gt;WidgetImpl&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;GazetteWindow&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="k"&gt;impl&lt;/span&gt; &lt;span class="n"&gt;WindowImpl&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;GazetteWindow&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="k"&gt;impl&lt;/span&gt; &lt;span class="n"&gt;ApplicationWindowImpl&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;GazetteWindow&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="k"&gt;impl&lt;/span&gt; &lt;span class="n"&gt;AdwApplicationWindowImpl&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;GazetteWindow&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nn"&gt;glib&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nd"&gt;wrapper!&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="nf"&gt;GazetteWindow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ObjectSubclass&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nn"&gt;imp&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;GazetteWindow&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;extends&lt;/span&gt; &lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Widget&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Window&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;ApplicationWindow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                 &lt;span class="nn"&gt;adw&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;ApplicationWindow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;implements&lt;/span&gt; &lt;span class="nn"&gt;gio&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;ActionGroup&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nn"&gt;gio&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;ActionMap&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;impl&lt;/span&gt; &lt;span class="n"&gt;GazetteWindow&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="n"&gt;new&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;P&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;IsA&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Application&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;application&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;P&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;Self&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nn"&gt;glib&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="nf"&gt;.property&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"application"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;application&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="nf"&gt;.build&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;The &lt;code&gt;@implements&lt;/code&gt; list above is truncated for readability — the actual generated code also includes &lt;code&gt;gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget, gtk::Native, gtk::Root, gtk::ShortcutManager&lt;/code&gt;. See the source on the &lt;a href="https://github.com/fromthearchitect/gnome-rust-gazette/tree/part-3" rel="noopener noreferrer"&gt;&lt;code&gt;part-3&lt;/code&gt; branch&lt;/a&gt; for the full version.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This introduces two new concepts: &lt;strong&gt;composite templates&lt;/strong&gt; and the &lt;strong&gt;widget trait chain&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;#[derive(gtk::CompositeTemplate)]&lt;/code&gt;&lt;/strong&gt; connects this Rust struct to a UI definition file. The &lt;code&gt;#[template(resource = "...")]&lt;/code&gt; attribute tells GTK where to find the template, and &lt;code&gt;#[template_child]&lt;/code&gt; fields create typed references to named widgets in that template. Here, &lt;code&gt;self.label&lt;/code&gt; gives us direct access to the &lt;code&gt;GtkLabel&lt;/code&gt; widget with &lt;code&gt;id="label"&lt;/code&gt; defined in the UI file.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;class_init&lt;/code&gt; and &lt;code&gt;instance_init&lt;/code&gt; functions bind the template at the class level (once) and initialise it for each instance. This is boilerplate that every composite template widget needs.&lt;/p&gt;

&lt;p&gt;The trait chain — &lt;code&gt;WidgetImpl&lt;/code&gt;, &lt;code&gt;WindowImpl&lt;/code&gt;, &lt;code&gt;ApplicationWindowImpl&lt;/code&gt;, &lt;code&gt;AdwApplicationWindowImpl&lt;/code&gt; — reflects the GTK class hierarchy. &lt;code&gt;AdwApplicationWindow&lt;/code&gt; extends &lt;code&gt;gtk::ApplicationWindow&lt;/code&gt;, which extends &lt;code&gt;gtk::Window&lt;/code&gt;, which extends &lt;code&gt;gtk::Widget&lt;/code&gt;. Each level of the hierarchy can override behaviour. For now, all these implementations are empty, but they must be present.&lt;/p&gt;




&lt;h2&gt;
  
  
  The UI template
&lt;/h2&gt;

&lt;h3&gt;
  
  
  window.ui
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;?xml version="1.0" encoding="UTF-8"?&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;interface&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;requires&lt;/span&gt; &lt;span class="na"&gt;lib=&lt;/span&gt;&lt;span class="s"&gt;"gtk"&lt;/span&gt; &lt;span class="na"&gt;version=&lt;/span&gt;&lt;span class="s"&gt;"4.0"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;requires&lt;/span&gt; &lt;span class="na"&gt;lib=&lt;/span&gt;&lt;span class="s"&gt;"Adw"&lt;/span&gt; &lt;span class="na"&gt;version=&lt;/span&gt;&lt;span class="s"&gt;"1.0"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;template&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"GazetteWindow"&lt;/span&gt; &lt;span class="na"&gt;parent=&lt;/span&gt;&lt;span class="s"&gt;"AdwApplicationWindow"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;property&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"title"&lt;/span&gt; &lt;span class="na"&gt;translatable=&lt;/span&gt;&lt;span class="s"&gt;"yes"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Gazette&lt;span class="nt"&gt;&amp;lt;/property&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;property&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"default-width"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;800&lt;span class="nt"&gt;&amp;lt;/property&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;property&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"default-height"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;600&lt;span class="nt"&gt;&amp;lt;/property&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;property&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"content"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;object&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"AdwToolbarView"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;child&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"top"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;lt;object&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"AdwHeaderBar"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;child&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"end"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
              &lt;span class="nt"&gt;&amp;lt;object&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"GtkMenuButton"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
                &lt;span class="nt"&gt;&amp;lt;property&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"primary"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;True&lt;span class="nt"&gt;&amp;lt;/property&amp;gt;&lt;/span&gt;
                &lt;span class="nt"&gt;&amp;lt;property&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"icon-name"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;open-menu-symbolic&lt;span class="nt"&gt;&amp;lt;/property&amp;gt;&lt;/span&gt;
                &lt;span class="nt"&gt;&amp;lt;property&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"tooltip-text"&lt;/span&gt;
                          &lt;span class="na"&gt;translatable=&lt;/span&gt;&lt;span class="s"&gt;"yes"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Main Menu&lt;span class="nt"&gt;&amp;lt;/property&amp;gt;&lt;/span&gt;
                &lt;span class="nt"&gt;&amp;lt;property&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"menu-model"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;primary_menu&lt;span class="nt"&gt;&amp;lt;/property&amp;gt;&lt;/span&gt;
              &lt;span class="nt"&gt;&amp;lt;/object&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;/child&amp;gt;&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;lt;/object&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;/child&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;property&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"content"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;lt;object&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"GtkLabel"&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"label"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;property&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"label"&lt;/span&gt;
                      &lt;span class="na"&gt;translatable=&lt;/span&gt;&lt;span class="s"&gt;"yes"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Hello, World!&lt;span class="nt"&gt;&amp;lt;/property&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;style&amp;gt;&lt;/span&gt;
              &lt;span class="nt"&gt;&amp;lt;class&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"title-1"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;/style&amp;gt;&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;lt;/object&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;/property&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;/object&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/property&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/template&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;menu&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"primary_menu"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;section&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;item&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;attribute&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"label"&lt;/span&gt;
                   &lt;span class="na"&gt;translatable=&lt;/span&gt;&lt;span class="s"&gt;"yes"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;_Preferences&lt;span class="nt"&gt;&amp;lt;/attribute&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;attribute&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"action"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;app.preferences&lt;span class="nt"&gt;&amp;lt;/attribute&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;/item&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;item&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;attribute&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"label"&lt;/span&gt;
                   &lt;span class="na"&gt;translatable=&lt;/span&gt;&lt;span class="s"&gt;"yes"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;_Keyboard Shortcuts&lt;span class="nt"&gt;&amp;lt;/attribute&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;attribute&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"action"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;app.shortcuts&lt;span class="nt"&gt;&amp;lt;/attribute&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;/item&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;item&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;attribute&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"label"&lt;/span&gt;
                   &lt;span class="na"&gt;translatable=&lt;/span&gt;&lt;span class="s"&gt;"yes"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;_About Gazette&lt;span class="nt"&gt;&amp;lt;/attribute&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;attribute&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"action"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;app.about&lt;span class="nt"&gt;&amp;lt;/attribute&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;/item&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/section&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/menu&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/interface&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is GTK's XML-based UI definition format.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;&lt;code&gt;&amp;lt;template&amp;gt;&lt;/code&gt;&lt;/strong&gt; element defines a composite template — it's bound to the &lt;code&gt;GazetteWindow&lt;/code&gt; class we defined in Rust. The &lt;code&gt;parent&lt;/code&gt; attribute must match the &lt;code&gt;ParentType&lt;/code&gt; in our &lt;code&gt;ObjectSubclass&lt;/code&gt; implementation.&lt;/p&gt;

&lt;p&gt;The widget hierarchy is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;AdwToolbarView&lt;/code&gt;&lt;/strong&gt; — a libadwaita container that manages header bars and content areas

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;AdwHeaderBar&lt;/code&gt;&lt;/strong&gt; (top) — the standard GNOME header bar with the window title and controls&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;GtkMenuButton&lt;/code&gt;&lt;/strong&gt; — the hamburger menu button in the top-right corner&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;GtkLabel&lt;/code&gt;&lt;/strong&gt; (content) — our "Hello, World!" text&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;strong&gt;&lt;code&gt;&amp;lt;menu&amp;gt;&lt;/code&gt;&lt;/strong&gt; element defines the hamburger menu model. Each item has a label (the &lt;code&gt;_&lt;/code&gt; prefix marks the keyboard accelerator character) and an action name. The &lt;code&gt;app.about&lt;/code&gt; entry maps back to the action we defined in &lt;code&gt;application.rs&lt;/code&gt;. The &lt;code&gt;app.preferences&lt;/code&gt; action isn't connected yet — clicking it does nothing. The &lt;code&gt;app.shortcuts&lt;/code&gt; action, however, already works: GTK automatically loads &lt;code&gt;shortcuts-dialog.ui&lt;/code&gt; from the resource base path we set in &lt;code&gt;application.rs&lt;/code&gt;. We'll wire up preferences in a later post.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;translatable="yes"&lt;/code&gt; attribute on strings marks them for extraction by gettext. Even if you don't plan to translate your app initially, marking strings as translatable from the start saves you from a painful retrofit later.&lt;/p&gt;

&lt;p&gt;In Part 4, we'll convert this XML template to Blueprint, which is dramatically more readable. But it's worth understanding the XML format first, since Blueprint compiles to it — and when things go wrong, the error messages reference XML structures.&lt;/p&gt;




&lt;h2&gt;
  
  
  The build system
&lt;/h2&gt;

&lt;p&gt;GNOME apps don't use Cargo as the top-level build system. They use &lt;strong&gt;Meson&lt;/strong&gt;. Cargo still compiles the Rust code, but Meson orchestrates everything else — and there's a lot of "everything else" in a GNOME app.&lt;/p&gt;

&lt;h3&gt;
  
  
  Root meson.build
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight meson"&gt;&lt;code&gt;&lt;span class="nb"&gt;project&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'gazette'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;'rust'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="n"&gt;version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;'0.1.0'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;meson_version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;'&amp;gt;= 1.0.0'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;default_options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="s"&gt;'warning_level=2'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;'werror=false'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;i18n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'i18n'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;gnome&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'gnome'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nb"&gt;subdir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'data'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;subdir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'src'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;subdir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'po'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;gnome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;post_install&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
     &lt;span class="n"&gt;glib_compile_schemas&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;gtk_update_icon_cache&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;update_desktop_database&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The root build file declares the project, imports the &lt;code&gt;i18n&lt;/code&gt; and &lt;code&gt;gnome&lt;/code&gt; Meson modules, and includes three subdirectories. The &lt;code&gt;post_install&lt;/code&gt; block runs after installation to compile GSettings schemas, update the icon cache, and refresh the desktop file database. These are standard GNOME post-install steps — without them, your app's icon won't appear in the application launcher and your settings won't be accessible.&lt;/p&gt;

&lt;h3&gt;
  
  
  src/meson.build — where Rust meets Meson
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight meson"&gt;&lt;code&gt;&lt;span class="n"&gt;pkgdatadir&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;get_option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'prefix'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nb"&gt;get_option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'datadir'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nb"&gt;meson&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;project_name&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;gnome&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'gnome'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;gnome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;compile_resources&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'gazette'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s"&gt;'gazette.gresource.xml'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;gresource_bundle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;install&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;install_dir&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;pkgdatadir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;conf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;configuration_data&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;conf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;set_quoted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'VERSION'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;meson&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;project_version&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="n"&gt;conf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;set_quoted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'GETTEXT_PACKAGE'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;'gazette'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;conf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;set_quoted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'LOCALEDIR'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;get_option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'prefix'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nb"&gt;get_option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'localedir'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;conf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;set_quoted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'PKGDATADIR'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pkgdatadir&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nb"&gt;configure_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;'config.rs.in'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;'config.rs'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;configuration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;conf&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's what it handles:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Compiles GResources.&lt;/strong&gt; The &lt;code&gt;gazette.gresource.xml&lt;/code&gt; manifest lists the UI files and other assets that should be bundled. Meson compiles them into a single binary blob (&lt;code&gt;gazette.gresource&lt;/code&gt;) and installs it alongside the binary.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Generates &lt;code&gt;config.rs&lt;/code&gt;.&lt;/strong&gt; The &lt;code&gt;config.rs.in&lt;/code&gt; template contains placeholders like &lt;code&gt;@VERSION@&lt;/code&gt; and &lt;code&gt;@PKGDATADIR@&lt;/code&gt;. Meson replaces them with real values at build time. This is how the Rust code knows where to find its installed resources — the paths are baked in at compile time.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Invokes Cargo.&lt;/strong&gt; Further down in the file, Meson runs &lt;code&gt;cargo build&lt;/code&gt; with the right flags and copies the resulting binary to the install location.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  gazette.gresource.xml
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;?xml version="1.0" encoding="UTF-8"?&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;gresources&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;gresource&lt;/span&gt; &lt;span class="na"&gt;prefix=&lt;/span&gt;&lt;span class="s"&gt;"/io/github/fromthearchitect/gazette"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;file&lt;/span&gt; &lt;span class="na"&gt;preprocess=&lt;/span&gt;&lt;span class="s"&gt;"xml-stripblanks"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;window.ui&lt;span class="nt"&gt;&amp;lt;/file&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;file&lt;/span&gt; &lt;span class="na"&gt;preprocess=&lt;/span&gt;&lt;span class="s"&gt;"xml-stripblanks"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;shortcuts-dialog.ui&lt;span class="nt"&gt;&amp;lt;/file&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/gresource&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/gresources&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This manifest lists every file that should be compiled into the GResource bundle. The &lt;code&gt;prefix&lt;/code&gt; attribute defines the virtual path — when our window template asks for &lt;code&gt;/io/github/fromthearchitect/gazette/window.ui&lt;/code&gt;, it's loading from this bundle, not from the filesystem. The &lt;code&gt;xml-stripblanks&lt;/code&gt; flag tells Meson to strip whitespace from the XML before bundling. It makes no real difference for an app this size, but it's standard practice and every GNOME app does it.&lt;/p&gt;

&lt;p&gt;Whenever you add a new UI file, icon, or CSS stylesheet, it needs to be listed here.&lt;/p&gt;




&lt;h2&gt;
  
  
  Desktop integration files
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;data/&lt;/code&gt; directory contains files that integrate your application with the Linux desktop. None of these are specific to GNOME or GTK — they're freedesktop.org standards used across all Linux desktop environments.&lt;/p&gt;

&lt;h3&gt;
  
  
  Desktop file
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Desktop Entry]&lt;/span&gt;
&lt;span class="py"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;Gazette&lt;/span&gt;
&lt;span class="py"&gt;Exec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;gazette&lt;/span&gt;
&lt;span class="py"&gt;Icon&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;io.github.fromthearchitect.gazette&lt;/span&gt;
&lt;span class="py"&gt;Terminal&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;false&lt;/span&gt;
&lt;span class="py"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;Application&lt;/span&gt;
&lt;span class="py"&gt;Categories&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;Utility;&lt;/span&gt;
&lt;span class="py"&gt;Keywords&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;GTK;&lt;/span&gt;
&lt;span class="py"&gt;StartupNotify&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;DBusActivatable&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This tells the desktop environment how to launch your app, what icon to display, and what category to put it in. The &lt;code&gt;.in&lt;/code&gt; suffix on the source file means Meson processes it through gettext to produce translated versions.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;DBusActivatable=true&lt;/code&gt; means the app can be launched via D-Bus, which enables the single-instance behaviour we saw in &lt;code&gt;application.rs&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  GSettings schema
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;schemalist&lt;/span&gt; &lt;span class="na"&gt;gettext-domain=&lt;/span&gt;&lt;span class="s"&gt;"gazette"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;schema&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"io.github.fromthearchitect.gazette"&lt;/span&gt;
          &lt;span class="na"&gt;path=&lt;/span&gt;&lt;span class="s"&gt;"/io/github/fromthearchitect/gazette/"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/schema&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/schemalist&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;GSettings is GNOME's configuration system — think of it as a typed key-value store for application preferences. The schema is empty now, but when we add user preferences (feed refresh interval, dark mode preference, etc.), this is where we'll define them.&lt;/p&gt;

&lt;h3&gt;
  
  
  AppStream metadata
&lt;/h3&gt;

&lt;p&gt;The metainfo file is what Flathub and GNOME Software actually read when they display your app. Get it wrong and your app looks abandoned regardless of how polished the code is. Builder generated a placeholder — we'll fill it in properly when we prepare for Flathub submission in Part 9.&lt;/p&gt;

&lt;h3&gt;
  
  
  D-Bus service file
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[D-BUS Service]&lt;/span&gt;
&lt;span class="py"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;io.github.fromthearchitect.gazette&lt;/span&gt;
&lt;span class="py"&gt;Exec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;@bindir@/gazette --gapplication-service&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This tells D-Bus how to activate your application on demand. The &lt;code&gt;@bindir@&lt;/code&gt; placeholder is replaced by Meson at build time with the actual installation path.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Flatpak manifest
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"io.github.fromthearchitect.gazette"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"runtime"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"org.gnome.Platform"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"runtime-version"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"48"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"sdk"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"org.gnome.Sdk"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"sdk-extensions"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"org.freedesktop.Sdk.Extension.rust-stable"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"gazette"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"finish-args"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"--share=network"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"--share=ipc"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"--socket=fallback-x11"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"--device=dri"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"--socket=wayland"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This defines how to build and sandbox the application as a Flatpak. The key fields:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;runtime&lt;/code&gt;&lt;/strong&gt; and &lt;strong&gt;&lt;code&gt;runtime-version&lt;/code&gt;&lt;/strong&gt;: The GNOME platform libraries your app links against at runtime. Version 48 corresponds to GNOME 48.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;sdk&lt;/code&gt;&lt;/strong&gt;: The matching SDK used at build time, which includes header files and development tools.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;sdk-extensions&lt;/code&gt;&lt;/strong&gt;: Additional SDK components — here, the Rust toolchain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;finish-args&lt;/code&gt;&lt;/strong&gt;: The sandbox permissions your app needs. Network access, Wayland display, GPU acceleration. Every permission you add here needs to be justified when you submit to Flathub. You might wonder why an RSS reader needs &lt;code&gt;--device=dri&lt;/code&gt; — GTK4 uses GPU-accelerated rendering by default, so it's a legitimate permission for virtually any GTK4 app.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Upgrading to the GNOME 50 platform
&lt;/h2&gt;

&lt;p&gt;Builder generated this project against &lt;strong&gt;GNOME 48&lt;/strong&gt; — that's what the current template targets. But in &lt;a href="https://dev.to/posts/gnome-rust-part-1-getting-started/"&gt;Part 1&lt;/a&gt;, we installed the &lt;strong&gt;GNOME 50 SDK&lt;/strong&gt;. Let's upgrade the Rust crates and Flatpak runtime to match.&lt;/p&gt;

&lt;p&gt;Three files need changes:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Cargo.toml — Rust crate versions
&lt;/h3&gt;

&lt;p&gt;The generated &lt;code&gt;Cargo.toml&lt;/code&gt; uses &lt;code&gt;gtk4&lt;/code&gt; 0.9 and &lt;code&gt;libadwaita&lt;/code&gt; 0.7:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="nn"&gt;[dependencies]&lt;/span&gt;
&lt;span class="py"&gt;gettext-rs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"0.7"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="py"&gt;features&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"gettext-system"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="py"&gt;gtk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"0.9"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="py"&gt;package&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"gtk4"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="py"&gt;features&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"gnome_47"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nn"&gt;[dependencies.adw]&lt;/span&gt;
&lt;span class="py"&gt;package&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"libadwaita"&lt;/span&gt;
&lt;span class="py"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"0.7"&lt;/span&gt;
&lt;span class="py"&gt;features&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"v1_6"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Update it to the latest versions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="nn"&gt;[dependencies]&lt;/span&gt;
&lt;span class="py"&gt;gettext-rs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"0.7"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="py"&gt;features&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"gettext-system"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="py"&gt;gtk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"0.11"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="py"&gt;package&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"gtk4"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="py"&gt;features&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"gnome_50"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="py"&gt;adw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"0.9"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="py"&gt;package&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"libadwaita"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="py"&gt;features&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"v1_9"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The version numbers here are the Rust crate versions, not the GTK version. &lt;code&gt;gtk4&lt;/code&gt; crate 0.11 wraps GTK 4.18, and &lt;code&gt;libadwaita&lt;/code&gt; crate 0.9 wraps libadwaita 1.9. The &lt;code&gt;gnome_50&lt;/code&gt; feature flag is a convenience flag that enables all the version-specific API features matching the GNOME 50 SDK — it saves you from having to figure out which individual &lt;code&gt;v4_xx&lt;/code&gt; flags correspond to which GNOME release.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Flatpak manifest — runtime version
&lt;/h3&gt;

&lt;p&gt;In &lt;code&gt;io.github.fromthearchitect.gazette.json&lt;/code&gt;, update the runtime version:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"runtime-version"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"50"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This tells Flatpak to use the GNOME 50 runtime and SDK when building and running your app.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Rebuild
&lt;/h3&gt;

&lt;p&gt;Hit &lt;strong&gt;Run&lt;/strong&gt; in Builder again. Builder will download the GNOME 50 SDK if it's not already installed, rebuild the project against the new dependencies, and launch the app. If everything compiles and runs — and it should, since we haven't used any version-specific APIs yet — you're on the latest platform.&lt;/p&gt;

&lt;p&gt;This kind of version bump is something you'll do roughly every six months as GNOME releases new platform versions. Getting comfortable with it now means it's routine rather than stressful later.&lt;/p&gt;




&lt;h2&gt;
  
  
  What we have so far
&lt;/h2&gt;

&lt;p&gt;The thing that takes longest to internalise when you start GNOME development is how much of this is not GTK. The desktop file, the D-Bus service, the AppStream metadata, GSettings — these are platform concerns, not application concerns. Builder stitches them all together, but you need to know what they are and why they exist, or you'll never debug a broken Flatpak submission.&lt;/p&gt;

&lt;p&gt;The application ID is the load-bearing string across all of it. It ties the desktop file to the D-Bus service to the GSettings schema to the Flatpak bundle. Getting this scaffolding right by hand would take hours of cross-referencing documentation. Builder gives it to you in thirty seconds.&lt;/p&gt;




&lt;h2&gt;
  
  
  What comes next
&lt;/h2&gt;

&lt;p&gt;We've got a running app, but the UI is still "Hello, World!". Before we start building the actual RSS reader interface, we need a better way to describe user interfaces than raw XML. &lt;/p&gt;

&lt;p&gt;In Part 4, we'll introduce Blueprint — a markup language that compiles to GTK's UI XML but is dramatically more readable. We'll convert the generated window template to Blueprint, build the initial Gazette layout with a sidebar and content pane, and start making it look like a real application.&lt;/p&gt;




&lt;p&gt;The source code at the end of this post lives on the &lt;code&gt;part-3&lt;/code&gt; branch of &lt;a href="https://github.com/fromthearchitect/gnome-rust-gazette/tree/part-3" rel="noopener noreferrer"&gt;fromthearchitect/gnome-rust-gazette&lt;/a&gt;. Subsequent posts in this series build on the same repo, with a branch per part.&lt;/p&gt;

</description>
      <category>gnome</category>
      <category>rust</category>
      <category>opensource</category>
      <category>gtk</category>
    </item>
  </channel>
</rss>
