<?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: K M Shahriar Hossain</title>
    <description>The latest articles on DEV Community by K M Shahriar Hossain (@devshakib).</description>
    <link>https://dev.to/devshakib</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%2F4106725%2Fb3e12c6b-18f9-4a00-a919-bd5fd68ee09d.jpg</url>
      <title>DEV Community: K M Shahriar Hossain</title>
      <link>https://dev.to/devshakib</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/devshakib"/>
    <language>en</language>
    <item>
      <title>Google Indexed the Blog and Nothing Else on My Flutter Web Site</title>
      <dc:creator>K M Shahriar Hossain</dc:creator>
      <pubDate>Fri, 18 Sep 2026 13:47:25 +0000</pubDate>
      <link>https://dev.to/devshakib/google-indexed-the-blog-and-nothing-else-on-my-flutter-web-site-2o7m</link>
      <guid>https://dev.to/devshakib/google-indexed-the-blog-and-nothing-else-on-my-flutter-web-site-2o7m</guid>
      <description>&lt;p&gt;On 10 September, Search Console's verdict on this site was 18 pages indexed and&lt;br&gt;
401 not. Four hundred of those sat in "Discovered — currently not indexed",&lt;br&gt;
which is Google saying it knows the URL exists and hasn't bothered to fetch it.&lt;/p&gt;

&lt;p&gt;The breakdown was stranger than the count. Of the 23 pages that had ever&lt;br&gt;
appeared in a search result, 22 were blog posts and the 23rd was the home page.&lt;br&gt;
Not one app, tool, game or package page had a single impression, and &lt;code&gt;/apps&lt;/code&gt;,&lt;br&gt;
the page that links to 96 of them, read "URL is unknown to Google".&lt;/p&gt;

&lt;p&gt;"Discovered — currently not indexed" usually gets blamed on crawl budget or a&lt;br&gt;
young domain. Neither explains why one kind of page gets in and every other&lt;br&gt;
kind doesn't. Something about the blog was different.&lt;/p&gt;
&lt;h2&gt;
  
  
  Flutter web draws text Google can't read
&lt;/h2&gt;

&lt;p&gt;The site is Flutter web with the CanvasKit renderer, which paints everything,&lt;br&gt;
text included, onto a &lt;code&gt;&amp;lt;canvas&amp;gt;&lt;/code&gt;. To a crawler, a canvas full of words is an&lt;br&gt;
image with no alt text.&lt;/p&gt;

&lt;p&gt;The usual answer is to ship the words separately. A build step&lt;br&gt;
(&lt;code&gt;tool/prerender.py&lt;/code&gt; here) writes a real HTML file for every route, with the&lt;br&gt;
page's text and links inside the served document:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"prerendered-content"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;h1&amp;gt;&lt;/span&gt;Apps&lt;span class="nt"&gt;&amp;lt;/h1&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;…&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/apps/helm"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Helm&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt; …
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's clipped to one pixel rather than hidden with &lt;code&gt;display: none&lt;/code&gt;, so it stays&lt;br&gt;
in the document for crawlers and screen readers without being seen. &lt;code&gt;curl&lt;/code&gt; any&lt;br&gt;
route and the content is there. That check passed on every page from the day&lt;br&gt;
it shipped.&lt;/p&gt;
&lt;h2&gt;
  
  
  The part &lt;code&gt;curl&lt;/code&gt; can't see
&lt;/h2&gt;

&lt;p&gt;Google doesn't index the HTML the server sends. It runs the page's JavaScript&lt;br&gt;
and indexes the DOM that results. For a Flutter app, that means &lt;code&gt;main.dart.js&lt;/code&gt;&lt;br&gt;
boots and paints a first frame.&lt;/p&gt;

&lt;p&gt;And &lt;code&gt;index.html&lt;/code&gt; had this, written alongside the prerendering:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;flutter-first-frame&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// ...fade out the loading screen...&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;seo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;prerendered-content&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seo&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;seo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remove&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 comment in the stylesheet explained why: the crawler copy goes "the moment&lt;br&gt;
Flutter paints the same content properly". That's true for a person looking at&lt;br&gt;
the screen. It isn't true for the DOM. Under CanvasKit, "painted properly" means&lt;br&gt;
pixels, so once the handler ran, the document held a canvas and nothing else:&lt;br&gt;
zero words and zero links on every route.&lt;/p&gt;

&lt;p&gt;Every route except blog posts. Posts carry a second copy: when a post opens,&lt;br&gt;
the app adds its body to the document as a hidden &lt;code&gt;&amp;lt;article&amp;gt;&lt;/code&gt;. The handler&lt;br&gt;
removed one element by id, and that wasn't it. That is the whole difference&lt;br&gt;
between the 22 pages Google knew and the rest.&lt;/p&gt;

&lt;p&gt;It also explains "Discovered". Google had found the other pages through the&lt;br&gt;
sitemap and nothing else. The hub pages that should have linked to them&lt;br&gt;
rendered as empty dead ends, and a URL that only appears in a sitemap is about&lt;br&gt;
the lowest crawl priority there is. So they waited.&lt;/p&gt;
&lt;h2&gt;
  
  
  Let the app keep the copy
&lt;/h2&gt;

&lt;p&gt;The handler isn't wrong in general; the loading screen should go. The copy just&lt;br&gt;
shouldn't go with it. So the app claims the block before its first frame by&lt;br&gt;
renaming it, and the handler's &lt;code&gt;getElementById&lt;/code&gt; finds nothing to remove:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;// In main(), before runApp.&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;el&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;web&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;document&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'#prerendered-content'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;el&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;el&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;'seo-prerendered'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;         &lt;span class="c1"&gt;// the first-frame handler now misses it&lt;/span&gt;
  &lt;span class="n"&gt;el&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'style'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;clipped&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// the clipping CSS was keyed on the old id&lt;/span&gt;
  &lt;span class="n"&gt;el&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'aria-hidden'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&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;Three details matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Re-apply the clipping inline.&lt;/strong&gt; The stylesheet rule that hid the block was
keyed on the old id. Rename it without this and the whole crawler copy
appears on screen.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hide it from screen readers.&lt;/strong&gt; The app turns on Flutter's semantics tree,
which is the accessible version of the page. With both exposed, a screen
reader would read every page twice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Drop it on navigation.&lt;/strong&gt; The block describes the route that was served.
When the visitor moves to another route inside the app, it's removed, so the
document never carries text for a page that isn't showing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's the same content the app paints, so this isn't a different page for&lt;br&gt;
Google. The crawler just gets the text version of the page instead of a picture&lt;br&gt;
of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed
&lt;/h2&gt;

&lt;p&gt;Search Console's live test afterwards returned "URL is available to Google",&lt;br&gt;
and the rendered HTML of &lt;code&gt;/apps&lt;/code&gt; had 159 words and 96 links instead of none.&lt;/p&gt;

&lt;p&gt;Then indexing was requested for eight pages: the home page, the six hubs&lt;br&gt;
(&lt;code&gt;/apps&lt;/code&gt;, &lt;code&gt;/tools&lt;/code&gt;, &lt;code&gt;/blog&lt;/code&gt;, &lt;code&gt;/games&lt;/code&gt;, &lt;code&gt;/work&lt;/code&gt;, &lt;code&gt;/packages&lt;/code&gt;) and one app page.&lt;br&gt;
Not the 400 leaves. Requests are capped at roughly ten a day, and one crawled&lt;br&gt;
hub hands Google about 96 links, which is how leaves get discovered without&lt;br&gt;
being requested one at a time.&lt;/p&gt;

&lt;p&gt;By 14 September: &lt;strong&gt;99 indexed, up from 18.&lt;/strong&gt; It isn't a controlled experiment,&lt;br&gt;
since the fix and the requests landed in the same week, but requesting an empty&lt;br&gt;
hub would have handed Google nothing to follow. And 330 pages are still in&lt;br&gt;
"Discovered — currently not indexed", so this is a slope, not a switch. The&lt;br&gt;
queries now arriving are for tools and posts ("flutter treemap", "tone&lt;br&gt;
generator"), the kinds of page that used to be invisible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check the rendered page, not the response
&lt;/h2&gt;

&lt;p&gt;If you ship Flutter web with prerendered HTML, &lt;code&gt;curl&lt;/code&gt; is not the test that&lt;br&gt;
matters:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Search Console → URL Inspection → Test live URL → View tested page →
HTML.&lt;/strong&gt; That's the DOM Google indexes. Search it for a sentence from your
page.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Or open the page, wait for it to paint, and run
&lt;code&gt;document.body.innerText.length&lt;/code&gt; in the console.&lt;/strong&gt; If it's near zero on a
page full of text, a crawler sees the same nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Search &lt;code&gt;index.html&lt;/code&gt; for anything that removes elements on
&lt;code&gt;flutter-first-frame&lt;/code&gt;,&lt;/strong&gt; and ask what exactly it removes.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The served HTML was right the whole time. The page deleted it before the one&lt;br&gt;
reader who mattered got there.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://devshakib.jumyn.com/blog/google-indexed-the-blog-and-nothing-else" rel="noopener noreferrer"&gt;devshakib.jumyn.com&lt;/a&gt;. I write about Flutter, Dart and the parts of shipping that are genuinely awkward — and publish the packages that came out of them at &lt;a href="https://pub.dev/publishers/jumyn.com/packages" rel="noopener noreferrer"&gt;pub.dev/publishers/jumyn.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>webdev</category>
      <category>seo</category>
      <category>dart</category>
    </item>
    <item>
      <title>Five Requests in Fifteen Seconds From One Status Bar Item</title>
      <dc:creator>K M Shahriar Hossain</dc:creator>
      <pubDate>Mon, 14 Sep 2026 16:40:57 +0000</pubDate>
      <link>https://dev.to/devshakib/five-requests-in-fifteen-seconds-from-one-status-bar-item-4kgh</link>
      <guid>https://dev.to/devshakib/five-requests-in-fifteen-seconds-from-one-status-bar-item-4kgh</guid>
      <description>&lt;p&gt;Claude Code knows how much of your usage limits you have left, but it shows you&lt;br&gt;
in a panel you have to go and open. The number belongs where you are already&lt;br&gt;
looking, so it went into the VS Code status bar:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Claude 5h 12% · 7d 39%
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Hover for every limit and when each one resets; the item turns amber at 75% and&lt;br&gt;
red at 90%. The first version worked. The second, released the same evening, had&lt;br&gt;
the usage endpoint answering 429: five requests in fifteen seconds, and every one&lt;br&gt;
of them extended the wait.&lt;/p&gt;
&lt;h2&gt;
  
  
  Every window runs your extension again
&lt;/h2&gt;

&lt;p&gt;The mistake was thinking of the extension as one program. It isn't. Each VS Code&lt;br&gt;
window runs its own copy, with its own timers and its own event handlers, and&lt;br&gt;
nothing is shared between them unless you share it.&lt;/p&gt;

&lt;p&gt;So the real schedule was never "one request every five minutes". It was:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;every open window, polling on its own timer,&lt;/li&gt;
&lt;li&gt;plus a fetch whenever any window gained focus,&lt;/li&gt;
&lt;li&gt;plus a fetch on every click of the item, including while the server was
saying wait.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Move between a few windows, click the item because the number looks stale, and&lt;br&gt;
you get what happened here: five requests in fifteen seconds, each one making&lt;br&gt;
the block longer.&lt;/p&gt;
&lt;h2&gt;
  
  
  One result, shared through a file
&lt;/h2&gt;

&lt;p&gt;VS Code gives every extension a global storage folder,&lt;br&gt;
&lt;code&gt;context.globalStorageUri&lt;/code&gt;, and it is the same folder in every window. That makes&lt;br&gt;
it the simplest place to keep a result that all of them can read.&lt;/p&gt;

&lt;p&gt;A window no longer asks the server just because it woke up. It reads the file&lt;br&gt;
first, and only fetches if the numbers there are older than the refresh&lt;br&gt;
interval:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Fresh enough? Then this is just a render — no request.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;age&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;last&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;last&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;at&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;Infinity&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;age&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;manual&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;CLAIM_MS&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;minutes&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;render&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two windows can still find the same stale file at the same moment, so the one&lt;br&gt;
that fetches writes a claim first, and any window that sees a claim younger than&lt;br&gt;
30 seconds stays out of it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Another window is already fetching.&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fetchingAt&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fetchingAt&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;CLAIM_MS&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The timers also carry up to 20 seconds of random jitter, so windows opened&lt;br&gt;
together don't wake together. Focusing a window now costs nothing: it re-renders&lt;br&gt;
what is already in the file.&lt;/p&gt;
&lt;h2&gt;
  
  
  A 429 is an instruction
&lt;/h2&gt;

&lt;p&gt;The worst part of the old version was the click. The item looked stuck, so it&lt;br&gt;
got clicked, and each click was another request to a server that had just said&lt;br&gt;
stop.&lt;/p&gt;

&lt;p&gt;Now a 429 sets a block, written to the same shared file so every window obeys it.&lt;br&gt;
Its length is whichever is longer: the server's own &lt;code&gt;retry-after&lt;/code&gt;, or 5 minutes&lt;br&gt;
doubling on each repeat up to 30.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;nextBlock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;strikeCount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;retryAfterMs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;curve&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;BLOCK_MAX&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;BLOCK_MIN&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;strikeCount&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;curve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;retryAfterMs&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And a click cannot override it. That felt wrong to write, because a button that&lt;br&gt;
does nothing looks broken. So the tooltip says when the next check is due, which&lt;br&gt;
turns "why won't it refresh" into an answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  A failed check is not an outage
&lt;/h2&gt;

&lt;p&gt;The very first version had the opposite problem: it treated one failure as news.&lt;/p&gt;

&lt;p&gt;The extension reads the login Claude Code already keeps: the macOS Keychain&lt;br&gt;
entry &lt;code&gt;Claude Code-credentials&lt;/code&gt;, or &lt;code&gt;~/.claude/.credentials.json&lt;/code&gt; elsewhere.&lt;br&gt;
Claude Code rewrites that entry whenever it renews its login, and a read that&lt;br&gt;
lands in that moment comes back without a token. The status bar showed a warning&lt;br&gt;
where the numbers should have been, until the next poll five minutes later.&lt;/p&gt;

&lt;p&gt;Now a failure keeps the last numbers on screen, falls back to the token already&lt;br&gt;
held, and retries after 20 seconds, then 60, then 120. The warning appears only&lt;br&gt;
after three failures in a row, and the tooltip always names the last one. Nothing&lt;br&gt;
is hidden; it just stops shouting.&lt;/p&gt;

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

&lt;p&gt;Anything that reads a login should say plainly what it does with it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The login, read-only.&lt;/strong&gt; The token is never refreshed, never written back and
never logged, so the extension cannot sign Claude Code out. If the stored token
has expired, the item says so, and using Claude Code once renews it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One request every few minutes&lt;/strong&gt;, to &lt;code&gt;api.anthropic.com&lt;/code&gt;, shared by every
window.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nothing else&lt;/strong&gt;: no telemetry, no other network calls.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The endpoint deserves the same honesty. It is the one Claude Code's own usage&lt;br&gt;
panel reads, not a published API, so a Claude Code update could change or remove&lt;br&gt;
it. If that happens, the extension shows a warning instead of numbers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three rules for a polling extension
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Assume one copy per window.&lt;/strong&gt; Anything on a timer or a focus event is
multiplied by the number of windows open. Share the result, not the
schedule.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat 429 as an instruction.&lt;/strong&gt; Store the wait where every copy can see it,
don't let a button override it, and show when the next try is due.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One failure is noise.&lt;/strong&gt; Keep the last good value, retry quickly, and warn
only when failures repeat.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;ds_usage is free and MIT licensed, on the&lt;br&gt;
&lt;a href="https://marketplace.visualstudio.com/items?itemName=devShakib.ds-usage" rel="noopener noreferrer"&gt;VS Code Marketplace&lt;/a&gt;,&lt;br&gt;
with the source on &lt;a href="https://github.com/devShakib015/ds_usage" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://devshakib.jumyn.com/blog/five-requests-in-fifteen-seconds-from-one-status-bar-item" rel="noopener noreferrer"&gt;devshakib.jumyn.com&lt;/a&gt;. I write about Flutter, Dart and the parts of shipping that are genuinely awkward — and publish the packages that came out of them at &lt;a href="https://pub.dev/publishers/jumyn.com/packages" rel="noopener noreferrer"&gt;pub.dev/publishers/jumyn.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>vscode</category>
      <category>claude</category>
      <category>javascript</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Full Disk Access Was On, and macOS Still Refused the App</title>
      <dc:creator>K M Shahriar Hossain</dc:creator>
      <pubDate>Sat, 12 Sep 2026 19:16:35 +0000</pubDate>
      <link>https://dev.to/devshakib/full-disk-access-was-on-and-macos-still-refused-the-app-55i9</link>
      <guid>https://dev.to/devshakib/full-disk-access-was-on-and-macos-still-refused-the-app-55i9</guid>
      <description>&lt;p&gt;A Mac app I maintain needs Full Disk Access. It measures disk usage, and most of&lt;br&gt;
what is worth measuring sits behind that permission. I switched it on in System&lt;br&gt;
Settings ▸ Privacy &amp;amp; Security ▸ Full Disk Access, and the app went on reporting&lt;br&gt;
limited access. Its banner said what these banners say: already granted?&lt;br&gt;
Relaunch. Relaunching changed nothing, and never could have.&lt;/p&gt;
&lt;h2&gt;
  
  
  The app did not validate
&lt;/h2&gt;

&lt;p&gt;A permission is granted to an app, and to macOS "the app" is not a name. So the&lt;br&gt;
first question is whether macOS can tell what the app is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;codesign &lt;span class="nt"&gt;--verify&lt;/span&gt; &lt;span class="nt"&gt;--deep&lt;/span&gt; &lt;span class="nt"&gt;--strict&lt;/span&gt; &lt;span class="nt"&gt;--verbose&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;2 /Applications/Helm.app
&lt;span class="go"&gt;/Applications/Helm.app: nested code is modified or invalid
file modified: /Applications/Helm.app/Contents/Frameworks/App.framework
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;App.framework&lt;/code&gt; is where Flutter puts the compiled Dart. Verified on its own it&lt;br&gt;
was fine, and so were the other four frameworks in the bundle. What failed was&lt;br&gt;
the app around them.&lt;/p&gt;

&lt;p&gt;An app's signature seals its contents. &lt;code&gt;Contents/_CodeSignature/CodeResources&lt;/code&gt;&lt;br&gt;
records, for every nested framework, the hash of the code that should be there.&lt;br&gt;
It recorded one &lt;code&gt;App.framework&lt;/code&gt;, and the bundle held another:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;seal records    Frameworks/App.framework   4ba5cc60…
actually there  Frameworks/App.framework   c7a45621…

Contents/_CodeSignature/CodeResources      15:08:47
Contents/MacOS/Helm                        15:08:47
Contents/Frameworks/App.framework/…/App    15:30:38
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The seal and the executable came from one build. The framework came from a build&lt;br&gt;
twenty-two minutes later, which put a new &lt;code&gt;App.framework&lt;/code&gt; in place and never&lt;br&gt;
re-sealed the app around it.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why a stale seal turns the switch into decoration
&lt;/h2&gt;

&lt;p&gt;TCC, the part of macOS behind every Privacy &amp;amp; Security switch, does not store&lt;br&gt;
"this app is allowed". It stores a code requirement and checks the running app&lt;br&gt;
against it. For an app signed with a Developer ID, that requirement names the&lt;br&gt;
developer's team. For an ad-hoc signature, which is what you have without one,&lt;br&gt;
it is effectively the hash of the code.&lt;/p&gt;

&lt;p&gt;A bundle whose nested code does not match its own seal does not validate, and&lt;br&gt;
code that does not validate cannot satisfy a requirement. The grant attaches to&lt;br&gt;
nothing. The switch reads ON, every protected path stays refused, and no relaunch&lt;br&gt;
touches any of it.&lt;/p&gt;

&lt;p&gt;Re-signing the bundle and granting access again fixed it: the app reported Full&lt;br&gt;
Disk Access on, and kept it through a restart.&lt;/p&gt;
&lt;h2&gt;
  
  
  How the seal went stale
&lt;/h2&gt;

&lt;p&gt;Two things write a Flutter macOS bundle. A script phase runs&lt;br&gt;
&lt;code&gt;macos_assemble.sh embed&lt;/code&gt;, which hands off to Flutter's &lt;code&gt;xcode_backend.dart&lt;/code&gt;: it&lt;br&gt;
copies the freshly compiled &lt;code&gt;App.framework&lt;/code&gt; into &lt;code&gt;Contents/Frameworks&lt;/code&gt; and signs&lt;br&gt;
it. Then Xcode signs the app, which writes the seal. In a good build the&lt;br&gt;
timestamps land a second apart, framework first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;build 1  clean              verify ok   seal 23:19:32   App.framework 23:19:31
build 2  one Dart change    verify ok   seal 23:20:06   App.framework 23:20:05
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So it is not simply what an incremental build does, which is what I assumed at&lt;br&gt;
first and wrote into a changelog. A clean build seals correctly, and so does a&lt;br&gt;
Dart-only rebuild straight after it.&lt;/p&gt;

&lt;p&gt;What reproduced it, once, was a crash. On a copy of the project, a Swift change&lt;br&gt;
built fine. The next build, a one-line Dart change, died:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;error: unexpected service error: The Xcode build system has crashed. Build again to continue.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Doing as told, with another Dart-only change:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;✓ Built build/macos/Build/Products/Release/Helm.app (47.1MB)

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;codesign &lt;span class="nt"&gt;--verify&lt;/span&gt; &lt;span class="nt"&gt;--strict&lt;/span&gt; Helm.app
&lt;span class="go"&gt;Helm.app: nested code is modified or invalid

seal records App.framework dda6dabc96dd    actual afaf15310e15
Contents/_CodeSignature/CodeResources      23:22:42
Contents/MacOS/Helm                        23:22:42
Contents/Frameworks/App.framework/…/App    23:23:28
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The build after the crash ran Flutter's embed step, skipped the app's signing,&lt;br&gt;
and reported success. Same fingerprint as the build that shipped: seal and&lt;br&gt;
executable from one build, framework from a later one.&lt;/p&gt;

&lt;p&gt;I can't prove the shipped build went through a crash. I had kept only the last&lt;br&gt;
few lines of each build, and a crash in an earlier one is exactly what that&lt;br&gt;
throws away. Nor do I know which other failures leave the same state. An&lt;br&gt;
interrupted build is the obvious suspect, and I haven't tested it. That&lt;br&gt;
uncertainty is the argument for the next two sections.&lt;/p&gt;

&lt;p&gt;(Reproduced on Flutter 3.47.3, Xcode 26.6, macOS 26.6.2. The shipped build was on&lt;br&gt;
Flutter 3.47.1, and none of the patch-release changes in between are about&lt;br&gt;
signing.)&lt;/p&gt;
&lt;h2&gt;
  
  
  Why nothing caught it
&lt;/h2&gt;

&lt;p&gt;Not because the check is subtle. My first theory was that only &lt;code&gt;--deep&lt;/code&gt; compares&lt;br&gt;
nested code with the outer seal, so a check without it would have waved this&lt;br&gt;
bundle through. It was wrong. On a copy with &lt;code&gt;App.framework&lt;/code&gt; swapped out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;codesign --verify App.framework        exit 0    valid on its own
codesign --verify Helm.app             exit 1    nested code is modified or invalid
SecStaticCodeCheckValidity, flags 0    -67021    nested code is modified or invalid
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No flags needed. Any verification of the app fails it.&lt;/p&gt;

&lt;p&gt;The release script did not verify at all. Its signing and its verification lived&lt;br&gt;
in the same branch:&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="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;$SIGN_ID&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="c"&gt;# a Developer ID certificate was found&lt;/span&gt;
  &lt;span class="c"&gt;# ...sign every framework, then the app...&lt;/span&gt;
  codesign &lt;span class="nt"&gt;--verify&lt;/span&gt; &lt;span class="nt"&gt;--strict&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BUILD_APP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The app is free and deliberately has no $99-a-year Developer ID, so that branch&lt;br&gt;
never ran. The path that always ran signed nothing and checked nothing, and the&lt;br&gt;
DMG shipped whatever &lt;code&gt;flutter build&lt;/code&gt; had left behind.&lt;/p&gt;
&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Sign and verify on every path, and let a failed verification fail the release:&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="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;$SIGN_ID&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;SIGN&lt;/span&gt;&lt;span class="o"&gt;=(&lt;/span&gt;&lt;span class="nt"&gt;--force&lt;/span&gt; &lt;span class="nt"&gt;--timestamp&lt;/span&gt; &lt;span class="nt"&gt;--options&lt;/span&gt; runtime &lt;span class="nt"&gt;--sign&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SIGN_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;else
  &lt;/span&gt;&lt;span class="nv"&gt;SIGN&lt;/span&gt;&lt;span class="o"&gt;=(&lt;/span&gt;&lt;span class="nt"&gt;--force&lt;/span&gt; &lt;span class="nt"&gt;--sign&lt;/span&gt; -&lt;span class="o"&gt;)&lt;/span&gt;   &lt;span class="c"&gt;# ad-hoc: never notarised, so no runtime or timestamp&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;

&lt;span class="c"&gt;# Inside-out: nested code first, then the bundle that holds it.&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;fw &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$APP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;/Contents/Frameworks/&lt;span class="k"&gt;*&lt;/span&gt;.framework&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;codesign &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;SIGN&lt;/span&gt;&lt;span class="p"&gt;[@]&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$fw&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done
&lt;/span&gt;codesign &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;SIGN&lt;/span&gt;&lt;span class="p"&gt;[@]&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--entitlements&lt;/span&gt; macos/Runner/Release.entitlements &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$APP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

codesign &lt;span class="nt"&gt;--verify&lt;/span&gt; &lt;span class="nt"&gt;--deep&lt;/span&gt; &lt;span class="nt"&gt;--strict&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$APP&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;echo&lt;/span&gt; &lt;span class="s2"&gt;"signature broken"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sign with a loop rather than &lt;code&gt;codesign --deep&lt;/code&gt;, which Apple advises against for&lt;br&gt;
signing. For verifying, &lt;code&gt;--deep&lt;/code&gt; is fine, and with &lt;code&gt;--verbose&lt;/code&gt; it names the file&lt;br&gt;
that changed.&lt;/p&gt;

&lt;p&gt;And the app now checks itself before telling anyone to relaunch. This goes&lt;br&gt;
through the Security framework rather than running &lt;code&gt;/usr/bin/codesign&lt;/code&gt;, which&lt;br&gt;
does ship with macOS, because it is a yes-or-no question with a status code for&lt;br&gt;
an answer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;import&lt;/span&gt; &lt;span class="kt"&gt;Security&lt;/span&gt;

&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;bundleValidates&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;Bool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;SecStaticCode&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;
  &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="kt"&gt;SecStaticCodeCreateWithPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Bundle&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bundleURL&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kt"&gt;CFURL&lt;/span&gt;&lt;span class="p"&gt;,&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;code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;errSecSuccess&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;code&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="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;          &lt;span class="c1"&gt;// couldn't ask: don't accuse&lt;/span&gt;
  &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;flags&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;SecCSFlags&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;rawValue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;kSecCSCheckNestedCode&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;kSecCSStrictValidate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kt"&gt;SecStaticCodeCheckValidity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;code&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="kc"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;errSecSuccess&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When access reads as denied and that returns false, the banner stops saying&lt;br&gt;
"relaunch" and says the true thing: this copy cannot hold the permission, so&lt;br&gt;
reinstall it and grant access again. When the check cannot run at all, it&lt;br&gt;
answers true. Telling someone a working app is broken is worse than saying&lt;br&gt;
nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that is not a bug
&lt;/h2&gt;

&lt;p&gt;An ad-hoc requirement is the hash of the code, so to TCC every build is a&lt;br&gt;
different app. Every update loses Full Disk Access and needs granting again. No&lt;br&gt;
release script fixes that. It is the cost of not having a Developer ID, whose&lt;br&gt;
requirement names the team instead of the bytes and so survives an update. For a&lt;br&gt;
free app, the honest move is to say so at the top of the release notes rather&lt;br&gt;
than let people find out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three rules
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Verify the bundle you ship, on every path.&lt;/strong&gt; Plain &lt;code&gt;codesign --verify&lt;/code&gt; is
enough. Never gate the check behind having a certificate: the path without
one is the path that always runs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't trust ✓ Built after a build that crashed.&lt;/strong&gt; Build clean, or verify.
The build after a crash can succeed at everything except sealing the app.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If your app needs a privacy permission, check its own signature before
blaming the user.&lt;/strong&gt; "Relaunch" is sometimes a loop with no exit.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The app is &lt;a href="https://github.com/devShakib015/helm" rel="noopener noreferrer"&gt;Helm&lt;/a&gt;, a free Mac utility&lt;br&gt;
built with Flutter. Its release script and self-check are in the repository.&lt;/p&gt;

&lt;p&gt;The self-check is a package now: &lt;a href="https://pub.dev/packages/macos_grants" rel="noopener noreferrer"&gt;macos_grants&lt;/a&gt; reads Full Disk Access, Accessibility and Screen Recording, and answers the question underneath them — whether this copy of your app can hold a grant at all.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://devshakib.jumyn.com/blog/full-disk-access-was-on-and-macos-still-refused-the-app" rel="noopener noreferrer"&gt;devshakib.jumyn.com&lt;/a&gt;. I write about Flutter, Dart and the parts of shipping that are genuinely awkward — and publish the packages that came out of them at &lt;a href="https://pub.dev/publishers/jumyn.com/packages" rel="noopener noreferrer"&gt;pub.dev/publishers/jumyn.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>macos</category>
      <category>flutter</category>
      <category>debugging</category>
      <category>programming</category>
    </item>
    <item>
      <title>macOS Made a File That Reports Zero Bytes and Occupies Sixty Kilobytes</title>
      <dc:creator>K M Shahriar Hossain</dc:creator>
      <pubDate>Tue, 08 Sep 2026 10:16:30 +0000</pubDate>
      <link>https://dev.to/devshakib/macos-made-a-file-that-reports-zero-bytes-and-occupies-sixty-kilobytes-3c4a</link>
      <guid>https://dev.to/devshakib/macos-made-a-file-that-reports-zero-bytes-and-occupies-sixty-kilobytes-3c4a</guid>
      <description>&lt;p&gt;I was writing a storage analyser, which means walking every file on the disk&lt;br&gt;
and adding up what it costs. Somewhere around the 140,000th file the progress&lt;br&gt;
line printed a path that does not exist:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/Users/me/Applications/Chrome Apps.localized/localized
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no &lt;code&gt;localized&lt;/code&gt; in that folder. There is a &lt;code&gt;.localized&lt;/code&gt;, and there is&lt;br&gt;
one other thing, and that other thing turned out to be two separate problems&lt;br&gt;
wearing one filename.&lt;/p&gt;
&lt;h2&gt;
  
  
  The file
&lt;/h2&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;ls&lt;/span&gt; &lt;span class="nt"&gt;-b&lt;/span&gt; ~/Applications/Chrome&lt;span class="se"&gt;\ &lt;/span&gt;Apps.localized/
Icon&lt;span class="se"&gt;\r&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Not &lt;code&gt;Icon&lt;/code&gt; followed by something. The name is the four characters &lt;code&gt;I&lt;/code&gt;, &lt;code&gt;c&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;o&lt;/code&gt;, &lt;code&gt;n&lt;/code&gt;, and then byte &lt;code&gt;0x0D&lt;/code&gt; — a carriage return. Python is blunt about it:&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="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listdir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&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;Icon&lt;/span&gt;&lt;span class="se"&gt;\r&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;.localized&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;macOS wrote that. Every folder with a custom icon gets one: Finder → Get Info →&lt;br&gt;
paste an image, and this appears. The trailing CR is a fossil from classic Mac&lt;br&gt;
OS, where it was the line terminator, and it is there to keep the file out of&lt;br&gt;
the way of anything that lists a directory naively. It has outlived the reason.&lt;/p&gt;
&lt;h2&gt;
  
  
  Problem one: it eats your log line
&lt;/h2&gt;

&lt;p&gt;A filename on a POSIX system may contain any byte except &lt;code&gt;/&lt;/code&gt; and NUL. That&lt;br&gt;
includes carriage return, newline, escape, and backspace. Almost nothing you&lt;br&gt;
write treats a path as anything other than text you can print, and this is&lt;br&gt;
where that assumption gets paid.&lt;/p&gt;

&lt;p&gt;Here is what a progress line actually emits:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;find box &lt;span class="nt"&gt;-type&lt;/span&gt; f &lt;span class="nt"&gt;-print0&lt;/span&gt; | &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nv"&gt;IFS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt; f&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
&lt;span class="gp"&gt;    printf 'scanning %s  42,318 files\n' "$&lt;/span&gt;f&lt;span class="s2"&gt;"
&lt;/span&gt;&lt;span class="go"&gt;  done | hexdump -C
00000000  73 63 61 6e 6e 69 6e 67  20 62 6f 78 2f 49 63 6f  |scanning box/Ico|
00000010  6e 0d 20 20 34 32 2c 33  31 38 20 66 69 6c 65 73  |n.  42,318 files|
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at offset &lt;code&gt;0x11&lt;/code&gt;: &lt;code&gt;6e 0d&lt;/code&gt; — the &lt;code&gt;n&lt;/code&gt; of &lt;code&gt;Icon&lt;/code&gt;, then &lt;code&gt;0d&lt;/code&gt;, the carriage&lt;br&gt;
return. The terminal receives it and does what a terminal has always done with&lt;br&gt;
a CR: it moves the cursor back to column zero. The rest of the line then&lt;br&gt;
overwrites what you just printed. You do not see a corrupted path. You see a&lt;br&gt;
line that is missing its front half, spliced onto whatever came next — which&lt;br&gt;
is precisely as confusing as it sounds when the count in front of it is also&lt;br&gt;
changing.&lt;/p&gt;

&lt;p&gt;The same byte does the same damage to a single-line label in a GUI, a CI log,&lt;br&gt;
and anything that later parses its own output. And the CR is the polite one: a&lt;br&gt;
filename containing &lt;code&gt;\n&lt;/code&gt; will break &lt;code&gt;find | while read line&lt;/code&gt; outright, because&lt;br&gt;
that loop's entire contract is that filenames do not contain newlines. Hence&lt;br&gt;
&lt;code&gt;-print0&lt;/code&gt; and &lt;code&gt;read -d ''&lt;/code&gt;, which exist for this reason and not for style.&lt;/p&gt;

&lt;p&gt;If you render paths anywhere a human will look, strip the control range first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;safe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;replaceAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;RegExp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sx"&gt;r'[\x00-\x1f\x7f]'&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Problem two: it is zero bytes, and it is sixty kilobytes
&lt;/h2&gt;

&lt;p&gt;This is the one that actually mattered for a disk tool.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;stat -f %z         (st_size)        0 bytes
ls -l                              0
os.path.getsize()                  0
------------------------------------------------
st_blocks × 512                61,440 bytes
du -k                              60 KB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The data fork is genuinely empty. The icon — 61,010 bytes of it — lives in an&lt;br&gt;
extended attribute:&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;xattr ~/Applications/Chrome&lt;span class="se"&gt;\ &lt;/span&gt;Apps.localized/Icon^M
com.apple.FinderInfo
com.apple.ResourceFork
com.apple.provenance
com.apple.quarantine
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;com.apple.ResourceFork&lt;/code&gt; is the resource fork, the other half of a classic Mac&lt;br&gt;
file, kept alive on APFS as an xattr and reachable through a magic path:&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;stat&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; %z ~/Applications/.../Icon^M/..namedfork/rsrc
61010
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;st_size&lt;/code&gt; describes the data fork. It always has. So a scanner that sums&lt;br&gt;
&lt;code&gt;st_size&lt;/code&gt; — which is the obvious thing to sum, and what &lt;code&gt;os.path.getsize&lt;/code&gt; hands&lt;br&gt;
you — reports this file as free. Sixty kilobytes, invisible, once per folder&lt;br&gt;
with a custom icon.&lt;/p&gt;

&lt;p&gt;Sixty kilobytes is nothing. The habit is not nothing. &lt;code&gt;st_size&lt;/code&gt; is a statement&lt;br&gt;
about content; &lt;strong&gt;&lt;code&gt;st_blocks&lt;/code&gt; is a statement about the disk&lt;/strong&gt;, and a disk usage&lt;br&gt;
tool is answering the second question. They come apart in three directions and&lt;br&gt;
resource forks are only one of them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sparse files&lt;/strong&gt; report a large &lt;code&gt;st_size&lt;/code&gt; and occupy almost no blocks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compressed files&lt;/strong&gt; — macOS transparently compresses much of &lt;code&gt;/System&lt;/code&gt; and
many app bundles — report the uncompressed size and occupy less.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resource forks and xattrs&lt;/strong&gt; report nothing and occupy real space.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Sum &lt;code&gt;st_size&lt;/code&gt; across a volume and your total will not match About This Mac, in&lt;br&gt;
either direction, and the user will believe Apple. Correctly. &lt;code&gt;du&lt;/code&gt; has always&lt;br&gt;
used &lt;code&gt;st_blocks&lt;/code&gt;; that is the whole reason &lt;code&gt;du&lt;/code&gt; and &lt;code&gt;ls -l&lt;/code&gt; disagree, and I had&lt;br&gt;
read that fact several times without it meaning anything until a zero-byte file&lt;br&gt;
turned up holding an icon.&lt;/p&gt;
&lt;h2&gt;
  
  
  The catch, if you write Dart
&lt;/h2&gt;

&lt;p&gt;Knowing to use &lt;code&gt;st_blocks&lt;/code&gt; and being able to are different problems. Dart's&lt;br&gt;
&lt;code&gt;FileStat&lt;/code&gt; exposes &lt;code&gt;size&lt;/code&gt;, &lt;code&gt;mode&lt;/code&gt;, &lt;code&gt;type&lt;/code&gt; and three timestamps. There is no&lt;br&gt;
&lt;code&gt;blocks&lt;/code&gt;. &lt;code&gt;File.length()&lt;/code&gt; is &lt;code&gt;st_size&lt;/code&gt; as well. The number you want is in the&lt;br&gt;
&lt;code&gt;stat&lt;/code&gt; struct the VM already called and did not surface, so reaching it means&lt;br&gt;
going around the standard library:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;// dart:ffi against stat(2), or a platform channel to&lt;/span&gt;
&lt;span class="c1"&gt;// URLResourceKey.totalFileAllocatedSizeKey on the Swift side.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Which is the actual reason so many size-summing tools get this wrong. It is not&lt;br&gt;
that &lt;code&gt;st_size&lt;/code&gt; is chosen over &lt;code&gt;st_blocks&lt;/code&gt;; it is that in most high-level&lt;br&gt;
languages &lt;code&gt;st_size&lt;/code&gt; is the only one handed to you, and the difference between&lt;br&gt;
"how big is this file" and "what does this file cost me" never comes up until&lt;br&gt;
something reports zero and takes sixty kilobytes.&lt;/p&gt;

&lt;p&gt;Two rules I would now write down before starting a tool like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Sum &lt;code&gt;st_blocks&lt;/code&gt;, not &lt;code&gt;st_size&lt;/code&gt;&lt;/strong&gt;, and if the language will not give you
&lt;code&gt;st_blocks&lt;/code&gt;, that is a dependency you take on day one rather than a
refactor you do after the totals disagree with About This Mac.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Strip &lt;code&gt;[\x00-\x1f]&lt;/code&gt; from any path before rendering it.&lt;/strong&gt; Paths are bytes,
not strings, and one of them on your Mac right now ends in a carriage
return.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The file is still there. It is on your Mac too — &lt;code&gt;find ~ -name 'Icon?' -type f&lt;/code&gt;&lt;br&gt;
will tell you, and the &lt;code&gt;?&lt;/code&gt; is the only way to match it that does not require&lt;br&gt;
you to type a carriage return into your shell.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://devshakib.jumyn.com/blog/the-zero-byte-file-that-occupies-sixty-kilobytes" rel="noopener noreferrer"&gt;devshakib.jumyn.com&lt;/a&gt;. I write about Flutter, Dart and the parts of shipping that are genuinely awkward — and publish the packages that came out of them at &lt;a href="https://pub.dev/publishers/jumyn.com/packages" rel="noopener noreferrer"&gt;pub.dev/publishers/jumyn.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>macos</category>
      <category>dart</category>
      <category>programming</category>
    </item>
    <item>
      <title>Flutter Assumes There Is Only One Window. I Gave It Two.</title>
      <dc:creator>K M Shahriar Hossain</dc:creator>
      <pubDate>Sun, 06 Sep 2026 17:02:19 +0000</pubDate>
      <link>https://dev.to/devshakib/flutter-assumes-there-is-only-one-window-i-gave-it-two-fmm</link>
      <guid>https://dev.to/devshakib/flutter-assumes-there-is-only-one-window-i-gave-it-two-fmm</guid>
      <description>&lt;p&gt;Document Picture-in-Picture is a browser API that gives you a real operating&lt;br&gt;
system window. Not an overlay pinned inside your page — an actual window the&lt;br&gt;
browser owns, floating above your editor and your terminal and everything else,&lt;br&gt;
which keeps running when you switch tabs.&lt;/p&gt;

&lt;p&gt;Chrome and Edge have had it since 116. Firefox shipped it in 151. Almost&lt;br&gt;
nothing uses it, and I could not find anything reaching it from Flutter, so I&lt;br&gt;
wrote &lt;a href="https://pub.dev/packages/document_pip" rel="noopener noreferrer"&gt;document_pip&lt;/a&gt; to find out why.&lt;/p&gt;

&lt;p&gt;The API itself is about four lines. The difficulty is entirely on the Flutter&lt;br&gt;
side, and it has one cause: &lt;strong&gt;the engine is built on the assumption that there&lt;br&gt;
is one window.&lt;/strong&gt; Not stated anywhere as a constraint — just quietly baked into&lt;br&gt;
three different singletons. A second window makes each of them wrong, and every&lt;br&gt;
one of them fails silently, in a way that looks like a different problem.&lt;/p&gt;
&lt;h2&gt;
  
  
  A package cannot turn multi-view on
&lt;/h2&gt;

&lt;p&gt;The first wall is structural. Multi-view Flutter has no single root, so an app&lt;br&gt;
that can grow a second view calls &lt;code&gt;runWidget&lt;/code&gt;, not &lt;code&gt;runApp&lt;/code&gt;. That part is&lt;br&gt;
documented.&lt;/p&gt;

&lt;p&gt;The part that is not: &lt;strong&gt;only the JavaScript app object returned by&lt;br&gt;
&lt;code&gt;engine.runApp()&lt;/code&gt; can add a view.&lt;/strong&gt; &lt;code&gt;dart:ui_web&lt;/code&gt; exposes the view list&lt;br&gt;
read-only. So the object that can create the pop-out's view lives in the&lt;br&gt;
bootstrap, before any Dart has run, and a package published to pub.dev cannot&lt;br&gt;
reach it. The app has to hand it over:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;engine&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;engineInitializer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;initializeEngine&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;multiViewEnabled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;runApp&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;documentPipApp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;// the package needs this&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addView&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;hostElement&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#app&lt;/span&gt;&lt;span class="dl"&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;Multi-view is a property of how the engine &lt;em&gt;starts&lt;/em&gt;. Nothing published to&lt;br&gt;
pub.dev can switch it on from the inside, which is worth knowing before you go&lt;br&gt;
looking for the API that does.&lt;/p&gt;

&lt;p&gt;One trap in that snippet. Never pass &lt;code&gt;document.body&lt;/code&gt; as &lt;code&gt;hostElement&lt;/code&gt;. Flutter&lt;br&gt;
clears a host element's children and sizes the view to 100% of it, so &lt;code&gt;body&lt;/code&gt;&lt;br&gt;
wipes your page — script tags included — and then measures zero. A blank screen&lt;br&gt;
and no exception. I had that exact line sitting in an error message until the&lt;br&gt;
pre-publish audit caught it — of the three copies of that snippet in the&lt;br&gt;
package, the wrong one was the copy a stuck developer actually reaches, and the&lt;br&gt;
only copy never run.&lt;/p&gt;
&lt;h2&gt;
  
  
  The window freezes exactly when you need it
&lt;/h2&gt;

&lt;p&gt;Here is the good one.&lt;/p&gt;

&lt;p&gt;You open the pop-out, switch to another tab to do the thing you opened it for,&lt;br&gt;
and it stops. Frozen frame, still floating, still on top, completely dead.&lt;/p&gt;

&lt;p&gt;The cause is a disagreement between two true statements. Chromium keeps&lt;br&gt;
painting a document-picture-in-picture opener at full rate while its tab is in&lt;br&gt;
the background — that is the entire point, the window has to keep working. But&lt;br&gt;
it &lt;em&gt;still reports the page as hidden&lt;/em&gt;, because by any normal definition it is:&lt;br&gt;
the user is looking at another tab.&lt;/p&gt;

&lt;p&gt;Flutter's web engine reads &lt;code&gt;visibilityState: "hidden"&lt;/code&gt; and turns it into&lt;br&gt;
&lt;code&gt;AppLifecycleState.hidden&lt;/code&gt;. &lt;code&gt;SchedulerBinding&lt;/code&gt; responds by clearing&lt;br&gt;
&lt;code&gt;framesEnabled&lt;/code&gt;, after which &lt;code&gt;scheduleFrame()&lt;/code&gt; returns early forever. That is&lt;br&gt;
correct behaviour and good citizenship — nobody wants a backgrounded tab&lt;br&gt;
burning battery. It is also exactly wrong here, because the one surface the&lt;br&gt;
user &lt;em&gt;can&lt;/em&gt; still see is the one Flutter just stopped drawing.&lt;/p&gt;

&lt;p&gt;Measured rather than argued, because the whole failure is about believing a&lt;br&gt;
report instead of checking:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Chromium, tab in the background&lt;/th&gt;
&lt;th&gt;browser frames&lt;/th&gt;
&lt;th&gt;Flutter frames&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;no pop-out open&lt;/td&gt;
&lt;td&gt;2 in 2.5s&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pop-out open&lt;/td&gt;
&lt;td&gt;302 in 2.5s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0 in 3s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pop-out open, with the fix&lt;/td&gt;
&lt;td&gt;302 in 2.5s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;311 in 3s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The browser was drawing at roughly 120fps. Flutter drew nothing.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;scheduleForcedFrame()&lt;/code&gt; is the documented way past it — it ignores&lt;br&gt;
&lt;code&gt;framesEnabled&lt;/code&gt; and only checks whether a frame is already pending. So the root&lt;br&gt;
widget re-arms it for exactly as long as both conditions hold:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;_keepPaintingWhileHidden&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Duration&lt;/span&gt; &lt;span class="n"&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;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;mounted&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="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;binding&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;WidgetsBinding&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;instance&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;binding&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;framesEnabled&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;// page is back&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DocumentPip&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;popOutViewIds&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isEmpty&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;// last window closed&lt;/span&gt;
  &lt;span class="n"&gt;binding&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;scheduleForcedFrame&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="n"&gt;binding&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;addPostFrameCallback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_keepPaintingWhileHidden&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 guards end the loop on their own, which matters: this is a hand-rolled&lt;br&gt;
frame pump, and one that cannot stop is a battery bug wearing the costume of a&lt;br&gt;
fix. A post-frame callback rather than a persistent one for the same reason —&lt;br&gt;
a persistent frame callback can never be removed, and would pin the &lt;code&gt;State&lt;/code&gt;&lt;br&gt;
forever.&lt;/p&gt;

&lt;p&gt;And it is a Chromium problem specifically. Firefox 151 and 155 keep reporting&lt;br&gt;
the opener &lt;code&gt;visible&lt;/code&gt; with a pop-out open — 308 frames in 2.5s against 9 for the&lt;br&gt;
same page without one — so &lt;code&gt;framesEnabled&lt;/code&gt; is never cleared and that first&lt;br&gt;
guard returns immediately. The workaround is gated on the failure rather than&lt;br&gt;
on the browser, so where the failure does not exist it never runs.&lt;/p&gt;
&lt;h2&gt;
  
  
  The keyboard is dead, but typing works
&lt;/h2&gt;

&lt;p&gt;The second singleton. Flutter's &lt;code&gt;KeyboardBinding&lt;/code&gt; attaches capture-phase&lt;br&gt;
&lt;code&gt;keydown&lt;/code&gt;/&lt;code&gt;keyup&lt;/code&gt; listeners &lt;strong&gt;once, globally, to the opener's &lt;code&gt;window&lt;/code&gt;&lt;/strong&gt;. A&lt;br&gt;
picture-in-picture window is a separate browsing context with its own &lt;code&gt;window&lt;/code&gt;,&lt;br&gt;
so while it has focus the engine hears nothing.&lt;/p&gt;

&lt;p&gt;What makes this genuinely nasty is which half breaks. Type into a text field in&lt;br&gt;
the pop-out and it works perfectly — the browser routes characters to the&lt;br&gt;
focused DOM element natively, and the engine reads them back off that element.&lt;br&gt;
So the feature you would test first is the one that is fine.&lt;/p&gt;

&lt;p&gt;Everything that travels as a &lt;em&gt;key event&lt;/em&gt; is dead: &lt;code&gt;Shortcuts&lt;/code&gt;, &lt;code&gt;Actions&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;Focus.onKeyEvent&lt;/code&gt;, &lt;code&gt;HardwareKeyboard&lt;/code&gt;, Escape, Tab traversal. Text selection&lt;br&gt;
has the same shape, since all three of the engine's &lt;code&gt;selectionchange&lt;/code&gt;&lt;br&gt;
subscriptions are on the opener's &lt;code&gt;document&lt;/code&gt; — so moving the caret with the&lt;br&gt;
arrow keys never reaches Flutter's editing state either.&lt;/p&gt;

&lt;p&gt;The fix is to replay the events into the opener and hand Flutter's focus to the&lt;br&gt;
pop-out's view when its window takes focus. Which produces its own trap worth&lt;br&gt;
stealing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;// One tear-off each, kept: every `.toJS` makes a NEW JS function, so&lt;/span&gt;
&lt;span class="c1"&gt;// removing with a second one silently leaves the listener attached.&lt;/span&gt;
&lt;span class="kd"&gt;late&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;JSFunction&lt;/span&gt; &lt;span class="n"&gt;_onKeyRef&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_onKey&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toJS&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;.toJS&lt;/code&gt; on the same Dart function twice gives you two different JavaScript&lt;br&gt;
objects. &lt;code&gt;removeEventListener&lt;/code&gt; compares by identity, finds nothing, removes&lt;br&gt;
nothing, and reports success. That is not specific to picture-in-picture — it&lt;br&gt;
applies to every &lt;code&gt;addEventListener&lt;/code&gt; you write in Dart, and it fails as a leak&lt;br&gt;
rather than as an error.&lt;/p&gt;

&lt;p&gt;Keys held when a window closes are released explicitly, too. Otherwise&lt;br&gt;
&lt;code&gt;HardwareKeyboard&lt;/code&gt; still believes they are down, and the next real press of the&lt;br&gt;
same key trips its consistency assertion — a crash somewhere else entirely,&lt;br&gt;
minutes later.&lt;/p&gt;
&lt;h2&gt;
  
  
  Two rules the browser imposes
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;open()&lt;/code&gt; must be the first &lt;code&gt;await&lt;/code&gt; in a gesture handler.&lt;/strong&gt; The browser only&lt;br&gt;
permits this while handling a real click, and awaiting anything beforehand&lt;br&gt;
spends the gesture. Load your data &lt;em&gt;after&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="nl"&gt;onPressed:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;final&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;await&lt;/span&gt; &lt;span class="n"&gt;DocumentPip&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;open&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// first&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;fetchTrack&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;           &lt;span class="c1"&gt;// then&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Get it wrong and Chrome says &lt;code&gt;NotAllowedError&lt;/code&gt; — which it also says when you&lt;br&gt;
call from an iframe, and when a window is already open. Three causes, one error&lt;br&gt;
name. The package lists all three rather than asserting the common one, because&lt;br&gt;
sending someone to fix a click handler that was already correct costs them an&lt;br&gt;
afternoon.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One window, browser-wide.&lt;/strong&gt; Not per tab, per browser. Opening a second closes&lt;br&gt;
the first, including one belonging to a completely different site. Your window&lt;br&gt;
can vanish because someone else opened theirs, so &lt;code&gt;closed&lt;/code&gt; completes for that&lt;br&gt;
too and you handle it like any other close.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the pattern actually is
&lt;/h2&gt;

&lt;p&gt;None of these are bugs in Flutter, and I want to be precise about that. Turning&lt;br&gt;
frames off for a hidden page is right. Binding the keyboard once is right.&lt;br&gt;
Assuming one view is right for every app that has one view, which is very&lt;br&gt;
nearly all of them.&lt;/p&gt;

&lt;p&gt;They are all the same &lt;em&gt;shape&lt;/em&gt; of problem: a framework generalisation that holds&lt;br&gt;
for every case but yours, failing in a way that produces no error. A frozen&lt;br&gt;
window looks like a rendering bug. A dead shortcut looks like a focus bug —&lt;br&gt;
especially when typing still works. Neither logs anything.&lt;/p&gt;

&lt;p&gt;The only thing that actually worked was refusing to reason about it. Every&lt;br&gt;
number in this post came from driving a real browser and counting frames,&lt;br&gt;
because in all three cases the platform's report was true and the conclusion&lt;br&gt;
drawn from it was wrong, and no amount of reading the source would have told me&lt;br&gt;
which.&lt;/p&gt;

&lt;p&gt;document_pip is MIT and on&lt;br&gt;
&lt;a href="https://pub.dev/packages/document_pip" rel="noopener noreferrer"&gt;pub.dev&lt;/a&gt;; the source is at&lt;br&gt;
&lt;a href="https://github.com/devShakib015/flutter_packages" rel="noopener noreferrer"&gt;github.com/devShakib015/flutter_packages&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://devshakib.jumyn.com/blog/flutter-assumes-there-is-only-one-window" rel="noopener noreferrer"&gt;devshakib.jumyn.com&lt;/a&gt;. I write about Flutter, Dart and the parts of shipping that are genuinely awkward — and publish the packages that came out of them at &lt;a href="https://pub.dev/publishers/jumyn.com/packages" rel="noopener noreferrer"&gt;pub.dev/publishers/jumyn.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>webdev</category>
      <category>dart</category>
      <category>programming</category>
    </item>
    <item>
      <title>What It Actually Takes to Run a Cross-Border Marketplace: Six Years of Shpper</title>
      <dc:creator>K M Shahriar Hossain</dc:creator>
      <pubDate>Sun, 06 Sep 2026 12:16:06 +0000</pubDate>
      <link>https://dev.to/devshakib/what-it-actually-takes-to-run-a-cross-border-marketplace-six-years-of-shpper-1a8h</link>
      <guid>https://dev.to/devshakib/what-it-actually-takes-to-run-a-cross-border-marketplace-six-years-of-shpper-1a8h</guid>
      <description>&lt;p&gt;Shpper is a cross-border personal-shopping marketplace. A buyer wants something&lt;br&gt;
they cannot get where they live. A traveller is already flying that route with&lt;br&gt;
unused luggage space. The platform introduces them, holds the money until the&lt;br&gt;
item arrives, and turns an empty few kilos in someone's suitcase into a delivery&lt;br&gt;
network.&lt;/p&gt;

&lt;p&gt;I am the CTO. I own the platform end to end — the Flutter apps for both sides,&lt;br&gt;
the backend, payments and escrow, identity verification, and the release&lt;br&gt;
pipeline. We are on version 14.2.0, rated 4.64 on the App Store from 72 ratings.&lt;/p&gt;

&lt;p&gt;Fourteen major versions is enough distance to say something useful about what&lt;br&gt;
this kind of product actually demands. Almost none of the hard parts were the&lt;br&gt;
ones I expected.&lt;/p&gt;

&lt;h2&gt;
  
  
  You are not building one app
&lt;/h2&gt;

&lt;p&gt;The first structural fact about a two-sided marketplace is that "the app" is two&lt;br&gt;
products with opposed interests, and you ship both.&lt;/p&gt;

&lt;p&gt;The buyer wants their item cheaply, quickly, and with certainty it will arrive.&lt;br&gt;
The traveller wants to be paid well, carry as little risk as possible, and not be&lt;br&gt;
blamed for customs. Those are not complementary desires. Every feature has to be&lt;br&gt;
designed twice, from two points of view, and any change to the shared middle —&lt;br&gt;
the request, the offer, the trip — reaches into both.&lt;/p&gt;

&lt;p&gt;This has a consequence people underestimate: &lt;strong&gt;your release cadence is bounded by&lt;br&gt;
the slower side.&lt;/strong&gt; A change to how offers work is not shipped when the buyer app&lt;br&gt;
ships. It is shipped when both apps are live, on both platforms, and enough of&lt;br&gt;
both populations have updated. Mobile app review is not a build step you can&lt;br&gt;
optimise away; it is a scheduling constraint that shapes how you design changes,&lt;br&gt;
which is why so much of the logic has to be able to change without a release.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hard problem is trust, and it is not a feature
&lt;/h2&gt;

&lt;p&gt;A marketplace's actual product is trust between strangers. Everything else is&lt;br&gt;
plumbing.&lt;/p&gt;

&lt;p&gt;Consider what the platform is asking. A buyer sends money for an item that does&lt;br&gt;
not exist yet, to be bought by someone they have never met, in a country they&lt;br&gt;
are not in, and carried across a border. A traveller agrees to spend their own&lt;br&gt;
money on someone else's goods, in exchange for a promise of repayment, and to&lt;br&gt;
carry those goods through customs under their own name.&lt;/p&gt;

&lt;p&gt;Neither side would do that for a stranger. They do it because the platform&lt;br&gt;
stands between them, and everything about the engineering follows from that&lt;br&gt;
position:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Escrow is not a payments feature, it is the entire trust mechanism.&lt;/strong&gt; Money is&lt;br&gt;
held from the moment a deal is struck until the item is confirmed delivered. That&lt;br&gt;
single design decision is what makes the rest possible — the buyer risks nothing&lt;br&gt;
until they have the item, and the traveller knows the money genuinely exists&lt;br&gt;
before they spend their own.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Identity verification is what makes escrow meaningful.&lt;/strong&gt; Held funds only&lt;br&gt;
protect you if there is a real, identified person on the other end when&lt;br&gt;
something goes wrong. Verification is not a compliance checkbox bolted on for&lt;br&gt;
regulators; it is the thing that makes the escrow promise enforceable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The state machine is the product.&lt;/strong&gt; A request becomes an offer, becomes an&lt;br&gt;
accepted deal, becomes a purchase, becomes a carried item, becomes a delivery,&lt;br&gt;
becomes a release of funds. Every transition can fail, and each failure needs a&lt;br&gt;
defined answer: what happens to the money, what each side sees, who is told what&lt;br&gt;
and when. The unhappy paths outnumber the happy one, and they are where the&lt;br&gt;
product either holds together or falls apart.&lt;/p&gt;

&lt;h2&gt;
  
  
  Money makes everything irreversible
&lt;/h2&gt;

&lt;p&gt;Ordinary app bugs are annoying. Bugs that touch money are a different category,&lt;br&gt;
because you usually cannot fix them by shipping a patch.&lt;/p&gt;

&lt;p&gt;A double-charge is not resolved by correcting the code — the money has already&lt;br&gt;
moved, and now there is a human on the other end who is angry and correct to be.&lt;br&gt;
A payout that fires twice is gone. A state transition that releases escrow early&lt;br&gt;
cannot be un-released by a hotfix.&lt;/p&gt;

&lt;p&gt;This changes how you write things. Every money-moving operation has to be&lt;br&gt;
idempotent, because networks retry and users tap twice and both of those are&lt;br&gt;
normal. State transitions have to be atomic, because a partial write in the&lt;br&gt;
middle of a payment is worse than a failure. And ledgers have to be&lt;br&gt;
append-only — the record of what happened is not something you edit, because the&lt;br&gt;
moment it is editable it stops being evidence.&lt;/p&gt;

&lt;p&gt;None of this is exotic. All of it is the difference between a bug you fix and a&lt;br&gt;
bug you refund.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cross-border is a category of its own
&lt;/h2&gt;

&lt;p&gt;Everything above applies to any escrow marketplace. Crossing a border adds&lt;br&gt;
problems that are not software problems at all, and the engineering has to&lt;br&gt;
absorb them anyway.&lt;/p&gt;

&lt;p&gt;Prices move while a deal is open, because currencies move. Customs and duty are&lt;br&gt;
real costs that land on somebody, and the product has to be unambiguous about who&lt;br&gt;
before anyone commits, not after. Items that are perfectly ordinary in one&lt;br&gt;
country are restricted in another. Trips get cancelled, delayed, or rerouted, and&lt;br&gt;
a delivery network built from other people's travel plans inherits all the&lt;br&gt;
volatility of those plans.&lt;/p&gt;

&lt;p&gt;The engineering lesson is that a design which assumes stability will spend the&lt;br&gt;
rest of its life being patched. Timelines slip, prices change, routes vanish.&lt;br&gt;
Building for that from the start is much cheaper than discovering it across&lt;br&gt;
fourteen versions.&lt;/p&gt;

&lt;h2&gt;
  
  
  What being CTO of this actually means
&lt;/h2&gt;

&lt;p&gt;The title suggests architecture diagrams. The reality is that owning it end to&lt;br&gt;
end means the boundary between "engineering problem" and "business problem" does&lt;br&gt;
not exist.&lt;/p&gt;

&lt;p&gt;When a delivery goes wrong, the question is not only what the code did. It is&lt;br&gt;
what the policy should be, who absorbs the cost, what both users are told, and&lt;br&gt;
what changes so it happens less often. That is one conversation, not four, and&lt;br&gt;
being the person who understands both the state machine and the commercial&lt;br&gt;
consequence is most of the job.&lt;/p&gt;

&lt;p&gt;The other half is choosing what not to build. Every marketplace has an infinite&lt;br&gt;
backlog because both sides always want more, and the sides want different things.&lt;br&gt;
Fourteen versions in, the decisions I am most glad about are almost all&lt;br&gt;
subtractions.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack, briefly
&lt;/h2&gt;

&lt;p&gt;Flutter for both apps, so one codebase covers iOS and Android on each side —&lt;br&gt;
which matters enormously when "shipping a feature" already means shipping two&lt;br&gt;
products. Firebase for the backend. The choice that has aged best is not a&lt;br&gt;
technology but a principle: &lt;strong&gt;keep as much behaviour as possible changeable&lt;br&gt;
without an app release&lt;/strong&gt;, because you cannot hotfix your way out of a marketplace&lt;br&gt;
problem when app review sits between you and your users.&lt;/p&gt;




&lt;p&gt;The thing I would tell someone starting one of these: you are not building&lt;br&gt;
software that connects buyers and sellers. You are building the reason two&lt;br&gt;
strangers are willing to take a risk on each other, and every technical decision&lt;br&gt;
either strengthens that or quietly erodes it. Escrow, verification, and an&lt;br&gt;
honest state machine are not features on a roadmap. They are the product, and&lt;br&gt;
everything else is how it is delivered.&lt;/p&gt;

&lt;p&gt;Shpper is at &lt;a href="https://www.shpper.com" rel="noopener noreferrer"&gt;shpper.com&lt;/a&gt;, and there is a longer&lt;br&gt;
&lt;a href="https://devshakib.jumyn.com/work/shpper" rel="noopener noreferrer"&gt;case study&lt;/a&gt; on this site.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://devshakib.jumyn.com/blog/what-it-takes-to-run-a-cross-border-marketplace" rel="noopener noreferrer"&gt;devshakib.jumyn.com&lt;/a&gt;. I write about Flutter, Dart and the parts of shipping that are genuinely awkward — and publish the packages that came out of them at &lt;a href="https://pub.dev/publishers/jumyn.com/packages" rel="noopener noreferrer"&gt;pub.dev/publishers/jumyn.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>firebase</category>
      <category>programming</category>
    </item>
    <item>
      <title>Your Flutter 404 Page Is Probably Crashing, and Your Server Is Probably Lying About It</title>
      <dc:creator>K M Shahriar Hossain</dc:creator>
      <pubDate>Sun, 06 Sep 2026 12:16:05 +0000</pubDate>
      <link>https://dev.to/devshakib/your-flutter-404-page-is-probably-crashing-and-your-server-is-probably-lying-about-it-k43</link>
      <guid>https://dev.to/devshakib/your-flutter-404-page-is-probably-crashing-and-your-server-is-probably-lying-about-it-k43</guid>
      <description>&lt;p&gt;Someone sent me a screenshot of my own 404 page. Washed-out grey text on a light&lt;br&gt;
background, barely readable, nothing like the dark theme every other page uses.&lt;br&gt;
My first thought was a styling bug.&lt;/p&gt;

&lt;p&gt;It was not a styling bug. The page was crashing before it could paint, and the&lt;br&gt;
fallback I had built for exactly that case was doing its job. Underneath it was a&lt;br&gt;
second bug that had been hiding the first one for months.&lt;/p&gt;
&lt;h2&gt;
  
  
  Bug one: the status code was a lie
&lt;/h2&gt;

&lt;p&gt;The site is a Flutter web app with every route prerendered to a real HTML file.&lt;br&gt;
Firebase Hosting served those files, and anything unmatched hit a catch-all&lt;br&gt;
rewrite:&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;"rewrites"&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;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&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;"destination"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/404.html"&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;That looks right. It is not, and the reason is worth internalising: &lt;strong&gt;a Firebase&lt;br&gt;
rewrite always responds 200.&lt;/strong&gt; That is what a rewrite &lt;em&gt;is&lt;/em&gt; — serve this other&lt;br&gt;
content under the requested URL. So every nonexistent URL on the site returned&lt;br&gt;
&lt;code&gt;200 OK&lt;/code&gt; with a page that said "Page not found".&lt;/p&gt;

&lt;p&gt;Browsers do not care. Crawlers care a great deal. A 200 means "this is a real&lt;br&gt;
page, index it", so every typo, every dead inbound link, every scanner probing&lt;br&gt;
for &lt;code&gt;/wp-admin&lt;/code&gt; was eligible to be indexed as a real page. This is the soft 404,&lt;br&gt;
and it is invisible from a browser because the page &lt;em&gt;looks&lt;/em&gt; correct.&lt;/p&gt;

&lt;p&gt;The fix is to delete the catch-all rather than repoint it. With every real route&lt;br&gt;
prerendered as a file, Firebase serves those directly, and for anything with no&lt;br&gt;
matching file it falls through to its own handling — which serves &lt;code&gt;404.html&lt;/code&gt;&lt;br&gt;
&lt;strong&gt;with an actual 404 status&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;One caveat that will bite you if your app has client-only routes. My admin panel&lt;br&gt;
has no prerendered file, so removing the catch-all 404'd my own admin URL. It&lt;br&gt;
needs a rewrite scoped to that path alone:&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;"rewrites"&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;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/your-client-only-path/**"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"destination"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/index.html"&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;Scoped rewrites for the routes that genuinely need them; no catch-all; real 404s&lt;br&gt;
for everything else.&lt;/p&gt;
&lt;h2&gt;
  
  
  Bug two: the page was crashing the whole time
&lt;/h2&gt;

&lt;p&gt;Fixing the status code is what made me actually look at the page, and that is&lt;br&gt;
when the grey rendering stopped looking like a theme problem.&lt;/p&gt;

&lt;p&gt;My prerendered HTML carries the page's text in the DOM, clipped to a single&lt;br&gt;
pixel, with a boot guard that reveals it if Flutter never paints. The idea is&lt;br&gt;
that a failed boot shows the content rather than a spinner turning forever. So&lt;br&gt;
the washed-out text was the guard firing correctly, telling me Flutter had&lt;br&gt;
died.&lt;/p&gt;

&lt;p&gt;The console had it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GoError: There is no GoRouterState above the current context.
This method should only be called under the sub tree of a RouteBase.builder.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here is the chain. &lt;code&gt;errorBuilder&lt;/code&gt; renders my &lt;code&gt;NotFoundPage&lt;/code&gt;. That page uses the&lt;br&gt;
same shell as every other page, and the shell contains the nav bar, and the nav&lt;br&gt;
bar asks which route is current so it can highlight the right link:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;final&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;GoRouterState&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;uri&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On every real route that is fine. On the 404 page it throws, because&lt;br&gt;
&lt;strong&gt;&lt;code&gt;errorBuilder&lt;/code&gt; renders outside any &lt;code&gt;RouteBase.builder&lt;/code&gt;&lt;/strong&gt; — there is no route&lt;br&gt;
subtree above it and therefore no &lt;code&gt;GoRouterState&lt;/code&gt; to inherit.&lt;/p&gt;
&lt;h2&gt;
  
  
  The fix that does not exist, and the one that also fails
&lt;/h2&gt;

&lt;p&gt;The instinct is &lt;code&gt;maybeOf&lt;/code&gt;. In go_router 17 &lt;strong&gt;there is no &lt;code&gt;GoRouterState.maybeOf&lt;/code&gt;.&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;of()&lt;/code&gt; throws unconditionally; there is no nullable variant to fall back to.&lt;/p&gt;

&lt;p&gt;So the next instinct is to ask the router itself, which definitely sits above&lt;br&gt;
everything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;GoRouter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;uri&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// also throws&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I tried exactly this, and it fails too — differently, which is what makes it&lt;br&gt;
interesting. &lt;code&gt;GoRouter.state&lt;/code&gt; reads &lt;code&gt;matches.last&lt;/code&gt; on the current match list, and&lt;br&gt;
on an unmatched URL &lt;strong&gt;that list is empty&lt;/strong&gt;, so you get a &lt;code&gt;StateError: Bad state:&lt;br&gt;
No element&lt;/code&gt;. The accessor fails for precisely the reason you are on the 404 page&lt;br&gt;
at all.&lt;/p&gt;

&lt;p&gt;Both router-side answers are dead ends. But the browser knows the path regardless&lt;br&gt;
of what the router made of it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&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;GoRouterState&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;uri&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&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="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Uri&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;base&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;path&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 broad catch is deliberate, and I would defend it specifically here.&lt;br&gt;
Determining which nav item to highlight is decoration. This widget renders inside&lt;br&gt;
every page on the site. Failing at decoration must never be able to take down the&lt;br&gt;
page around it, and the two failure modes above are different exception types&lt;br&gt;
from different call sites — narrowing the catch buys nothing except a chance to&lt;br&gt;
miss the third one.&lt;/p&gt;

&lt;p&gt;Nothing highlights on a 404, which is correct: no nav item corresponds to a page&lt;br&gt;
that does not exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it survived so long
&lt;/h2&gt;

&lt;p&gt;Because the two bugs concealed each other. The 404 page had been broken for&lt;br&gt;
months and rendered identically whether it answered 200 or 404 — so nothing&lt;br&gt;
looked wrong from a browser, and nothing in the logs distinguished it. Crawlers&lt;br&gt;
saw 200 and were happy. I never visited my own 404 page, because why would I.&lt;/p&gt;

&lt;p&gt;The status-code fix did not cause the crash. It made someone look at the page,&lt;br&gt;
which is a different and more useful thing.&lt;/p&gt;

&lt;p&gt;Two habits fall out of this. &lt;strong&gt;Check your 404 page's status code, not just its&lt;br&gt;
appearance&lt;/strong&gt; — &lt;code&gt;curl -o /dev/null -w '%{http_code}' https://yoursite/nonsense&lt;/code&gt;&lt;br&gt;
takes two seconds and is the only way to see a soft 404. And &lt;strong&gt;actually load your&lt;br&gt;
error page in a browser after a routing change&lt;/strong&gt;, because it is the one page in&lt;br&gt;
your app that renders through a completely different code path from every other&lt;br&gt;
page, and therefore the one page your testing never touches.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://devshakib.jumyn.com/blog/your-flutter-404-page-is-probably-crashing" rel="noopener noreferrer"&gt;devshakib.jumyn.com&lt;/a&gt;. I write about Flutter, Dart and the parts of shipping that are genuinely awkward — and publish the packages that came out of them at &lt;a href="https://pub.dev/publishers/jumyn.com/packages" rel="noopener noreferrer"&gt;pub.dev/publishers/jumyn.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>seo</category>
      <category>dart</category>
      <category>mobile</category>
    </item>
    <item>
      <title>A Squarified Treemap by Hand, Because Charting Packages Cannot Drill Down</title>
      <dc:creator>K M Shahriar Hossain</dc:creator>
      <pubDate>Sun, 06 Sep 2026 12:15:09 +0000</pubDate>
      <link>https://dev.to/devshakib/a-squarified-treemap-by-hand-because-charting-packages-cannot-drill-down-353g</link>
      <guid>https://dev.to/devshakib/a-squarified-treemap-by-hand-because-charting-packages-cannot-drill-down-353g</guid>
      <description>&lt;p&gt;Helm's storage tool shows your disk as a treemap: every folder a rectangle,&lt;br&gt;
every rectangle sized by how much space it takes. Click one and you are inside&lt;br&gt;
it, looking at its children, with a breadcrumb trail back out. It goes from a&lt;br&gt;
494 GB volume down to a single file.&lt;/p&gt;

&lt;p&gt;I drew it by hand. Not because the packages are bad, but because the thing that&lt;br&gt;
makes a treemap useful for disk space is the part they do not expose.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the packages give you, and what they don't
&lt;/h2&gt;

&lt;p&gt;Search for a Flutter treemap and you will find several that work. Hand them a&lt;br&gt;
list of values, get back a coloured rectangle layout. For a dashboard showing&lt;br&gt;
revenue by region, that is the whole job.&lt;/p&gt;

&lt;p&gt;A disk browser needs four things beyond that, and each one reaches into the&lt;br&gt;
layout rather than sitting on top of it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hit-testing that returns the node, not a coordinate.&lt;/strong&gt; A tap has to resolve
to &lt;em&gt;which folder&lt;/em&gt;, at whatever depth you are currently at.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Navigation as a first-class state.&lt;/strong&gt; Diving into a rectangle re-lays out the
entire canvas from a new root, and the breadcrumb has to be able to climb back.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Labels that adapt.&lt;/strong&gt; A rectangle 400 px wide gets a name and a size. One
20 px wide gets nothing, because a clipped half-word is worse than blank space.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Layout over a subtree, not a list.&lt;/strong&gt; The input is not fifty values, it is a
tree with a million nodes, and you lay out one level at a time.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Any one of those is a fork of the package. All four means you are writing it&lt;br&gt;
anyway, and you would rather own it than fight an abstraction built for a&lt;br&gt;
different problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Squarified, and why it is worth the trouble
&lt;/h2&gt;

&lt;p&gt;The naive treemap slices the rectangle repeatedly along one axis. It is easy and&lt;br&gt;
it produces slivers — long thin shapes that are impossible to read, impossible&lt;br&gt;
to tap, and misleading, because human eyes judge area badly when the aspect&lt;br&gt;
ratio is extreme.&lt;/p&gt;

&lt;p&gt;The squarified algorithm keeps rectangles as close to square as it can. The idea&lt;br&gt;
is simple enough to hold in your head: sort children largest first, then add&lt;br&gt;
them one at a time to the current row while doing so &lt;em&gt;improves&lt;/em&gt; the worst aspect&lt;br&gt;
ratio in that row. The moment adding one makes the worst ratio worse, close the&lt;br&gt;
row, and start a new one in the remaining space.&lt;/p&gt;

&lt;p&gt;That is the entire algorithm. Sort descending, accumulate greedily, close the&lt;br&gt;
row when the worst ratio stops improving, recurse into the space that is left.&lt;/p&gt;

&lt;p&gt;Two implementation notes that cost me time:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sort descending or the greedy step is meaningless.&lt;/strong&gt; The whole method assumes&lt;br&gt;
you place the biggest item first. Feed it unsorted input and it still produces&lt;br&gt;
a layout — a bad one, with no error to tell you why.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Guard against zero.&lt;/strong&gt; Empty folders, and the remaining space after the last&lt;br&gt;
row, both produce zero-width or zero-height rectangles, which turn into&lt;br&gt;
divide-by-zero in the ratio computation and &lt;code&gt;NaN&lt;/code&gt; in the layout. &lt;code&gt;NaN&lt;/code&gt;&lt;br&gt;
propagates silently through a layout pass and you get an empty canvas with no&lt;br&gt;
exception, which is a genuinely unpleasant thing to debug.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making it fast enough over a real disk
&lt;/h2&gt;

&lt;p&gt;Scanning a 347 GB volume walks several hundred thousand files. Two things keep&lt;br&gt;
that from freezing the UI, and neither is about the treemap itself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scan off the main isolate.&lt;/strong&gt; Walking a filesystem is not CPU-heavy so much as&lt;br&gt;
relentless — hundreds of thousands of stat calls. Done on the main isolate it&lt;br&gt;
blocks the frame loop, and the app appears hung exactly while it is doing the&lt;br&gt;
work the user asked for.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lay out one level at a time.&lt;/strong&gt; The tree has a million nodes; the screen shows&lt;br&gt;
a few dozen rectangles. There is no reason to lay out anything but the current&lt;br&gt;
level's children. Diving in is a fresh layout over a smaller subtree, which is&lt;br&gt;
why it stays instant no matter how deep you go — the work is bounded by what is&lt;br&gt;
visible, not by what exists.&lt;/p&gt;

&lt;p&gt;The second point is the one that also solves the label problem. Because you only&lt;br&gt;
lay out the visible level, you know each rectangle's real pixel size at paint&lt;br&gt;
time, so deciding whether a name fits is a measurement rather than a guess.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bit that has nothing to do with drawing
&lt;/h2&gt;

&lt;p&gt;The hardest part of Helm's storage tool is not the treemap. It is making the&lt;br&gt;
numbers agree with the operating system.&lt;/p&gt;

&lt;p&gt;macOS reports &lt;strong&gt;purgeable&lt;/strong&gt; space — snapshots and caches the system will reclaim&lt;br&gt;
when it needs to. If you sum file sizes and ignore purgeable, your total&lt;br&gt;
disagrees with About This Mac, and when a user sees two numbers they believe the&lt;br&gt;
one from Apple. Correctly, too: yours is the one that is wrong.&lt;/p&gt;

&lt;p&gt;So the categories have to be disjoint buckets that reconcile to the volume's&lt;br&gt;
reported capacity, purgeable included as its own slice rather than quietly&lt;br&gt;
dropped or quietly counted as used. A beautiful visualisation of numbers the&lt;br&gt;
user does not trust is worth nothing, and trust here is a single comparison&lt;br&gt;
against a system dialog they already know.&lt;/p&gt;

&lt;h2&gt;
  
  
  Was it worth writing?
&lt;/h2&gt;

&lt;p&gt;For a dashboard, no — use a package. For anything where the treemap is the&lt;br&gt;
interface rather than a picture of the data, you will end up writing it, because&lt;br&gt;
drill-down, hit-testing and adaptive labels all live below the API surface that&lt;br&gt;
a general charting package exposes.&lt;/p&gt;

&lt;p&gt;The reward is that it is genuinely the fastest way to answer "what is eating my&lt;br&gt;
disk". A list of the largest folders makes you read and compare. A treemap makes&lt;br&gt;
the answer the biggest thing on screen.&lt;/p&gt;

&lt;p&gt;Helm is free and MIT, and the treemap is in&lt;br&gt;
&lt;code&gt;lib/tools/storage/ui/widgets/treemap.dart&lt;/code&gt; if you want to read it rather than&lt;br&gt;
reimplement it: &lt;a href="https://github.com/devShakib015/helm" rel="noopener noreferrer"&gt;github.com/devShakib015/helm&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://devshakib.jumyn.com/blog/a-squarified-treemap-by-hand-in-flutter" rel="noopener noreferrer"&gt;devshakib.jumyn.com&lt;/a&gt;. I write about Flutter, Dart and the parts of shipping that are genuinely awkward — and publish the packages that came out of them at &lt;a href="https://pub.dev/publishers/jumyn.com/packages" rel="noopener noreferrer"&gt;pub.dev/publishers/jumyn.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>dart</category>
      <category>macos</category>
      <category>mobile</category>
    </item>
    <item>
      <title>The DNS Field That Could Run Anything as Root</title>
      <dc:creator>K M Shahriar Hossain</dc:creator>
      <pubDate>Sun, 06 Sep 2026 12:15:08 +0000</pubDate>
      <link>https://dev.to/devshakib/the-dns-field-that-could-run-anything-as-root-3jmo</link>
      <guid>https://dev.to/devshakib/the-dns-field-that-could-run-anything-as-root-3jmo</guid>
      <description>&lt;p&gt;Sonar has a Control tab. One of the things it does is switch the DNS resolver&lt;br&gt;
for your Mac — tap Cloudflare, tap Quad9, or paste in the address of your own&lt;br&gt;
Pi-hole. Changing a network service's DNS servers is a privileged operation, so&lt;br&gt;
the app asks macOS for authorisation and runs &lt;code&gt;networksetup&lt;/code&gt; on the other side&lt;br&gt;
of that prompt.&lt;/p&gt;

&lt;p&gt;That is a completely ordinary feature. It is also where I put a&lt;br&gt;
command-injection that ran as root.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the bug
&lt;/h2&gt;

&lt;p&gt;The custom-resolver field exists because people run Pi-hole and NextDNS on their&lt;br&gt;
own networks, and hardcoding four public resolvers would have been useless to&lt;br&gt;
them. So the field takes a string. The string goes into the arguments of a&lt;br&gt;
privileged command. Between those two facts there was nothing at all.&lt;/p&gt;

&lt;p&gt;The mental model that produced this is worth naming, because it is the one that&lt;br&gt;
produces most injection bugs: &lt;em&gt;it's my own app, and the field is for an IP&lt;br&gt;
address, so it will contain an IP address.&lt;/em&gt; Both halves are wrong. The field is&lt;br&gt;
for whatever the user types, and "my own app" is not a security boundary — the&lt;br&gt;
whole point of asking for authorisation is that the code on the other side of&lt;br&gt;
the prompt has more power than the code before it.&lt;/p&gt;

&lt;p&gt;A privileged call is a trust boundary even when both sides are yours. Especially&lt;br&gt;
when both sides are yours, because that is exactly when you stop looking.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "it's just a settings field" is not a defence
&lt;/h2&gt;

&lt;p&gt;Two arguments came to mind while I was fixing it, and both are bad.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"The user would only be attacking themselves."&lt;/strong&gt; Sometimes. But the value&lt;br&gt;
that reaches that field does not have to be typed by the person sitting at the&lt;br&gt;
machine. Anything that can put text into a field — a paste from a webpage, a&lt;br&gt;
support article that says "paste this into the custom DNS box", an&lt;br&gt;
accessibility-driven automation — becomes a way to run a command with elevated&lt;br&gt;
privileges. Social engineering is a lot easier when the last step is &lt;em&gt;paste this&lt;br&gt;
string into the box and click Apply&lt;/em&gt;, and the app has already trained the user&lt;br&gt;
to expect an admin prompt at that point.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"The authorisation prompt is the protection."&lt;/strong&gt; The prompt authorises &lt;em&gt;the&lt;br&gt;
operation the app described&lt;/em&gt;, not whatever ends up in the argument list. The&lt;br&gt;
user consenting to "change DNS servers" has not consented to anything else the&lt;br&gt;
string can be made to mean. Consent obtained for one action does not cover a&lt;br&gt;
different one that happens to travel through the same code path.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix, in the right order
&lt;/h2&gt;

&lt;p&gt;The instinct is to reach for escaping — quote the string, strip the dangerous&lt;br&gt;
characters, block a list of metacharacters. That is the wrong first move. Every&lt;br&gt;
blocklist is a bet that you thought of everything, and you did not; that is what&lt;br&gt;
a blocklist &lt;em&gt;is&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Validate against what the value is allowed to be, then pass it in a way that&lt;br&gt;
cannot be reinterpreted.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Allowlist the shape.&lt;/strong&gt; A DNS server is an IPv4 or IPv6 address. That is a&lt;br&gt;
tiny, completely specified grammar. Parse the string into an address type and&lt;br&gt;
reject anything that does not parse — not "remove the bad characters", but&lt;br&gt;
&lt;em&gt;refuse the input&lt;/em&gt;. If it is not an address, there is no version of it that is&lt;br&gt;
safe to pass along, so there is nothing to sanitise.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Never build a shell string.&lt;/strong&gt; Pass arguments as an array to the process, so&lt;br&gt;
there is no shell to interpret them and no quoting to get right. This is the&lt;br&gt;
part people skip because a formatted string is quicker to write, and it is the&lt;br&gt;
single highest-value habit in this whole area: if there is no shell, an&lt;br&gt;
injection has nothing to inject into.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keep the privileged surface small.&lt;/strong&gt; The privileged side should accept the&lt;br&gt;
narrowest possible instruction — "set the resolvers for this service to these&lt;br&gt;
validated addresses" — not a command to run. The less expressive the interface&lt;br&gt;
across the trust boundary, the less there is to abuse.&lt;/p&gt;

&lt;p&gt;Those three in that order. Validation is the fix; the array-argument call is the&lt;br&gt;
belt; the narrow interface is the braces.&lt;/p&gt;

&lt;h2&gt;
  
  
  The other twenty-five
&lt;/h2&gt;

&lt;p&gt;I found this during a full pass over the app before shipping version 2, which&lt;br&gt;
turned up twenty-six fixes. Three others are worth repeating because they are&lt;br&gt;
the same category of mistake wearing different clothes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Credentials in a plist.&lt;/strong&gt; NextDNS and Pi-hole integrations need API tokens.&lt;br&gt;
They were sitting in preferences, which is a plain file that any process running&lt;br&gt;
as you can read, and which gets swept into backups and sync. They belong in the&lt;br&gt;
Keychain, which is the one place on macOS designed to hold them. That is not a&lt;br&gt;
hard fix; it is a fix nobody makes until they look.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CSV export as an attack.&lt;/strong&gt; Sonar exports the network map. A device name is&lt;br&gt;
attacker-controlled — anyone on your network can name their device. If a name&lt;br&gt;
starts with &lt;code&gt;=&lt;/code&gt;, a spreadsheet treats it as a formula when the file is opened.&lt;br&gt;
Export is a place where your data becomes someone else's input, and that&lt;br&gt;
direction gets almost no attention compared to import.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Corrupt-safe writes.&lt;/strong&gt; The device history was written in place. A crash or a&lt;br&gt;
full disk mid-write left a truncated file, and the next launch would read it,&lt;br&gt;
fail, and start empty — silently destroying months of history. Writes are now&lt;br&gt;
atomic, and a file that fails to parse is preserved rather than overwritten. The&lt;br&gt;
distinction matters: losing data is bad, but losing it &lt;em&gt;quietly&lt;/em&gt; means nobody&lt;br&gt;
finds out until they need it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would tell myself a version earlier
&lt;/h2&gt;

&lt;p&gt;None of these were exotic. Every one is in the first chapter of anything written&lt;br&gt;
about the subject, and I still shipped them, in a tool whose entire premise is&lt;br&gt;
that it tells you when your network is exposed.&lt;/p&gt;

&lt;p&gt;The reason is not ignorance, it is attention. Features get scrutiny while you&lt;br&gt;
build them and then never again. The DNS field was written in an afternoon,&lt;br&gt;
worked immediately, and was never reopened, because working code does not ask&lt;br&gt;
for anything. A settings field that has never failed is the safest-feeling code&lt;br&gt;
in the project and one of the most dangerous.&lt;/p&gt;

&lt;p&gt;So the practice that actually helps is not a checklist. It is a question, asked&lt;br&gt;
of the code you are least worried about: &lt;em&gt;where does user input cross into&lt;br&gt;
something with more power than the code that received it?&lt;/em&gt; Find those crossings&lt;br&gt;
and look at every one, especially the boring ones you wrote in an afternoon and&lt;br&gt;
never thought about again.&lt;/p&gt;

&lt;p&gt;Sonar is free and open source under MIT, and all of this is in the history if&lt;br&gt;
you want to read the diffs rather than take my word for it:&lt;br&gt;
&lt;a href="https://github.com/devShakib015/sonar" rel="noopener noreferrer"&gt;github.com/devShakib015/sonar&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://devshakib.jumyn.com/blog/the-dns-field-that-could-run-anything-as-root" rel="noopener noreferrer"&gt;devshakib.jumyn.com&lt;/a&gt;. I write about Flutter, Dart and the parts of shipping that are genuinely awkward — and publish the packages that came out of them at &lt;a href="https://pub.dev/publishers/jumyn.com/packages" rel="noopener noreferrer"&gt;pub.dev/publishers/jumyn.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>macos</category>
      <category>swift</category>
      <category>programming</category>
    </item>
    <item>
      <title>Saving a Real File From Flutter Web, Instead of Downloading Another Copy</title>
      <dc:creator>K M Shahriar Hossain</dc:creator>
      <pubDate>Sat, 05 Sep 2026 23:32:13 +0000</pubDate>
      <link>https://dev.to/devshakib/saving-a-real-file-from-flutter-web-instead-of-downloading-another-copy-5ah3</link>
      <guid>https://dev.to/devshakib/saving-a-real-file-from-flutter-web-instead-of-downloading-another-copy-5ah3</guid>
      <description>&lt;p&gt;Build an editor on Flutter Web. The user opens &lt;code&gt;budget.csv&lt;/code&gt;, edits it, hits&lt;br&gt;
save. They get &lt;code&gt;budget.csv&lt;/code&gt; in their Downloads folder.&lt;/p&gt;

&lt;p&gt;They edit again, save again: &lt;code&gt;budget (1).csv&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Again: &lt;code&gt;budget (2).csv&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Their real file — the one on their Desktop that they opened — has never been&lt;br&gt;
touched. By the end of an afternoon they have nine numbered copies and no idea&lt;br&gt;
which is current. This is not a Flutter problem; it is what "saving" has meant on&lt;br&gt;
the web for twenty years. The browser hands you a download, and a download is&lt;br&gt;
always a new file.&lt;/p&gt;
&lt;h2&gt;
  
  
  The API that changed this
&lt;/h2&gt;

&lt;p&gt;The &lt;strong&gt;File System Access API&lt;/strong&gt; gives a page a real handle to a real file, with&lt;br&gt;
the user's permission, granted through the browser's own picker. With a handle&lt;br&gt;
you can write back to the same file. Not a copy. That one.&lt;/p&gt;

&lt;p&gt;It is a genuine capability, gated properly: the user picks the file, the browser&lt;br&gt;
mediates every access, and the permission can be revoked. There is no way for a&lt;br&gt;
page to reach a file the user has not deliberately handed over.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;file&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;FileSystemAccess&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;openFile&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;file&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// the same file, in place&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No download. No &lt;code&gt;(1)&lt;/code&gt;. The file on their Desktop now has their edits.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why the old way works the way it does
&lt;/h3&gt;

&lt;p&gt;The download-a-copy behaviour is not laziness on anyone's part. For most of the&lt;br&gt;
web's history, a page having write access to your filesystem would have been an&lt;br&gt;
enormous security hole, so the only sanctioned direction was &lt;em&gt;out&lt;/em&gt;: the page&lt;br&gt;
produces bytes, the browser saves them somewhere the page cannot see or reach.&lt;/p&gt;

&lt;p&gt;That is why &lt;code&gt;&amp;lt;a download&amp;gt;&lt;/code&gt; and the blob-URL trick that every Flutter Web file-save&lt;br&gt;
snippet uses cannot ever overwrite. They are not writing a file. They are handing&lt;br&gt;
the browser a payload and asking it to save one, and "save one" means a new one.&lt;/p&gt;

&lt;p&gt;Understanding that is what tells you the File System Access API is a genuinely&lt;br&gt;
different mechanism rather than a nicer wrapper over the same thing. The&lt;br&gt;
permission model changed; the API followed.&lt;/p&gt;
&lt;h2&gt;
  
  
  Coming back after a reload
&lt;/h2&gt;

&lt;p&gt;The second half is what makes it feel like an application rather than a web page.&lt;/p&gt;

&lt;p&gt;Handles can be &lt;strong&gt;persisted&lt;/strong&gt;. Store one, and after a page reload — or the next&lt;br&gt;
morning — you can ask for that same file again. The browser will re-prompt for&lt;br&gt;
permission, which is correct and is the point, but the user is confirming access&lt;br&gt;
to a file they already chose rather than hunting through a picker to find it&lt;br&gt;
again.&lt;/p&gt;

&lt;p&gt;That single behaviour is the difference between "a web tool I paste things into"&lt;br&gt;
and "the thing I edit my file with".&lt;/p&gt;

&lt;p&gt;Handles are stored in IndexedDB — they are structured-cloneable objects, not&lt;br&gt;
strings — so they persist like any other browser data and survive until the user&lt;br&gt;
clears site data. Which means the recovery flow is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;On startup, look for a stored handle.&lt;/li&gt;
&lt;li&gt;If there is one, ask whether permission is still granted.&lt;/li&gt;
&lt;li&gt;If it is, open silently and show the document.&lt;/li&gt;
&lt;li&gt;If it is not, show one button: &lt;em&gt;Reopen budget.csv&lt;/em&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step four is the honest version of "restore my session". You are not&lt;br&gt;
re-prompting for a file the user must find again; you are asking them to confirm&lt;br&gt;
a file you both already know about.&lt;/p&gt;
&lt;h3&gt;
  
  
  Directories, not just files
&lt;/h3&gt;

&lt;p&gt;The same API grants handles to whole directories. That unlocks a different class&lt;br&gt;
of application entirely — a project folder, a photo set, a folder of Markdown&lt;br&gt;
notes — where the app can enumerate, read and write within the folder the user&lt;br&gt;
chose, and nothing outside it.&lt;/p&gt;

&lt;p&gt;For anything resembling an editor, that is the difference between a single-file&lt;br&gt;
toy and something people keep their work in.&lt;/p&gt;
&lt;h2&gt;
  
  
  What you must handle
&lt;/h2&gt;

&lt;p&gt;This is where an honest package earns its keep, because the API is not&lt;br&gt;
universally available and pretending otherwise produces a broken app on a third&lt;br&gt;
of browsers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Support is real but partial.&lt;/strong&gt; Chrome, Edge and other Chromium browsers have&lt;br&gt;
it. Safari and Firefox largely do not. So the capability check is not optional&lt;br&gt;
housekeeping — it decides which UI you show:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;FileSystemAccess&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isSupported&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// "Save" — writes in place&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="c1"&gt;// "Download a copy" — the old behaviour, honestly labelled&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Label the fallback accurately. A button that says &lt;em&gt;Save&lt;/em&gt; and silently produces&lt;br&gt;
&lt;code&gt;budget (3).csv&lt;/code&gt; is worse than a button that says &lt;em&gt;Download a copy&lt;/em&gt;, because the&lt;br&gt;
first one lies about what just happened.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Permission is per-handle and revocable.&lt;/strong&gt; A write can fail because the user&lt;br&gt;
revoked access, or the file moved, or the browser dropped the grant. Handle it as&lt;br&gt;
a normal outcome rather than an exception path you never test.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It needs a user gesture.&lt;/strong&gt; The picker cannot be opened from a timer or an&lt;br&gt;
async continuation far from a tap. Open it directly in the button handler.&lt;/p&gt;

&lt;h3&gt;
  
  
  Design for the fallback, do not bolt it on
&lt;/h3&gt;

&lt;p&gt;The temptation is to write the good path and add a fallback later. That produces&lt;br&gt;
an app whose Safari experience is an afterthought, and Safari is not a rounding&lt;br&gt;
error.&lt;/p&gt;

&lt;p&gt;A structure that works: define one interface with &lt;code&gt;open&lt;/code&gt;, &lt;code&gt;save&lt;/code&gt; and &lt;code&gt;saveAs&lt;/code&gt;.&lt;br&gt;
Implement it twice — once over the File System Access API, once over the classic&lt;br&gt;
picker-and-download. Choose at startup. The rest of your app never branches, and&lt;br&gt;
the difference is confined to which implementation is behind the interface and&lt;br&gt;
what the save button says.&lt;/p&gt;

&lt;p&gt;The label matters more than it sounds. If the fallback button says &lt;strong&gt;Save&lt;/strong&gt; and&lt;br&gt;
produces &lt;code&gt;budget (3).csv&lt;/code&gt;, the app has lied about what it did. If it says&lt;br&gt;
&lt;strong&gt;Download a copy&lt;/strong&gt;, the user understands the platform limitation immediately and&lt;br&gt;
does not blame the app when their original file is unchanged.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters more on Flutter Web than elsewhere
&lt;/h2&gt;

&lt;p&gt;Flutter Web is unusually good at the kind of app this unlocks — editors,&lt;br&gt;
spreadsheets, diagram tools, code playgrounds, anything with a document. Those&lt;br&gt;
are exactly the apps where "download another copy" is most obviously wrong.&lt;/p&gt;

&lt;p&gt;It is also the gap people cite when they say Flutter Web "isn't ready for real&lt;br&gt;
apps". Not being able to save a file properly is a fair thing to hold against a&lt;br&gt;
platform. It is no longer true, on the browsers that matter, and it is worth&lt;br&gt;
knowing.&lt;/p&gt;

&lt;h2&gt;
  
  
  A note on security, since people ask
&lt;/h2&gt;

&lt;p&gt;Every time this API comes up someone asks whether a web page can now read their&lt;br&gt;
whole disk. It cannot, and the reasons are worth knowing so you can answer the&lt;br&gt;
question when a user asks you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Every handle comes from a user gesture&lt;/strong&gt; through the browser's own picker.
A page cannot construct a handle to a path it names.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Access is per-handle.&lt;/strong&gt; Being granted &lt;code&gt;budget.csv&lt;/code&gt; grants nothing about the
folder it sits in, or any other file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The browser blocks sensitive locations&lt;/strong&gt; — system directories, and in
Chromium's case a maintained blocklist that includes things like the user's
home root and library folders.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Permission is revocable&lt;/strong&gt; and is re-confirmed after a reload rather than
granted permanently in the background.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It requires a secure context.&lt;/strong&gt; No HTTPS, no API.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The design is closer to "the user hands your app a file" than to "your app gets&lt;br&gt;
filesystem access", which is the right shape for a capability this significant.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://devshakib.jumyn.com/packages/file_system_access" rel="noopener noreferrer"&gt;&lt;code&gt;file_system_access&lt;/code&gt;&lt;/a&gt; is on pub.dev — MIT, 160/160&lt;br&gt;
pub points. It wraps the picker, the in-place write, the persisted handle and the&lt;br&gt;
capability check.&lt;/p&gt;

&lt;p&gt;It returns &lt;code&gt;false&lt;/code&gt; from the capability check on browsers that cannot do this,&lt;br&gt;
rather than throwing or half-working — so your fallback is a decision you make at&lt;br&gt;
startup rather than a crash you discover from a Safari user three weeks after&lt;br&gt;
launch.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://devshakib.jumyn.com/blog/flutter-web-save-file-in-place" rel="noopener noreferrer"&gt;devshakib.jumyn.com&lt;/a&gt;. I write about Flutter, Dart and the parts of shipping that are genuinely awkward — and publish the packages that came out of them at &lt;a href="https://pub.dev/publishers/jumyn.com/packages" rel="noopener noreferrer"&gt;pub.dev/publishers/jumyn.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>dart</category>
      <category>mobile</category>
      <category>programming</category>
    </item>
    <item>
      <title>Scrolling to an Index in a Flutter Lazy List, Without Building Everything Above It</title>
      <dc:creator>K M Shahriar Hossain</dc:creator>
      <pubDate>Sat, 05 Sep 2026 23:22:11 +0000</pubDate>
      <link>https://dev.to/devshakib/scrolling-to-an-index-in-a-flutter-lazy-list-without-building-everything-above-it-7o9</link>
      <guid>https://dev.to/devshakib/scrolling-to-an-index-in-a-flutter-lazy-list-without-building-everything-above-it-7o9</guid>
      <description>&lt;p&gt;You have a list of a million rows and you want to jump to row 842,013.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ScrollController.jumpTo&lt;/code&gt; takes a &lt;em&gt;pixel&lt;/em&gt; offset, not an index. To convert one&lt;br&gt;
to the other you need the total height of the 842,012 rows above your target —&lt;br&gt;
and a lazy list has never built them, so it does not know how tall they are.&lt;br&gt;
That is not an oversight. It is the entire point of a lazy list.&lt;/p&gt;

&lt;p&gt;Flutter ships no answer. The two packages that did are both dead:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;scrollable_positioned_list&lt;/code&gt;&lt;/strong&gt; — archived by Google&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;scroll_to_index&lt;/code&gt;&lt;/strong&gt; — last published in 2022&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Between them they still serve over a million downloads a month, which tells you&lt;br&gt;
how many apps are relying on an unmaintained solution to this.&lt;/p&gt;
&lt;h2&gt;
  
  
  How the archived approach worked
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;scrollable_positioned_list&lt;/code&gt; built a &lt;strong&gt;second complete list&lt;/strong&gt;, anchored at the&lt;br&gt;
target index, and cross-faded from the old one to the new one.&lt;/p&gt;

&lt;p&gt;It works. It is also why jumping felt the way it did: for the duration of the&lt;br&gt;
transition there are two full sets of children alive in the tree, both being&lt;br&gt;
laid out, both painting. On a heavy row widget that is visible — a flash, a&lt;br&gt;
frame drop, sometimes a scrollbar that jumps twice.&lt;/p&gt;

&lt;p&gt;And it is why the package was hard to maintain. Two lists that must agree about&lt;br&gt;
scroll position, item extents and which one is currently authoritative is a lot&lt;br&gt;
of state to keep correct across Flutter versions.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why the obvious workarounds do not hold
&lt;/h2&gt;

&lt;p&gt;Before the real answer, it is worth walking the approaches people try first,&lt;br&gt;
because each fails in a way that teaches something.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fixed item extent.&lt;/strong&gt; If every row is exactly 72 pixels, index 842,013 is at&lt;br&gt;
offset 60,624,936 and you are done. &lt;code&gt;SliverFixedExtentList&lt;/code&gt; exists precisely for&lt;br&gt;
this and it is genuinely the fastest option. It also stops working the moment one&lt;br&gt;
row wraps to two lines, and "all my rows are identical forever" is a promise most&lt;br&gt;
products break within a month.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Estimate, jump, correct.&lt;/strong&gt; Guess an average height, jump to the estimate, then&lt;br&gt;
measure where you landed and adjust. This visibly hunts — the list arrives near&lt;br&gt;
the target and then shuffles, sometimes several times. Worse, the correction&lt;br&gt;
happens &lt;em&gt;after&lt;/em&gt; paint, so the user sees the wrong content first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;ensureVisible&lt;/code&gt; on a &lt;code&gt;GlobalKey&lt;/code&gt;.&lt;/strong&gt; This works beautifully and only for items&lt;br&gt;
that are already built. The element for index 842,013 does not exist, so there is&lt;br&gt;
no context to scroll to. It is the right tool for "scroll to this form field",&lt;br&gt;
and no tool at all for "jump into a list".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Build everything.&lt;/strong&gt; A million &lt;code&gt;ListTile&lt;/code&gt;s is a million elements, a million&lt;br&gt;
render objects, and a frame budget measured in seconds. This is what&lt;br&gt;
&lt;code&gt;shrinkWrap: true&lt;/code&gt; quietly does to you in some nestings, which is why it has the&lt;br&gt;
reputation it has.&lt;/p&gt;

&lt;p&gt;Each of these is the sensible next idea after the previous one fails. The reason&lt;br&gt;
none of them work is that they are all trying to &lt;em&gt;compute&lt;/em&gt; an offset that the&lt;br&gt;
list fundamentally does not know.&lt;/p&gt;
&lt;h2&gt;
  
  
  The primitive Flutter already has
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Viewport&lt;/code&gt; can nominate a &lt;strong&gt;centre sliver&lt;/strong&gt; via its &lt;code&gt;center&lt;/code&gt; property. It is&lt;br&gt;
there for chat-style lists that grow upwards, and it does something unusual:&lt;br&gt;
content &lt;em&gt;before&lt;/em&gt; the centre sliver lays out at &lt;strong&gt;negative&lt;/strong&gt; scroll offset.&lt;/p&gt;

&lt;p&gt;That is the whole solution, once you see it.&lt;/p&gt;

&lt;p&gt;Split the list in two at the anchor index:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;everything &lt;strong&gt;before&lt;/strong&gt; the anchor goes in one sliver, laid out backwards from
zero into negative offsets&lt;/li&gt;
&lt;li&gt;the anchor and everything &lt;strong&gt;after&lt;/strong&gt; it go in a second sliver, marked as the
centre&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now scroll offset zero &lt;em&gt;is&lt;/em&gt; the anchor. Not "approximately the anchor once we&lt;br&gt;
have measured our way there" — it is the anchor, by definition, because that is&lt;br&gt;
what nominating a centre sliver means.&lt;/p&gt;

&lt;p&gt;Jumping to index 842,013 becomes: rebuild with the anchor set to 842,013, offset&lt;br&gt;
zero. There is nothing to measure, nothing to estimate, and nothing above it to&lt;br&gt;
build. It costs the same as jumping to index 3.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;controller&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;AnchoredListController&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="n"&gt;AnchoredList&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nl"&gt;controller:&lt;/span&gt; &lt;span class="n"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;itemCount:&lt;/span&gt; &lt;span class="mi"&gt;1000000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;itemBuilder:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;ListTile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;title:&lt;/span&gt; &lt;span class="n"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'Item &lt;/span&gt;&lt;span class="si"&gt;$index&lt;/span&gt;&lt;span class="s"&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;controller&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;jumpToIndex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;842013&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// same cost as jumping to item 3&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One viewport, one set of children, no cross-fade.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why negative offsets are not a hack
&lt;/h3&gt;

&lt;p&gt;If nominating a centre sliver and laying content out at negative offsets sounds&lt;br&gt;
like an exotic trick, it is worth knowing it is what Flutter uses for its own&lt;br&gt;
reverse-scrolling lists. &lt;code&gt;CustomScrollView&lt;/code&gt; exposes &lt;code&gt;center&lt;/code&gt; as public API, and&lt;br&gt;
the framework's chat-style examples rely on exactly this behaviour.&lt;/p&gt;

&lt;p&gt;The insight this package contributes is not the primitive. It is noticing that&lt;br&gt;
"the offset origin can be placed anywhere" is the same problem as "jump to an&lt;br&gt;
arbitrary index" — and that once the origin &lt;em&gt;is&lt;/em&gt; the target, there is no&lt;br&gt;
arithmetic left to do.&lt;/p&gt;

&lt;p&gt;That is why the jump is O(1) rather than merely fast. It is not that the search&lt;br&gt;
got quicker; there is no search.&lt;/p&gt;

&lt;h2&gt;
  
  
  The second thing this fixes
&lt;/h2&gt;

&lt;p&gt;There is a related problem the same structure solves for free.&lt;/p&gt;

&lt;p&gt;You are reading a chat, or a feed, or a log. New items arrive &lt;strong&gt;above&lt;/strong&gt; where you&lt;br&gt;
are looking. In an ordinary &lt;code&gt;ListView&lt;/code&gt; every insertion above your position pushes&lt;br&gt;
your content down, because your scroll offset is measured from the top of a list&lt;br&gt;
that just got taller.&lt;/p&gt;

&lt;p&gt;With a centre sliver, items above the anchor live at negative offsets. Inserting&lt;br&gt;
one extends the list &lt;em&gt;upwards&lt;/em&gt;, into more-negative territory. Your position is&lt;br&gt;
measured from the anchor, and the anchor has not moved — so your content does&lt;br&gt;
not move either.&lt;/p&gt;

&lt;p&gt;That is why the package is called &lt;code&gt;anchored_list&lt;/code&gt; rather than something about&lt;br&gt;
jumping. Holding your place while things arrive above you is the same mechanism&lt;br&gt;
as jumping instantly, seen from a different angle.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this changes what you can build
&lt;/h2&gt;

&lt;p&gt;Two features become straightforward that are usually quietly dropped.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deep linking into a list.&lt;/strong&gt; A notification says "someone replied to your&lt;br&gt;
comment", and the comment is 4,000 items down. Ordinarily you either load a&lt;br&gt;
separate detail screen or you build a paginated approximation and hope. With an&lt;br&gt;
anchored list you open the list &lt;em&gt;at&lt;/em&gt; that comment, and scrolling up from it works&lt;br&gt;
normally because the items above are real list items, lazily built as the user&lt;br&gt;
reaches them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Restoring scroll position properly.&lt;/strong&gt; Saving a pixel offset and restoring it&lt;br&gt;
is unreliable, because content above may have changed length between sessions.&lt;br&gt;
Saving an &lt;em&gt;index&lt;/em&gt; and restoring the anchor is exact — the user comes back to the&lt;br&gt;
item they were reading, not to a coordinate that used to contain it.&lt;/p&gt;

&lt;p&gt;Both of these usually get cut during estimation because "scroll to an arbitrary&lt;br&gt;
position in a long list" sounds like a week of work. It is a controller call.&lt;/p&gt;

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

&lt;p&gt;Honest limitations, because a list package that claims none is hiding some:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Two slivers, not one.&lt;/strong&gt; If you were relying on a single-sliver structure for
something exotic, this changes it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scrollbar geometry is estimated.&lt;/strong&gt; A lazy list genuinely does not know its
own total height, so the thumb size is a best guess that improves as more is
measured. Every solution to this problem shares that limit, including the
archived ones.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The anchor is a rebuild&lt;/strong&gt;, not an animation. &lt;code&gt;jumpToIndex&lt;/code&gt; is instant by
design. If you want a visible scroll across a million rows, that is a
different feature and a much slower one — and one nobody actually wants, since
a five-second animated scroll past 800,000 rows is not a better experience than
arriving.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Items above the anchor build as you scroll up&lt;/strong&gt;, exactly as items below build
as you scroll down. Jumping to index 842,013 and immediately flinging upward
will build items in that direction — which is correct, but means the work is
proportional to how far you scroll, not zero.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  On replacing an archived package
&lt;/h3&gt;

&lt;p&gt;If you are migrating from &lt;code&gt;scrollable_positioned_list&lt;/code&gt;, the mental model changes&lt;br&gt;
in one way worth flagging. That package thinks in terms of "scroll such that item&lt;br&gt;
N is at alignment 0.0–1.0 within the viewport". This one thinks in terms of "item&lt;br&gt;
N &lt;em&gt;is&lt;/em&gt; the origin". For the common case — put item N at the top — they express&lt;br&gt;
the same thing. For "put item N one third down the viewport", you are adjusting&lt;br&gt;
an offset from the anchor rather than passing an alignment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://devshakib.jumyn.com/packages/anchored_list" rel="noopener noreferrer"&gt;&lt;code&gt;anchored_list&lt;/code&gt;&lt;/a&gt; is on pub.dev — MIT, no&lt;br&gt;
dependencies, all six platforms, 160/160 pub points.&lt;/p&gt;

&lt;p&gt;The repository has a demo that jumps around a million-row list while showing the&lt;br&gt;
live child count. That counter is the part worth watching: it stays flat as you&lt;br&gt;
jump from index 3 to 842,013 and back. A flat child count during a jump is the&lt;br&gt;
evidence that nothing above the anchor is being built — and it is the number that&lt;br&gt;
distinguishes this approach from every workaround above, all of which make it&lt;br&gt;
spike.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://devshakib.jumyn.com/blog/flutter-scroll-to-index-in-a-lazy-list" rel="noopener noreferrer"&gt;devshakib.jumyn.com&lt;/a&gt;. I write about Flutter, Dart and the parts of shipping that are genuinely awkward — and publish the packages that came out of them at &lt;a href="https://pub.dev/publishers/jumyn.com/packages" rel="noopener noreferrer"&gt;pub.dev/publishers/jumyn.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>dart</category>
      <category>mobile</category>
      <category>programming</category>
    </item>
    <item>
      <title>Talking to Native: FFI, Pigeon, and Knowing Which One You Need</title>
      <dc:creator>K M Shahriar Hossain</dc:creator>
      <pubDate>Sat, 05 Sep 2026 23:12:10 +0000</pubDate>
      <link>https://dev.to/devshakib/talking-to-native-ffi-pigeon-and-knowing-which-one-you-need-37pj</link>
      <guid>https://dev.to/devshakib/talking-to-native-ffi-pigeon-and-knowing-which-one-you-need-37pj</guid>
      <description>&lt;p&gt;A &lt;code&gt;MethodChannel&lt;/code&gt; typo cost us three days and a hotfix release, and the compiler never said a word. That's the whole story of Flutter native interop in one sentence: the easy path is a stringly-typed message bus that fails silently in the field, and almost everyone reaches for it first.&lt;/p&gt;

&lt;p&gt;Every Flutter developer's first brush with native code goes the same way. You need something the framework doesn't give you — a battery level, a hardware sensor, a C library your backend team already trusts — and the first search result says &lt;code&gt;MethodChannel&lt;/code&gt;. You copy the snippet, wire up a stringly-typed channel name, and it works. Ship it.&lt;/p&gt;

&lt;p&gt;Then it grows. Six months later that one channel has fourteen methods, each one a &lt;code&gt;switch&lt;/code&gt; on a string, each argument a &lt;code&gt;Map&amp;lt;String, dynamic&amp;gt;&lt;/code&gt; you &lt;code&gt;as&lt;/code&gt;-cast and pray over. At Shpper we had exactly this: a device channel that had quietly become the single largest source of crash-free-rate regressions in one of our apps. The regression that cost us the three days was a renamed method on the Kotlin side that nobody renamed on the Dart side — green build, green tests, &lt;code&gt;MissingPluginException&lt;/code&gt; on real hardware two days after release. Not one of those crashes was catchable by the compiler, because we'd built the boundary out of strings. The lesson I keep relearning: &lt;code&gt;MethodChannel&lt;/code&gt; is the default answer and it is usually the wrong one.&lt;/p&gt;

&lt;p&gt;This post is the decision framework I wish I'd had earlier: the three ways Flutter talks to native code, what each one actually costs, and how to pick before you write a line of glue.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three Flutter native interop paths, and what they actually cost
&lt;/h2&gt;

&lt;p&gt;Flutter gives you three real ways to reach native code. They are not interchangeable, and picking the wrong one is where the pain comes from.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;dart:ffi&lt;/code&gt;&lt;/strong&gt; — call C (and Rust, and anything with a C ABI) directly, in-process, synchronously. No serialization, no message passing, no platform thread. This is the fastest path and the one people reach for last.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pigeon&lt;/strong&gt; — a code generator that turns a schema of Dart abstract classes into type-safe, generated method channels for you. Same transport as raw channels underneath, but the compiler now checks both sides.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Raw &lt;code&gt;MethodChannel&lt;/code&gt; / &lt;code&gt;EventChannel&lt;/code&gt;&lt;/strong&gt; — the hand-written message bus between Dart and the platform (Kotlin/Java, Swift/Obj-C). Async, dynamically typed, and manual on both ends.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here's the mental model I use. FFI is for &lt;em&gt;code&lt;/em&gt; — you have a native function and you want to call it. Pigeon is for &lt;em&gt;platform APIs&lt;/em&gt; — you need to talk to Android or iOS SDKs and want a typed contract. Raw channels are for the awkward middle: event streams, plugin ecosystems, and things Pigeon can't express yet.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;FFI&lt;/th&gt;
&lt;th&gt;Pigeon&lt;/th&gt;
&lt;th&gt;Raw MethodChannel&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Talks to&lt;/td&gt;
&lt;td&gt;C / Rust / C ABI&lt;/td&gt;
&lt;td&gt;Kotlin / Swift SDKs&lt;/td&gt;
&lt;td&gt;Kotlin / Swift SDKs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Call style&lt;/td&gt;
&lt;td&gt;Synchronous&lt;/td&gt;
&lt;td&gt;Async (Future)&lt;/td&gt;
&lt;td&gt;Async (Future)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Type safety&lt;/td&gt;
&lt;td&gt;Compile-time (C types)&lt;/td&gt;
&lt;td&gt;Compile-time (generated)&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Serialization&lt;/td&gt;
&lt;td&gt;None (raw memory)&lt;/td&gt;
&lt;td&gt;Standard codec&lt;/td&gt;
&lt;td&gt;Standard codec&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Runs on&lt;/td&gt;
&lt;td&gt;Calling thread&lt;/td&gt;
&lt;td&gt;Platform thread&lt;/td&gt;
&lt;td&gt;Platform thread&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best for&lt;/td&gt;
&lt;td&gt;Hot paths, existing C libs&lt;/td&gt;
&lt;td&gt;New platform integrations&lt;/td&gt;
&lt;td&gt;Streams, edge cases&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you take one thing away: the interesting decision is between FFI and Pigeon. Raw channels are the fallback, not the starting point. Everything below is really about earning the confidence to &lt;em&gt;not&lt;/em&gt; hand-write a channel by default.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where raw MethodChannel bites you in production
&lt;/h2&gt;

&lt;p&gt;The problem with raw channels isn't that they don't work. They work fine on the happy path, which is exactly why they're dangerous — the cost is deferred to the moment you least want it. A &lt;code&gt;MethodChannel&lt;/code&gt; is a &lt;code&gt;BasicMessageChannel&lt;/code&gt; with a method-call codec bolted on, and that's all the safety you get: a string name and a bag of dynamically-typed arguments.&lt;/p&gt;

&lt;p&gt;Look at a typical hand-rolled channel:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;_channel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;MethodChannel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'com.shpper/device'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;getBatteryLevel&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_channel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;invokeMethod&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'getBatteryLevel'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// hope it's really an int&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the Kotlin side:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="n"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setMethodCallHandler&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt;
  &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="s"&gt;"getBatteryLevel"&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&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;success&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;batteryLevel&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="c1"&gt;// typo "getBateryLevel" here? compiles fine, fails at runtime&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&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;notImplemented&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 failure modes are baked in and none of them are caught by a compiler:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Stringly-typed dispatch.&lt;/strong&gt; The method name is a string on both sides. Rename it on one side and you get a silent &lt;code&gt;MissingPluginException&lt;/code&gt; in the field. Grep is your only "refactoring tool," and grep doesn't know the difference between a channel name and a comment.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Untyped arguments.&lt;/strong&gt; Everything crosses the boundary as &lt;code&gt;Object?&lt;/code&gt;. You cast on the Dart side and cast again in Kotlin. Change an argument's shape — an &lt;code&gt;int&lt;/code&gt; that becomes a &lt;code&gt;long&lt;/code&gt;, a field that becomes nullable — and nothing warns you until a specific device tries it. Worse, the standard codec silently promotes small integers, so a value that's fine in the emulator can &lt;code&gt;ClassCastException&lt;/code&gt; on a payload that happens to exceed 32 bits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Always async, even when it shouldn't be.&lt;/strong&gt; Every call is a &lt;code&gt;Future&lt;/code&gt;, even reading a constant. That async hop forces &lt;code&gt;await&lt;/code&gt; into call sites that are conceptually synchronous, which spreads through your architecture and makes otherwise-pure functions infectious.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Raw channels are the assembly language of Flutter interop. Sometimes you need assembly. You just shouldn't write your whole app in it — and you definitely shouldn't reach for it first for a plain typed request/response API.&lt;/p&gt;

&lt;h2&gt;
  
  
  dart:ffi for synchronous C and Rust interop
&lt;/h2&gt;

&lt;p&gt;When you actually have native &lt;em&gt;code&lt;/em&gt; — an image codec, a crypto primitive, a Rust core you share across platforms — FFI is a different universe. There's no message bus. Dart calls the C function directly and gets the result back on the same thread, synchronously, with zero serialization.&lt;/p&gt;

&lt;p&gt;On a project last year we needed to hash and verify a few thousand small records on-device during a sync. Doing it over a method channel meant a round trip per record, and the platform-thread hop killed us — the per-call overhead dominated the actual work. Moving the hot loop to a tiny C function behind FFI took the whole operation from "spinner the user notices" to "done before the frame ends." The win wasn't a faster hash; it was deleting the boundary entirely.&lt;/p&gt;

&lt;p&gt;The mechanics: you declare the native signature and the Dart signature, then bind them.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'dart:ffi'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'package:ffi/ffi.dart'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// C: uint32_t crc32(const uint8_t* data, int len);&lt;/span&gt;
&lt;span class="kd"&gt;typedef&lt;/span&gt; &lt;span class="n"&gt;_Crc32C&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Uint32&lt;/span&gt; &lt;span class="kt"&gt;Function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Pointer&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Uint8&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;,&lt;/span&gt; &lt;span class="n"&gt;Int32&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;typedef&lt;/span&gt; &lt;span class="n"&gt;_Crc32Dart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="kt"&gt;Function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Pointer&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Uint8&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;_lib&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DynamicLibrary&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'libhash.so'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;_crc32&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_lib&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;lookupFunction&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;_Crc32C&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_Crc32Dart&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;'crc32'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;crc32&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;ptr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;malloc&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Uint8&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;length&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;view&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ptr&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;asTypedList&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;length&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="n"&gt;view&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;_crc32&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ptr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;length&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;malloc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;free&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ptr&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// you own this memory now&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 the two typedefs: one uses native FFI types (&lt;code&gt;Uint32&lt;/code&gt;, &lt;code&gt;Int32&lt;/code&gt;, &lt;code&gt;Pointer&lt;/code&gt;) to describe the C ABI, the other uses plain Dart types (&lt;code&gt;int&lt;/code&gt;) for the call site. &lt;code&gt;lookupFunction&lt;/code&gt; marries them. Get a width wrong — &lt;code&gt;Int32&lt;/code&gt; where the header says &lt;code&gt;int64_t&lt;/code&gt; — and you'll read garbage or corrupt the stack, so this is exactly the place to let &lt;code&gt;ffigen&lt;/code&gt; transcribe headers instead of doing it by hand.&lt;/p&gt;

&lt;p&gt;That &lt;code&gt;try/finally&lt;/code&gt; is the whole game with FFI. The moment you cross into native memory, Dart's garbage collector stops helping you. Every &lt;code&gt;malloc&lt;/code&gt; needs a &lt;code&gt;free&lt;/code&gt;, and if you throw in between, you leak. My rules after getting this wrong more than once:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Allocate and free in the same function&lt;/strong&gt; whenever you can. If a pointer has to outlive the call, wrap it in a class with a &lt;code&gt;dispose()&lt;/code&gt; and treat it like a file handle — and consider a &lt;code&gt;NativeFinalizer&lt;/code&gt; as a backstop so a forgotten &lt;code&gt;dispose()&lt;/code&gt; degrades to a late free instead of a permanent leak.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use &lt;code&gt;package:ffi&lt;/code&gt;'s &lt;code&gt;malloc&lt;/code&gt;/&lt;code&gt;calloc&lt;/code&gt;&lt;/strong&gt;, not raw &lt;code&gt;Pointer&lt;/code&gt; gymnastics — they give you &lt;code&gt;.free()&lt;/code&gt; and helpers like &lt;code&gt;.toNativeUtf8()&lt;/code&gt; for the ever-annoying string marshalling.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Copy at the boundary.&lt;/strong&gt; &lt;code&gt;asTypedList&lt;/code&gt; gives you a &lt;em&gt;view&lt;/em&gt; into native memory; if that memory gets freed, the view is a dangling pointer and reading it is undefined behaviour. Copy into a Dart &lt;code&gt;List&lt;/code&gt; before you free if the data escapes the function.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Blocking calls block the isolate.&lt;/strong&gt; FFI is synchronous, so a slow native call freezes your UI. For anything long-running, use &lt;code&gt;Isolate.run&lt;/code&gt; or an async FFI callback via &lt;code&gt;NativeCallable.listener&lt;/code&gt; so native code can call back into Dart from another thread.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two things make modern FFI far less painful. &lt;strong&gt;&lt;code&gt;ffigen&lt;/code&gt;&lt;/strong&gt; reads a C header and generates all the typedefs and bindings for you, so you're not hand-transcribing signatures and getting integer widths wrong. And &lt;strong&gt;&lt;code&gt;flutter_rust_bridge&lt;/code&gt;&lt;/strong&gt; does the same for Rust, generating the FFI glue and handling the memory dance so a Rust core feels like a normal async Dart API — including turning long-running Rust work into proper Dart &lt;code&gt;Future&lt;/code&gt;s and &lt;code&gt;Stream&lt;/code&gt;s. If you have a real algorithmic core to share across platforms, that combination is the strongest option Flutter has.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pigeon for type-safe platform channels
&lt;/h2&gt;

&lt;p&gt;FFI is great when the thing you're calling is C. But most native work isn't C — it's "please open the iOS share sheet" or "read this value from the Android KeyStore." That means talking to platform SDKs in Swift and Kotlin, and for that, the right tool is Pigeon.&lt;/p&gt;

&lt;p&gt;Pigeon isn't a runtime; there's nothing to add to your app's dependency footprint at ship time. It's a code generator you run at build time. You write a schema in Dart — just abstract classes and data classes — and Pigeon emits the channel plumbing for Dart, Kotlin/Java, and Swift/Obj-C. The wire is still a method channel underneath. The difference is the compiler now sees both ends of it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;// pigeons/device_api.dart — this file is the schema, not shipped code&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'package:pigeon/pigeon.dart'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DeviceInfo&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;late&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;late&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;batteryLevel&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;late&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;isCharging&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;@HostApi&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="kd"&gt;abstract&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DeviceApi&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;DeviceInfo&lt;/span&gt; &lt;span class="n"&gt;getDeviceInfo&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nd"&gt;@async&lt;/span&gt;
  &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;authenticate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;reason&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 &lt;code&gt;dart run pigeon --input pigeons/device_api.dart&lt;/code&gt; and you get a generated Dart class you call like any typed API, plus a Kotlin &lt;code&gt;interface&lt;/code&gt; and a Swift &lt;code&gt;protocol&lt;/code&gt; you &lt;em&gt;implement&lt;/em&gt;. Now the compiler is your integration test:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DevicePlugin&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;DeviceApi&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;getDeviceInfo&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nc"&gt;DeviceInfo&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;DeviceInfo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Build&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;MODEL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="n"&gt;batteryLevel&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;currentBattery&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
      &lt;span class="n"&gt;isCharging&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;charging&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;// forget authenticate()? Kotlin won't compile. That's the point.&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What you get for free:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Rename-safe by construction.&lt;/strong&gt; Change a method or a field and both platforms fail to compile until you fix them. No more field-day &lt;code&gt;MissingPluginException&lt;/code&gt; from a one-character typo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real data classes&lt;/strong&gt; instead of &lt;code&gt;Map&amp;lt;String, dynamic&amp;gt;&lt;/code&gt;. The DTO is defined once and generated everywhere, so there's a single source of truth for the shape crossing the boundary.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;HostApi&lt;/code&gt; and &lt;code&gt;FlutterApi&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;@HostApi()&lt;/code&gt; lets Dart call &lt;em&gt;into&lt;/em&gt; native; &lt;code&gt;@FlutterApi()&lt;/code&gt; lets native call &lt;em&gt;back into&lt;/em&gt; Dart. Both are typed, so bidirectional flows (a native SDK pushing a callback into your Dart layer) stop being a stringly-typed guessing game.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A practical workflow note: check the generated files into version control and regenerate them in CI, then fail the build if the output changed. That turns "someone edited the schema but forgot to regenerate" into a red build instead of a runtime surprise — the same discipline I apply to any generated code.&lt;/p&gt;

&lt;p&gt;My default now for any new platform integration is Pigeon first, and I only drop to a raw channel when I hit something Pigeon genuinely can't model. The generated code is boring, which on a boundary this error-prone is the highest praise I can give.&lt;/p&gt;

&lt;h2&gt;
  
  
  Threading: where the native boundary really lives
&lt;/h2&gt;

&lt;p&gt;The part that trips people up isn't syntax — it's which thread the code runs on. Get this wrong and you'll ship an app that janks or, worse, deadlocks intermittently on some devices and never in the office.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Method/Pigeon channel handlers run on the platform's main thread&lt;/strong&gt; (the Android main thread, the iOS main queue) by default. Do heavy work there and you block the native UI thread. On Android that's an ANR waiting to happen; on iOS it's a watchdog kill.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dart async is not a thread.&lt;/strong&gt; &lt;code&gt;await&lt;/code&gt; yields on the single UI isolate. It keeps the UI responsive for I/O-bound work, but a tight CPU loop in Dart still blocks the frame — concurrency and parallelism are not the same thing here.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;FFI calls run on the calling thread.&lt;/strong&gt; Call it from the UI isolate and a slow function freezes your app; there's no free hop to a background thread like channels give you.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the real rules:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;In a native channel handler, if the work is slow, &lt;strong&gt;dispatch to a background thread&lt;/strong&gt; (a Kotlin coroutine on &lt;code&gt;Dispatchers.IO&lt;/code&gt;, a &lt;code&gt;DispatchQueue.global()&lt;/code&gt;) and post the result back to the channel. Don't do the work inline on the platform thread.&lt;/li&gt;
&lt;li&gt;For heavy Dart-side or FFI work, &lt;strong&gt;move it off the UI isolate.&lt;/strong&gt; &lt;code&gt;Isolate.run(() =&amp;gt; crc32(bytes))&lt;/code&gt; runs the synchronous FFI call on a separate isolate so the main one keeps rendering. Remember that data crossing isolate boundaries is copied unless it's transferable (like &lt;code&gt;TransferableTypedData&lt;/code&gt;), so measure before you assume the isolate hop is free.&lt;/li&gt;
&lt;li&gt;For continuous native events (location, sensors, download progress), use an &lt;strong&gt;&lt;code&gt;EventChannel&lt;/code&gt;&lt;/strong&gt; — a typed stream — rather than polling with repeated method calls. Polling turns a push problem into an N-round-trips-per-second problem.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The mistake I see most: someone benchmarks a single channel call, sees it's "fast," and assumes it's free. Then they call it 60 times a frame in a scroll listener. The per-call cost is small; the platform-thread contention and codec churn at that frequency are not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Passing big payloads without killing the codec
&lt;/h2&gt;

&lt;p&gt;Every method channel serializes its arguments through the &lt;strong&gt;standard message codec&lt;/strong&gt;. For small structured data that's fine. For big payloads it is a trap, and it's a trap you fall into precisely when performance matters most.&lt;/p&gt;

&lt;p&gt;Two things go wrong at scale. First, a large &lt;code&gt;Map&lt;/code&gt; or &lt;code&gt;List&lt;/code&gt; is walked and encoded field by field — slow and allocation-heavy, with garbage-collection pressure on both sides. Second, that encode/decode happens on threads you care about, so a fat payload stalls the UI while it's being marshalled.&lt;/p&gt;

&lt;p&gt;The fix is to stop sending structure and start sending bytes. The standard codec has a fast path for &lt;code&gt;Uint8List&lt;/code&gt; — it's passed as a raw byte buffer, not walked element by element.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Slow: a 100k-element list gets encoded item by item&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;points&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;channel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;invokeMethod&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'getPoints'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// List&amp;lt;dynamic&amp;gt;, ouch&lt;/span&gt;

&lt;span class="c1"&gt;// Fast: hand back a packed byte buffer, decode it in Dart&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;buffer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;channel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;invokeMethod&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'getPointsBytes'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// Uint8List&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;floats&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;buffer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;buffer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;asFloat32List&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 native side, pack your data into a &lt;code&gt;ByteArray&lt;/code&gt;/&lt;code&gt;Data&lt;/code&gt; and return that. You decode it in Dart with a typed-data view — no per-element codec cost, just a pointer reinterpretation. For a mesh of a few hundred thousand floats, this was the difference between a visible hitch and nothing at all. Mind endianness and struct alignment when you do this: agree on a layout (little-endian, tightly packed) and, if in doubt, use a &lt;code&gt;ByteData&lt;/code&gt; with explicit &lt;code&gt;getFloat32(offset, Endian.little)&lt;/code&gt; reads rather than a raw view.&lt;/p&gt;

&lt;p&gt;And if you're already passing large buffers back and forth constantly, that's a signal you might want FFI instead. FFI shares memory by pointer — zero copy, zero serialization. The codec's byte fast-path is the right answer &lt;em&gt;inside&lt;/em&gt; the channel world; FFI is the right answer when the channel world itself is the bottleneck.&lt;/p&gt;

&lt;h2&gt;
  
  
  Packaging native code so consumers don't need a toolchain
&lt;/h2&gt;

&lt;p&gt;This is the part that turns a working prototype into a shippable plugin, and it's where a lot of FFI projects quietly die. Your C or Rust code has to be &lt;em&gt;built&lt;/em&gt; for every target — Android arm64/armeabi-v7a/x86_64, iOS device and simulator, and ideally macOS/Windows/Linux for desktop. If installing your package means "now go set up the NDK and a Rust cross-compiler," nobody will use it, including your future self on a fresh machine.&lt;/p&gt;

&lt;p&gt;Flutter's plugin system does have hooks for this. The clean options:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Prebuilt binaries.&lt;/strong&gt; Build your &lt;code&gt;.so&lt;/code&gt;/&lt;code&gt;.a&lt;/code&gt;/&lt;code&gt;.xcframework&lt;/code&gt; in CI and ship them inside the plugin (or, to keep the package small, download-on-first-build). This is the approach I lean toward — I already build binaries in CI and attach them to GitHub Releases for other parts of the stack, so the pattern is familiar and it keeps consumers toolchain-free. The trade-off is that you're responsible for reproducible, correctly-signed builds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Native build hooks.&lt;/strong&gt; Point the Android Gradle/CMake and iOS CocoaPods/&lt;code&gt;podspec&lt;/code&gt; at your source so it compiles as part of the app build. Simplest to set up, but now every consumer needs the full native toolchain and eats the compile time on every clean build.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;flutter_rust_bridge&lt;/code&gt; + &lt;code&gt;cargokit&lt;/code&gt;&lt;/strong&gt; automates the Rust build across all targets and is the least painful route if you're in Rust — it wires the cross-compilation into the standard Flutter build so &lt;code&gt;flutter build&lt;/code&gt; just produces the right artifacts.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Whichever you pick, the goal is the same: &lt;code&gt;flutter pub add your_package&lt;/code&gt; should just work, with no README section titled "First, install these seven tools." The moment native code needs a manual setup step, adoption falls off a cliff.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Flutter native interop decision tree you can apply on Monday
&lt;/h2&gt;

&lt;p&gt;Strip away the nuance and the choice is short:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Are you calling native code you already have as C or Rust?&lt;/strong&gt; Use &lt;strong&gt;FFI&lt;/strong&gt;. Wrap &lt;code&gt;ffigen&lt;/code&gt; or &lt;code&gt;flutter_rust_bridge&lt;/code&gt; around it, mind the memory (every &lt;code&gt;malloc&lt;/code&gt; its &lt;code&gt;free&lt;/code&gt;), and push slow calls off the UI isolate. Synchronous, zero-copy, no codec.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Are you integrating with a platform SDK (iOS/Android APIs)?&lt;/strong&gt; Use &lt;strong&gt;Pigeon&lt;/strong&gt;. Write the schema, generate typed bindings, implement the &lt;code&gt;HostApi&lt;/code&gt; on each side, and let the compiler catch your mistakes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do you need a continuous stream of native events, or something Pigeon can't model yet?&lt;/strong&gt; Use a raw &lt;strong&gt;&lt;code&gt;EventChannel&lt;/code&gt;/&lt;code&gt;MethodChannel&lt;/code&gt;&lt;/strong&gt; — deliberately, knowing you own the type safety by hand and wrapping it thinly so the untyped surface doesn't leak through your app.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Are you moving big payloads?&lt;/strong&gt; Send &lt;code&gt;Uint8List&lt;/code&gt;, not maps. And if you're doing it constantly, reconsider whether the problem is actually an FFI problem.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Notice what's &lt;em&gt;not&lt;/em&gt; on this list as a default: hand-writing a &lt;code&gt;MethodChannel&lt;/code&gt; for a new typed API. That's the option to talk yourself out of, not into.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;MethodChannel&lt;/code&gt; is the well-known answer and usually the wrong one&lt;/strong&gt; — it's stringly-typed, untyped, always async, and every failure it hides shows up in production instead of at compile time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reach for FFI when you're calling actual C or Rust code&lt;/strong&gt;: synchronous, no serialization, zero-copy — but you own the memory. Every &lt;code&gt;malloc&lt;/code&gt; needs its &lt;code&gt;free&lt;/code&gt;, views into native memory can dangle, and slow calls belong off the UI isolate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reach for Pigeon for platform SDK integrations&lt;/strong&gt;: you get generated, type-safe channels where the compiler checks both the Dart and native sides, and renames become compile errors instead of field crashes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep raw channels for event streams and the genuine edge cases&lt;/strong&gt; Pigeon can't express — chosen on purpose, not by default, and wrapped thinly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mind the thread&lt;/strong&gt;: channel handlers run on the platform main thread, FFI runs on the calling isolate, and heavy work belongs on a background thread or a spawned isolate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pass large data as &lt;code&gt;Uint8List&lt;/code&gt;&lt;/strong&gt; to hit the codec's fast path, and &lt;strong&gt;package your native binaries in CI&lt;/strong&gt; so consumers never need a toolchain.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The right tool depends entirely on whether you're crossing to &lt;em&gt;code&lt;/em&gt; or to a &lt;em&gt;platform API&lt;/em&gt; — answer that first, and the rest of the decision makes itself.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://devshakib.jumyn.com/blog/talking-to-native-ffi-pigeon-and-knowing-which-one-you-need" rel="noopener noreferrer"&gt;devshakib.jumyn.com&lt;/a&gt;. I write about Flutter, Dart and the parts of shipping that are genuinely awkward — and publish the packages that came out of them at &lt;a href="https://pub.dev/publishers/jumyn.com/packages" rel="noopener noreferrer"&gt;pub.dev/publishers/jumyn.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>dart</category>
      <category>mobile</category>
      <category>programming</category>
    </item>
  </channel>
</rss>
