<?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: Jassi Singh</title>
    <description>The latest articles on DEV Community by Jassi Singh (@scanner).</description>
    <link>https://dev.to/scanner</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%2F3475371%2Fa0bd897c-9b72-4222-b4c9-ab0918937ed0.jpg</url>
      <title>DEV Community: Jassi Singh</title>
      <link>https://dev.to/scanner</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/scanner"/>
    <language>en</language>
    <item>
      <title>Phone to PC without the cloud: a local HTTPS server, a pinned certificate and mDNS</title>
      <dc:creator>Jassi Singh</dc:creator>
      <pubDate>Tue, 27 Oct 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/scanner/phone-to-pc-without-the-cloud-a-local-https-server-a-pinned-certificate-and-mdns-39a7</link>
      <guid>https://dev.to/scanner/phone-to-pc-without-the-cloud-a-local-https-server-a-pinned-certificate-and-mdns-39a7</guid>
      <description>&lt;p&gt;You take a photo on your phone and need it on your laptop, which is right there on the desk. On a Mac with an iPhone, AirDrop does it in two taps. On Windows, or with an Android phone, the photo goes up to someone's cloud, through a chat app or into an email to yourself, and back down again: two metres, by way of a data centre.&lt;/p&gt;

&lt;p&gt;The phone and the computer are on the same Wi-Fi. They should be able to talk to each other directly, with no account and no server in between. That's the idea behind &lt;a href="https://scannertechs.com/apps/dropple/" rel="noopener noreferrer"&gt;Dropple&lt;/a&gt;. It runs on Mac and Windows, and works with any phone. This post is about how it works, and about the parts that were harder than I expected.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of it
&lt;/h2&gt;

&lt;p&gt;The desktop app is built with Tauri 2, with a Rust backend. Inside it runs an &lt;a href="https://github.com/tokio-rs/axum" rel="noopener noreferrer"&gt;axum&lt;/a&gt; server on the local network: plain HTTP on one port for first contact, and HTTPS (rustls) on another.&lt;/p&gt;

&lt;p&gt;On the phone there are two ways in:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Any phone's browser.&lt;/strong&gt; You scan a QR code and the computer serves the whole phone side as a web page. Nothing gets installed, and it works on an iPhone too.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A native Android app&lt;/strong&gt;, which talks to the same API and stays connected in the background.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Files stream both ways. Text sent from the phone goes straight onto the computer's clipboard. Live updates go out to every connected phone as Server-Sent Events, which turned out much simpler than WebSockets for a list that only changes on one side at a time.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;phone ──HTTPS──▶ axum (Rust, inside the Tauri app)
  ▲                    │
  └──── SSE /api/events ┘   "the list changed, fetch it"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Trust without a certificate authority
&lt;/h2&gt;

&lt;p&gt;HTTPS on a home network is awkward. There's no domain name, so no public certificate.&lt;/p&gt;

&lt;p&gt;Dropple makes its own certificate authority on first run (with &lt;a href="https://github.com/rustls/rcgen" rel="noopener noreferrer"&gt;rcgen&lt;/a&gt;) and uses it to issue a certificate for the computer's current IP address. When the address changes, it issues a new one. The authority is name-constrained to private addresses and &lt;code&gt;.local&lt;/code&gt;, so even if it were trusted somewhere, it couldn't vouch for a real website.&lt;/p&gt;

&lt;p&gt;A browser would need that authority installed before it trusts the connection, which is a real hurdle on a phone. The Android app gets around it by &lt;strong&gt;pinning&lt;/strong&gt; instead:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The QR code carries the SHA-256 fingerprint of the computer's authority.&lt;/li&gt;
&lt;li&gt;The app's trust manager accepts a connection only if the server's chain contains a certificate with exactly that fingerprint, and the server's own certificate was signed by it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Nothing goes into the phone's trust store, there's nothing for the user to install, and a lookalike server on the same Wi-Fi gets nowhere. I've written up the trust manager itself separately.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding the computer again
&lt;/h2&gt;

&lt;p&gt;Laptops change IP addresses. They join a different network, the router hands out a new lease, or the laptop wakes up from sleep.&lt;/p&gt;

&lt;p&gt;The computer announces itself over mDNS as &lt;code&gt;_dropple._tcp&lt;/code&gt;, with the start of its fingerprint in a TXT record. When the saved address stops answering, the Android app searches with Android's NSD and reconnects to the computer whose fingerprint matches. Connecting still checks the full fingerprint, so the TXT record only narrows the search; it never grants trust.&lt;/p&gt;

&lt;p&gt;One Samsung phone turned this into a bug hunt. Its NSD results contained only an IPv6 link-local address (&lt;code&gt;fe80::…%wlan0&lt;/code&gt;), which you can't put into an &lt;code&gt;https://&lt;/code&gt; URL. The app sat on "Connecting…" forever. Details in the NSD post.&lt;/p&gt;

&lt;h2&gt;
  
  
  Staying connected on Android, politely
&lt;/h2&gt;

&lt;p&gt;Receiving files in the background means a foreground service of type &lt;code&gt;connectedDevice&lt;/code&gt;, with an ongoing notification. Two things made that bearable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Explaining before asking.&lt;/strong&gt; Before Android shows its notification prompt, a short sheet says what the notifications are for. More people say yes, and nobody is surprised.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An off switch for every annoying part.&lt;/strong&gt; Users can hide the "Connected" notification's channel, or turn the background connection off entirely.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Android also won't let an app in the background read the clipboard, so "send what I just copied" needs a trick. A Quick Settings tile opens an invisible activity, which waits until it has window focus, reads the clipboard, sends it and closes. Write-up here.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell someone building something similar
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pin, don't install.&lt;/strong&gt; If you control both ends, pin the certificate rather than asking users to trust a new authority.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test discovery on more than one brand of phone.&lt;/strong&gt; NSD results differ between them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make reconnecting visible.&lt;/strong&gt; A spinner with no words looks like a hang; "Looking for your computer…" doesn't. Users forgive waiting when they can see what's being waited for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the last state.&lt;/strong&gt; If the computer is out of reach, show the last list you had rather than an empty screen.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Sharing in Dropple is free, on Mac, Windows, any phone's browser, and Android through Google Play: &lt;a href="https://scannertechs.com/apps/dropple/" rel="noopener noreferrer"&gt;https://scannertechs.com/apps/dropple/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Happy to answer questions about any of this in the comments.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>android</category>
      <category>rust</category>
      <category>security</category>
    </item>
    <item>
      <title>How to build a macOS screensaver in Swift: everything Apple doesn't document</title>
      <dc:creator>Jassi Singh</dc:creator>
      <pubDate>Tue, 20 Oct 2026 14:00:00 +0000</pubDate>
      <link>https://dev.to/scanner/how-to-build-a-macos-screensaver-in-swift-everything-apple-doesnt-document-8n0</link>
      <guid>https://dev.to/scanner/how-to-build-a-macos-screensaver-in-swift-everything-apple-doesnt-document-8n0</guid>
      <description>&lt;p&gt;I built Nap, a small paid app that installs a collection of screensavers on the Mac: a flip clock that drifts around the display and bounces off the edges, a football fired out of a slingshot, a pendulum wave, a pen plotter, a self-solving puzzle cube and a paper plane, all over a procedural topographic map.&lt;/p&gt;

&lt;p&gt;Screensaver development on macOS is barely documented. Apple's reference covers the API but not how the thing actually runs, and most of what's online predates the sandboxed host. The most surprising thing I found: a screensaver moving one small square still costs about 3 W of battery, and nothing in your own code can fix it.&lt;/p&gt;

&lt;p&gt;Here's everything that cost me real time, with the code that fixed it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How a .saver bundle actually works
&lt;/h2&gt;

&lt;p&gt;A screensaver is a loadable bundle (&lt;code&gt;MH_BUNDLE&lt;/code&gt;) whose principal class subclasses &lt;code&gt;ScreenSaverView&lt;/code&gt;. I don't use an Xcode project: &lt;code&gt;swiftc&lt;/code&gt; compiles the sources and &lt;code&gt;clang -bundle&lt;/code&gt; links them, because &lt;code&gt;swiftc&lt;/code&gt; alone only emits executables and dylibs.&lt;/p&gt;

&lt;p&gt;The lifecycle is short:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;init?(frame:isPreview:)&lt;/code&gt; creates the view.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;startAnimation()&lt;/code&gt; / &lt;code&gt;stopAnimation()&lt;/code&gt; bracket a run.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;animateOneFrame()&lt;/code&gt; is called every &lt;code&gt;animationTimeInterval&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;draw(_:)&lt;/code&gt; paints, like any &lt;code&gt;NSView&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;hasConfigureSheet&lt;/code&gt; / &lt;code&gt;configureSheet&lt;/code&gt; provide the Options panel in System Settings.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What matters more is &lt;em&gt;where&lt;/em&gt; it runs. Your bundle is loaded into &lt;code&gt;legacyScreenSaver&lt;/code&gt;, a sandboxed Apple app extension, not into System Settings or your own app. Almost every problem below comes from that.&lt;/p&gt;

&lt;p&gt;I kept the &lt;code&gt;ScreenSaverView&lt;/code&gt; subclass thin. Everything that draws lives in an &lt;code&gt;Engine/&lt;/code&gt; folder that never imports ScreenSaver, so the same scene renders in the screensaver, in the app's live preview, or straight to a PNG in a test harness:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;@objc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;NapView&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="kt"&gt;NapView&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;ScreenSaverView&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;scene&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;BounceScene&lt;/span&gt;

    &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;animateOneFrame&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;CACurrentMediaTime&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;elapsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;lastTick&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;lastTick&lt;/span&gt;
        &lt;span class="c1"&gt;// Capped, so a stalled frame doesn't make the sprite jump.&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;dt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;CGFloat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;elapsed&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="n"&gt;lastTick&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;
        &lt;span class="c1"&gt;// ...&lt;/span&gt;
        &lt;span class="n"&gt;scene&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;dt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;dt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;scene&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dirtyRect&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isEmpty&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;setNeedsDisplay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scene&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dirtyRect&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;draw&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="nv"&gt;rect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;NSRect&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;NSGraphicsContext&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cgContext&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;scene&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;draw&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;in&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;rect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;bounds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To check the boundary, compile &lt;code&gt;Engine/*.swift&lt;/code&gt; on its own without &lt;code&gt;-framework ScreenSaver&lt;/code&gt;. Rendering frames without installing anything was the most useful thing in the project, because installing and triggering a real screensaver is a slow loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 1: settings that never reach the screensaver
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Symptom.&lt;/strong&gt; I hosted the Options panel in an ordinary window so I could iterate on it without System Settings. Changing settings in it did nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why.&lt;/strong&gt; &lt;code&gt;ScreenSaverDefaults&lt;/code&gt; resolves to the &lt;em&gt;host's&lt;/em&gt; container:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;~/Library/Containers/com.apple.ScreenSaver.Engine.legacyScreenSaver/Data/Library/Preferences/ByHost/&amp;lt;bundleid&amp;gt;.&amp;lt;uuid&amp;gt;.plist
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An unsandboxed process calling the identical API writes to &lt;code&gt;~/Library/Preferences/ByHost/&lt;/code&gt; instead, which the screensaver never reads. And inside the host, &lt;code&gt;NSHomeDirectory()&lt;/code&gt; is remapped into the container, so the screensaver can't find a file your app wrote by the usual route either.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix.&lt;/strong&gt; I probed the sandbox from inside the running screensaver. Against real absolute paths, the host can &lt;em&gt;read&lt;/em&gt; &lt;code&gt;~/Library/Application Support/&lt;/code&gt;, though not write to it, and nothing prompts the user. So the app writes a plain JSON settings file there, and the screensaver finds the real home directory through the password database:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;/// The user's real home. Inside the screensaver host `NSHomeDirectory()` is&lt;/span&gt;
&lt;span class="c1"&gt;/// remapped into that host's sandbox container, so it cannot be used to find&lt;/span&gt;
&lt;span class="c1"&gt;/// anything the user or our app wrote.&lt;/span&gt;
&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;realHomeDirectory&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;entry&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getpwuid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;getuid&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;dir&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pointee&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pw_dir&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;cString&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;dir&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="kt"&gt;NSHomeDirectory&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 file is layered over &lt;code&gt;ScreenSaverDefaults&lt;/code&gt;: reads prefer the file and fall back to the host's store. Since the two stores can't be reconciled, the app owns settings, full stop.&lt;/p&gt;

&lt;p&gt;Related: on recent macOS, &lt;strong&gt;which&lt;/strong&gt; screensaver is selected lives in &lt;code&gt;~/Library/Application Support/com.apple.wallpaper/Store/Index.plist&lt;/code&gt;, not in &lt;code&gt;com.apple.screensaver moduleDict&lt;/code&gt;, which macOS 14 and later ignore. There's no public API to set it, and that file also holds the desktop wallpaper, so Nap only ever reads it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 2: you installed the update, and the old one keeps running
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Symptom.&lt;/strong&gt; Copy a new &lt;code&gt;Nap.saver&lt;/code&gt; into &lt;code&gt;~/Library/Screen Savers/&lt;/code&gt; and the old version keeps running. The Options button in System Settings can look dead. Forum posts say it's broken on modern macOS; in my experience it works, and when it seems dead the host is holding a stale copy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why.&lt;/strong&gt; &lt;code&gt;legacyScreenSaver&lt;/code&gt; caches the bundle it loaded, and copying over a live bundle can leave stale files behind.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix.&lt;/strong&gt; When developing: &lt;code&gt;rm -rf&lt;/code&gt; the installed bundle, copy the new one, then &lt;code&gt;killall legacyScreenSaver&lt;/code&gt;. Every time. The app does the same when it installs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;/// The host caches the bundle, so without this a reinstall appears to do&lt;/span&gt;
&lt;span class="c1"&gt;/// nothing and the settings panel looks broken.&lt;/span&gt;
&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;restartScreenSaverHost&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;task&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Process&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;executableURL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;fileURLWithPath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"/usr/bin/killall"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;arguments&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"legacyScreenSaver"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;standardError&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;FileHandle&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nullDevice&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitUntilExit&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;To know whether what's installed is what it ships, the app hashes the executables, because a weaker check showed a tick next to a stale copy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="nv"&gt;saver&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;binary&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;saver&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;appendingPathComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Contents/MacOS/Nap"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="kt"&gt;Data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;contentsOf&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;binary&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kt"&gt;SHA256&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;map&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="nv"&gt;format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"%02x"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$0&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="nf"&gt;joined&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This bit me again later. The self-updater replaced &lt;code&gt;/Applications/Nap.app&lt;/code&gt; but not &lt;code&gt;~/Library/Screen Savers/Nap.saver&lt;/code&gt;, which is what macOS actually loads, so a successful update looked like a failed one. Now the app replaces a mismatched screensaver at launch and restarts the host quietly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 3: a bundle you can't unload
&lt;/h2&gt;

&lt;p&gt;The app's live preview loaded the installed &lt;code&gt;.saver&lt;/code&gt;, so after an Update it kept showing the old version until the app was relaunched. A loaded bundle's code can't be unloaded from a process. The fix was to preview the copy bundled inside the app, which only changes when the app does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 4: the battery cost is in the host, not your drawing
&lt;/h2&gt;

&lt;p&gt;This was the most expensive lesson. The numbers below were measured on one 14-inch M4 Pro MacBook Pro, on battery, over one evening, so treat them as one machine's results rather than a benchmark.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Symptom.&lt;/strong&gt; A plain black screen drew about 2.9 W for the whole Mac. Nap's styles, running as the real screensaver, drew 7.1 to 8.0 W. That's roughly 21 hours of battery down to about 8.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First fix: redraw only what changed.&lt;/strong&gt; Nap used to redraw the whole screen 60 times a second. Now each style reports the part it touched, and only that plus last frame's rect is invalidated:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;markDirty&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;display&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;CGRect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;zero&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;bounds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;notice&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;sprite&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;redrawRect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;drawOrigin&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;intersection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;display&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;null&lt;/span&gt;
    &lt;span class="n"&gt;dirtyRect&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;redrawAll&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nv"&gt;display&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;union&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lastRedraw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;integral&lt;/span&gt;
    &lt;span class="n"&gt;lastRedraw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;
    &lt;span class="n"&gt;redrawAll&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The football now redraws 2.6% of the screen per frame, the flip clock 23%. If &lt;code&gt;redrawRect&lt;/code&gt; misses anything a style draws, like a drop shadow, it smears, so a tool compares every style in every theme pixel for pixel against a full redraw.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it didn't help as much as expected.&lt;/strong&gt; In an ordinary window the fix cut GPU use from about 15–18% to 2.5–4%. In the real screensaver it saved only 0.3 to 0.5 W. A screensaver moving &lt;strong&gt;one small square&lt;/strong&gt; at 60 fps, redrawing only that square, still cost about 3 W over a black screen, against 0.65 to 1.3 W in an ordinary window. As far as I can tell, &lt;code&gt;legacyScreenSaver&lt;/code&gt; pushes the whole screen every frame no matter what you redraw.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The lever that's left: frame rate.&lt;/strong&gt; The floor is paid per frame, so Nap's Automatic setting uses the display's full rate on power and 30 on battery:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;framesPerSecond&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;onBattery&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Bool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;displayMax&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;Int&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;full&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;FrameRate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;saving&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;displayMax&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;smooth&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;full&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;batterySaver&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kt"&gt;FrameRate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;saving&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;automatic&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;onBattery&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="kt"&gt;FrameRate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;saving&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;full&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 screensaver's CPU fell from 37% at 120 fps to 16% at 30, yet power barely moved in the first measurement. Only alternating the two rates in one session showed the real saving: about 0.9 W for the paper plane and 1.1 W for the clock. Measure power, not CPU, and measure it back to back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 5: small rendering gotchas
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;draw()&lt;/code&gt; can run before the first &lt;code&gt;animateOneFrame()&lt;/code&gt;.&lt;/strong&gt; The clock painted &lt;code&gt;--&lt;/code&gt; on its first frame. Seed the scene in &lt;code&gt;init&lt;/code&gt;:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;  &lt;span class="n"&gt;scene&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;bounds&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;backingScale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;reseed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="c1"&gt;// Seed the sprite now; draw() can run before the first animateOneFrame.&lt;/span&gt;
  &lt;span class="nf"&gt;applyLicence&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="n"&gt;scene&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;dt&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Shadow units change with context.&lt;/strong&gt; In your own bitmap context, a shadow's offset and blur are in pixels; in a window on a Retina display they're in points, so an offset of 40 moved it 80 pixels. Multiply by the scale when baking a shadow into an image. For a live shadow, &lt;code&gt;ctx.ctm&lt;/code&gt; gives the scale (1 in a window, 2 in a Retina bitmap); &lt;code&gt;userSpaceToDeviceSpaceTransform&lt;/code&gt; reports 2 in both.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Draw cached images at whole pixels.&lt;/strong&gt; An image drawn into a rect off by a fraction of a pixel is resampled every frame: 13 ms for the plotter's card, against under 1 ms copied straight.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Animating the flip clock
&lt;/h2&gt;

&lt;p&gt;The clock is drawn with CoreGraphics every frame, with no Core Animation, which lets the engine run outside the screensaver and keeps the dirty-rect logic in one place.&lt;/p&gt;

&lt;p&gt;Each card keeps its current and previous digits and how far into a flip it is. A flip lasts 0.64 seconds, in two stages:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;First half:&lt;/strong&gt; the new digits already sit in the top half. The old top flap collapses toward the seam, hiding them until it's flat.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Second half:&lt;/strong&gt; the new bottom flap opens out of the seam over the old bottom half.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The "rotation" is a vertical scale about the seam, with an opaque backing on each flap and a shade that fades as it opens.&lt;/p&gt;

&lt;p&gt;The easing is a plain smoothstep, &lt;code&gt;x * x * (3 - 2 * x)&lt;/code&gt;. Progress advances by the frame's &lt;code&gt;dt&lt;/code&gt; rather than by frame count, so a flip takes the same time at 30 fps on battery as at 120 on power. Because &lt;code&gt;dt&lt;/code&gt; is capped at 0.05 s, a stalled frame slows the flip down instead of skipping it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shipping it without being notarized
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Install without a Gatekeeper warning
&lt;/h3&gt;

&lt;p&gt;I don't have an Apple Developer account, so Nap isn't notarized. It's signed with my own self-signed certificate, which gives every build a stable identity, but anything &lt;strong&gt;downloaded in a browser&lt;/strong&gt; is quarantined and macOS warns on first launch. The DMG is still there, with instructions for Privacy &amp;amp; Security → Open Anyway.&lt;/p&gt;

&lt;p&gt;The other path is a one-line Terminal install:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://trynap.app/install.sh | bash
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;curl&lt;/code&gt; doesn't set the quarantine flag, so Nap opens without a word. The script checks a SHA-256 against what the site published, verifies the signature, and installs without &lt;code&gt;sudo&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Updates come from inside the app, never a browser, for the same reason. The updater checks the checksum and the Scanner Techs signature before swapping the bundle in. One trap: &lt;strong&gt;App Translocation.&lt;/strong&gt; macOS runs a quarantined app from a random read-only path, where it can't replace itself. The updater detects that (&lt;code&gt;bundleURL.path.contains("/AppTranslocation/")&lt;/code&gt;) and points people at the install page instead.&lt;/p&gt;

&lt;h3&gt;
  
  
  License keys with an offline grace period
&lt;/h3&gt;

&lt;p&gt;Nap is free for two days, then a one-off purchase through Dodo Payments, with no server of mine in the loop.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The app activates, validates and deactivates keys with Dodo directly. One key per Mac.&lt;/li&gt;
&lt;li&gt;Dodo's activation accepts any key from any Dodo product, so the app checks that the product in the reply is Nap's. Easy to miss if you sell more than one app.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The screensaver never talks to the network.&lt;/strong&gt; The app does all the checking, so a process you don't control isn't making web requests.&lt;/li&gt;
&lt;li&gt;If Dodo can't be reached, the license keeps working for 14 days, counted from the first failed check rather than the last success.&lt;/li&gt;
&lt;li&gt;When the trial ends, the screensaver shows the map with one dim line of text and drops to one frame a second.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Bonus: the same designs on Android
&lt;/h2&gt;

&lt;p&gt;Nap is also an Android live wallpaper. It shares no code with the Mac: it's Kotlin, drawing the same designs on Android's canvas. What's shared is one design-tokens file with every style's colors, the contour map, the backgrounds, and the flip clock's proportions and timings. A script checks the Mac and the website against it and generates Android's tokens, so nothing quietly drifts between platforms.&lt;/p&gt;

&lt;p&gt;Keeping the Mac engine free of ScreenSaver from day one is what made the port a translation job rather than an excavation.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Measure power on day one.&lt;/strong&gt; I measured after building six styles, then found the host's per-frame floor makes most rendering optimizations worth a fraction of what they look like in Activity Monitor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use a file for settings from the start.&lt;/strong&gt; I began with &lt;code&gt;ScreenSaverDefaults&lt;/code&gt; and an Options sheet, and both became fallbacks once the app owned settings.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat "installed" as "the installed executable matches".&lt;/strong&gt; Every check weaker than a hash showed a tick next to stale code at some point.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want to see where all this ended up, Nap is at &lt;a href="https://trynap.app" rel="noopener noreferrer"&gt;trynap.app&lt;/a&gt;: free for two days, then $5 once. It runs on macOS 13 or later, on Apple silicon and Intel.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://trynap.app/blog/macos-screensaver-development" rel="noopener noreferrer"&gt;trynap.app&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>macos</category>
      <category>swift</category>
      <category>tutorial</category>
      <category>indiedev</category>
    </item>
    <item>
      <title>Why collapsible tab headers jump in React Native (and how to fix it)</title>
      <dc:creator>Jassi Singh</dc:creator>
      <pubDate>Thu, 15 Oct 2026 13:30:00 +0000</pubDate>
      <link>https://dev.to/scanner/why-collapsible-tab-headers-jump-in-react-native-and-how-to-fix-it-5d4f</link>
      <guid>https://dev.to/scanner/why-collapsible-tab-headers-jump-in-react-native-and-how-to-fix-it-5d4f</guid>
      <description>&lt;p&gt;You've seen this screen a hundred times: a profile header, a row of tabs underneath (Posts, Media, Likes), and the header collapses as you scroll. Twitter, Instagram, Spotify artist pages.&lt;/p&gt;

&lt;p&gt;Now try this on most implementations:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Scroll &lt;strong&gt;Posts&lt;/strong&gt; down a few screens. The header collapses.&lt;/li&gt;
&lt;li&gt;Switch to &lt;strong&gt;Media&lt;/strong&gt;, which is still at the top.&lt;/li&gt;
&lt;li&gt;Watch the header &lt;strong&gt;jump&lt;/strong&gt;: snap open, snap closed, or leave a gap.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I hit this enough times that I ended up writing my own library. Here's what's actually going on.&lt;/p&gt;

&lt;h2&gt;
  
  
  The root cause: the header is derived from the active tab's scroll
&lt;/h2&gt;

&lt;p&gt;The common approach is simple: &lt;code&gt;headerTranslateY = -clamp(activeTab.scrollY, 0, headerHeight)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It works perfectly with one tab. With several, every tab has its &lt;strong&gt;own&lt;/strong&gt; scroll offset, and the header position is a function of whichever tab is active. Switch tabs and the input changes instantly, so the output (your header) teleports.&lt;/p&gt;

&lt;p&gt;Libraries usually patch around it by force-syncing scroll offsets on every tab switch (scrolling the new tab to match the header). That's where you get flicker, lost scroll positions, and fights with list virtualization.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: decouple the header from tab offsets
&lt;/h2&gt;

&lt;p&gt;Instead of deriving the header from a scroll offset, make the header position &lt;strong&gt;its own piece of state&lt;/strong&gt; (a Reanimated shared value), and drive it with scroll &lt;em&gt;deltas&lt;/em&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Scrolling down&lt;/strong&gt; collapses the header in sync with content.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scrolling up&lt;/strong&gt; keeps the header collapsed until the content's top edge reaches it, then expands it in sync. The header never detaches from content and never leaves a gap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Switching tabs&lt;/strong&gt; doesn't touch the header at all. Each tab keeps its own scroll position, and the header stays exactly where it was.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No offset-syncing, no jump, and every tab remembers where you left it.&lt;/p&gt;

&lt;h3&gt;
  
  
  The trade-off (there's always one)
&lt;/h3&gt;

&lt;p&gt;Because each tab keeps its own scroll memory, a deep-scrolled tab can have content sitting &lt;em&gt;underneath&lt;/em&gt; an expanded header. So the header needs a &lt;strong&gt;solid background&lt;/strong&gt;; translucent headers don't work with this model. I think that's the right trade for jump-free switching, but it's worth knowing upfront.&lt;/p&gt;

&lt;h2&gt;
  
  
  In practice
&lt;/h2&gt;

&lt;p&gt;▶️ &lt;strong&gt;&lt;a href="https://github.com/user-attachments/assets/f7fdfd2b-8899-4192-9343-120e3c7b505b" rel="noopener noreferrer"&gt;Watch the demo: switching tabs with zero header jump&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I packaged this approach as &lt;a href="https://rnct.scannertechs.com" rel="noopener noreferrer"&gt;react-native-collapsible-tab&lt;/a&gt;. It's built on Reanimated, Gesture Handler and Pager View, with all animation on the UI thread.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install &lt;/span&gt;react-native-collapsible-tab
npx expo &lt;span class="nb"&gt;install &lt;/span&gt;react-native-reanimated react-native-gesture-handler react-native-pager-view
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Tabs&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react-native-collapsible-tab&lt;/span&gt;&lt;span class="dl"&gt;'&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;Profile&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="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Tabs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Container&lt;/span&gt;
      &lt;span class="na"&gt;renderHeader&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ProfileHeader&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="c1"&gt;// height measured automatically&lt;/span&gt;
      &lt;span class="na"&gt;minHeaderHeight&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;insets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;top&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;           &lt;span class="c1"&gt;// stays visible when collapsed&lt;/span&gt;
      &lt;span class="na"&gt;lazy&lt;/span&gt;                                   &lt;span class="c1"&gt;// mount tabs on first focus&lt;/span&gt;
      &lt;span class="na"&gt;snapThreshold&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;                    &lt;span class="c1"&gt;// settle fully open/closed on release&lt;/span&gt;
    &lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Tabs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Tab&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"posts"&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Posts"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Tabs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;FlatList&lt;/span&gt;
          &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;posts&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
          &lt;span class="na"&gt;renderItem&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;item&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;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Post&lt;/span&gt; &lt;span class="na"&gt;post&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
          &lt;span class="na"&gt;keyExtractor&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Tabs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Tab&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Tabs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Tab&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"media"&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Media"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Tabs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ScrollView&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;MediaGrid&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Tabs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ScrollView&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Tabs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Tab&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Tabs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Container&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things I cared about while building it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Twitter-style reveal&lt;/strong&gt;: &lt;code&gt;revealHeaderOnScroll&lt;/code&gt; brings the header back on any upward scroll.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Snap without deadlocks&lt;/strong&gt;: snap is a plain shared-value animation, not offset-driven scrolling, which is known to lock up the UI thread on the New Architecture.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;FlashList v2 and LegendList adapters&lt;/strong&gt;, plus FlatList, ScrollView and SectionList.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Windowed memory&lt;/strong&gt;: with React 19.2's &lt;code&gt;&amp;lt;Activity&amp;gt;&lt;/code&gt;, &lt;code&gt;windowConfig={{ ahead: 1, behind: 1 }}&lt;/code&gt; keeps 60 tabs from all staying mounted. Only the focused one and its neighbours stay live, and scroll positions survive.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  One gotcha that bites everyone: negative scroll offsets
&lt;/h2&gt;

&lt;p&gt;If you animate anything from scroll position (a progress bar, a fade), remember that on iOS &lt;code&gt;contentOffset.y&lt;/code&gt; goes &lt;strong&gt;negative&lt;/strong&gt; when the list bounces past the top. Android clamps at 0. Clamp both ends:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ❌ negative offset → negative width during bounce&lt;/span&gt;
&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;scrollY&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;TOTAL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;%`&lt;/span&gt;

&lt;span class="c1"&gt;// ✅ overscroll reads as 0%&lt;/span&gt;
&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;scrollY&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;TOTAL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;))}&lt;/span&gt;&lt;span class="s2"&gt;%`&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;bounces={false}&lt;/code&gt; hides the symptom; clamping is the actual fix.&lt;/p&gt;




&lt;p&gt;Docs: &lt;a href="https://rnct.scannertechs.com" rel="noopener noreferrer"&gt;rnct.scannertechs.com&lt;/a&gt; · GitHub: &lt;a href="https://github.com/JassiSingh08/react-native-collapsible-tab" rel="noopener noreferrer"&gt;JassiSingh08/react-native-collapsible-tab&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you've fought this bug with a different approach, I'd love to hear how you solved it in the comments.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>javascript</category>
      <category>mobile</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
