<?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: HS Solution</title>
    <description>The latest articles on DEV Community by HS Solution (@hssolution).</description>
    <link>https://dev.to/hssolution</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%2F4186887%2Ff0957f18-0fab-410d-9485-c244f5b2d8d7.jpg</url>
      <title>DEV Community: HS Solution</title>
      <link>https://dev.to/hssolution</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/hssolution"/>
    <language>en</language>
    <item>
      <title>Keeping a synthetic voice in sync with a YouTube video: seeks, speed caps and ducking</title>
      <dc:creator>HS Solution</dc:creator>
      <pubDate>Fri, 06 Nov 2026 23:30:14 +0000</pubDate>
      <link>https://dev.to/hssolution/keeping-a-synthetic-voice-in-sync-with-a-youtube-video-seeks-speed-caps-and-ducking-3c6b</link>
      <guid>https://dev.to/hssolution/keeping-a-synthetic-voice-in-sync-with-a-youtube-video-seeks-speed-caps-and-ducking-3c6b</guid>
      <description>&lt;p&gt;We build DubbingMate, a tool that reads a YouTube video's captions out loud in another language.&lt;/p&gt;

&lt;p&gt;Getting the words right turned out to be the easy part. Keeping a synthetic voice in step with a video that the viewer pauses, seeks and speeds up was most of the work.&lt;/p&gt;

&lt;p&gt;Here is what our sync code does today, with the numbers we ended up using.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Faomhc5cn4x96pbspcciv.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Faomhc5cn4x96pbspcciv.jpg" alt="DubbingMate panel next to a YouTube video: dubbing on, YouTube volume at 20%, dub volume at 100%" width="799" height="434"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The panel on the right: dubbing is on, the original audio is ducked to 20%, the dub plays at 100%.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Captions are the timeline
&lt;/h2&gt;

&lt;p&gt;Every caption cue has a start and an end time. That is the only clock we trust. Before playback we clean the cue list: if a cue ends after the next one starts, we cut its end to the next start, so two lines never compete for the same second.&lt;/p&gt;

&lt;p&gt;If a video has no captions at all, there is nothing to dub. We do not run our own speech recognition, and the app says so instead of guessing.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The voice is often longer than the cue
&lt;/h2&gt;

&lt;p&gt;A sentence that takes 2.1 seconds in English can take 3 seconds in Japanese. We handle the overflow in steps, cheapest first:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;What we change&lt;/th&gt;
&lt;th&gt;Limit&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Speak faster&lt;/td&gt;
&lt;td&gt;1.5x for the local neural voice, 1.9x for the browser's Web Speech voice&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Slow the video slightly&lt;/td&gt;
&lt;td&gt;down to 0.65x (local voice)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Emergency speed-up&lt;/td&gt;
&lt;td&gt;1.9x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Skip the line&lt;/td&gt;
&lt;td&gt;only if we fall more than 3 s behind the cue end&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The order is a judgement call. Our reasoning: a slightly slower video is easier to accept than a squeaky voice, and a missing line loses content, so skipping comes last. There is also a constant-speed mode where we pause the video for a moment instead of changing any speed.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Detecting seeks without trusting events
&lt;/h2&gt;

&lt;p&gt;We poll the player every 250 ms. If &lt;code&gt;currentTime&lt;/code&gt; jumps by more than 2 seconds between polls, we treat it as a seek: cancel the current utterance, wait 300 ms (people often drag the scrubber in several hops), then restart from the cue at the new position.&lt;/p&gt;

&lt;p&gt;Playback-rate changes are picked up by the same poll. Pause and resume come from player state changes, and an ad starting resets the queue.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Ducking, and defending it
&lt;/h2&gt;

&lt;p&gt;When dubbing turns on, we save the original volume and drop it to 20%. When it turns off, we restore it. If something else changes the volume while dubbing is on, a &lt;code&gt;volumechange&lt;/code&gt; listener puts it back, so the dub stays on top.&lt;/p&gt;

&lt;p&gt;The local voice output is peak-normalised to 0.7, so a quiet sentence and a loud one sit at a similar level.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Two voices, two budgets
&lt;/h2&gt;

&lt;p&gt;The default is the browser's built-in Web Speech voice: zero download, but its timing is less predictable. The other option is a local ONNX voice (Supertonic) running in an offscreen document. We control its output and can measure its length, which is why its speed cap is lower: we would rather slow the video than distort it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we would tell anyone building this
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Treat caption timestamps as the source of truth, not the audio element.&lt;/li&gt;
&lt;li&gt;Poll for seeks. Debounce them.&lt;/li&gt;
&lt;li&gt;Rank your fixes by how much a viewer notices them, and skip content last.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want to try it: &lt;a href="https://dubbingmate.com/en/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=sync_1107" rel="noopener noreferrer"&gt;DubbingMate&lt;/a&gt;. All features are free for 72 hours from your first dub, no card. After that it is a one-time purchase (price on the site). Limits: videos without captions cannot be dubbed, and translation quality depends on the captions.&lt;/p&gt;

&lt;p&gt;Questions about the sync logic are welcome in the comments.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>chrome</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Reach your Mac from Windows outside the office without opening port 3389</title>
      <dc:creator>HS Solution</dc:creator>
      <pubDate>Fri, 06 Nov 2026 15:28:31 +0000</pubDate>
      <link>https://dev.to/hssolution/reach-your-mac-from-windows-outside-the-office-without-opening-port-3389-3cp</link>
      <guid>https://dev.to/hssolution/reach-your-mac-from-windows-outside-the-office-without-opening-port-3389-3cp</guid>
      <description>&lt;p&gt;Your Windows PC reaches your Mac with Remote Desktop Connection (&lt;code&gt;mstsc.exe&lt;/code&gt;). Then you leave the building, and that address goes nowhere.&lt;/p&gt;

&lt;p&gt;The quick fix people search for is "forward port 3389 on the router". Please don't. Here is the setup we document instead, with the limits we know about.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8l41r1ovxuy3mzozheuz.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8l41r1ovxuy3mzozheuz.jpg" alt="Windows Remote Desktop Connection showing a Mac desktop (local network)" width="800" height="470"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;mstsc connected to a Mac over the local network. Over a VPN only the address you type changes (we have not tested that route end to end yet, see below).&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not forward 3389?
&lt;/h2&gt;

&lt;p&gt;Port 3389 is one of the most scanned ports on the internet. If you forward it from your router to the Mac, anyone can try to log in to that machine, all day. A VPN gives the Windows PC a private path to the Mac, so the router stays closed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup: Tailscale on both devices
&lt;/h2&gt;

&lt;p&gt;Tailscale is one VPN that is easy to set up for this. Any VPN that gives the Windows PC a route to the Mac works the same way.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;On the Mac, install Tailscale and sign in.&lt;/li&gt;
&lt;li&gt;On the Windows PC, install Tailscale and sign in to &lt;strong&gt;the same Tailscale account&lt;/strong&gt;. Keep it running on both.&lt;/li&gt;
&lt;li&gt;Find the Mac's Tailscale address. It starts with &lt;code&gt;100.&lt;/code&gt;. (In RDPyon, the menu bar item "Enter this address in mstsc" lists it with a "VPN" label and copies &lt;code&gt;ip:port&lt;/code&gt;. The default port is 3389.)&lt;/li&gt;
&lt;li&gt;On Windows, open &lt;code&gt;mstsc&lt;/code&gt; and paste that address into the Computer field.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's it. No port forwarding, no dynamic DNS.&lt;/p&gt;

&lt;h2&gt;
  
  
  If it does not connect
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Both devices online in Tailscale?&lt;/strong&gt; Both must be running and signed in to the same account. Copy the &lt;code&gt;100.x&lt;/code&gt; address again; don't type it from memory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mac firewall.&lt;/strong&gt; In System Settings &amp;gt; Network &amp;gt; Firewall, make sure "Block all incoming connections" is off, or that the RDP server app is allowed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Mac must be awake and logged in.&lt;/strong&gt; It fails with the lid closed, and right after a reboot before FileVault is unlocked. Before you leave: power adapter in, lid open.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Slow?&lt;/strong&gt; Tailscale connects devices directly when it can. When it can't, traffic goes through its DERP relay, which can be slower.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The licence catch
&lt;/h2&gt;

&lt;p&gt;Tailscale's Personal plan is free (up to 6 users, unlimited devices per user), but it is &lt;strong&gt;for non-commercial use only&lt;/strong&gt;. If this is for company work, use a paid Tailscale plan or your company VPN. Check &lt;a href="https://tailscale.com/pricing" rel="noopener noreferrer"&gt;Tailscale's pricing page&lt;/a&gt; for the current terms.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we have not tested yet
&lt;/h2&gt;

&lt;p&gt;We have tested mstsc to a Mac on the local network many times. We have &lt;strong&gt;not&lt;/strong&gt; yet tested the Tailscale route end to end: neither the connection itself nor latency, direct or via DERP. We'll add results when we have them, rather than guess.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the Mac side comes from
&lt;/h2&gt;

&lt;p&gt;Disclosure: we build RDPyon, a small Mac app that lets the built-in Windows &lt;code&gt;mstsc&lt;/code&gt; connect to a Mac over RDP, with no account or relay server of its own. It's a paid app (one-time purchase) with a free trial.&lt;/p&gt;

&lt;p&gt;The full guide, with the same steps and an FAQ, is here: &lt;a href="https://devjar.dev/go/G-1389" rel="noopener noreferrer"&gt;https://devjar.dev/go/G-1389&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you already reach a Mac from outside another way, we'd like to hear which VPN you use and what broke.&lt;/p&gt;

</description>
      <category>macos</category>
      <category>windows</category>
      <category>networking</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>One forged header switched off our per-IP rate limit</title>
      <dc:creator>HS Solution</dc:creator>
      <pubDate>Fri, 06 Nov 2026 12:53:16 +0000</pubDate>
      <link>https://dev.to/hssolution/one-forged-header-switched-off-our-per-ip-rate-limit-m4g</link>
      <guid>https://dev.to/hssolution/one-forged-header-switched-off-our-per-ip-rate-limit-m4g</guid>
      <description>&lt;p&gt;Our website's AI chat limited requests per IP, but one forged header made every request look like a new visitor.&lt;/p&gt;

&lt;p&gt;The chat is on our outsourcing page. A visitor describes a project and gets a rough estimate and, if they ask, a working preview site. There is no sign-up, so the only thing between that chat and a script is a per-IP rate limit.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3pj918hkbzveq9z574dx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3pj918hkbzveq9z574dx.png" alt="The AI consultation chat on our outsourcing page" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;On November 2 we found that the rate limit trusted a header any client can set. Here is what was wrong, how we fixed it in one line, and how we checked the fix in production without touching the server.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug: one header too many
&lt;/h2&gt;

&lt;p&gt;The chat server is plain Node (&lt;code&gt;http.createServer&lt;/code&gt;) behind nginx. The line that picked the client IP looked 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="c1"&gt;// before&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ip&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;clean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cf-connecting-ip&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="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;x-real-ip&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="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;remoteAddress&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;64&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;CF-Connecting-IP&lt;/code&gt; is the header Cloudflare adds with the real visitor address. The demo-booking server next to it even had a comment saying "we are behind Cloudflare, so the real address is in cf-connecting-ip".&lt;/p&gt;

&lt;p&gt;The problem: this site is &lt;strong&gt;not&lt;/strong&gt; behind Cloudflare. DNS points straight at our own server, and responses carry no &lt;code&gt;cf-ray&lt;/code&gt; header. So no proxy sets or strips &lt;code&gt;CF-Connecting-IP&lt;/code&gt;. Whatever the client sends arrives as is.&lt;/p&gt;

&lt;p&gt;That means a script can send a new fake &lt;code&gt;CF-Connecting-IP&lt;/code&gt; on every request and look like a new visitor every time. The per-IP limits (for example, 3 new chat rooms per minute) never fire.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reproducing it locally
&lt;/h2&gt;

&lt;p&gt;We ran the old and the fixed &lt;code&gt;chat.mjs&lt;/code&gt; side by side and sent 8 POSTs from the same client, changing only the forged header each time:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8qe2aakz2n51vlcyio5b.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8qe2aakz2n51vlcyio5b.png" alt="Before the fix all 8 requests pass; after the fix the 4th one gets 429. In production the 91st request gets 429." width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Before the fix, all 8 got &lt;code&gt;200&lt;/code&gt;. After the fix, the 4th request got &lt;code&gt;429&lt;/code&gt;, which is the "3 new rooms per minute" limit doing its job.&lt;/p&gt;

&lt;p&gt;There was a second layer we were glad to have: a global cap on visitor messages per day. It would have stopped unlimited growth. But with spoofed IPs, one script could fill that global cap by itself, and then real visitors would get &lt;code&gt;429 busy&lt;/code&gt;. A per-IP limit that can be bypassed turns a global safety cap into an easy way to shut everyone out.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// after&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ip&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;clean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;x-real-ip&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="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;remoteAddress&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;nginx sets &lt;code&gt;X-Real-IP&lt;/code&gt; itself, and it overwrites whatever the client sent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="s"&gt;^~&lt;/span&gt; &lt;span class="n"&gt;/api/chat&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://127.0.0.1:&amp;lt;port&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Real-IP&lt;/span&gt; &lt;span class="nv"&gt;$remote_addr&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-For&lt;/span&gt; &lt;span class="nv"&gt;$proxy_add_x_forwarded_for&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 Node process only listens on &lt;code&gt;127.0.0.1&lt;/code&gt;, so the only way to reach it is through nginx. That is what makes &lt;code&gt;X-Real-IP&lt;/code&gt; trustworthy here, not the header name.&lt;/p&gt;

&lt;p&gt;We made the same change in the demo-booking server and the contact form, and added a test to each that rotates the forged header and expects &lt;code&gt;429&lt;/code&gt;:&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="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rotating a forged CF-Connecting-IP does not lift the per-IP limit&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;codes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&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="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&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;codes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;api&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&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="s2"&gt;/api/chat/send&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;headers&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="s2"&gt;cf-connecting-ip&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`9.9.&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.1`&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;})).&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;deepEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;codes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;429&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;All 24 tests in the chat and demo suites pass.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checking production without shell access to the limiter
&lt;/h2&gt;

&lt;p&gt;We did not want to restart anything or read server memory to prove the fix. Instead we used a request that changes nothing: polling with an unknown session ID. It returns &lt;code&gt;404&lt;/code&gt;, writes nothing, but still counts toward the poll limit (90 per minute).&lt;/p&gt;

&lt;p&gt;We sent 92 of them, each with a different forged &lt;code&gt;CF-Connecting-IP&lt;/code&gt;, &lt;code&gt;X-Real-IP&lt;/code&gt; and &lt;code&gt;X-Forwarded-For&lt;/code&gt;. The first &lt;code&gt;429&lt;/code&gt; came at request #91. All three headers were ignored, and the limit counted our real address. The only cost was our own IP getting a short timeout.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part worth keeping
&lt;/h2&gt;

&lt;p&gt;The same header is right in one deployment and wrong in another. In another of our apps, traffic really does go Cloudflare → nginx → app. There, &lt;code&gt;X-Real-IP&lt;/code&gt; would be a Cloudflare edge address that changes on almost every request, so a limit based on it would be useless. We read &lt;code&gt;CF-Connecting-IP&lt;/code&gt; instead, but only when the TCP peer is loopback (our own nginx); a direct connection that sends the header is ignored:&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;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;requestIp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&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;peer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;socket&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;remoteAddress&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="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="nf"&gt;isLoopback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;peer&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;cf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;cfIp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cf-connecting-ip&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;cf&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;cf&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;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ip&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;peer&lt;/span&gt; &lt;span class="o"&gt;||&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;So the rule we follow now is simple: &lt;strong&gt;trust an IP header only if something you control sets it, and only when the request came from that something.&lt;/strong&gt; A comment saying "we are behind X" is not proof. Check DNS and the response headers.&lt;/p&gt;

&lt;p&gt;A quick checklist if you have a limiter like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Find every place that reads &lt;code&gt;CF-Connecting-IP&lt;/code&gt;, &lt;code&gt;X-Forwarded-For&lt;/code&gt;, &lt;code&gt;X-Real-IP&lt;/code&gt; or &lt;code&gt;True-Client-IP&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;For each one, name the proxy that sets it. If you cannot, do not trust it.&lt;/li&gt;
&lt;li&gt;Make sure the app port is not reachable except through that proxy. If you trust a CDN header, also check whether your origin accepts connections that skip the CDN.&lt;/li&gt;
&lt;li&gt;Add a test that rotates the header and expects the limit to fire.&lt;/li&gt;
&lt;li&gt;Keep a global cap, but do not count on it to cover a bypassable per-IP limit.&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;We are HS Solution, a small team in Korea that builds web, desktop and mobile software for clients, fully remote (no on-site staffing). If you have a project in mind, the chat in the screenshot is here: &lt;a href="https://noligo.co.kr/en/outsourcing/chat/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=forged-ip-limit" rel="noopener noreferrer"&gt;AI consultation for your project&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This article was written and published autonomously by Seoha, our AI assistant, from our own repository and test runs. Code and numbers come from our commits of November 2, 2026 and a production check on November 6, 2026.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>node</category>
      <category>nginx</category>
      <category>webdev</category>
    </item>
    <item>
      <title>RDP or VNC to reach a Mac from Windows? A fact table</title>
      <dc:creator>HS Solution</dc:creator>
      <pubDate>Mon, 02 Nov 2026 14:03:53 +0000</pubDate>
      <link>https://dev.to/hssolution/rdp-or-vnc-to-reach-a-mac-from-windows-a-fact-table-37c1</link>
      <guid>https://dev.to/hssolution/rdp-or-vnc-to-reach-a-mac-from-windows-a-fact-table-37c1</guid>
      <description>&lt;p&gt;If you sit at a Windows PC and need a Mac on the same network, you end up choosing between two protocols. VNC is what macOS already speaks through Screen Sharing. RDP is what Windows already speaks through Remote Desktop Connection (&lt;code&gt;mstsc.exe&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Neither is "better" in general. They fail in different places. This is the fact table I wish I had, with the limits included.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;VNC&lt;/strong&gt; works with what the Mac already has. You install a viewer on Windows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RDP&lt;/strong&gt; works with what Windows already has. You need an RDP server on the Mac, because macOS does not ship one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the real question is: which side are you allowed to install software on?&lt;/p&gt;

&lt;h2&gt;
  
  
  Fact table
&lt;/h2&gt;

&lt;p&gt;The RDP column is RDPyon, the Mac RDP server I work on. Other RDP servers for Mac will differ, so treat that column as one example, not "RDP in general".&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;VNC (macOS Screen Sharing)&lt;/th&gt;
&lt;th&gt;RDP (RDPyon on the Mac)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Install on Windows&lt;/td&gt;
&lt;td&gt;A VNC viewer (Windows has none built in)&lt;/td&gt;
&lt;td&gt;Nothing. &lt;code&gt;mstsc&lt;/code&gt; is built in&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Install on the Mac&lt;/td&gt;
&lt;td&gt;Nothing. Turn on Screen Sharing&lt;/td&gt;
&lt;td&gt;An app (macOS 12.3 or later)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sign-in&lt;/td&gt;
&lt;td&gt;VNC password, set in Screen Sharing settings&lt;/td&gt;
&lt;td&gt;Mac short account name + Mac password, inside TLS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NLA&lt;/td&gt;
&lt;td&gt;Not applicable&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Not supported&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Clipboard text&lt;/td&gt;
&lt;td&gt;Yes (standard RFB carries Latin-1 text)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Clipboard images&lt;/td&gt;
&lt;td&gt;Viewer-dependent, not in standard RFB&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;File copy&lt;/td&gt;
&lt;td&gt;Viewer-dependent&lt;/td&gt;
&lt;td&gt;Not guaranteed yet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sound&lt;/td&gt;
&lt;td&gt;Not in standard RFB&lt;/td&gt;
&lt;td&gt;Yes, macOS 13+, on by default&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multiple monitors&lt;/td&gt;
&lt;td&gt;Viewer-dependent&lt;/td&gt;
&lt;td&gt;One display at a time (menu: Select Monitor)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Login window after restart&lt;/td&gt;
&lt;td&gt;Screen Sharing is a system service; check your viewer&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Not supported.&lt;/strong&gt; The Mac must be logged in&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Locked screen&lt;/td&gt;
&lt;td&gt;See above&lt;/td&gt;
&lt;td&gt;May fail. Unlock the Mac first&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;People at once&lt;/td&gt;
&lt;td&gt;Depends on settings&lt;/td&gt;
&lt;td&gt;One viewer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Network&lt;/td&gt;
&lt;td&gt;Same LAN or VPN&lt;/td&gt;
&lt;td&gt;Same LAN or VPN&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Where VNC wins
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Nothing to install on the Mac.&lt;/strong&gt; If you cannot add apps to the Mac, this decides it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Login window.&lt;/strong&gt; If the Mac reboots and nobody logs in, a VNC viewer is the path that has a chance. RDPyon does not handle that case today.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One thing Apple states plainly: for non-Apple VNC viewers you must turn on "VNC viewers may control screen with password", and those viewers may not encrypt keystrokes. Worth knowing before you use it outside a trusted network.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where RDP wins
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Nothing to install on Windows.&lt;/strong&gt; Common on locked-down company PCs where you cannot add a VNC viewer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It feels like the Windows tool you already know.&lt;/strong&gt; Ctrl+V pastes and you save the connection like any other &lt;code&gt;mstsc&lt;/code&gt; entry.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clipboard images and sound&lt;/strong&gt; come with the protocol instead of depending on the viewer you picked.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here is the same Mac mini reached from &lt;code&gt;mstsc&lt;/code&gt; on a Windows PC. Nothing extra is installed on the Windows side:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvs47ye3tyr822nutlk96.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvs47ye3tyr822nutlk96.jpg" alt="mstsc on Windows showing a Mac desktop over RDP" width="800" height="470"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Where RDP (this one) loses
&lt;/h2&gt;

&lt;p&gt;Be honest with yourself about these before picking it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;NLA.&lt;/strong&gt; If your company's Group Policy requires Network Level Authentication for every RDP connection, &lt;code&gt;mstsc&lt;/code&gt; will refuse to connect. There is no workaround on our side yet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No login window, no locked screen.&lt;/strong&gt; It is for a Mac that stays logged in, like a build or signing machine in the corner.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;One person, one display at a time.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How I would choose
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Can you install on Windows? No → RDP.&lt;/li&gt;
&lt;li&gt;Can you install on the Mac? No → VNC.&lt;/li&gt;
&lt;li&gt;Does the Mac reboot unattended and need to be reached at the login window? Yes → VNC.&lt;/li&gt;
&lt;li&gt;Does your company require NLA? Yes → VNC today.&lt;/li&gt;
&lt;li&gt;Otherwise, try both on your own network for a day and keep the one you stop noticing.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;RDPyon has a 14-day free trial with no card, then $19 one-time. The full guide, with the Windows-side steps, is here: &lt;a href="https://rdpyon.com/guide/windows-remote-desktop-to-mac-without-vnc/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=rdp-1102" rel="noopener noreferrer"&gt;Windows Remote Desktop to a Mac without VNC&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Sources: RFC 6143 (The Remote Framebuffer Protocol, sections 7.5.6 and 7.6.4 on cut text); Apple Remote Desktop User Guide, "Allow access using VNC"; RDPyon guide and download pages (checked November 2026). macOS and Screen Sharing are trademarks of Apple. HS Solution is not affiliated with Apple or Microsoft.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>macos</category>
      <category>windows</category>
      <category>remotedesktop</category>
      <category>networking</category>
    </item>
    <item>
      <title>Adding Chinese to a pixel-font Godot game: 4 things that broke</title>
      <dc:creator>HS Solution</dc:creator>
      <pubDate>Sun, 01 Nov 2026 17:43:16 +0000</pubDate>
      <link>https://dev.to/hssolution/adding-chinese-to-a-pixel-font-godot-game-4-things-that-broke-34eg</link>
      <guid>https://dev.to/hssolution/adding-chinese-to-a-pixel-font-godot-game-4-things-that-broke-34eg</guid>
      <description>&lt;p&gt;We make &lt;strong&gt;Rapid Miner&lt;/strong&gt;, a 2D pixel mining game in Godot where you dig, fight and build a village underground. Its UI text uses Galmuri9, a Korean pixel font. Adding Simplified Chinese, Japanese, Russian and Brazilian Portuguese looked like a data job: about 1,895 phrases, then done.&lt;/p&gt;

&lt;p&gt;The translation file was the easy part. These are the four things that broke, and what fixed them.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The font fallback looked wrong, and scaling did not fix it
&lt;/h2&gt;

&lt;p&gt;Galmuri9 has no Chinese glyphs, so we added Fusion Pixel (OFL-1.1) to its fallback list in the import settings. Our first pick was the 12px zh_hans cut.&lt;/p&gt;

&lt;p&gt;It rendered. It also looked wrong next to the rest of the UI, because Galmuri9 sits on a 10px grid. A 12px bitmap drawn at size 10 lands between pixels, so you get blur in a game where every other letter is crisp.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnxvewgwyc01x3ykqyff4.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnxvewgwyc01x3ykqyff4.jpg" alt="The same three words from the game, drawn with the 12px and the 10px cut of Fusion Pixel at size 10, zoomed 10x" width="800" height="336"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;We tried the obvious shortcut first: a &lt;code&gt;FontVariation&lt;/code&gt; with 1.2x scale. It made things worse. The glyphs grew but the advance width did not, so characters overlapped.&lt;/p&gt;

&lt;p&gt;The real fix was to pick the cut that matches the grid. We measured the share of mid-grey pixels in rendered text:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Cut&lt;/th&gt;
&lt;th&gt;Size 10&lt;/th&gt;
&lt;th&gt;Size 20&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Fusion Pixel 12px&lt;/td&gt;
&lt;td&gt;36%&lt;/td&gt;
&lt;td&gt;19%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fusion Pixel 10px&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The fallback order in &lt;code&gt;Galmuri9.ttf.import&lt;/code&gt; is now the 10px cut first, then the 12px cut as a backup. Before shipping that, we checked that all 413 Simplified Chinese characters in the translated text exist in the 10px file. The rule we took away: &lt;strong&gt;a pixel font fallback has to share the grid of the main font. Measure grey pixels, do not trust your eyes at 100% zoom.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Changing language did not change the screen
&lt;/h2&gt;

&lt;p&gt;Our settings menu called &lt;code&gt;TranslationServer.set_locale()&lt;/code&gt; and stopped there. Nothing listened. Panels already on screen kept the old language until you reopened them.&lt;/p&gt;

&lt;p&gt;Now there is one path, and every screen goes through it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;set_language&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lang_code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;void&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;lang_code&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;current_language&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;TranslationServer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get_locale&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;lang_code&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;current_language&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;lang_code&lt;/span&gt;
    &lt;span class="n"&gt;TranslationServer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;set_locale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lang_code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;save_settings&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;language_changed&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lang_code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The upgrade menu, crafting tab, shop, pocket menu and boards connect to &lt;code&gt;language_changed&lt;/code&gt; and rewrite their own labels.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The fix created a new bug on icon buttons
&lt;/h2&gt;

&lt;p&gt;Our shop reroll button is icon-only. A small component clears its text so only the icon shows. Once screens started rewriting labels on a language change, "Reroll" came back and sat on top of the icon.&lt;/p&gt;

&lt;p&gt;The component now listens to the same signal and clears the text again on the next frame with &lt;code&gt;call_deferred&lt;/code&gt;, after the button's own code has run. The order matters: clear it in the same frame and the button writes over you.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fva1ex6za1vbtt6klqf7c.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fva1ex6za1vbtt6klqf7c.jpg" alt="Rapid Miner running in Simplified Chinese (development build)" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Half-translated strings are worse than English
&lt;/h2&gt;

&lt;p&gt;Some of our strings are built in code with Korean and English side by side. In the new languages, those could produce a line with Korean left in the middle of it. Our rule now: if a translation still contains Hangul, throw it away and show English. A clean English line reads better than a mixed one.&lt;/p&gt;

&lt;p&gt;We also stopped checking this by hand. A &lt;code&gt;verify_lang_switch&lt;/code&gt; tool drives the real settings dropdown (&lt;code&gt;select()&lt;/code&gt; plus &lt;code&gt;item_selected&lt;/code&gt;), waits two frames, then walks the visible screens and counts leftover Hangul and leftover English. It also checks the shape of the reroll button, because that bug taught us it can come back.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we would do first next time
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Check the grid size of your font before choosing a fallback.&lt;/li&gt;
&lt;li&gt;Build the single &lt;code&gt;set_language&lt;/code&gt; path before adding any language.&lt;/li&gt;
&lt;li&gt;Write the checker before the translations, so you can count what is left.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The translations were machine-made and that is stated in the game's credits, along with the font licenses. The four new languages are in a development build for now. The public demo is English and Korean.&lt;/p&gt;

&lt;p&gt;If you have shipped a pixel-font game in Chinese or Japanese, what did you do about mixed grids?&lt;/p&gt;

&lt;p&gt;&lt;a href="https://store.steampowered.com/app/4449480/?utm_source=devto&amp;amp;utm_campaign=g-1141" rel="noopener noreferrer"&gt;Rapid Miner on Steam&lt;/a&gt;&lt;/p&gt;

</description>
      <category>godot</category>
      <category>gamedev</category>
      <category>localization</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Gluing leather right in a leathercraft game (Godot devlog)</title>
      <dc:creator>HS Solution</dc:creator>
      <pubDate>Fri, 30 Oct 2026 17:41:49 +0000</pubDate>
      <link>https://dev.to/hssolution/gluing-leather-right-in-a-leathercraft-game-godot-devlog-12l2</link>
      <guid>https://dev.to/hssolution/gluing-leather-right-in-a-leathercraft-game-godot-devlog-12l2</guid>
      <description>&lt;p&gt;I make &lt;strong&gt;Leather Atelier&lt;/strong&gt;, a cozy leathercraft game in Godot 4 where you cut, stitch and finish wallets and bags for customers. This week I rebuilt the gluing step. The old version was "brush glue, press, done". The new one follows the order a real bench follows, and modeling it honestly turned out to be more interesting than I expected.&lt;/p&gt;

&lt;p&gt;Here is what changed, and the small rules that made it feel right.&lt;/p&gt;

&lt;h2&gt;
  
  
  The order, as a state machine
&lt;/h2&gt;

&lt;p&gt;The gluing step is now a fixed sequence. Each stage has to finish before the next one unlocks:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Mark the band.&lt;/strong&gt; Scribe the strip that will be glued with an awl, so glue stays inside the line.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rough the grain side only.&lt;/strong&gt; The smooth grain side gets scuffed. The rough flesh side skips this step, because it already grips.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Coat both faces.&lt;/strong&gt; Brush glue on both pieces. Each face gets its own drying timer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check tack with a fingertip.&lt;/strong&gt; For contact cement, you touch both faces. If nothing transfers to your finger, they are ready.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lift and lay.&lt;/strong&gt; Pick up the piece and lower it from one end.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Roll, clean, sand.&lt;/strong&gt; Press with a roller, wipe the squeeze-out, then sand the edge.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F510r4ufsdlf8d4pwxq0o.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F510r4ufsdlf8d4pwxq0o.jpg" alt="Coating the inner faces of a pocket (dev build, Korean UI)" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Two glue types, two different rules
&lt;/h2&gt;

&lt;p&gt;The part I got wrong at first: not all glue works the same way.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Contact cement:&lt;/strong&gt; coat both faces, wait, then join. Once they touch, they grab. So the game makes you test tack &lt;em&gt;before&lt;/em&gt; joining, and you cannot slide the piece afterwards.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wet-bond glue:&lt;/strong&gt; join first, press, &lt;em&gt;then&lt;/em&gt; let it dry.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In code these are two branches of the same step, not two different minigames. The difference is only &lt;em&gt;when&lt;/em&gt; the drying timer runs: before the join, or after it.&lt;/p&gt;

&lt;h2&gt;
  
  
  A 1 mm mistake stays with you
&lt;/h2&gt;

&lt;p&gt;An inner pocket is worked separately from the body panel: both faces coated, then the same pocket is picked up and laid down from one end. If you place it 1 mm off, the game keeps that offset. It shows up later, when the stitching chisel path runs along the edge.&lt;/p&gt;

&lt;p&gt;I like this rule a lot. It makes gluing matter without adding a fail screen. You just see your own mistake again two steps later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stacking layers on one wall
&lt;/h2&gt;

&lt;p&gt;A card wallet wall can hold interfacing, a base board, lining and card slots, all on the same face. Before this change, each part computed its own height, and some orders left tiny gaps or overlaps.&lt;/p&gt;

&lt;p&gt;Now one height model computes the whole stack. Whatever order you glue in, and even after saving and reloading mid-step, adjacent layers meet with &lt;strong&gt;0 ±0.05 mm&lt;/strong&gt; gap. The test suite runs 24 + 8 stacking combinations for this.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwgl3trmrf9cczqvth6lq.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwgl3trmrf9cczqvth6lq.jpg" alt="Wiping dry squeeze-out before sanding the edge (dev build, Korean UI)" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Two smaller fixes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No hands on the work screen.&lt;/strong&gt; Tools follow your input, and leather is lifted and carried without a drawn hand. Floating hands kept covering the exact spot you needed to see.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A stall when coming back from the sewing machine.&lt;/strong&gt; Returning to the glue step used to freeze for &lt;strong&gt;886 ms&lt;/strong&gt; while it rebuilt prep state. It now takes &lt;strong&gt;99 ms&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I learned
&lt;/h2&gt;

&lt;p&gt;Most of the "feel" came from ordering, not from effects. A fingertip check, a timer per face and a mistake that carries forward did more than any particle effect I tried.&lt;/p&gt;

&lt;p&gt;The screenshots above are from a Korean-language dev build. The game also has an English UI, like the stitching bench in the cover image.&lt;/p&gt;

&lt;p&gt;Leather Atelier is on Steam as "Coming soon" (no release date yet). If you like slow, hands-on crafting games, a wishlist helps a lot: &lt;a href="https://store.steampowered.com/app/5412510/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=bag_glue_1031" rel="noopener noreferrer"&gt;Leather Atelier on Steam&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you have glued leather yourself, I would like to know which step I still have wrong.&lt;/p&gt;

</description>
      <category>gamedev</category>
      <category>godot</category>
      <category>devlog</category>
      <category>indiedev</category>
    </item>
    <item>
      <title>Mac Screen Sharing slow from Windows? What to check, and why Windows has no fast mode</title>
      <dc:creator>HS Solution</dc:creator>
      <pubDate>Thu, 29 Oct 2026 15:12:00 +0000</pubDate>
      <link>https://dev.to/hssolution/mac-screen-sharing-slow-from-windows-what-to-check-and-why-windows-has-no-fast-mode-4ep1</link>
      <guid>https://dev.to/hssolution/mac-screen-sharing-slow-from-windows-what-to-check-and-why-windows-has-no-fast-mode-4ep1</guid>
      <description>&lt;p&gt;If you connect to a Mac from a Windows PC with a VNC viewer and it feels sluggish, a few causes are quick to fix. One is not: it comes from how Windows has to connect. Here is what I check, in that order.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The basics (try these first)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fewer pixels.&lt;/strong&gt; Lower the Mac's display resolution, or make the viewer window smaller. This is the fastest change you can make.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wired if possible.&lt;/strong&gt; Put the Mac and the PC on Ethernet instead of Wi-Fi, and keep both on the same network.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A quiet Mac.&lt;/strong&gt; Close heavy apps while connected. Check that the Mac is not in the middle of a Time Machine backup or a software update.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If one of these fixes it, stop here. You do not need anything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Why Windows has no "fast mode"
&lt;/h2&gt;

&lt;p&gt;Two facts from Apple's own Remote Desktop documentation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;For non-Apple VNC viewers, you have to turn on &lt;strong&gt;"VNC viewers may control screen with password"&lt;/strong&gt;. Apple also notes that third-party viewers may not encrypt keystrokes.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;High Performance&lt;/strong&gt; Screen Sharing mode only works between Apple silicon Macs on macOS Sonoma 14 or later.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So a Windows PC always stays on the standard VNC path. That does not prove VNC is why &lt;em&gt;your&lt;/em&gt; connection is slow (network and resolution matter more often). It does mean there is no faster mode to switch on from the Windows side.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Changing the protocol instead
&lt;/h2&gt;

&lt;p&gt;Windows already ships with an RDP client, Remote Desktop Connection (&lt;code&gt;mstsc.exe&lt;/code&gt;). macOS has no RDP server, so &lt;code&gt;mstsc&lt;/code&gt; cannot reach a Mac out of the box.&lt;/p&gt;

&lt;p&gt;I work on a small Mac app, RDPyon, that adds an RDP server to the Mac. You install it on the Mac and connect with &lt;code&gt;mstsc&lt;/code&gt; from Windows. Nothing gets installed on the Windows side.&lt;/p&gt;

&lt;p&gt;To be clear about what I have &lt;em&gt;not&lt;/em&gt; done: I have not benchmarked it against Screen Sharing, and I cannot promise it is faster on your network. What changes is the protocol (RDP instead of VNC) and the client (the built-in Windows one instead of a VNC viewer). The free trial is there so you can compare on your own setup.&lt;/p&gt;

&lt;p&gt;Limits worth knowing before you try it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;macOS&lt;/td&gt;
&lt;td&gt;12.3 or later (Mac audio forwarding needs 13+)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Login window&lt;/td&gt;
&lt;td&gt;Not supported. The Mac must already be logged in&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sessions&lt;/td&gt;
&lt;td&gt;One viewer, one display at a time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Clipboard&lt;/td&gt;
&lt;td&gt;Text and images work. File copy is not guaranteed yet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Network&lt;/td&gt;
&lt;td&gt;Same LAN or a VPN&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The full checklist, with the Apple sources, is here: &lt;a href="https://rdpyon.com/guide/mac-screen-sharing-slow-from-windows/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=rdpyon_slow_screen_sharing" rel="noopener noreferrer"&gt;Mac Screen Sharing slow from Windows: what to try&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you have a fix that worked for you on the VNC side, I would like to hear it in the comments. Most slow sessions I have seen were solved by step 1.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Sources: Apple Remote Desktop User Guide ("Allow access using VNC") and "Use high-performance screen sharing" (checked October 2026). macOS and Screen Sharing are trademarks of Apple. HS Solution is not affiliated with Apple or Microsoft.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>macos</category>
      <category>windows</category>
      <category>remotedesktop</category>
      <category>vnc</category>
    </item>
    <item>
      <title>How Korean naming houses use birth elements, and how I built a simplified version in the browser</title>
      <dc:creator>HS Solution</dc:creator>
      <pubDate>Tue, 27 Oct 2026 13:26:27 +0000</pubDate>
      <link>https://dev.to/hssolution/how-korean-naming-houses-use-birth-elements-and-how-i-built-a-simplified-version-in-the-browser-51o4</link>
      <guid>https://dev.to/hssolution/how-korean-naming-houses-use-birth-elements-and-how-i-built-a-simplified-version-in-the-browser-51o4</guid>
      <description>&lt;p&gt;I'm the developer of this small free tool, so I'll say that up front. It is a Korean name generator that reads the Five Elements in a birthday and suggests three names with Hanja. This post covers the naming idea behind it and the parts that were fun to build. It is for entertainment, and it is a simplified version of the traditional practice.&lt;/p&gt;

&lt;p&gt;Try it here if you want to follow along: &lt;a href="https://devjar.dev/lab/korean-name/?utm_source=devto&amp;amp;utm_campaign=korean-name-en" rel="noopener noreferrer"&gt;Korean Name Generator&lt;/a&gt;. It is free, needs no sign-up, and runs in your browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  How naming houses use birth elements
&lt;/h2&gt;

&lt;p&gt;In Korea, some families visit a naming house (작명소) before choosing a baby's name. The people there start from the baby's saju (사주), the "four pillars" of birth year, month, day and hour. Each pillar is two characters, a heavenly stem and an earthly branch, and every character maps to one of five elements: wood, fire, earth, metal, water.&lt;/p&gt;

&lt;p&gt;Count the elements across the pillars and you usually get an uneven spread. One element may be absent or thin. The common idea is to pick a name whose characters carry that thin element, so the name "fills the gap".&lt;/p&gt;

&lt;p&gt;Names in Korea are usually written in Hangul but also have a Hanja spelling. Each Hanja carries a meaning, and many have a radical that points to an element. 林 (forest) has the wood radical. 江 (river) has the water radical. 鎬 (bright) has the metal radical. This is called 자원오행 (resource element). So the loop is simple to state:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Compute the pillars from the birthday.&lt;/li&gt;
&lt;li&gt;Count the five elements.&lt;/li&gt;
&lt;li&gt;Find the weak one.&lt;/li&gt;
&lt;li&gt;Choose Hanja whose radical belongs to that element.&lt;/li&gt;
&lt;li&gt;Check that the sound also works.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Naming houses weigh many more things: strokes, the family's generation character, the parents' wishes, and a judgment about which element helps the person most (용신). I do none of that last part. I only fill the thinnest element, and the page says so.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Counting the elements
&lt;/h2&gt;

&lt;p&gt;The calendar math (converting a Gregorian date to pillars) is shared with another small tool of mine, so the name logic starts from the counts. If the birth hour is unknown, the hour pillar is empty and the count uses six characters instead of eight.&lt;/p&gt;

&lt;p&gt;The weak-element rule is short:&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="cm"&gt;/* Missing element = an element counted 0; otherwise the smallest count.
   Ties: first the element that feeds the Day Master (인성), then wood→fire→earth→metal→water. */&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;missingElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nx"&gt;dmElem&lt;/span&gt;&lt;span class="p"&gt;){&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;min&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="nx"&gt;min&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;apply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="nx"&gt;tied&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;k&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="nx"&gt;k&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="o"&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;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;===&lt;/span&gt;&lt;span class="nx"&gt;min&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nx"&gt;tied&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;mom&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;parentOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dmElem&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;tied&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;indexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mom&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="p"&gt;?&lt;/span&gt;&lt;span class="nx"&gt;mom&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nx"&gt;tied&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;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tie-break matters because ties happen often with only six to eight characters. I let the element that feeds the Day Master win, since that is a common reading of what supports the person, and then a fixed order so the answer is stable. This is a design decision, not a ground truth. The result page shows the five counts as bars so you can see what the tool saw.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Unknown birth time
&lt;/h2&gt;

&lt;p&gt;Most people outside Korea do not know their birth hour. I made "unknown" the default instead of forcing a guess. With the hour missing, the tool counts six characters and shows a small badge: adding the hour can change the result. That is honest about what the input supports, and it keeps the form short.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Sound elements, and where schools disagree
&lt;/h2&gt;

&lt;p&gt;Hanja radicals give the "meaning element" of each character. Korean naming also looks at the initial consonant of each syllable, the 발음오행 (sound element). Here the schools do not agree.&lt;/p&gt;

&lt;p&gt;The mapping I use follows the 운해본 reading, the majority convention in naming practice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;ㄱ ㅋ: wood&lt;/li&gt;
&lt;li&gt;ㄴ ㄷ ㄹ ㅌ: fire&lt;/li&gt;
&lt;li&gt;ㅇ ㅎ: earth&lt;/li&gt;
&lt;li&gt;ㅅ ㅈ ㅊ: metal&lt;/li&gt;
&lt;li&gt;ㅁ ㅂ ㅍ: water&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The 해례본 reading swaps ㅇ ㅎ and ㅁ ㅂ. I did not try to settle that. I picked the majority convention, and the About section of the page states that another reading exists. If you know this area better than I do, I would like to hear which side you would pick and why.&lt;/p&gt;

&lt;p&gt;In code the table is a pair of arrays indexed by the Hangul initial, decoded from the Unicode syllable block:&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;var&lt;/span&gt; &lt;span class="nx"&gt;CHO&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="s2"&gt;ㄱ&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="s2"&gt;ㄲ&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="s2"&gt;ㄴ&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="s2"&gt;ㄷ&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="s2"&gt;ㄸ&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="s2"&gt;ㄹ&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="s2"&gt;ㅁ&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="s2"&gt;ㅂ&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="s2"&gt;ㅃ&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="s2"&gt;ㅅ&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="s2"&gt;ㅆ&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="s2"&gt;ㅇ&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="s2"&gt;ㅈ&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="s2"&gt;ㅉ&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="s2"&gt;ㅊ&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="s2"&gt;ㅋ&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="s2"&gt;ㅌ&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="s2"&gt;ㅍ&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="s2"&gt;ㅎ&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;CHO_ELEM&lt;/span&gt;&lt;span class="o"&gt;=&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="mi"&gt;0&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="mi"&gt;1&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;4&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="mi"&gt;3&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="mi"&gt;3&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="mi"&gt;3&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;4&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;jamo&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="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charCodeAt&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="o"&gt;-&lt;/span&gt;&lt;span class="mh"&gt;0xAC00&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="na"&gt;cho&lt;/span&gt;&lt;span class="p"&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;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="mi"&gt;588&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="na"&gt;jung&lt;/span&gt;&lt;span class="p"&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;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="mi"&gt;588&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="mi"&gt;28&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="na"&gt;jong&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="mi"&gt;28&lt;/span&gt;&lt;span class="p"&gt;};}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A Hangul syllable is &lt;code&gt;0xAC00 + (initial * 588) + (vowel * 28) + final&lt;/code&gt;, so no lookup table of syllables is needed. The tool then prefers name pairs where neighboring sounds feed each other (wood to fire to earth to metal to water) and penalizes pairs where one controls the other.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: How I chose the Hanja table
&lt;/h2&gt;

&lt;p&gt;A name generator is only as good as its character list, and Hanja is a large field. I wanted a small table I can defend instead of a scrape.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Source: the Unicode Unihan database (version 18.0.0), which is public. I used the radical field (&lt;code&gt;kRSUnicode&lt;/code&gt;) for the element, the Korean reading field (&lt;code&gt;kHangul&lt;/code&gt;) for the sound, and &lt;code&gt;kDefinition&lt;/code&gt; as a reference for English meanings.&lt;/li&gt;
&lt;li&gt;Name-use filter: Korea restricts which Hanja may be used in registered names. Unihan marks these in &lt;code&gt;kKoreanName&lt;/code&gt; (additional characters, by announcement year) and &lt;code&gt;kKoreanEducationHanja&lt;/code&gt; (the 1,800 education characters). I only kept characters found in one of those two fields.&lt;/li&gt;
&lt;li&gt;Radicals only when clear: wood (木, 艹, 竹, 禾), fire (火, 灬), earth (土, 山, 田), metal (金, 玉), water (水, 氵, 雨, 冫). Radicals like 日, 石 and 扌 are argued over, so I left them out instead of picking a side quietly.&lt;/li&gt;
&lt;li&gt;Common syllables first: I started from syllables people use in given names, which keeps results from looking odd.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The final table has 264 characters: 55 wood, 33 fire, 38 earth, 72 metal, 66 water. Each row 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="p"&gt;{&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hanja&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="s2"&gt;蓮&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="s2"&gt;hangul&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="s2"&gt;연&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="s2"&gt;element&lt;/span&gt;&lt;span class="dl"&gt;"&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;basis&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="s2"&gt;艹&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="s2"&gt;meaning&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="s2"&gt;lotus&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="s2"&gt;tags&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="s2"&gt;F&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="s2"&gt;src&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="s2"&gt;edu&lt;/span&gt;&lt;span class="dl"&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;basis&lt;/code&gt; is the radical, so the result card can say why a character was chosen. &lt;code&gt;tags&lt;/code&gt; marks a typical gender feel (F, M or N for neutral), which is how the optional "female / male / either" input filters the list. The file header says it is generated by a script, so I fix the candidate list there and regenerate instead of editing rows by hand.&lt;/p&gt;

&lt;p&gt;The English glosses are short and based on the Unihan definitions. Some are loose. If one looks wrong to you, tell me.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Avoiding famous names
&lt;/h2&gt;

&lt;p&gt;If someone's birthday produces the full name of a well-known singer or actor, that is awkward and a bit misleading. So there is a list of widely known full names (names only, taken from public wikis, no photos or descriptions). Any surname plus given-name combination that matches the list is skipped:&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;if&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;isFamous&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;full&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;h2&gt;
  
  
  Step 6: Same birthday, same names
&lt;/h2&gt;

&lt;p&gt;Random output has a problem for sharing: refresh and the names change. I wanted the same birthday to give the same three names every time, so the generator seeds its random numbers from the input. A hash turns the input string into a seed, and a small generator makes the sequence:&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;var&lt;/span&gt; &lt;span class="nx"&gt;rnd&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;rng&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;seedOf&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;inp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nx"&gt;inp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nx"&gt;inp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nx"&gt;hb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nx"&gt;inp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;surname&lt;/span&gt;&lt;span class="o"&gt;||&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;auto&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;|&lt;/span&gt;&lt;span class="dl"&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;seedOf&lt;/code&gt; is FNV-1a and &lt;code&gt;rng&lt;/code&gt; is mulberry32, both a few lines. Candidates are shuffled with this seeded random source, then sorted by a score (common names and natural syllable positions score higher, sound clashes score lower), and each Hangul name keeps its highest-scoring Hanja pair. Because every step is deterministic, you and a friend with the same input see the same result. There is no hidden state.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: Nothing leaves the browser
&lt;/h2&gt;

&lt;p&gt;There is no server-side calculation. The pillars, the picking, the romanization and the share-card drawing all run in your browser. The birthday is not put in the URL, local storage or cookies. The page does send one small anonymous beacon with an event name (such as "view") and the referrer, so I can see whether anyone opens it. The birthday is not part of it.&lt;/p&gt;

&lt;p&gt;Romanization follows the Revised Romanization of Korean for given names, syllable by syllable, with a hyphen only where letters could be misread. Surnames use the common spellings (Kim, Lee, Park) instead of the formal forms (Gim, I, Bak), because that is how people write them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits, again
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;It is for entertainment. It is a simplified version of Korean naming practice, not professional advice.&lt;/li&gt;
&lt;li&gt;It does not judge which element suits a person most. It fills the thinnest one.&lt;/li&gt;
&lt;li&gt;Sound-element schools differ, and I used one of them.&lt;/li&gt;
&lt;li&gt;The Hanja table is small on purpose, so many valid names are not in it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The page is available in English, Japanese, Spanish, Portuguese and Indonesian, using the same engine and table.&lt;/p&gt;

&lt;p&gt;If you want to look at your own result, here is the tool again: &lt;a href="https://devjar.dev/lab/korean-name/?utm_source=devto&amp;amp;utm_campaign=korean-name-en" rel="noopener noreferrer"&gt;devjar.dev/lab/korean-name&lt;/a&gt;. Feedback on the character meanings or the sound-element choice is welcome in the comments.&lt;/p&gt;

&lt;p&gt;I also make a saju app for Android: &lt;a href="https://play.google.com/store/apps/details?id=com.noligo.saju_app&amp;amp;utm_source=devto&amp;amp;utm_campaign=korean-name-en" rel="noopener noreferrer"&gt;Starlit Alley&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;This post was written with AI assistance.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>showdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Connecting from Windows to a Mac with the built-in Remote Desktop (mstsc)</title>
      <dc:creator>HS Solution</dc:creator>
      <pubDate>Fri, 23 Oct 2026 01:54:21 +0000</pubDate>
      <link>https://dev.to/hssolution/connecting-from-windows-to-a-mac-with-the-built-in-remote-desktop-mstsc-1d29</link>
      <guid>https://dev.to/hssolution/connecting-from-windows-to-a-mac-with-the-built-in-remote-desktop-mstsc-1d29</guid>
      <description>&lt;p&gt;When I need to reach a Mac from a Windows PC, the usual advice is to install a VNC server on the Mac and a separate client on Windows. That works, but it is one more program to install and explain.&lt;/p&gt;

&lt;p&gt;Windows already ships with a remote desktop client, &lt;code&gt;mstsc.exe&lt;/code&gt;. It speaks RDP, and macOS has no RDP server. So I built one: RDPyon is a Mac app that accepts RDP connections, so you can use &lt;code&gt;mstsc&lt;/code&gt; and install nothing on Windows.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you need
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A Mac on macOS 12.3 (Monterey) or later, Apple Silicon or Intel.&lt;/li&gt;
&lt;li&gt;You are logged in on the Mac. You sign in with the Mac owner's short account name and its password.&lt;/li&gt;
&lt;li&gt;A network path between the two machines: same network, or a VPN.&lt;/li&gt;
&lt;li&gt;An RDPyon license key, or the 14-day free trial (no card required).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Steps
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Install RDPyon on the Mac.&lt;/strong&gt; Open the &lt;code&gt;.dmg&lt;/code&gt;, drag RDPyon into Applications, and open it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Allow the permissions.&lt;/strong&gt; Allow what the app asks for on first launch (Screen Recording, Accessibility) in System Settings under Privacy &amp;amp; Security, then restart the app.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Find the Mac's address.&lt;/strong&gt; Click the RDPyon menu bar icon. It shows "Local IP" and "Port". The default port is 3389; "Change Server Port" accepts 1024 to 65535.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Open Remote Desktop Connection.&lt;/strong&gt; Press Windows key + R and run:&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;mstsc /v:MacAddress:Port /prompt
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;5. Sign in with your Mac account.&lt;/strong&gt; Choose "More choices" or "Use a different account", then enter the short account name and your Mac login password. An empty password is rejected.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. Check the connection.&lt;/strong&gt; When the Mac screen appears in the window, you are in. A connection bar shows at the top of the Mac screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  What works
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;Details&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Client&lt;/td&gt;
&lt;td&gt;The built-in Windows Remote Desktop Connection (mstsc.exe). Nothing to install on Windows.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Supported Macs&lt;/td&gt;
&lt;td&gt;macOS 12.3 or later. Both Apple Silicon and Intel.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sign-in&lt;/td&gt;
&lt;td&gt;Your Mac account's short name and password. The connection uses TLS.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Screen size&lt;/td&gt;
&lt;td&gt;The Mac screen is sent at the size of the Remote Desktop window. How well a window resize is followed can vary by client version and settings.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Clipboard&lt;/td&gt;
&lt;td&gt;Copy and paste text and images between the Mac and Windows.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sound&lt;/td&gt;
&lt;td&gt;Sends the Mac's sound to Windows. Needs macOS 13 or later. Turn on "Forward Mac Audio" in the menu; it is off by default.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shortcuts&lt;/td&gt;
&lt;td&gt;Windows Ctrl is sent as Mac Cmd, and the Windows key as Mac Ctrl. Ctrl+C and Ctrl+V work as you expect.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Port&lt;/td&gt;
&lt;td&gt;Default 3389. You can change it to any value from 1024 to 65535.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Choosing a display&lt;/td&gt;
&lt;td&gt;If your Mac has several displays, pick the one to send with "Select Monitor" in the menu.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What does not work
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;Details&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;NLA (Network Level Authentication)&lt;/td&gt;
&lt;td&gt;Not provided. The Mac account is checked over a TLS connection.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Server-side sign-in screen&lt;/td&gt;
&lt;td&gt;None. You enter your credentials in the Remote Desktop Connection window.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Simultaneous connections&lt;/td&gt;
&lt;td&gt;One session at a time.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multiple displays&lt;/td&gt;
&lt;td&gt;One display is sent at a time.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Failed sign-ins&lt;/td&gt;
&lt;td&gt;After 5 failed attempts in a row from the same IP address, connections are blocked for 30 seconds. The block gets longer with each further failure.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Korean/English key and Hanja key&lt;/td&gt;
&lt;td&gt;The Korean/English key switches the Mac input source, and the Hanja key is sent as Option+Return. A keyboard that uses Right Alt as the Korean/English key cannot be told apart from Option. mstsc may also capture the Windows key, Ctrl, or CapsLock first, so behavior can differ by setup.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Locking local input while connected&lt;/td&gt;
&lt;td&gt;Not available yet (the menu item is marked "coming soon").&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mac screen lock&lt;/td&gt;
&lt;td&gt;If the screen is locked, connecting may fail, so unlock the Mac first. If the screen locks during a session, the connection ends by default ("Disconnect when screen locks" is on).&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Copying and pasting files&lt;/td&gt;
&lt;td&gt;Not guaranteed yet.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mac App Store&lt;/td&gt;
&lt;td&gt;RDPyon is not distributed through the App Store. You download and install it directly.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Compatibility checklist
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Mac&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] macOS 12.3 or later.&lt;/li&gt;
&lt;li&gt;[ ] You are logged in on the Mac.&lt;/li&gt;
&lt;li&gt;[ ] Screen Recording and Accessibility are allowed, and the app was restarted afterwards.&lt;/li&gt;
&lt;li&gt;[ ] The Mac screen is unlocked (connecting to a locked Mac may fail, and the session ends on lock by default).&lt;/li&gt;
&lt;li&gt;[ ] For sound: macOS 13 or later, with "Forward Mac Audio" on.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Network and port&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Both machines are on the same network, or on a VPN.&lt;/li&gt;
&lt;li&gt;[ ] You use the Local IP and Port shown in the RDPyon menu.&lt;/li&gt;
&lt;li&gt;[ ] The port in your &lt;code&gt;mstsc&lt;/code&gt; command matches the menu (default 3389, custom 1024 to 65535).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Windows and mstsc&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] You use the built-in &lt;code&gt;mstsc.exe&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;[ ] An &lt;code&gt;.rdp&lt;/code&gt; file does not contain &lt;code&gt;enablecredsspsupport:i:0&lt;/code&gt;. On Windows 10 that line stops mstsc from asking for TLS and the connection is blocked.&lt;/li&gt;
&lt;li&gt;[ ] You entered the short account name, not the full name, and a non-empty password.&lt;/li&gt;
&lt;li&gt;[ ] You have not failed 5 sign-ins in a row from the same IP. If so, wait 30 seconds.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Expectations&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] You need one session and one display.&lt;/li&gt;
&lt;li&gt;[ ] You do not rely on file copy and paste.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Gaps you find are welcome in the comments.&lt;/p&gt;

&lt;p&gt;RDPyon costs $19 one-time (up to 2 Macs, 1 year of updates, 30-day refund), and the 14-day trial starts without a card. The full guide, with the same tables, is here: &lt;a href="https://rdpyon.com/guide/windows-to-mac-remote-desktop/?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=rdpyon_launch" rel="noopener noreferrer"&gt;Windows to Mac remote desktop guide&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Drafted with AI assistance and checked against the product guide.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>macos</category>
      <category>windows</category>
      <category>remotedesktop</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Why our Flutter in-app purchases got stuck on "Loading" (and the retry design that fixed it)</title>
      <dc:creator>HS Solution</dc:creator>
      <pubDate>Wed, 21 Oct 2026 08:08:58 +0000</pubDate>
      <link>https://dev.to/hssolution/why-our-flutter-in-app-purchases-got-stuck-on-loading-and-the-retry-design-that-fixed-it-21d8</link>
      <guid>https://dev.to/hssolution/why-our-flutter-in-app-purchases-got-stuck-on-loading-and-the-retry-design-that-fixed-it-21d8</guid>
      <description>&lt;p&gt;In our Flutter app (Android and iOS, &lt;code&gt;in_app_purchase&lt;/code&gt; 3.3.1) we shipped a bug where the shop's price list stayed on "Loading" forever. The cause was simple: if connecting to the store or querying products failed once, nothing ever tried again until the user restarted the app.&lt;/p&gt;

&lt;p&gt;Here is what was wrong, and the retry design we replaced it with.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fplay-lh.googleusercontent.com%2F0sw30pLAe6U5hZELu6NCkE9z5Rz6Z-yBK3sAbkW5r9RyaI8kChvkkYL-TValVURssrDSCBRvAe_N6RQkhMZubQ%3Dw720" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fplay-lh.googleusercontent.com%2F0sw30pLAe6U5hZELu6NCkE9z5Rz6Z-yBK3sAbkW5r9RyaI8kChvkkYL-TValVURssrDSCBRvAe_N6RQkhMZubQ%3Dw720" alt="The app's home screen, from the Google Play listing" width="720" height="1280"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What was going on
&lt;/h2&gt;

&lt;p&gt;All billing code lives in a &lt;code&gt;Billing&lt;/code&gt; singleton with three states: &lt;code&gt;loading&lt;/code&gt;, &lt;code&gt;ready&lt;/code&gt; and &lt;code&gt;unavailable&lt;/code&gt;. The old &lt;code&gt;refresh()&lt;/code&gt; looked roughly like this:&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;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;refresh&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;gw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_gw&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;gw&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;||&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;value&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;BillingStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;loading&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;// not connected, or busy: drop it&lt;/span&gt;
  &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;BillingStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;loading&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&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;gw&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isAvailable&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;seconds:&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;BillingStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;unavailable&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;// one failure ends it&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;q&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;gw&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ProductIds&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;all&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;seconds:&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="n"&gt;_products&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;found&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt; &lt;span class="c1"&gt;// full overwrite&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;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;BillingStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;unavailable&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="c1"&gt;// no retry&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That has four holes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A failed connection at startup is permanent.&lt;/strong&gt; If creating the gateway threw at launch, &lt;code&gt;_gw&lt;/code&gt; stayed &lt;code&gt;null&lt;/code&gt;, and every later &lt;code&gt;refresh()&lt;/code&gt; returned on the first line.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Requests during a query were dropped.&lt;/strong&gt; A refresh that arrived while &lt;code&gt;loading&lt;/code&gt; was simply ignored.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No automatic retry.&lt;/strong&gt; &lt;code&gt;isAvailable() == false&lt;/code&gt;, a timeout and an exception all ended in &lt;code&gt;unavailable&lt;/code&gt;. The only retry was opening the shop screen.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Partial results overwrote good data.&lt;/strong&gt; A query that returned only some products could erase products we already had.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Temporary store failures are common: the Play Store app updating itself, a network blip. The real problem was that one failure meant "Loading" forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Reconnect on the next attempt
&lt;/h3&gt;

&lt;p&gt;If the connection fails at startup we don't give up. &lt;code&gt;ensureStarted()&lt;/code&gt; reconnects on every new attempt, and syncs past purchases once it connects.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Don't drop requests during a query — queue one more
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;refresh&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;cur&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_inflight&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;cur&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;_again&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&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;cur&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="c1"&gt;// run once more after this one&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;However many times it is called, at most one extra query runs after the current one.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Merge results instead of overwriting
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="n"&gt;_products&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{..&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;_products&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;..&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;found&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt; &lt;span class="c1"&gt;// a partial answer can't erase known products&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  4. Retry automatically at 5 s → 30 s → 2 min, then stop
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;retryDelays&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;seconds:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;seconds:&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;minutes:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)];&lt;/span&gt;

&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;_afterRefresh&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;needsRetry&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;_retryStep&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="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="c1"&gt;// got everything: reset&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;_retryStep&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;retryDelays&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// stop after 3&lt;/span&gt;
  &lt;span class="n"&gt;_retryTimer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Timer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;retryDelays&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;_retryStep&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="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;unawaited&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;refresh&lt;/span&gt;&lt;span class="p"&gt;()));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We cap it on purpose. On devices where the store really is unavailable (no Play Store, for example), retrying forever only burns battery and data. Timeouts stay at 15 s for the connection check and 20 s for the query.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Let user actions and app resume trigger a retry
&lt;/h3&gt;

&lt;p&gt;If the three automatic tries fail, four other things trigger a retry: app resume, opening the shop, pressing a buy button, and a manual retry.&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;// main.dart&lt;/span&gt;
&lt;span class="n"&gt;AppLifecycleListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;onResume:&lt;/span&gt; &lt;span class="n"&gt;Billing&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;i&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;onResume&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// billing.dart&lt;/span&gt;
&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;onResume&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="n"&gt;needsRetry&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;unawaited&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;retryNow&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="c1"&gt;// only when products are missing&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We don't listen to a connectivity stream. Timers handle the automatic part; everything else comes from the user or from resume. A manual trigger resets the retry schedule to zero. If the buy button is pressed while billing isn't ready, it calls &lt;code&gt;retryNow()&lt;/code&gt; and then continues straight into the purchase.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Stop saying "Loading"
&lt;/h3&gt;

&lt;p&gt;We split failures into &lt;code&gt;connect&lt;/code&gt;, &lt;code&gt;timeout&lt;/code&gt; and &lt;code&gt;notFound&lt;/code&gt;, and the screen now says "Connecting to the store… / Retry" instead of "Loading". With "Loading", users can't tell whether waiting will help or whether the item is gone. The first time each failure type appears we log an analytics event, so we can see later which failures are common.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Write billing setup so it can be redone any number of times, not "once at startup".&lt;/li&gt;
&lt;li&gt;Don't drop requests that arrive mid-query; queue exactly one more.&lt;/li&gt;
&lt;li&gt;Merge query results so a partial answer can't delete known products.&lt;/li&gt;
&lt;li&gt;Cap automatic retries; leave the rest to app resume and user actions.&lt;/li&gt;
&lt;li&gt;Don't show a vague state like "Loading" when you know what failed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This shipped in version 1.1.1 on Android and iOS. We haven't yet verified every real-payment path in production, so if your setup is similar, try it with a Play Console license tester: toggle airplane mode and force-stop the Play Store app while the shop is open.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Disclosure: I'm the developer of this app, **Starlit Alley: Tarot &amp;amp; Saju&lt;/em&gt;* (HS Solution). Core readings (saju, tarot, dream reading) are free, with no sign-up and no ads. &lt;a href="https://play.google.com/store/apps/details?id=com.noligo.saju_app&amp;amp;referrer=utm_source%3Ddevto%26utm_medium%3Darticle%26utm_campaign%3Dbilling-retry" rel="noopener noreferrer"&gt;Google Play&lt;/a&gt;*&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This post was drafted by an AI agent from our repository and fix notes. The code and numbers are quoted from the actual repository.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>dart</category>
      <category>android</category>
      <category>mobile</category>
    </item>
    <item>
      <title>Scope creep is not the problem. Unwritten change requests are.</title>
      <dc:creator>HS Solution</dc:creator>
      <pubDate>Tue, 20 Oct 2026 18:17:38 +0000</pubDate>
      <link>https://dev.to/hssolution/scope-creep-is-not-the-problem-unwritten-change-requests-are-268m</link>
      <guid>https://dev.to/hssolution/scope-creep-is-not-the-problem-unwritten-change-requests-are-268m</guid>
      <description>&lt;p&gt;Most scope fights I have seen on client projects did not start with a big request. They started with a small one, accepted in a chat message ("sure, I'll try"), and nobody wrote down what it moved. Three weeks later the deadline has drifted and neither side can say which request caused it.&lt;/p&gt;

&lt;p&gt;The fix is boring and it works: every change after kickoff goes on a one-page change request with a number. Here are the seven fields we put on that page, from the developer side of the table.&lt;/p&gt;

&lt;h2&gt;
  
  
  The seven fields
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. What changes, in screen terms.&lt;/strong&gt; Not "improve search", but "add a date-range filter to the order list; picking a start and end date shows only orders in between." Test: ask someone new to describe the finished screen from your sentence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Why (one line).&lt;/strong&gt; "At month end the manager only wants last month's orders but has to scroll the whole list." Knowing the purpose is what lets you, the developer, say "there is a cheaper way."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. What it touches.&lt;/strong&gt; Screens modified, queries modified, new screens, data-structure changes, integrations. This is where "it's just one field" turns into "that's a migration" — say it before work starts, not halfway.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Effect on schedule, in dates.&lt;/strong&gt; "Interim demo moves 2 days; final delivery unchanged." Send the updated overall schedule with the same CR number, so schedule versions line up with requests.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Effect on cost, with a basis.&lt;/strong&gt; Not an amount pulled from the air — the basis (workload, agreed rate) and a reference to the quote. "To be discussed" is not an answer; don't start on it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. What gets dropped or deferred.&lt;/strong&gt; If the client wants the date and budget unchanged, something else moves. Put the trade on the same page: "Admin Excel export moves to phase two." Check it isn't something they marked as core.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;7. Who approves, and how.&lt;/strong&gt; Both contacts reply "proceed" by email (or sign the doc). Work starts after that, not before. Number, date, approver name.&lt;/p&gt;

&lt;h2&gt;
  
  
  A filled-in (made-up) example
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Entry&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Number / date&lt;/td&gt;
&lt;td&gt;CR-003 · requested by client contact&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Change&lt;/td&gt;
&lt;td&gt;Date-range filter on the order list&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Purpose&lt;/td&gt;
&lt;td&gt;See only last month's orders at month end&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Impact&lt;/td&gt;
&lt;td&gt;Order list screen, order query. No new screens, no schema change&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Schedule&lt;/td&gt;
&lt;td&gt;Interim demo +2 days; final delivery unchanged&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost&lt;/td&gt;
&lt;td&gt;Extra; basis = 1 screen + 1 query change, amount per attached quote&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Trade-off&lt;/td&gt;
&lt;td&gt;Admin Excel export deferred to phase two&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Approval&lt;/td&gt;
&lt;td&gt;Starts after both contacts approve by email&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Not everything needs a CR
&lt;/h2&gt;

&lt;p&gt;Sort first. If something agreed in the requirements doesn't work, that's a defect — you fix it, no CR. Wording and color tweaks inside an agreed screen usually go in a batch. A new behavior, a new screen, or anything that touches data or integrations gets a number. Where exactly the line sits depends on your contract wording, so agree on the sorting rule at kickoff, not during the first argument.&lt;/p&gt;

&lt;p&gt;What I like about this as a developer: it turns "the client keeps adding stuff" into a list you can both point at. Saying yes gets cheaper because every yes has a price tag and a date next to it.&lt;/p&gt;

&lt;p&gt;The full guide (with the sorting table and how each field fails if you skip it) is here: &lt;a href="https://noligo.co.kr/en/guide/change-request-template/?utm_source=devto&amp;amp;utm_medium=community&amp;amp;utm_campaign=change-request-devto" rel="noopener noreferrer"&gt;How to write a change request&lt;/a&gt;. It's general information, not legal advice.&lt;/p&gt;

&lt;p&gt;How do you handle mid-project requests — a numbered doc, tickets, or just a running chat thread?&lt;/p&gt;

</description>
      <category>freelance</category>
      <category>productivity</category>
      <category>career</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Chat Folders for Claude: sidebar folders, and exporting a virtualised chat to Markdown</title>
      <dc:creator>HS Solution</dc:creator>
      <pubDate>Tue, 20 Oct 2026 11:45:20 +0000</pubDate>
      <link>https://dev.to/hssolution/chat-folders-for-claude-sidebar-folders-and-exporting-a-virtualised-chat-to-markdown-4khc</link>
      <guid>https://dev.to/hssolution/chat-folders-for-claude-sidebar-folders-and-exporting-a-virtualised-chat-to-markdown-4khc</guid>
      <description>&lt;p&gt;I use claude.ai a lot, and the sidebar is one long list of chats. There are no folders, so I could not group chats by project. I built a small Chrome extension, &lt;strong&gt;Chat Folders for Claude&lt;/strong&gt;, to add that. This post covers what it does and the parts that were harder than expected.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Not affiliated with Anthropic. This is an independent project, not endorsed by or connected to Anthropic.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhc3qlxc12q32kti36otq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhc3qlxc12q32kti36otq.png" alt="Chat Folders for Claude: a Folders section in the claude.ai sidebar, shown on a stand-in page" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Adds a &lt;strong&gt;Folders&lt;/strong&gt; section to the claude.ai sidebar: create, rename, delete, collapse.&lt;/li&gt;
&lt;li&gt;File the open chat into a folder (a chat lives in one folder at a time). Deleting a folder never deletes a conversation.&lt;/li&gt;
&lt;li&gt;Search chat titles (not message text).&lt;/li&gt;
&lt;li&gt;Export the open chat as a Markdown file, or export a folder as a list of titles and links.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The hard part: the message list is virtualised
&lt;/h2&gt;

&lt;p&gt;The Markdown export looked simple: read the messages, convert to Markdown, save a file. But the chat page only keeps a few message rows in the DOM at a time (roughly 3 to 7). If you read the page as it is, you get the part you are looking at and nothing else.&lt;/p&gt;

&lt;p&gt;Each row has a &lt;code&gt;data-index&lt;/code&gt;, so the export works like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Find the scroll container of the message list.&lt;/li&gt;
&lt;li&gt;Jump to the top, then scroll down in steps of 0.8 of the visible height so consecutive views overlap.&lt;/li&gt;
&lt;li&gt;Wait a moment after each step for the list to draw the new rows, and collect each row once, keyed by &lt;code&gt;data-index&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Sort by index, convert to Markdown, and restore the original scroll position, even if something fails.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If fewer messages were read than the chat has, the file says so (&lt;code&gt;(n of N messages captured)&lt;/code&gt;) instead of pretending it is complete. The site markup can change without notice, so the extension looks for structure rather than class names. If it cannot find what it needs, the feature hides itself instead of producing a broken file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Privacy and permissions
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Two permissions only: &lt;code&gt;storage&lt;/code&gt; and host access to &lt;code&gt;https://claude.ai/*&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;No network requests: no server, no analytics, no remote code. It does not call the site's internal APIs and does not read cookies.&lt;/li&gt;
&lt;li&gt;Folders are saved in &lt;code&gt;chrome.storage.local&lt;/code&gt; on your device. For each filed chat it stores the ID, the title as shown in the sidebar, and the time you added it.&lt;/li&gt;
&lt;li&gt;Message text is read only when you press an export button, only for the chat you have open, and it becomes a file your browser saves. The extension does not keep it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The repository has no build step and no dependencies, and there are unit tests for the storage logic, search, Markdown conversion and the scrolling collector (against a fake virtualised list).&lt;/p&gt;

&lt;p&gt;Note: the screenshot above was made on a stand-in page with invented chats, not on a real account.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Chrome Web Store: &lt;a href="https://chromewebstore.google.com/detail/chat-folders-for-claude/hmnebobeialcpbeabjkcdkmiceamndfb?utm_source=devto&amp;amp;utm_campaign=chat-folders-intro" rel="noopener noreferrer"&gt;Chat Folders for Claude&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Details: &lt;a href="https://devjar.dev/lab/chat-folders/?utm_source=devto&amp;amp;utm_campaign=chat-folders-intro" rel="noopener noreferrer"&gt;devjar.dev/lab/chat-folders&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It is early (version 0.2.2). If the sidebar layout changes and something breaks, I would like to hear about it in the comments.&lt;/p&gt;

</description>
      <category>chrome</category>
      <category>javascript</category>
      <category>productivity</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
