<?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>The Same Seed Picked Minute 13 on the Phone and 18 on the Web</title>
      <dc:creator>K M Shahriar Hossain</dc:creator>
      <pubDate>Mon, 28 Sep 2026 18:24:21 +0000</pubDate>
      <link>https://dev.to/devshakib/the-same-seed-picked-minute-13-on-the-phone-and-18-on-the-web-4o6i</link>
      <guid>https://dev.to/devshakib/the-same-seed-picked-minute-13-on-the-phone-and-18-on-the-web-4o6i</guid>
      <description>&lt;p&gt;A dev.to thread last week was about daily reminders: fire one at a&lt;br&gt;
random minute each afternoon, but make the minute the same for a given day, so&lt;br&gt;
the schedule can be rebuilt as often as you like without a reminder appearing&lt;br&gt;
to move. The standard trick is a seeded generator:&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;minute&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Random&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'2026-09-29'&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;hashCode&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;nextInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same date, same seed, same minute. I compiled that one line four ways on Dart&lt;br&gt;
3.13.4 and ran each on a Mac: the native builds directly, the JavaScript and&lt;br&gt;
Wasm output under Node, and later all of it again in Chrome.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;compiler&lt;/th&gt;
&lt;th&gt;where it runs&lt;/th&gt;
&lt;th&gt;minute&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;AOT (&lt;code&gt;dart compile exe&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Android and iOS release builds&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;13&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JIT (&lt;code&gt;dart run&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;debug builds, tests&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;13&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;dart2wasm&lt;/td&gt;
&lt;td&gt;Flutter web built with &lt;code&gt;--wasm&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;13&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;dart2js&lt;/td&gt;
&lt;td&gt;Flutter web, the default build&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;18&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The phone and the website disagree about the date.&lt;/p&gt;
&lt;h2&gt;
  
  
  Random isn't the problem
&lt;/h2&gt;

&lt;p&gt;The first suspect is &lt;code&gt;Random&lt;/code&gt;, and it's innocent. &lt;code&gt;Random(42)&lt;/code&gt; produced the&lt;br&gt;
same sequence, &lt;code&gt;587, 758, 4, 885, 77&lt;/code&gt;, under all four compilers, and so did&lt;br&gt;
every seed I tried from −2³¹−1 up to 2³³, along with &lt;code&gt;List.shuffle(Random(7))&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;What changed was the seed. &lt;code&gt;'2026-09-29'.hashCode&lt;/code&gt; is &lt;code&gt;160143713&lt;/code&gt; on the VM&lt;br&gt;
and on Wasm, and &lt;code&gt;374027133&lt;/code&gt; when compiled to JavaScript. It isn't only&lt;br&gt;
strings:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;expression&lt;/th&gt;
&lt;th&gt;VM, AOT&lt;/th&gt;
&lt;th&gt;dart2wasm&lt;/th&gt;
&lt;th&gt;dart2js&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;'2026-09-29'.hashCode&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;160143713&lt;/td&gt;
&lt;td&gt;160143713&lt;/td&gt;
&lt;td&gt;374027133&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;7.hashCode&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;81207&lt;/td&gt;
&lt;td&gt;81207&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;true.hashCode&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1231&lt;/td&gt;
&lt;td&gt;1231&lt;/td&gt;
&lt;td&gt;519018&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;3.5.hashCode&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;3377700795056128&lt;/td&gt;
&lt;td&gt;786432&lt;/td&gt;
&lt;td&gt;349197722&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DateTime.utc(2026, 9, 29).hashCode&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;487035471&lt;/td&gt;
&lt;td&gt;487035471&lt;/td&gt;
&lt;td&gt;518150374&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Object.hash(1, 2)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;different every run&lt;/td&gt;
&lt;td&gt;different every run&lt;/td&gt;
&lt;td&gt;different every run&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;None of this is a bug. &lt;code&gt;Object.hashCode&lt;/code&gt; is documented as something that "need&lt;br&gt;
not be consistent between executions of the same program", and &lt;code&gt;Object.hash&lt;/code&gt;&lt;br&gt;
says outright that it may depend on values that change on each run, and every&lt;br&gt;
run here gave a new number. A hash code exists to put things in a&lt;br&gt;
&lt;code&gt;Map&lt;/code&gt; during one execution. It was never a fingerprint.&lt;/p&gt;

&lt;p&gt;What makes it slip through is that the VM happens to be stable for strings:&lt;br&gt;
same value every run, so every test on your machine and every run on your&lt;br&gt;
phone agrees with itself. &lt;code&gt;'a'.hashCode&lt;/code&gt; even matches on all three. The&lt;br&gt;
difference only shows up on the build you probably tested least.&lt;/p&gt;
&lt;h2&gt;
  
  
  So hash it yourself — and it breaks again
&lt;/h2&gt;

&lt;p&gt;The obvious fix is to stop borrowing &lt;code&gt;hashCode&lt;/code&gt; and hash the bytes. FNV-1a is&lt;br&gt;
a few lines:&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;int&lt;/span&gt; &lt;span class="nf"&gt;fnv1a&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;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mh"&gt;0x811c9dc5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;utf8&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;^=&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mh"&gt;0x01000193&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mh"&gt;0xffffffff&lt;/span&gt;&lt;span class="p"&gt;;&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;h&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;Phone: minute 7. dart2js: minute 24.&lt;/p&gt;

&lt;p&gt;On the web, a Dart &lt;code&gt;int&lt;/code&gt; is a JavaScript number, and Dart's number&lt;br&gt;
documentation spells out the two consequences. Integers "lose precision past 53&lt;br&gt;
bits", and for bitwise operators "the operands are truncated to 32-bit&lt;br&gt;
integers". Here the multiply is the problem: a 32-bit hash times a 25-bit prime&lt;br&gt;
is a 57-bit product, and the low bits, the only ones the mask keeps, are&lt;br&gt;
exactly the ones JavaScript rounded away.&lt;/p&gt;

&lt;p&gt;Shifts fail the same way. &lt;code&gt;1 &amp;lt;&amp;lt; 40&lt;/code&gt; is &lt;code&gt;1099511627776&lt;/code&gt; on the phone and &lt;code&gt;0&lt;/code&gt; on&lt;br&gt;
the web, so a seed built with a shift was never the same number in the first&lt;br&gt;
place.&lt;/p&gt;
&lt;h2&gt;
  
  
  One web app, two answers
&lt;/h2&gt;

&lt;p&gt;The table's last two rows are both Flutter web, and a &lt;code&gt;--wasm&lt;/code&gt; build doesn't&lt;br&gt;
choose between them — the browser does. A &lt;code&gt;--wasm&lt;/code&gt; build compiles to&lt;br&gt;
JavaScript as well, and Flutter's loader decides which one to start. In&lt;br&gt;
Flutter 3.47, &lt;code&gt;flutter.js&lt;/code&gt; ships a default allow-list of&lt;br&gt;
&lt;code&gt;{blink: true, gecko: false, webkit: false}&lt;/code&gt;: Chrome and Edge get the Wasm&lt;br&gt;
build, and Safari, Firefox and every browser on iOS get the JavaScript one.&lt;/p&gt;

&lt;p&gt;So the same deploy, at the same URL, picks minute 13 for someone in Chrome and&lt;br&gt;
minute 18 for someone on an iPhone. Without &lt;code&gt;--wasm&lt;/code&gt; it's 18 for everyone on&lt;br&gt;
the web and 13 in the app, which is at least consistent within each.&lt;/p&gt;

&lt;p&gt;That matters well beyond reminders: A/B bucketing by user id, a daily puzzle&lt;br&gt;
seeded by the date, procedurally generated levels, shuffled quiz order, a&lt;br&gt;
cache key or shard derived from a string. Anything where two devices are meant&lt;br&gt;
to agree.&lt;/p&gt;
&lt;h2&gt;
  
  
  What held
&lt;/h2&gt;

&lt;p&gt;Two versions gave the same answer under all four compilers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A real hash from &lt;code&gt;package:crypto&lt;/code&gt;&lt;/strong&gt;, which does its arithmetic in 32-bit&lt;br&gt;
pieces on purpose:&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;int&lt;/span&gt; &lt;span class="nf"&gt;stableHash&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;s&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;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sha256&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;convert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;utf8&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;bytes&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="n"&gt;d&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="p"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mh"&gt;0x7fffffff&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Or FNV-1a with every intermediate kept under 2⁵³&lt;/strong&gt;, by multiplying the low&lt;br&gt;
and high halves separately:&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;int&lt;/span&gt; &lt;span class="nf"&gt;fnv1a&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;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mh"&gt;0x811c9dc5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;utf8&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;^&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mh"&gt;0xffffffff&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mh"&gt;0xffff&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mh"&gt;0x01000193&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
            &lt;span class="p"&gt;(((&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mh"&gt;0x0193&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mh"&gt;0xffff&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;
        &lt;span class="mh"&gt;0xffffffff&lt;/span&gt;&lt;span class="p"&gt;;&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;h&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then take the value from the hash, not from a generator seeded with 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="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;minute&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;stableHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;day&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;60&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;Random(seed)&lt;/code&gt; does agree across compilers today, but its own documentation&lt;br&gt;
says "the implementation of the random stream can change between releases of&lt;br&gt;
the library." An SDK upgrade could move every reminder. A hash reduced with&lt;br&gt;
&lt;code&gt;%&lt;/code&gt; can't.&lt;/p&gt;
&lt;h2&gt;
  
  
  Test it where it breaks
&lt;/h2&gt;

&lt;p&gt;A golden value catches all of this, but only if the test runs on each&lt;br&gt;
compiler. Pin the answer once and run the same file three ways:&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;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'the minute for a date is fixed'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;minuteFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'2026-09-29'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;53&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// pinned from one VM run&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dart &lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; vm
dart &lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; chrome
dart &lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; chrome &lt;span class="nt"&gt;-c&lt;/span&gt; dart2wasm
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;hashCode&lt;/code&gt; or the first FNV-1a, the VM and Wasm runs pass and the Chrome&lt;br&gt;
run fails, with &lt;code&gt;Expected: &amp;lt;53&amp;gt;&lt;/code&gt; and &lt;code&gt;Actual: &amp;lt;33&amp;gt;&lt;/code&gt; for &lt;code&gt;hashCode&lt;/code&gt;. With either&lt;br&gt;
of the versions above, all three pass. &lt;code&gt;-p vm&lt;/code&gt; alone passes everything, which&lt;br&gt;
is how this ships.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://devshakib.jumyn.com/blog/the-same-seed-picked-minute-13-on-the-phone-and-18-on-the-web" 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>webdev</category>
      <category>testing</category>
    </item>
    <item>
      <title>Firestore Security Rules: Deny by Default, Then Open the Narrowest Hole</title>
      <dc:creator>K M Shahriar Hossain</dc:creator>
      <pubDate>Thu, 24 Sep 2026 12:05:45 +0000</pubDate>
      <link>https://dev.to/devshakib/firestore-security-rules-deny-by-default-then-open-the-narrowest-hole-4nnf</link>
      <guid>https://dev.to/devshakib/firestore-security-rules-deny-by-default-then-open-the-narrowest-hole-4nnf</guid>
      <description>&lt;p&gt;Every few months someone opens an issue on a Firebase project along the lines of "your API key is exposed in the JavaScript bundle." It isn't a vulnerability, and the panic is misplaced — but the instinct behind it is right, just aimed at the wrong thing. &lt;strong&gt;A Firebase Web API key is a project identifier, not a credential.&lt;/strong&gt; It's supposed to be public. What people are actually reaching for when they panic about it is the real question: if anyone can point a Firestore client at my project, what stops them reading everything and writing whatever they like?&lt;/p&gt;

&lt;p&gt;The answer is one file. Not obfuscation, not a proxy, not "nobody knows the collection names." Security rules are the &lt;em&gt;entire&lt;/em&gt; perimeter, and they're the only part of the stack an attacker can't route around, because they execute on Google's servers before a single byte of your data moves. Everything else — your Flutter code, your admin panel's login screen, the &lt;code&gt;if (isAdmin)&lt;/code&gt; check that hides the delete button — is client-side theatre that a curious person can bypass with the browser console and ten minutes.&lt;/p&gt;

&lt;p&gt;My portfolio runs 114 blog posts, 98 browser tools, 32 games and an admin panel on Firestore and Cloud Storage, and it costs me exactly $0 a month on the Blaze plan. That $0 is partly a rules property: there are no public writes to content anywhere in the schema, so there is no path by which an anonymous visitor can run up my bill. This post is the rules file I actually ship, section by section, with the reasoning behind each line — including the one behaviour that makes people think their rules are broken when they're working perfectly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with the deny, then open the narrowest hole
&lt;/h2&gt;

&lt;p&gt;The mental model that keeps rules files honest is to write them backwards from how you'd naturally think about them. Don't start with "the blog should be readable." Start with &lt;strong&gt;nothing is readable and nothing is writable&lt;/strong&gt;, and then make yourself justify every hole you punch in it.&lt;/p&gt;

&lt;p&gt;Concretely, that means the last block in my file is this, and it was the first block I wrote:&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;// Default deny: anything not matched above is locked down&lt;/span&gt;
&lt;span class="nx"&gt;match&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="o"&gt;=**&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;allow&lt;/span&gt; &lt;span class="nx"&gt;read&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;write&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="kc"&gt;false&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;Firestore's default is already deny — an unmatched path is denied without you saying so. Writing it explicitly anyway matters for two reasons. First, it documents intent: the next person to open the file (which is me, eight months later) sees immediately that this is a closed-by-default schema and not an accidentally incomplete one. Second, it changes how you add things. With the deny at the bottom, adding a feature means consciously adding a &lt;code&gt;match&lt;/code&gt; block above it and deciding on read and write separately. Without it, people tend to grow rules by loosening whatever's nearest.&lt;/p&gt;

&lt;p&gt;The order of operations I follow when a new collection shows up:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Write the &lt;code&gt;match&lt;/code&gt; block with nothing allowed.&lt;/li&gt;
&lt;li&gt;Ask who needs to read it — public, admin, or nobody — and open exactly that.&lt;/li&gt;
&lt;li&gt;Ask who needs to write it, which is almost always "admin only."&lt;/li&gt;
&lt;li&gt;If the answer to step 3 is "the public," write a validation predicate before you write the &lt;code&gt;allow&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 4 is where most of the real work is, and where most of the bugs live. I'll get to it.&lt;/p&gt;

&lt;p&gt;One structural note: rules do &lt;strong&gt;not&lt;/strong&gt; cascade the way file permissions do. A &lt;code&gt;match /posts/{docId}&lt;/code&gt; block does not grant anything to &lt;code&gt;/posts/{docId}/comments/{commentId}&lt;/code&gt; — subcollections need their own match, or a recursive &lt;code&gt;{document=**}&lt;/code&gt; wildcard. This trips people in both directions: they assume a parent grant covers children (it doesn't), or they use a recursive wildcard to save typing and accidentally expose a subcollection they hadn't thought about. Be explicit.&lt;/p&gt;

&lt;h2&gt;
  
  
  One admin, identified by an email claim
&lt;/h2&gt;

&lt;p&gt;Here's the whole authorisation model for my site:&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;isAdmin&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;admin@yourdomain.com&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;That's it. One function, one address, no roles collection, no permissions document, no membership lookup. Every write in the entire schema goes through it.&lt;/p&gt;

&lt;p&gt;The reason this is safe is worth stating precisely, because it looks flimsy at first glance: &lt;strong&gt;the email in &lt;code&gt;request.auth.token&lt;/code&gt; is not client-supplied.&lt;/strong&gt; It's a claim inside a Firebase ID token, signed by Google, and verified server-side before the rule ever runs. You can't set it from the client any more than you can set your own bank balance. To make &lt;code&gt;isAdmin()&lt;/code&gt; return true, an attacker has to actually authenticate as that account — which means having the password, or the second factor if you've enabled one. The email address itself isn't a secret and doesn't need protecting; knowing it buys you nothing.&lt;/p&gt;

&lt;p&gt;There's a cheaper reason to like it too: it costs &lt;strong&gt;zero document reads&lt;/strong&gt;. A role-collection approach — &lt;code&gt;get(/databases/$(database)/documents/roles/$(request.auth.uid)).data.admin == true&lt;/code&gt; — does a real Firestore read on every single rule evaluation, and those are billable. On a site whose entire purpose is to cost nothing, that's a design constraint, not a micro-optimisation.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where this is the wrong answer
&lt;/h3&gt;

&lt;p&gt;I want to be honest about the trade, because "single admin identified by email" is genuinely bad advice above a team size of one.&lt;/p&gt;

&lt;p&gt;The moment there are two admins, you're editing a rules file and running a deploy to grant access, which is a terrible way to manage people. It doesn't revoke cleanly, it has no audit trail, it can't express "this person can publish posts but not delete apps," and the rules file becomes a personnel record living in git history forever.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The right answer at team scale is custom claims&lt;/strong&gt;: &lt;code&gt;request.auth.token.admin == true&lt;/code&gt;, or &lt;code&gt;request.auth.token.role in ['editor', 'admin']&lt;/code&gt;, set through the Firebase Admin SDK. Claims are still signed, still free to check, and they decouple identity from authorisation — you grant and revoke by running a script against a UID, not by deploying rules. The catch, and the reason I haven't done it, is that setting a custom claim requires somewhere privileged to run the Admin SDK, and I have deliberately no Cloud Functions in this project (they're the main way a "free" Firebase project stops being free). A one-off local Node script against a service account key works fine for a handful of users; a real team wants that wired into an onboarding flow.&lt;/p&gt;

&lt;p&gt;So: email claim for one person, custom claims the moment there are two. Role collections mostly when the permission model is genuinely dynamic and data-driven, and then accept that you're paying a read for every rule evaluation.&lt;/p&gt;

&lt;p&gt;One hardening note I'd add before enabling a second auth provider: check &lt;code&gt;request.auth.token.email_verified == true&lt;/code&gt; alongside the address. With a single email/password account it's redundant, but some identity providers will hand you a token carrying an email the user never proved they own, and the whole model rests on that address meaning something.&lt;/p&gt;

&lt;h2&gt;
  
  
  Public read of published content only
&lt;/h2&gt;

&lt;p&gt;Content collections split into two shapes. The always-public ones are trivial:&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="nx"&gt;match&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;profile&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;docId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;allow&lt;/span&gt; &lt;span class="na"&gt;read&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;allow&lt;/span&gt; &lt;span class="na"&gt;write&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isAdmin&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 ones with drafts need a predicate:&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;isPublished&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;status&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;draft&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;published&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;span class="nx"&gt;match&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;posts&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;docId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;allow&lt;/span&gt; &lt;span class="na"&gt;read&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isPublished&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nf"&gt;isAdmin&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;allow&lt;/span&gt; &lt;span class="nx"&gt;create&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;update&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isAdmin&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 details in there earn their place. &lt;code&gt;resource&lt;/code&gt; (no &lt;code&gt;request.&lt;/code&gt; prefix) is &lt;strong&gt;the document as it currently exists in the database&lt;/strong&gt; — this is a read rule, so we're inspecting stored data, not incoming data. And &lt;code&gt;.get('status', 'draft')&lt;/code&gt; supplies a default, so a legacy document that predates the &lt;code&gt;status&lt;/code&gt; field is treated as a draft rather than blowing up the rule. Defaulting to the &lt;em&gt;closed&lt;/em&gt; state is the right instinct: if the data is ambiguous, deny.&lt;/p&gt;

&lt;p&gt;Splitting &lt;code&gt;write&lt;/code&gt; into &lt;code&gt;create, update, delete&lt;/code&gt; rather than using the &lt;code&gt;write&lt;/code&gt; shorthand is deliberate too. They're identical here, but when I later needed different logic for create versus update on the leads collection, the block was already shaped to take it.&lt;/p&gt;

&lt;h3&gt;
  
  
  The list-query behaviour that looks exactly like a bug
&lt;/h3&gt;

&lt;p&gt;Now the part that generates the most confused Stack Overflow questions in the whole Firebase ecosystem.&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;allow read: if isPublished()&lt;/code&gt; in place, this query fails with &lt;code&gt;permission-denied&lt;/code&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="n"&gt;FirebaseFirestore&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;instance&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'posts'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every single document in that collection might be published. It still fails. And the first time it happens it looks completely broken — you can fetch an individual published post by ID and it works, but listing the collection doesn't.&lt;/p&gt;

&lt;p&gt;The reason is a rule Firebase states plainly and everyone forgets: &lt;strong&gt;security rules are not filters.&lt;/strong&gt; The server does not run your query, fetch the matching documents, evaluate the rule against each one, and quietly drop the failures. That would be a query-cost and information-leak problem, and it would make query latency depend on how much data you can't see. Instead, Firestore evaluates the rule against &lt;em&gt;the query itself&lt;/em&gt; and demands that the query is provably safe before it runs. An unconstrained &lt;code&gt;collection('posts')&lt;/code&gt; could match an unpublished draft, therefore the whole request is denied.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;allow read&lt;/code&gt; covers two operations — &lt;code&gt;get&lt;/code&gt; (one document by ID) and &lt;code&gt;list&lt;/code&gt; (a query). Fetching a published post by ID satisfies the rule because there's a concrete &lt;code&gt;resource&lt;/code&gt; to evaluate. A &lt;code&gt;list&lt;/code&gt; has no single &lt;code&gt;resource&lt;/code&gt;, so Firestore checks whether your query's constraints guarantee the rule holds for everything it could return.&lt;/p&gt;

&lt;p&gt;The fix is to make the constraint explicit in the query:&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;FirebaseFirestore&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;instance&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'posts'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'status'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;isEqualTo:&lt;/span&gt; &lt;span class="s"&gt;'published'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;orderBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'publishedAt'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;descending:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the query itself proves that every document it can return satisfies &lt;code&gt;isPublished()&lt;/code&gt;, and it's allowed. &lt;strong&gt;The denial was correct behaviour and the client was wrong&lt;/strong&gt; — which is exactly the shape of a good security control, and exactly why it feels like a bug.&lt;/p&gt;

&lt;p&gt;Two practical consequences. First, your admin panel and your public site need genuinely different query code, not the same call with a different UI. Second, that &lt;code&gt;where&lt;/code&gt; plus &lt;code&gt;orderBy&lt;/code&gt; combination needs a composite index; mine lives in &lt;code&gt;firestore.indexes.json&lt;/code&gt; as &lt;code&gt;status&lt;/code&gt; ascending plus &lt;code&gt;publishedAt&lt;/code&gt; descending, with a second one adding &lt;code&gt;tags&lt;/code&gt; array-contains for the tag pages. You'll find this out from a console error with a link that creates it for you, but it's worth knowing that &lt;strong&gt;a rule requiring a filter implies an index requiring deployment&lt;/strong&gt; — the two are coupled, and forgetting the second one breaks production while the emulator (which builds indexes on the fly) stays green.&lt;/p&gt;

&lt;h2&gt;
  
  
  Validating request.resource on write
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;resource&lt;/code&gt; is what's in the database. &lt;strong&gt;&lt;code&gt;request.resource&lt;/code&gt; is what the client is trying to put there.&lt;/strong&gt; Read rules inspect the first; write rules inspect the second. Mixing them up is the single most common rules bug I see, and it fails in the dangerous direction — a &lt;code&gt;create&lt;/code&gt; has no existing &lt;code&gt;resource&lt;/code&gt;, so a rule that references &lt;code&gt;resource.data&lt;/code&gt; on create evaluates against null and denies, which you'll notice; but a rule that checks &lt;code&gt;resource.data.ownerId&lt;/code&gt; on &lt;em&gt;update&lt;/em&gt; is checking the stored owner, not the incoming one, which may or may not be what you meant.&lt;/p&gt;

&lt;p&gt;For anything the public can write, the rule needs to constrain the incoming document hard. My contact form is the only such path on the site, and it looks like this:&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="nx"&gt;match&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;leads&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;docId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;allow&lt;/span&gt; &lt;span class="na"&gt;create&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt;
    &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;hasOnly&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;name&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;email&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;subject&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;message&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;budget&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;company&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;read&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;createdAt&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;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="nx"&gt;is&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;size&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;size&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt;
    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;isValidEmail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="nx"&gt;is&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;size&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;size&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;5000&lt;/span&gt;
    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;read&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;createdAt&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;time&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;allow&lt;/span&gt; &lt;span class="nx"&gt;read&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;update&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isAdmin&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four separate defences in there, each closing a specific hole.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The field whitelist.&lt;/strong&gt; &lt;code&gt;keys().hasOnly([...])&lt;/code&gt; is the most important line in the block. Without it, a client can post a perfectly valid contact form that &lt;em&gt;also&lt;/em&gt; carries &lt;code&gt;{isAdmin: true}&lt;/code&gt;, or &lt;code&gt;{read: true}&lt;/code&gt; to hide itself from my inbox, or 400 junk fields. The rule schema and the app's model class drift apart the moment someone adds a field to one and not the other, and a whitelist makes that drift a failed write instead of silent data pollution. Note it's &lt;code&gt;hasOnly&lt;/code&gt;, not &lt;code&gt;hasAll&lt;/code&gt; — &lt;code&gt;hasAll&lt;/code&gt; checks required keys, &lt;code&gt;hasOnly&lt;/code&gt; forbids extras, and you frequently want both.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Size caps on every free-text field.&lt;/strong&gt; A Firestore document can be just under 1 MiB. A &lt;code&gt;message&lt;/code&gt; field with no cap means an anonymous visitor can write a megabyte per submission, as fast as they can loop. That's a storage bill and a bandwidth bill in my admin panel, and it's trivially scriptable. Every string a stranger can set gets a maximum. Note the optional fields get the same treatment — a field being optional doesn't mean it's safe if present:&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="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;company&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;company&lt;/span&gt; &lt;span class="nx"&gt;is&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
        &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;company&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;size&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Server-controlled fields pinned to their initial value.&lt;/strong&gt; &lt;code&gt;read == false&lt;/code&gt; on create means a submission always lands unread; a client can't ship one pre-marked. Any field whose meaning is owned by your backend rather than the user needs pinning like this, or excluding from the whitelist entirely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Never trusting client timestamps.&lt;/strong&gt; &lt;code&gt;request.resource.data.createdAt == request.time&lt;/code&gt; is my favourite line in the file. &lt;code&gt;request.time&lt;/code&gt; is the server's clock at the moment of evaluation. The only way a client can satisfy that equality is by sending &lt;code&gt;FieldValue.serverTimestamp()&lt;/code&gt; — the sentinel that tells Firestore to fill the value in server-side. Anything the client computes itself, including &lt;code&gt;DateTime.now()&lt;/code&gt;, fails. &lt;strong&gt;This turns "please use a server timestamp" from a code-review convention into an enforced invariant.&lt;/strong&gt; Without it, a spammer can backdate submissions to the bottom of my sorted inbox, and more generally, any ordering or expiry logic built on a client clock is built on a value the client fully controls.&lt;/p&gt;

&lt;h2&gt;
  
  
  Storage: content type and size are the whole game
&lt;/h2&gt;

&lt;p&gt;Cloud Storage rules follow the same shape, with two extra levers:&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;isAllowedUpload&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;
    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;contentType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/.*|application/pdf&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;span class="nx"&gt;match&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="kr"&gt;public&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;allPaths&lt;/span&gt;&lt;span class="o"&gt;=**&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;allow&lt;/span&gt; &lt;span class="na"&gt;read&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;allow&lt;/span&gt; &lt;span class="na"&gt;write&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isAdmin&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;isAllowedUpload&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;allow&lt;/span&gt; &lt;span class="na"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isAdmin&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;match&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;allPaths&lt;/span&gt;&lt;span class="o"&gt;=**&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;allow&lt;/span&gt; &lt;span class="nx"&gt;read&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;write&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;request.resource.size&lt;/code&gt; is the byte count of the incoming object and &lt;code&gt;contentType&lt;/code&gt; is its declared MIME type, and on a bucket where uploads are admin-only these are belt-and-braces — but they're the difference between a compromised admin session costing me an afternoon and costing me a bandwidth bill. On any bucket where users can upload, they are not optional.&lt;/p&gt;

&lt;p&gt;The caveat worth naming: &lt;strong&gt;&lt;code&gt;contentType&lt;/code&gt; is client-declared metadata, not a content inspection.&lt;/strong&gt; Restricting to &lt;code&gt;image/.*&lt;/code&gt; stops someone casually uploading a 200 MB video; it does not stop someone uploading a disguised payload with an image content type. Rules can constrain the envelope, not the contents. If what's inside matters, you need processing behind the upload, and that's a different system.&lt;/p&gt;

&lt;p&gt;The structural decision that matters more than either rule: &lt;strong&gt;large binaries don't live in Storage at all.&lt;/strong&gt; APKs and DMGs for my apps sit on GitHub Releases, and the site links out. Download bandwidth is the line item that turns a free Firebase project into a surprising invoice, and the cheapest way to control it is to not serve the large files.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rules are a security boundary, not a validation framework
&lt;/h2&gt;

&lt;p&gt;Everything above might read as an argument for pushing all your validation into rules. It isn't, and the distinction is the most useful thing in this post.&lt;/p&gt;

&lt;p&gt;Rules are the right place for anything that &lt;strong&gt;must&lt;/strong&gt; hold no matter who is calling — the field whitelist, the size caps, the server timestamp, "only the admin writes here." These are properties an attacker must not be able to violate, so they belong on the server side of the trust boundary, and there is no other server side in a Firebase-only architecture.&lt;/p&gt;

&lt;p&gt;Rules are a bad place for everything else, and the constraints of the language tell you why. Rules Language has no loops, no regular-expression capture groups, no way to call out to anything, a hard limit on expression complexity, and a cap on &lt;code&gt;get()&lt;/code&gt; calls per evaluation. You can't cross-validate two documents meaningfully. You get no error message back beyond &lt;code&gt;permission-denied&lt;/code&gt;, so a user who fills a form wrong sees a generic failure with nothing actionable in it. And every additional &lt;code&gt;get()&lt;/code&gt; you add to satisfy a business rule is a billable read on every single request.&lt;/p&gt;

&lt;p&gt;So the split I use:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Business validation lives in the client&lt;/strong&gt;, where you can give a real error message next to the offending field, in real time, before anything is sent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security invariants live in rules&lt;/strong&gt;, where they're unbypassable and where the error being unhelpful is fine, because the only people hitting it are misbehaving.&lt;/li&gt;
&lt;li&gt;Where they overlap — an email must be well-formed, a message must be non-empty — I write it in both places and accept the duplication. The client version is a user experience; the rules version is a guarantee.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The last piece is testing, because a rules file is executable code that ships with no test coverage by default. &lt;code&gt;@firebase/rules-unit-testing&lt;/code&gt; against the emulator lets you assert both directions, and the negative assertions are the ones that matter:&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;assertFails&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;posts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;              &lt;span class="c1"&gt;// unfiltered list denied&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;assertSucceeds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;publishedPostsQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;          &lt;span class="c1"&gt;// filtered list allowed&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;assertFails&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;leads&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;isAdmin&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I run those with &lt;code&gt;firebase emulators:exec&lt;/code&gt; before any rules deploy. Rules changes are the highest-blast-radius deploys in the whole project — one loosened predicate and your drafts are public — and they're also the easiest to get wrong, because the failure is invisible. Nothing errors. Nothing looks different. The data is just readable now.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Your Firebase API key is a project identifier, not a credential&lt;/strong&gt; — everything in the client bundle is public by definition, which makes security rules the only real perimeter your data has.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write the deny-by-default rule first, then justify every hole you open&lt;/strong&gt;, and split &lt;code&gt;write&lt;/code&gt; into &lt;code&gt;create&lt;/code&gt;/&lt;code&gt;update&lt;/code&gt;/&lt;code&gt;delete&lt;/code&gt; so the block is already shaped for the day the logic diverges.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A single admin identified by a signed email claim is correct at team-size one&lt;/strong&gt; and wrong the moment there are two — move to custom claims before you're editing a rules file to onboard a colleague.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security rules are not filters&lt;/strong&gt;: an unfiltered collection query is denied even when every document in it would pass, because Firestore validates the query rather than the results — and the fix is a &lt;code&gt;where&lt;/code&gt; clause plus the composite index it implies.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;resource&lt;/code&gt; is what's stored and &lt;code&gt;request.resource&lt;/code&gt; is what's incoming&lt;/strong&gt; — write rules must constrain the second with a &lt;code&gt;hasOnly&lt;/code&gt; field whitelist, size caps on every free-text field, and server-owned fields pinned to their initial values.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Comparing a timestamp field to &lt;code&gt;request.time&lt;/code&gt; forces the client to use &lt;code&gt;FieldValue.serverTimestamp()&lt;/code&gt;&lt;/strong&gt;, turning "don't trust client clocks" from a convention into an invariant the server enforces.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rules are a security boundary, not a validation framework&lt;/strong&gt; — put unbypassable invariants there, put helpful user-facing validation in the client, and test the denials against the emulator before every deploy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The rules file is the smallest, highest-leverage piece of code in a Firebase project. Mine is 192 lines and it's the only thing standing between an anonymous visitor and every document I own — and because it's server-side, it's the only part of my stack whose guarantees survive a determined person with the browser console open. Write the deny first. Open the narrowest possible hole. Constrain every incoming document by shape and by size. Let the server set the clock. And when a query comes back &lt;code&gt;permission-denied&lt;/code&gt; and the data looks like it should have been readable, consider — before you loosen anything — that the rule might simply be right.&lt;/p&gt;

&lt;p&gt;Most of the mistakes above can be seen without running a single rule, so there is now a VS Code extension that looks for them as you type: &lt;a href="https://devshakib.jumyn.com/apps/ds_rules" rel="noopener noreferrer"&gt;ds_rules&lt;/a&gt; flags open writes, test mode left on, anyone-can-edit and unchecked uploads, and stays quiet about the public reads and admin writes you meant.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://devshakib.jumyn.com/blog/firestore-security-rules-deny-by-default-then-open-the-narrowest-hole" 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>firebase</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <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>
  </channel>
</rss>
