<?xml version="1.0" encoding="utf-8" standalone="yes" ?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>Zinc • Alin Panaitiu's notes and ramblings</title>
    <link>https://notes.alinpanaitiu.com/</link>
    <description>Short-form content on macOS apps, music and computers in general</description>
    <generator>Caddy -- caddyserver.com</generator>
    <language>en-us</language>
    <managingEditor>alin@panaitiu.com (Alin Panaitiu)</managingEditor>
    <webMaster>alin@panaitiu.com (Alin Panaitiu)</webMaster>
    <copyright>This work is licensed under a Creative Commons Attribution-ShareAlike 4.0 International License.</copyright>
    <lastBuildDate>19 Aug 2022 22:09:26 +0300</lastBuildDate>
    <atom:link href="https://notes.alinpanaitiu.com/index.xml" rel="self" type="application/rss+xml" />
            <item>
              <title>App Store doesn&#39;t accept &#34;too simple&#34; apps</title>
              <link>https://notes.alinpanaitiu.com/Dealing-with-App-Store-rejections</link>
              
              <pubDate>Fri, 19 Aug 2022 00:00:00 +0000</pubDate>
              
              <author>alin@panaitiu.com (Alin Panaitiu)</author>
              <guid>https://notes.alinpanaitiu.com/Dealing-with-App-Store-rejections</guid><description>
                  <![CDATA[<h1 id="app-store-doesnt-accept-too-simple-apps">App Store doesn't accept &quot;too simple&quot; apps</h1>
<p>I've had my fair share of App Store rejections in the past:</p>
<ul>
<li><strong><a href="https://lowtechguys.com/rcmd">⌘ rcmd</a></strong> was rejected for <a href="https://alinpanaitiu.com/blog/apps-outside-app-store/#the-app-store-review">usage of private APIs</a></li>
<li><a href="https://lowtechguys.com/gammadimmer/">GammaDimmer</a> was rejected for having a link to <strong><a href="https://lunar.fyi/">🌕 Lunar</a></strong> <em>(my app for controlling monitors that would never get accepted on the App Store)</em></li>
</ul>
<p>But I wasn't prepared to be rejected because my app is <em>&quot;not good enough&quot;</em> for the App Store.</p>
<h3 id="422---design---minimum-functionality">4.2.2 - Design - Minimum Functionality</h3>
<p>I tried to launch a simple, no-frills iOS app for party-goers and music festivals in 🇷🇴 Romania.</p>
<p>The backend would be a simple Google Sheet which my brother would update daily with curated underground parties and the usual festivals.</p>
<p><em>My brother is not a programmer, so entering data had to be as low tech as possible.</em></p>
<p>So ok, I did a basic <code>SwiftUI</code> implementation where I fetch the <code>.csv</code> of the sheet, massage that data into a grid of events, and add the following useful iOS features:</p>
<ul>
<li><strong>Add to Calendar</strong> button <em>(which uses <code>EventKit</code> to fill in the URL, Location, End Date fields automatically, and adds the necessary reminder alerts)</em></li>
<li><strong>Get directions</strong> button <em>(which gives you a Google Maps link to the exact coordinates of the event. Super useful as some events are in forests or places where the address is not enough)</em></li>
<li><strong>Buy tickets</strong> button <em>(which should always point you to the correct website to get tickets from)</em></li>
</ul>
<p><img src="https://notes.alinpanaitiu.com/attachments/image19.png" alt="image">
All of the above are only possible because my brother spends hours of his time every day to look for event location, coordinates, ticket website, FB/IG/Official website links. Info which is not readily available on a single internet webpage.</p>
<p>Then I created a simple website at <strong><a href="https://subsol.one/">subsol.one</a></strong> and sent the app to App Store review.</p>
<p>After a full day of waiting, I got the most stupid rejection I ever read:</p>
<blockquote>
<p>Guideline 4.2.2 - Design - Minimum Functionality</p>
<p>We noticed that your app only includes links, images, or content aggregated from the Internet with limited or no native iOS functionality. Although this content may be curated from the web specifically for your users, since it does not sufficiently differ from a mobile web browsing experience, it is not appropriate for the App Store.</p>
<p>Next Steps</p>
<p>We encourage you to review your app concept and work towards creating an app that offers customers an engaging and lasting experience that also meets the App Store’s high expectations for quality and functionality.</p>
</blockquote>
<p>So the app <em><strong>does not sufficiently differ from a mobile web browsing experience</strong></em>. Ok. Fair enough.</p>
<h3 id="second-try">Second try</h3>
<p>I thought the app is useful enough as it is for now.</p>
<p>Considering that the iOS App Store still can't get rid of <a href="https://www.vpncheck.org/84-fraudulent-apps-scamming-users-for-100m-annually-2022/">so many scam apps</a> which are even used for extortion and blackmail, I really didn't understand how this was an acceptable reason for a rejection.</p>
<p>The app is simple, fast, does what it says without any BS unneeded complexity. I thought subsequent features would be added based on what the users would ask for.</p>
<p>But sure, let's add some premature <em>iOS native</em> features for Apple:</p>
<ul>
<li><strong>Push Notifications</strong>: so you can know instantly when new parties are found</li>
<li><strong>User Location</strong>: used for sorting by how close the events are to you, and for notifying only on events near you</li>
<li><strong>Share button</strong>: for sharing Universal Links to events with other people</li>
</ul>
<p>Still, after even more agonising days of waiting, the same rejection came along.</p>
<h3 id="third-try">Third try</h3>
<p>I added more iOS features, because why not:</p>
<ul>
<li><strong>Events on the map</strong>: to visually assess where each event is happening in the country
<ul>
<li>This was surprisingly easy to do in SwiftUI, I was amazed myself</li>
</ul>
</li>
<li><strong>Homescreen widgets</strong>: utterly useless, but can't get more iOS-y and less webapp-y than this, right?</li>
</ul>
<p><img src="https://notes.alinpanaitiu.com/attachments/57E523F3-66A3-4841-BE7C-95856C4BD069.png" alt="image"></p>
<p>Another day of <em>Waiting for review</em> and, as before, another rejection with the same generic message.
This time I had to ask, what the heck did they want from me?</p>
<p>I sent the following message to the App Store reviewers:</p>
<blockquote>
<p>What exactly do you need for this app to be accepted? I have people asking for it, it's already done and these rejections are keeping them from using the app.</p>
<p>It already uses the following native iOS functionalities:</p>
<ul>
<li>Push Notifications on new events (this is not possible on the web in iOS)</li>
<li>Getting user location using CoreLocation and sorting events by how close they are</li>
<li>Uses the above two functions for notifying only on new parties within 30km of the user location (again, not possible to do such a thing in a web app)</li>
<li>Shows the events on the native iOS MapKit UI</li>
<li>Uses a custom URL scheme (subsol:) and Universal Links for easy sharing of parties</li>
<li>Has homescreen widgets for viewing the latest events</li>
<li>Allows the user to add the event to calendar with most fields already filled in (event location, when it ends, useful URLs etc.)</li>
</ul>
</blockquote>
<p>And all I got was another generic response:</p>
<blockquote>
<p>Hello,</p>
<p>Thank you for your response. We encourage you to consider ways to make your app stand out.</p>
<p>We understand that it can be difficult to determine what the best experience is to offer your users.</p>
<p>While there isn’t one set answer that works for every app, the following iOS development videos offer great information for helping understand how your app can provide a great user experience:
– Essential Design Principles
– Design Tips for Great Games</p>
<p>You may also want to review the Human Interface Guidelines available on Apple Developer.</p>
<p>Best regards,</p>
<p>App Store Review</p>
</blockquote>
<h3 id="now-what">Now what?</h3>
<p>I considered using some more device sensors to justify the app being an app.</p>
<p>I even did a parallax animation <em>(because I thought it looked cool and it uses the accelerometer)</em> and added one of those Taptic Engine button-like vibrations on clicking on the event image.</p>
<figure class="video-container"><video src="https://files.alinpanaitiu.com/subsol-parallax.mp4" autoplay loop muted playsinline disablepictureinpicture></video><figcaption>parallax animation</figcaption></figure>
<p><a href="https://twitter.com/alinp32/status/1558855422131507201">Parallax animation -- Twitter</a></p>
<p>But I think they just don't like the idea of the app, and no matter what I add to it, they won't accept it.</p>
<hr>
<h2 id="update-26-aug-2022-at-1930">Update: 26 Aug 2022 at 19:30</h2>
<h3 id="uhm-something-just-happened">Uhm.. something just happened</h3>
<p>About a week after this article started spinning around the internet, I was quietly working on a chat feature for this app when something odd happened.</p>
<p>I got an email from App Store Connect that the app is now <strong>In Review</strong>. Without me submitting anything more than what was already there from the <a href="https://notes.alinpanaitiu.com/#third-try">third try</a>.</p>
<p>10 minutes later, the app was <strong>accepted</strong> and is now there for anyone to <a href="https://apps.apple.com/us/app/sub-sol-underground-parties/id1636775618">download on the App Store</a>.</p>
<p>So, not sure what happened, I can only guess that someone from the review board might have stumbled upon this article.</p>
<p>If that is the case, thank you kind reviewer! You made my day, and my little brother's as well.</p>
<p>I forgot to mention in the first part of the article that the whole idea started so that my brother can have a basic income while he's going to art college, starting this autumn. The main reason why this is an app and not a website is because in-app purchases are easier to work with than payments on the internet <em>(no need for creating a user account, payment data is usually already available etc.)</em>.</p>
<p>So yeah, we're finally one step further towards that goal. Just one more thing to figure out: who would pay for such a thing?</p>
]]>
                </description></item>
            <item>
              <title>Weird monitor bugs people sent me in the last 5 years</title>
              <link>https://notes.alinpanaitiu.com/Weird-monitor-bugs</link>
              
              <pubDate>Sun, 28 Aug 2022 00:00:00 +0000</pubDate>
              
              <author>alin@panaitiu.com (Alin Panaitiu)</author>
              <guid>https://notes.alinpanaitiu.com/Weird-monitor-bugs</guid><description>
                  <![CDATA[<h1 id="weird-monitor-bugs-people-sent-me-in-the-last-5-years">Weird monitor bugs people sent me in the last 5 years</h1>
<p>I develop 🌕 <strong><a href="https://lunar.fyi/">Lunar</a></strong>, a macOS app for getting <strong>intelligent adaptive brightness</strong> on <strong>external monitors</strong> and controlling every aspect of your monitor using <a href="https://en.m.wikipedia.org/wiki/Display_Data_Channel">DDC</a> and other obscure protocols.</p>
<p>I get tons of email about Lunar, especially after it became my full time “job” in 2021. A lot of those emails complain about Lunar bugs, but sometimes, after a good multi day chase, I conclude it’s actually a monitor bug and the user has to kind of.. deal with it.</p>
<p><em>”Buy another monitor”</em> is never a good response, so I try to help and provide a workaround whenever I can.</p>
<p>But anyway, here’s a small collection of those bugs.</p>
<h2 id="dual-monitors-swapped-positions">Dual monitors swapped positions</h2>
<p>You know how people get two identical monitors and place them side by side for a neat ultrawide-like look.</p>
<p><img src="https://notes.alinpanaitiu.com/attachments/97746684-1D71-40E2-A97B-1ACD631A99DD.png" alt="image"></p>
<p>Well a lot of those people experience a very annoying behavior:
<strong>After standby or restart, the monitors get swapped by the OS, so moving the cursor to the left monitor will actually appear on the right monitor, and vice-versa.</strong></p>
<p>If the monitors also had some of the settings different, like resolution or color profile, those also get swapped.</p>
<p>This doesn’t happen for Apple vendored monitors like the Pro Display XDR in the above image, and you’ll see why in a minute.</p>
<h4 id="how-monitor-settings-are-stored">How monitor settings are stored</h4>
<p>Whenever you arrange monitors, the system has to store those coordinates persistently and restore them when the monitor reconnects.
<em>In standby, the monitor is actually disconnected and disappears from the I/O Registry, so on system wake, it reconnects and the OS has to rearrange based on the stored settings.</em></p>
<p>To store something persistently, you need to have a way of identifying that monitor precisely. So the OS computes an <code>UUID</code> from whatever it can find inside the data provided by the monitor.</p>
<p>That data is called the <a href="https://en.m.wikipedia.org/wiki/Extended_Display_Identification_Data">EDID structure</a> and it contains things like vendor ID, week/year of manufacture, <strong>serial number</strong> etc.</p>
<h4 id="why-does-this-happen">Why does this happen?</h4>
<p>The problem comes from vendors who flash the same exact firmware with the same EDID to multiple monitors in the same batch.</p>
<p>Serial number is emphasized above especially because by definition it should be a unique number that increments with each monitor.
Well it doesn’t. And it has nothing to do with the serial number you see on the back of the monitor or the box or wherever it’s placed.</p>
<p>So if you have the bad luck to buy two identical monitors, from the same batch, with the same EDID, then the OS will compute the same UUID for both.</p>
<p>That means that on reconnection, the monitors will look identical from the OS side, so the stored settings will have to be assigned randomly for each monitor. That will sometimes, not always, but deterministically when it's most annoying, cause the swapping bug.</p>
<p>And there’s nothing you can do to fix it.</p>
<h4 id="workaround">Workaround</h4>
<p>The simplest solution is present in Lunar's Shortcuts <em>(available without a Pro license)</em>:</p>
<ul>
<li><strong><a href="https://lunar.fyi/shortcuts#swap-monitors">Swap Monitors</a></strong> to fix arrangement on setups with <em>2 monitors</em></li>
<li><strong><a href="https://lunar.fyi/shortcuts#fix-monitor-arrangement">Fix Monitor Arrangement</a></strong> for setups with <em>3 or more monitors</em></li>
</ul>
<figure class="video-container"><video src="https://files.lunar.fyi/fix-monitor-arrangement-demo.mp4" autoplay loop muted playsinline disablepictureinpicture></video><figcaption>Demo of how Fix Monitor Arrangement works</figcaption></figure>
<hr>
<p>For people that don't want to use Lunar, I wrote a simple CLI for swapping monitors here:</p>
<p><a href="https://github.com/alin23/mac-utils/blob/main/SwapMonitors.swift">mac-utils/SwapMonitors.swift · GitHub</a></p>
<h5 id="installation">Installation</h5>
<ul>
<li>You can either install it from the Terminal:</li>
</ul>
<pre class="chroma"><code><span class="line"><span class="cl"><span class="nf">export</span> <span class="nv">BINDIR</span><span class="o">=</span>/usr/local/bin
</span></span><span class="line"><span class="cl"><span class="nf">export</span> <span class="nv">BINURL</span><span class="o">=</span><span class="s2">&#34;https://github.com/alin23/mac-utils/raw/main/bin/SwapMonitors&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nf">mkdir</span> <span class="na">-p</span> <span class="s2">&#34;</span><span class="nv">$BINDIR</span><span class="s2">&#34;</span> <span class="o">&amp;&amp;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  <span class="nf">curl</span> <span class="s2">&#34;</span><span class="nv">$BINURL</span><span class="s2">&#34;</span> <span class="o">&gt;</span> <span class="s2">&#34;</span><span class="nv">$BINDIR</span><span class="s2">/SwapMonitors&#34;</span> <span class="o">&amp;&amp;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  <span class="nf">chmod</span> +x <span class="s2">&#34;</span><span class="nv">$BINDIR</span><span class="s2">/SwapMonitors&#34;</span>
</span></span></code></pre><ul>
<li>Or use a Shortcut that does that automatically</li>
</ul>
<p><a href="https://www.icloud.com/shortcuts/3c9f6a71589a4813904973b3ef493c1f"><img src="https://notes.alinpanaitiu.com/attachments/add-to-shortcuts.svg" alt="image"></a></p>
<p>The shortcut also provides a way to bind a hotkey, or run it from the menu bar.</p>
<p><img src="https://notes.alinpanaitiu.com/attachments/shortcuts-config.png" alt="image"></p>
<h5 id="usage">Usage</h5>
<ul>
<li>Just run it and let the algorithm find the monitors that should be swapped: <em>(either press the hotkey you assigned to the Shortcut, or run it from the command line)</em></li>
</ul>
<pre class="chroma"><code><span class="line"><span class="cl">⌁ ❯ SwapMonitors
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nf">Swapping</span> Dell U2719D <span class="o">(</span><span class="nf">2</span><span class="o">)</span> <span class="o">[</span>ID: <span class="m">17</span><span class="o">]</span> with Dell U2719D <span class="o">(</span><span class="nf">1</span><span class="o">)</span> <span class="o">[</span>ID: <span class="m">18</span><span class="o">]</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nf">External</span> Display <span class="m">1</span>: <span class="nv">x</span><span class="o">=</span><span class="m">4872</span>.<span class="m">0</span> <span class="nv">y</span><span class="o">=</span><span class="m">0</span>.<span class="m">0</span> <span class="nv">width</span><span class="o">=</span><span class="m">2560</span>.<span class="m">0</span> <span class="nv">height</span><span class="o">=</span><span class="m">1440</span>.<span class="m">0</span>
</span></span><span class="line"><span class="cl">	Moving to: <span class="nv">x</span><span class="o">=</span><span class="m">1800</span>.<span class="m">0</span> <span class="nv">y</span><span class="o">=</span><span class="m">0</span>.<span class="m">0</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nf">External</span> Display <span class="m">2</span>: <span class="nv">x</span><span class="o">=</span><span class="m">1800</span>.<span class="m">0</span> <span class="nv">y</span><span class="o">=</span><span class="m">0</span>.<span class="m">0</span> <span class="nv">width</span><span class="o">=</span><span class="m">3072</span>.<span class="m">0</span> <span class="nv">height</span><span class="o">=</span><span class="m">1620</span>.<span class="m">0</span>
</span></span><span class="line"><span class="cl">	Moving to: <span class="nv">x</span><span class="o">=</span><span class="m">4872</span>.<span class="m">0</span> <span class="nv">y</span><span class="o">=</span><span class="m">0</span>.<span class="m">0</span>
</span></span></code></pre><ul>
<li>Or pass the IDs of the monitors to swap as arguments</li>
</ul>
<pre class="chroma"><code><span class="line"><span class="cl">⌁ ❯ SwapMonitors print-ids
</span></span><span class="line"><span class="cl"><span class="nf">IDs</span> of monitors in order of appearance from left to right: <span class="o">[</span><span class="m">17</span>, <span class="m">19</span>, <span class="m">18</span><span class="o">]</span>
</span></span><span class="line"><span class="cl"><span class="nf">Names</span> of monitors in order of appearance from left to right: <span class="o">[</span><span class="s2">&#34;Dell U2719D (2)&#34;</span>, <span class="s2">&#34;LG Ultrafine&#34;</span>, <span class="s2">&#34;Dell U2719D (1)&#34;</span><span class="o">]</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">⌁ ❯ SwapMonitors <span class="m">17</span> <span class="m">18</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nf">Swapping</span> Dell U2719D <span class="o">(</span><span class="nf">2</span><span class="o">)</span> <span class="o">[</span>ID: <span class="m">17</span><span class="o">]</span> with Dell U2719D <span class="o">(</span><span class="nf">1</span><span class="o">)</span> <span class="o">[</span>ID: <span class="m">18</span><span class="o">]</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nf">External</span> Display <span class="m">1</span>: <span class="nv">x</span><span class="o">=</span><span class="m">1800</span>.<span class="m">0</span> <span class="nv">y</span><span class="o">=</span><span class="m">0</span>.<span class="m">0</span> <span class="nv">width</span><span class="o">=</span><span class="m">2560</span>.<span class="m">0</span> <span class="nv">height</span><span class="o">=</span><span class="m">1440</span>.<span class="m">0</span>
</span></span><span class="line"><span class="cl">	Moving to: <span class="nv">x</span><span class="o">=</span><span class="m">4360</span>.<span class="m">0</span> <span class="nv">y</span><span class="o">=</span><span class="m">0</span>.<span class="m">0</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nf">External</span> Display <span class="m">2</span>: <span class="nv">x</span><span class="o">=</span><span class="m">4360</span>.<span class="m">0</span> <span class="nv">y</span><span class="o">=</span><span class="m">0</span>.<span class="m">0</span> <span class="nv">width</span><span class="o">=</span><span class="m">3072</span>.<span class="m">0</span> <span class="nv">height</span><span class="o">=</span><span class="m">1620</span>.<span class="m">0</span>
</span></span><span class="line"><span class="cl">	Moving to: <span class="nv">x</span><span class="o">=</span><span class="m">1800</span>.<span class="m">0</span> <span class="nv">y</span><span class="o">=</span><span class="m">0</span>.<span class="m">0</span>
</span></span></code></pre><h2 id="monochrome-hdr">Monochrome HDR</h2>
<p>While developing external monitor support for <strong><a href="https://lunar.fyi/#xdr">XDR Brightness</a></strong>, I needed a way to test it.</p>
<p>And since I don't have a <a href="https://displayhdr.org/#tab-600">600nits+ monitor</a>, I reached out to my friend István Tóth, the developer of <a href="https://betterdisplay.pro/">BetterDisplay</a> <em>(and also a previous Lunar user who became a MonitorControl maintainer by porting <a href="https://alinpanaitiu.com/blog/journey-to-ddc-on-m1-macs/">Lunar's M1 DDC code</a> to MonitorControl, long story)</em>.</p>
<p>When István turned on XDR Brightness on his Lenovo Creator Extreme, it was suddenly stripped of all color and everything became grayscale on it.</p>
<p>Other Lunar users also mentioned this issue on other specific monitor models, even before XDR Brightness was a thing. I thought I had an idea then on what could cause this, but this new data point dismantled my previous theory.</p>
<p>Some causes I can think of:</p>
<ul>
<li>The macOS ICC profile for that monitor computes a <code>0</code> chroma value in specific Gamma table configurations</li>
<li>The monitor internal color profile/preset is reverted to some default value on receiving a DDC command that's not handled correctly</li>
<li>The grayscale filter of macOS is enabled somehow? ¯\_(ツ)_/¯</li>
</ul>
<p>In the end, I have no idea what causes the problem, I simply disabled XDR for external monitors by default in Lunar, and made it opt-in.</p>
<h2 id="blacking-out">Blacking out</h2>
<p>This is the bug that causes the most angry and panicky emails I get.</p>
<p>On receiving specific DDC commands, some monitors simply <strong>go black and stop responding to any user input</strong>.</p>
<p>Reconnecting the video cable doesn't do anything, the power button doesn't work, from the user perspective it looks like Lunar just bricked their monitors.</p>
<p>The fix is simple: <strong>unplug the power cable, and plug it back again</strong></p>
<h4 id="whats-happening">What's happening?</h4>
<p>I have an idea.</p>
<h6 id="firmware-bugs">Firmware bugs</h6>
<p>Since Lunar doesn't do anything unusual <em>(it just sends standard DDC commands for setting brightness and contrast)</em>, I assume the bug is in the DDC handler code inside the monitor controller firmware.</p>
<p>It's possible that either a specific series of bytes, or a burst of DDC messages, causes the firmware to crash, and maybe even enter in a tight loop since it doesn't seem to restart itself.</p>
<p>I keep an extensive FAQ with possible monitor features and settings that can break DDC and even cause this bug in specific setups: <a href="https://lunar.fyi/faq#brightness-not-changing">FAQ - The brightness or volume doesn't change</a></p>
<h6 id="long-cables">Long cables</h6>
<p>On rare occasions, the issue was actually being caused by a too long Thunderbolt cable.</p>
<p>Most people don't know that there's no such thing as a 6 meter Thunderbolt cable, not without built-in signal amplifiers or <a href="https://www.corning.com/optical-cables-by-corning/worldwide/en/products/thunderbolt-optical-cables.html">fiber optics</a> and expensive converter chips.</p>
<p>So, expectedly, when using the data channel for DDC for longer than a few milliseconds, the video signal will drop completely, or revert to a much lower resolution and refresh rate.</p>
<h6 id="custom-color-profiles">Custom color profiles</h6>
<p>In other cases, the issue was a custom color profile, and the problem was actually caused by a macOS bug.</p>
<p>One Lunar user was an astronomer, and had a self-made observatory at home. He created a color profile that pushed most colors towards dark red, to keep his natural night vision when working.</p>
<p>But when Lunar adjusted the Gamma tables, the macOS color formula was sometimes reaching a point where every color computed to <code>rgb(0, 0, 0)</code>.</p>
<p>The Lunar adjustments were completely normal and well within the documented range, and I found no way to predict on what values would the system reach that point.</p>
<p>I had to add a periodic timer that checks if the macOS color curve gets to 0, and reset all color adjustments, then forcefully disable that functionality for the user and let him know about it.</p>
<p><img src="https://notes.alinpanaitiu.com/attachments/screen-blanking-dialog.png" alt="image"></p>
<h2 id="switching-input-just-blinks-the-screen">Switching input just blinks the screen</h2>
<p>The DDC protocol (or more exactly the <a href="https://files.lunar.fyi/mccs.pdf">Monitor Control Command Set (MCCS)</a> specification), defines a command for switching monitor inputs.</p>
<p>So you might have:</p>
<ul>
<li>a MacBook on the Thunderbolt input of your monitor</li>
<li>a PlayStation on the HDMI1 input</li>
<li>a Raspberry Pi on HDMI2</li>
</ul>
<p>And to switch between those, you can fiddle with the monitor buttons, or you can do that with handy keyboard shortcuts through DDC.</p>
<p>Because KVMs are expensive, I implemented that functionality in Lunar for people that don't really need the &quot;keyboard switching&quot; part of a KVM, but mostly the &quot;video switching&quot; part.</p>
<p><img src="https://notes.alinpanaitiu.com/attachments/1280_input-key.png" alt="image"></p>
<h5 id="so-wheres-the-bug">So, where's the bug?</h5>
<p>The thing is, some monitors lie about this functionality.</p>
<p>Even if the monitor reports that the VCP code <code>0x60</code> for Input Select is implemented, when any input value is sent, the monitor just blinks. By that I mean it goes black for a second, then returns to the previous image.</p>
<p>There's no software fix for these monitors.</p>
<h2 id="mute-command-doesnt-do-anything">Mute command doesn't do anything</h2>
<p>Kind of in the same vein as with the input switching bug above, the DDC Mute command just doesn't do anything on some monitors.</p>
<p>The odd thing is that the monitors do have a mute switch in their OSD, but it's not controllable by the VCP code <code>0x8D</code> for Audio Mute.</p>
<p>Instead, on some monitors, it seems that setting the volume to some non-standard value like <code>255</code> (or <code>-1</code> if we flip from <code>unsigned</code> to <code>signed</code> integers), might affect the volume setting. On most monitors though, it doesn't do anything at all.</p>
<p>That's another workaround that got its own section in Lunar's settings because of how often it's encountered.</p>
<p><img src="https://notes.alinpanaitiu.com/attachments/mute-workarounds.png" alt="image"></p>
]]>
                </description></item>
            <item>
              <title>My App Store review timeline (mostly rejections)</title>
              <link>https://notes.alinpanaitiu.com/App-Store-review-timeline</link>
              
              <pubDate>Thu, 15 Sep 2022 00:00:00 +0000</pubDate>
              
              <author>alin@panaitiu.com (Alin Panaitiu)</author>
              <guid>https://notes.alinpanaitiu.com/App-Store-review-timeline</guid><description>
                  <![CDATA[<h1 id="my-app-store-review-timeline-mostly-rejections">My App Store review timeline (mostly rejections)</h1>
<h2 id="lunar-lite">Lunar Lite</h2>
<p>This was my first attempt at publishing on the App Store. I started earning money on my 🌕 <strong><a href="https://lunar.fyi">Lunar Pro</a></strong> app for controlling native monitor brightness in <strong>May 2021</strong>. But after a few months I noticed some people were asking for a cheaper and more basic alternative to Lunar, that can also be installed from the App Store.</p>
<p>So I built <a href="https://apps.apple.com/us/app/lunar-lite-screen-brightness/id1594174299">Lunar Lite</a>, an app that was supposed to dim monitor brightness by altering the Gamma curve to make the colors appear darker, and submitted it to the App Store on <strong>Nov 8, 2021 at 5:46 PM</strong>.</p>
<p><img src="https://notes.alinpanaitiu.com/attachments/D734653D-51E5-46AA-B9D9-A5B0D7DA9CFC.png" alt="image"></p>
<p>After about 2 hours and 30 minutes, on <strong>Nov 8, 2021 at 8:11 PM</strong>, I got a rejection.</p>
<p>There were two functionalities that the App Store review board rejected:</p>
<ul>
<li>Using a private macOS API to control the native brightness of the built-in MacBook/iMac display (<code>DisplayServicesSetBrightness</code>)
<ul>
<li>The reason given by the reviewer was that private APIs are not guaranteed to remain available and can break in subtle ways</li>
<li>There's no other way to get this functionality so controlling native brightness had to be left out</li>
</ul>
</li>
<li>Showing a menu item that linked to my other app, Lunar Pro, with a description that said: <em>For more advanced features and native brightness control, check out Lunar Pro</em>
<ul>
<li>The reason given was that I'm not allowed to link to other methods of payment for the app, even though I was linking to a completely different app</li>
</ul>
</li>
</ul>
<p>I appealed this, stating that my case falls under <a href="https://developer.apple.com/app-store/review/guidelines/%233.2.1">3.2 Other Business Model Issues - 3.2.1 Acceptable</a>:</p>
<blockquote>
<p>3.2.1 Acceptable</p>
<p>(i) Displaying your own apps for purchase or promotion within your app, provided the app is not merely a catalog of your apps.</p>
</blockquote>
<p>The appeal was rejected with the same generic message, so I just removed the menu item just to get it accepted.</p>
<p>This affected my business because of multiple reasons:</p>
<ul>
<li>Some people will hear about Lunar <strong>Pro</strong> from a friend, and go searching for it on the App Store, not knowing that advanced apps can only be distributed outside the App Store
<ul>
<li>Those people downloaded Lunar <strong>Lite</strong> thinking it would control the native brightness, and then would leave a bad review when they noticed it only did software dimming, even if the description clearly stated that limitation</li>
</ul>
</li>
<li>I wanted to give a 30% discount on Lunar <strong>Pro</strong> licenses for those Lunar <strong>Lite</strong> users that found that they needed more features after buying the App Store app
<ul>
<li>But that wasn't possible because I was forbidden from adding a coupon code link to Lunar Pro inside the app</li>
</ul>
</li>
</ul>
<p>Eventually I phased out Lunar Lite because bad reviews kept appearing from users that thought they were getting a good deal on the full-featured Lunar Pro app, and the App Store provides no way of handling those reviews.</p>
<p>All I could do was reply to those reviews with clarifications, but not a single user replied back or changed their review in any way.</p>
<h3 id="lunar-lite-v1-timeline">Lunar Lite v1 timeline</h3>
<p><img src="https://notes.alinpanaitiu.com/attachments/18B6DC25-C5CD-4DE4-8FE1-2C2B0196F512.png" alt="image"></p>
<hr>
<h2 id="rcmd">rcmd</h2>
<p>My second app for the App Store was a much simpler idea: an instant app switching method called <strong><a href="https://lowtechguys.com/rcmd">rcmd</a></strong> that uses the <strong>Right Command</strong> key + <strong>the first letter of the app name</strong></p>
<p>The execution of the idea was not so simple though because the App Store sandbox doesn't allow the usual way of listening to key events to differentiate between Left and Right <strong>⌘ Command</strong>. I wrote at length on that here: <a href="https://alinpanaitiu.com/blog/apps-outside-app-store/#the-sandbox">Why aren't the most useful Mac apps on the App Store?</a></p>
<p>I submitted the first version of rcmd on <strong>Nov 18, 2021 at 1:37 PM</strong> and after 2 hours 26 min at <strong>Nov 19, 2021 at 4:03 PM</strong> the app was rejected.</p>
<p>The reason was the usage of yet another private API that I needed for opening Exposé on the windows of a single app.</p>
<p><em>Exposé preview</em></p>
<p><img src="https://notes.alinpanaitiu.com/attachments/041167F9-159D-4DAB-970D-AC014766F9D3.jpeg" alt="image"></p>
<p><em>The rejection email</em></p>
<p><img src="https://notes.alinpanaitiu.com/attachments/E590EA51-D1FF-4A52-8370-EF0F12E95DB9.png" alt="image"></p>
<p>That wasn't such a big deal though, it was a useful functionality but I could live without it. I removed the feature and got the app accepted on my second attempt.</p>
<p>Because of App Store sandbox limitations, there was no way to switch to specific windows of an app <em>(like for example, focus a specific PDF opened in Preview)</em> so I got some bad reviews from people that expected that feature to be present.</p>
<p>Again, the App Store was completely unhelpful in getting rid of those reviews, even if they were clearly irrelevant because the user did not read the description before buying.</p>
<p>I tried both replying and reporting the reviews to App Store but nothing changed.</p>
<p>This was a paid app and planned to continue developing it and make a sizeable income on it, but people were reluctant to buying it without trying it first.</p>
<p>The missing support for free trials for one-time purchase apps on the App Store had the biggest impact on the business side. There is <a href="https://alinpanaitiu.com/blog/apps-outside-app-store/#free-trials"><em>some support</em></a> but it's awkward to both implement and present to users.</p>
<h3 id="free-trial-not-allowed-anymore">“Free trial” not allowed anymore</h3>
<p><em>Date Submitted: 24 October 2021 at 20:28</em></p>
<p>Apparently my <strong>Free trial available on lowtechguys.com/rcmd</strong> line is not allowed anymore in the <strong>Promotional text</strong> section.</p>
<p>It has been there for the last few months, already approved by other reviewers.</p>
<p>But when I tried to release <code>v2.3.0</code> with <a href="https://youtu.be/dlwjSf7aIy8">updates for Stage Manager</a> on macOS Ventura, the app was rejected on the basis that the trial line is not app metadata.</p>
<blockquote>
<p><strong>Guideline 2.3 - Performance - Accurate Metadata</strong></p>
<p>We noticed that your app's metadata includes the following information, which is not relevant to the app's content and functionality:</p>
<p><em>Free trial is available on the official website at lowtechguys.com/rcmd</em></p>
</blockquote>
<p>I tried explaining that it is the only way to let people know that the app can be tried for free but was further rejected. Resubmitting the app got the same rejection.</p>
<p>In the end I removed the line just to push the update to users. I will remove rcmd from the App Store in the near future and publish it independently on my website.</p>
<h3 id="rcmd-v1-timeline">rcmd v1 timeline</h3>
<p><img src="https://notes.alinpanaitiu.com/attachments/C1827A9F-33BF-4D59-A88F-9C6AB5E7577D.png" alt="image"></p>
<h3 id="copyright-infringement-on-cover-artwork">Copyright Infringement on cover artwork</h3>
<p><em>Date Submitted: 2 October 2024</em></p>
<p>The video I'm using to demo how rcmd works with Stage Manager, shows how I switch to a Music stage where I have Spotify in the foreground. And because I've got <a href="https://open.spotify.com/album/70UczE3bLcRVJ51Bv7lRKe?si=5IJc7-lWTR2QEPW9t2bLyw">FKJ's VINCENT album</a> in that window with the cover artwork obviously showing, I'm apparently at risk of copyright infringement.</p>
<p>The reviewer helpfully included a very low-res screenshot of where I'm doing that:</p>
<p><img src="https://notes.alinpanaitiu.com/attachments/fkj-album-copyright-cover.jpeg" alt="image"></p>
<p>I removed that video as I didn't have time to edit it and resubmitted.</p>
<p>Another rejection came in as my other main video also switched to Spotify for a second and showed some cover artworks.</p>
<p><img src="https://notes.alinpanaitiu.com/attachments/rcmd-video-spotify-2.png" alt="image"></p>
<p>I didn't have that original video anymore, and Apple offers no way of downloading the video from App Store Connect.. whatever, deleted the video and resubmitted with only screenshots. I'll get out of the App Store soon anyway, I'm sick of this.</p>
<hr>
<h2 id="volum">Volum</h2>
<p>My third attempt, and my first foray into multiplatform development (both iOS and macOS) was Volum, an app for controlling DIY smart home devices, that don't have an app or a visual way to control them.</p>
<p>It started as a volume control app for Yamaha AV Receivers, because I wanted to be able to press volume keys to control the sound from my Mac, instead of reaching for the AV receiver volume knob.</p>
<p>The first version of the app was rejected on legal matters, stating that I was not allowed to mention Yamaha's brand and products.
So I removed mentions to Yamaha from the descriptions and screenshots, and advertised the app as a generic smart home control app.</p>
<p>I didn't see much hope in making money from this app, as it fills a very narrow niche, so this didn't impact my business.</p>
<h3 id="volum-v1-timeline">Volum v1 timeline</h3>
<p><img src="https://notes.alinpanaitiu.com/attachments/1A386C86-22E2-48E0-9A4E-3E2CE070FFD0.png" alt="image"></p>
<hr>
<h2 id="sub-sol">Sub Sol</h2>
<p>This was the most frustrating experience I had with the App Store review board, because I had no idea what they were asking from me.</p>
<p>Besides the fact that iOS apps take a lot longer to get reviewed <em>(measured in days as opposed to hours for macOS apps)</em>, the reason for the rejection felt plain stupid: the app was too simple, and not worthy of being on the App Store.</p>
<p>I wrote some notes on my blog about the experience, which quickly became viral after publishing the link on <a href="https://news.ycombinator.com/item?id=32526047">Hacker News:</a> <a href="https://notes.alinpanaitiu.com/Dealing%2520with%2520App%2520Store%2520rejections">App Store doesn't accept &quot;too simple&quot; apps — Alin Panaitiu</a></p>
<p>In the article you can find my 3 attempts at fixing issues and resubmitting the app, along with my appeal that changed nothing.</p>
<p>The app was my way of helping my brother earn a basic income until he gets to go on his first year of college in autumn.</p>
<p>The idea was that he would curate this list of underground parties and festivals instead of scrubbing dishes or working in retail for the summer. I needed the app on the App Store so I could monetise it easily.</p>
<p>The target audience wouldn't pay for such a thing on the web. It's a lot less friction to authorise a $1 app purchase with your face than inputting your credit card details on a web form, and I needed that convenience.</p>
<p>After a few days of the article being shared all over the internet, suddenly the app was changed to <strong>In Review</strong> without me doing anything, and accepted, just like that.</p>
<p>But it was already too late. The summer had passed, festivals were ending, there were less and less parties, and the idea of making money from this was fading.
The college admission process and finding rent was also taking all my brother's remaining time by now so we made the app free for the moment. We'll try again next year.</p>
<h3 id="sub-sol-v1-timeline">Sub Sol v1 timeline</h3>
<p><img src="https://notes.alinpanaitiu.com/attachments/F0FD49BA-7992-4817-B88A-962DD228B836.png" alt="image"></p>
]]>
                </description></item>
            <item>
              <title>How I write this blog on my iPhone, from a train</title>
              <link>https://notes.alinpanaitiu.com/How-I-write-this-blog-on-my-iPhone-in-a-train</link>
              
              <pubDate>Sat, 05 Nov 2022 00:00:00 +0000</pubDate>
              
              <author>alin@panaitiu.com (Alin Panaitiu)</author>
              <guid>https://notes.alinpanaitiu.com/How-I-write-this-blog-on-my-iPhone-in-a-train</guid><description>
                  <![CDATA[<h1 id="how-i-write-this-blog-on-my-iphone-from-a-train">How I write this blog on my iPhone, from a train</h1>
<p><img src="https://notes.alinpanaitiu.com/attachments/IMG_0774_1500.jpeg" alt="image"></p>
<p>I’ve been unemployed since May 2021, and occupied my time by mostly working on macOS apps for my small app studio: <a href="https://lowtechguys.com/">The low-tech guys</a>.</p>
<p>That may sound ideal for some, but it’s actually a very frustrating type of work most of the time. And to vent that frustration, somehow, I started writing.</p>
<p>I’m not really sure how it happened. One day, I just found myself morphing my hastily written findings into a short story, until it started looking like something I would want to read.</p>
<h3 id="copper-and-hugo">Copper and Hugo</h3>
<p>I first started writing on <a href="https://alinpanaitiu.com/blog/">this blog I called Copper</a>, which existed there as a placeholder since when I created my personal website using <a href="https://gohugo.io/"><strong>Hugo</strong></a>.</p>
<p><em>At the time, I wanted to share my PCB reverse engineering efforts, hence the name Copper. Nothing came out of that though.</em></p>
<p>I liked how fast Hugo is at hot reloading, and how the basics for writing a blog are builtin: a fast and complete Markdown renderer, RSS, a sitemap, categories, series, tags etc.</p>
<p>But it took me a while to bend it to my idea of a blog.</p>
<p>I started with the <a href="https://github.com/formspree/blogophonic-hugo"><strong>Blogophonic</strong></a> theme <em>(which I most likely didn't need)</em> that uses the <a href="https://tachyons.io/"><strong>Tachyons CSS</strong></a> framework <em>(yet another thing to learn)</em>, and had to work my way through shortcodes, templates, archetypes, <a href="https://tachyons.io/docs/table-of-styles/">obscure CSS classes</a>, page layouts, YAML frontmatter.. it felt like a burden.</p>
<p>When all you need is to get that yarn ball of sentences out of your head, onto a page where eyes can make some sense out of it, meddling with the innards of a site generator is the last thing you want to start with.</p>
<p>After seeing how well received that first article was, I wrote a few more, mostly to share what I had learned in those last few months of research. But it was getting tiring to do all the <code>git</code> and <code>hugo</code> dance just to write some words.</p>
<h3 id="noteplan">NotePlan</h3>
<p>I found this super useful note taking app called <a href="https://noteplan.co/"><strong>NotePlan</strong></a> when I first subscribed to <strong>Setapp</strong>, and been using it ever since because of its good looking markdown themes, per-day notes and calendar integration.</p>
<p>I was already using NotePlan for tasks and ideas, so I also started writing those articles in NotePlan first before moving them to Hugo for refinement. That’s because I could more easily see how the markdown will look like when it’s rendered.</p>
<p><img src="https://notes.alinpanaitiu.com/attachments/9FB5608B-5D92-4858-B351-C29BC7C66012.png" alt="image"></p>
<p><em>Also because I love writing in the NotePlan iPad app with the Apple Pencil, but not exactly in the way most would imagine.</em></p>
<figure class="video-container"><video src="https://files.alinpanaitiu.com/ipad-writing.mp4" autoplay loop muted playsinline disablepictureinpicture></video><figcaption>Keyboard swiping with Apple Pencil</figcaption></figure>
<hr>
<p>I had some shorter pages written in NotePlan on various findings that I didn’t move in Hugo because I felt that the blog should be destined for long form articles based on deeper research.</p>
<p>I knew NotePlan has a <a href="https://help.noteplan.co/article/130-how-to-publish-a-note-on-the-web"><strong>Publish with iCloud</strong></a> button and used that for sharing rendered Markdown pages a few times. I thought, why not use that for a notes page where I can share every little tidbit instantly?</p>
<p>I created <a href="https://notes.alinpanaitiu.com">notes.alinpanaitiu.com</a>, which at first was just an HTML file where I manually added links to my notes published from NotePlan.</p>
<p>While testing, I quickly realized something I was sure would not scale: on every browser visit, the published page was using <code>CloudKit.js</code> to fetch the markdown text of my note from a database and render it to HTML.</p>
<p>I know that this is what allowed me to see my NotePlan changes rendered on the web without delays. But it felt slow, wasteful, and I would probably run into some CloudKit rate limits sooner or later.</p>
<h3 id="caddy">Caddy</h3>
<p>I have a <a href="https://caddyserver.com/"><strong>Caddy</strong></a> server running on <a href="https://www.hetzner.com/"><strong>Hetzner</strong></a>, where I manage all my domains, reverse proxies and static file servers from a single <code>Caddyfile</code>.</p>
<p>Somehow I stumbled upon the fact that it also renders markdown and Go templates <a href="https://caddyserver.com/docs/caddyfile/directives/templates">out of the box</a>. That was a nice coincidence!</p>
<p>Since NotePlan stores the notes as plain <code>.md</code> files in a single local folder, I <code>autorsync</code>-ed that folder to my server and quickly configured Caddy to render the files on the <code>notes.alinpanaitiu.com</code> domain.</p>
<pre><code> notes.alinpanaitiu.com {
    root * /static/notes/

    file_server
    templates {
        mime text/html text/plain text/xml
    }

    encode zstd gzip

    try_files {path} /index.html?path={path}
}
</code></pre>
<p>Unexpectedly, even though it's a rare event in the web dev world, the thing just worked!</p>
<p>The pages looked crude without styling, but I was now able to write beautiful markdown in NotePlan and have it instantly available as a fast and ugly public web page.</p>
<p>I ported the style from my blog as a <a href="https://notes.alinpanaitiu.com/common.styl">single CSS file</a> with no framework <em>(turns out you don’t really need that nowadays, huh)</em>, wrote an <code>index.html</code>, a <code>sitemap.xml</code> and an <code>rss.xml</code> using simple Go templating <code>❴❴ stuff ❵❵</code> for the dynamic bits, called it <strong>Zinc</strong> <em>(because Copper was already taken)</em>, and published it to the world!</p>
<p><em>The first thing there were my notes on how App Store reviewers rejected my first iOS app multiple times: <a href="https://notes.alinpanaitiu.com/Dealing%2520with%2520App%2520Store%2520rejections">App Store doesn’t accept “too simple” apps</a>. The piece became viral quickly, helped get the app accepted, and also got my name mentioned in a <a href="https://www.wired.com/story/apples-app-store-review-fix-fails-placate-developers/">Wired article</a>. To keep the surprises coming, an old friend noticed my name in that article and decided to contact me, creating a nice opportunity to reunite.</em></p>
<h3 id="i-like-trains-">I like trains 🚂</h3>
<figure class="video-container"><video src="https://files.alinpanaitiu.com/iliketrains.mp4" autoplay loop muted playsinline disablepictureinpicture></video><figcaption>I like trains asdfmovie</figcaption></figure>
<p>I don't own a car and never plan to. My wife and I, we're the weird ones amongst our friends and relatives because of that. We never went to driving school, we like walking a lot, and we get sick after 5 minutes in a car. <em>Nowadays we get sick even faster because of the obnoxious, vomit inducing, smelly &quot;air fresheners&quot; that are EVERYWHERE. How do those people breathe all day??</em></p>
<p><img src="https://notes.alinpanaitiu.com/attachments/car-fragrance.jpeg" alt="image"></p>
<p>Out of necessity, we've grown accustomed to take the train wherever possible, and even started to like the conditions:</p>
<ul>
<li>large leg space</li>
<li>a table between the seats for the laptop</li>
<li>sockets for charging electronics</li>
<li>no tight turns, mostly going slowly in a straight line</li>
<li>long, boring rides because Romania's rail infrastructure has been neglected for the last 40 years, and trains have to limit their speed to 30km/h on most segments
<ul>
<li>not exactly a fan of this.</li>
</ul>
</li>
</ul>
<figure class="video-container"><video src="https://files.alinpanaitiu.com/train-interior-brasov.mp4" autoplay loop muted playsinline disablepictureinpicture></video><figcaption>Interior of a train traveling from Brasov</figcaption></figure>
<p><em>You can even sleep in those things if you don't mind the claustrophobic beds.</em></p>
<figure class="video-container"><video src="https://files.alinpanaitiu.com/train-couchette.mp4" autoplay loop muted playsinline disablepictureinpicture></video><figcaption>Perspective from a train bed</figcaption></figure>
<p>And because we travel a lot and I don’t want to haul my 2kg of Apple Silicon in laptop form every time, I wanted to make sure I can write and publish these notes from my overpowered iPhone.</p>
<p>NotePlan is already on my iPhone, iPad and MacBook and it syncs flawlessly everywhere, so writing the articles is a solved problem.</p>
<p>For publishing, I went with a semi-automated solution based on <a href="https://syncthing.net/">Syncthing</a>, my favorite peer-to-peer file sync software, since I already had it running on the Hetzner server where Caddy is running.</p>
<p>I installed <a href="https://www.mobiussync.com/">Möbius Sync</a>, a Syncthing client for iOS, and tried pointing it to the notes folder. But because each iOS app runs in its own sandbox, Möbius couldn't see NotePlan's files.</p>
<p>I had to create a separate <code>notes</code> folder that Möbius could see, then made a Shortcut to copy files from NotePlan's folder to Möbius's folder in a single tap. That turned out to be an even better solution because I could now edit notes without confusing readers with pages that end abruptly in the mi... ddle of the sentence.</p>
<p><img src="https://notes.alinpanaitiu.com/attachments/sync-shortcut.png" alt="image"></p>
<h3 id="details">Details</h3>
<p>I kinda like this solution for now. It gives me the feeling that I own it and that I know how all the moving parts interact.</p>
<p>I can also write when I’m offline and get things synced later, even when I get just the thinnest trickle of internet bandwidth.</p>
<p>Now I wonder if a ready to use service like this already exists and I didn’t know what to look for.</p>
<p>My key points seem to be:</p>
<ul>
<li>A native app that works offline, syncs on all devices, and looks clean</li>
<li>A way to bring my own custom domain</li>
<li>Markdown with syntax highlighting</li>
<li>Custom CSS support</li>
<li>RSS</li>
<li>Sitemap file</li>
<li>Opengraph, Twitter etc. meta tags generator</li>
<li>Lean HTML, no unnecessary JS</li>
<li>Templating (although it can be omitted if sensible defaults are in place)</li>
<li>Media files that I can just drag and drop in the editor</li>
</ul>
<p>That last point still needs a bit of manual work right now. NotePlan supports dragging images and have them rendered inline without issues. It even places the image files in a <code>[note title]_attachments</code> folder right beside the <code>[note title].md</code> file which allows relative links to work even after moving the folder around.</p>
<p>Optimizing images is important to me and I’m glad I found out about <a href="https://imgproxy.net/">imgproxy</a>. I have it running on my server and I replace every <code>&lt;img src=&quot;&quot;&gt;</code> with a <code>&lt;picture&gt;</code> equivalent that resize images on the fly and converts them to either AVIF or WEBP based on what the client supports.</p>
<p>Moving pictures are a pain though.</p>
<p>I never use GIFs because of their size inefficiency and bad quality. So I convert everything to highly efficient videos, then upload them to a separate static file server <em>(because Mobius Sync has a 20MB limit on the free plan)</em>.</p>
<p>But I have to use <code>ffmpeg</code> on my laptop for that, and the videos don’t get rendered in NotePlan <em>(although that’s not that big of a deal)</em>. I should probably automate that on my server somehow.</p>
<p>I like <code>autoplay muted loop</code> videos, but I wish they were loaded lazily when the scroll position reached the part of the page where the video is. I’ll probably have to add some JS for that in the future, or move to <em>play on click</em> mode.</p>
<hr>
<p>It feels odd that we have to go through all of this just to get some colorful text, some images and videos on a public web page owned by the author.</p>
<p>What I have now is a bit fragile, but it’s still a joy to use. It’s simply convenient and always available on all my devices.</p>
<p>I don’t have to use a laggy web editor that generates a bloated web page, or a command line that’s severely limited and hard to type in a mobile environment.</p>
<p>I hop on a noisy train in the dark, watch the sun rise from behind mountains still covered by Romania’s old trees, and start laying my thoughts on this virtual paper that’s always here in my pocket.</p>
<p>Someone, somewhere, will read them.</p>
]]>
                </description></item>
            <item>
              <title>SwiftUI is convenient, but slow</title>
              <link>https://notes.alinpanaitiu.com/SwiftUI-is-convenient,-but-slow</link>
              
              <pubDate>Sun, 27 Nov 2022 00:00:00 +0000</pubDate>
              
              <author>alin@panaitiu.com (Alin Panaitiu)</author>
              <guid>https://notes.alinpanaitiu.com/SwiftUI-is-convenient,-but-slow</guid><description>
                  <![CDATA[<h1 id="swiftui-is-convenient-but-slow">SwiftUI is convenient, but slow</h1>
<p><img src="https://notes.alinpanaitiu.com/attachments/grila-swiftui-slow.png" alt="image"></p>
<p>I've been sitting on publishing these notes for a while, because I don't want to diminish the work of the talented people that engineered SwiftUI and plowed through years of AppKit and UIKit code to get to this state.</p>
<p>But I'd like to draw attention to some performance limitations, in the hope that a SwiftUI engineer might see this and understand pain points that might not be so obvious from their side.</p>
<h2 id="grilahttpslowtechguyscomgrila"><a href="https://lowtechguys.com/grila">Grila</a></h2>
<p>I built a <a href="https://lowtechguys.com/grila">calendar app</a> for macOS. There are many calendar apps out there, but this one is mine, and I wanted it to fit two very specific use cases:</p>
<ul>
<li><strong>keyboard-oriented UI and navigation</strong></li>
<li><strong>always one key-press away</strong></li>
</ul>
<p>This is the <em>How?</em>, an idea of the behaviour that the calendar should have when it's done - but the <em>Why?</em> is a bit of a longer story.</p>
<p>In October 2022, my little brother started college, and moving from the countryside to a big city meant large costs of living that my parents weren't prepared for. I wanted to support him somehow.</p>
<p>To support himself, he currently has to work 12 hours a day, 5 days a week (including weekends) at a coffee shop. He barely sees the college building.</p>
<p>I thought I could build a low effort, low maintenance, affordable but still useful app, from which all the proceeds will go towards funding his costs. <em>I actually tried that <a href="https://notes.alinpanaitiu.com/Dealing%20with%20App%20Store%20rejections">in the summer</a> but the App Store rejections rendered my efforts useless.</em></p>
<p>So sifting through my noted app ideas, I thought a keyboard-driven macOS calendar would be the simplest to build, especially with the newest SwiftUI additions.</p>
<p>After all it's just a grid of numbers that doesn't even need to be clickable, how hard could it be? <em>(I'm saying that a lot, aren't I?..)</em></p>
<p><img src="https://notes.alinpanaitiu.com/attachments/grila-all-views-light.png" alt="image">
<img src="https://notes.alinpanaitiu.com/attachments/grila-all-views-dark.png" alt="image"></p>
<h2 id="the-swiftui-convenience">The SwiftUI convenience</h2>
<p>After a week of long days stretching into the night, I had this thing working that I called <strong><a href="https://lowtechguys.com/grila">Grila</a></strong> <em>(Romanian word for &quot;grid&quot;)</em></p>
<p>In 7 days I had 3 different views (year, month and 3-months), light and dark mode support, customisable color schemes, keyboard navigation and help hints to make it discoverable, and best of all: <strong>✨ ANIMATIONS! ✨</strong></p>
<p><em>These damn animations man.. they're so fluid and springy and easy to use in SwiftUI, that it lures you into adding them everywhere.</em></p>
<figure class="video-container"><video src="https://files.alinpanaitiu.com/grila-help-animation-fluid.mp4" autoplay loop muted playsinline disablepictureinpicture></video><figcaption>Fluid animation when toggling Help in Grila</figcaption></figure>
<h3 id="the-grid">The grid</h3>
<p>I started with the only obvious grid element for laying out the weeks as rows of days: <code>LazyHGrid</code></p>
<pre class="chroma"><code><span class="line"><span class="cl"><span class="n">LazyHGrid</span><span class="p">(</span><span class="n">rows</span><span class="p">:</span> <span class="p">(</span><span class="mf">1.</span><span class="p">..</span><span class="mi">7</span><span class="p">).</span><span class="bp">map</span> <span class="p">{</span> <span class="kc">_</span> <span class="k">in</span> <span class="n">GridItem</span><span class="p">(.</span><span class="n">fixed</span><span class="p">(</span><span class="mi">30</span><span class="p">))</span> <span class="p">})</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">ForEach</span><span class="p">(</span><span class="n">days</span><span class="p">)</span> <span class="p">{</span> <span class="n">day</span> <span class="k">in</span>
</span></span><span class="line"><span class="cl">        <span class="n">DayView</span><span class="p">(</span><span class="n">day</span><span class="p">:</span> <span class="n">day</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre><p>And it was obviously wrong for my use case...</p>
<p>This container is <strong>lazy</strong> so it takes more time get the view rendered because SwiftUI has to check if each row is visible before rendering it.</p>
<p>Since there was no other grid element, I had to write my own using <code>HStack</code>s inside <code>VStack</code>s. This took longer than I expected because I had to compute how many weeks a month has and on what column it starts and ends. As usual, fixing an edge case brings with it three more.</p>
<p>But after this hiccup, almost everything that followed felt surprisingly easy to build.</p>
<h3 id="the-overviews">The (over)views</h3>
<p>For example I wanted to be able to switch between a full year view and month views, which also meant the window had to be resized to fit each view. As soon as I had my separate views, this was all I had to do in my main view:</p>
<pre class="chroma"><code><span class="line"><span class="cl"><span class="kd">enum</span> <span class="nc">GridZoom</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">case</span> <span class="n">month</span>
</span></span><span class="line"><span class="cl">    <span class="k">case</span> <span class="n">multiMonth</span>
</span></span><span class="line"><span class="cl">    <span class="k">case</span> <span class="n">year</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kd">struct</span> <span class="nc">MainView</span><span class="p">:</span> <span class="n">View</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="p">@</span><span class="n">State</span> <span class="kd">var</span> <span class="nv">zoom</span><span class="p">:</span> <span class="n">GridZoom</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="kd">var</span> <span class="nv">body</span><span class="p">:</span> <span class="n">some</span> <span class="n">View</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="k">switch</span> <span class="n">zoom</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="k">case</span> <span class="p">.</span><span class="n">month</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">                <span class="n">MonthView</span><span class="p">().</span><span class="n">fixedSize</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">            <span class="k">case</span> <span class="p">.</span><span class="n">multiMonth</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">                <span class="n">MultiMonthView</span><span class="p">().</span><span class="n">fixedSize</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">            <span class="k">case</span> <span class="p">.</span><span class="n">year</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">                <span class="n">YearView</span><span class="p">().</span><span class="n">fixedSize</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre><p>The <code>.fixedSize()</code> modifier would resize the window automatically, and simply setting the <code>zoom</code> property would render the appropriate view.</p>
<figure class="video-container"><video src="https://files.alinpanaitiu.com/grila-switching-views-[630w].mp4" autoplay loop muted playsinline disablepictureinpicture></video><figcaption>Grila switching between views</figcaption></figure>
<h3 id="color-schemes">Color schemes</h3>
<p>I also wanted to have configurable color schemes because not everyone likes the same colors as I do, and because sometimes I get bored of the chosen colors myself.</p>
<p>With 3 sliders for <strong>hue</strong>, <strong>saturation</strong> and <strong>contrast</strong>, and a few built-in SwiftUI modifiers, I had created the easiest way to adjust the color palette:</p>
<pre class="chroma"><code><span class="line"><span class="cl"><span class="kd">struct</span> <span class="nc">ColorPreferencesView</span><span class="p">:</span> <span class="n">View</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="p">@</span><span class="n">AppStorage</span><span class="p">(</span><span class="s">&#34;hue&#34;</span><span class="p">)</span> <span class="kd">var</span> <span class="nv">hue</span> <span class="p">=</span> <span class="mf">30.0</span>
</span></span><span class="line"><span class="cl">    <span class="p">@</span><span class="n">AppStorage</span><span class="p">(</span><span class="s">&#34;saturation&#34;</span><span class="p">)</span> <span class="kd">var</span> <span class="nv">saturation</span> <span class="p">=</span> <span class="mf">1.0</span>
</span></span><span class="line"><span class="cl">    <span class="p">@</span><span class="n">AppStorage</span><span class="p">(</span><span class="s">&#34;contrast&#34;</span><span class="p">)</span> <span class="kd">var</span> <span class="nv">contrast</span> <span class="p">=</span> <span class="mf">1.0</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="kd">var</span> <span class="nv">body</span><span class="p">:</span> <span class="n">some</span> <span class="n">View</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="n">Slider</span><span class="p">(</span><span class="n">value</span><span class="p">:</span> <span class="err">$</span><span class="n">hue</span><span class="p">,</span> <span class="k">in</span><span class="p">:</span> <span class="mf">0.0</span> <span class="p">...</span> <span class="mf">359.0</span><span class="p">,</span> <span class="n">label</span><span class="p">:</span> <span class="p">{</span> <span class="n">Text</span><span class="p">(</span><span class="s">&#34;Hue&#34;</span><span class="p">)</span> <span class="p">})</span>
</span></span><span class="line"><span class="cl">        <span class="n">Slider</span><span class="p">(</span><span class="n">value</span><span class="p">:</span> <span class="err">$</span><span class="n">saturation</span><span class="p">,</span> <span class="k">in</span><span class="p">:</span> <span class="mf">0.0</span> <span class="p">...</span> <span class="mf">2.0</span><span class="p">,</span> <span class="n">label</span><span class="p">:</span> <span class="p">{</span> <span class="n">Text</span><span class="p">(</span><span class="s">&#34;Saturation&#34;</span><span class="p">)</span> <span class="p">})</span>
</span></span><span class="line"><span class="cl">        <span class="n">Slider</span><span class="p">(</span><span class="n">value</span><span class="p">:</span> <span class="err">$</span><span class="n">contrast</span><span class="p">,</span> <span class="k">in</span><span class="p">:</span> <span class="mf">0.0</span> <span class="p">...</span> <span class="mf">2.0</span><span class="p">,</span> <span class="n">label</span><span class="p">:</span> <span class="p">{</span> <span class="n">Text</span><span class="p">(</span><span class="s">&#34;Contrast&#34;</span><span class="p">)</span> <span class="p">})</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kd">struct</span> <span class="nc">ContentView</span><span class="p">:</span> <span class="n">View</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="p">@</span><span class="n">AppStorage</span><span class="p">(</span><span class="s">&#34;hue&#34;</span><span class="p">)</span> <span class="kd">var</span> <span class="nv">hue</span> <span class="p">=</span> <span class="mf">30.0</span>
</span></span><span class="line"><span class="cl">    <span class="p">@</span><span class="n">AppStorage</span><span class="p">(</span><span class="s">&#34;saturation&#34;</span><span class="p">)</span> <span class="kd">var</span> <span class="nv">saturation</span> <span class="p">=</span> <span class="mf">1.0</span>
</span></span><span class="line"><span class="cl">    <span class="p">@</span><span class="n">AppStorage</span><span class="p">(</span><span class="s">&#34;contrast&#34;</span><span class="p">)</span> <span class="kd">var</span> <span class="nv">contrast</span> <span class="p">=</span> <span class="mf">1.0</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="kd">var</span> <span class="nv">body</span><span class="p">:</span> <span class="n">some</span> <span class="n">View</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="n">MainView</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">            <span class="p">.</span><span class="n">hueRotation</span><span class="p">(.</span><span class="n">degrees</span><span class="p">(</span><span class="n">hue</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">            <span class="p">.</span><span class="n">saturation</span><span class="p">(</span><span class="n">saturation</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">            <span class="p">.</span><span class="n">contrast</span><span class="p">(</span><span class="n">contrast</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre><figure class="video-container"><video src="https://files.alinpanaitiu.com/grila-themes-and-colors.mp4" autoplay loop muted playsinline disablepictureinpicture></video><figcaption>Grila color scheme sliders</figcaption></figure>
<h3 id="keyboard-oriented-ui">Keyboard-oriented UI</h3>
<p>Reacting to keyboard events is still something I do outside of SwiftUI when I need single-key shortcuts (e.g. <code>/</code> instead of <code>⌘/</code>).</p>
<p>That's easily done by using a local event monitor:</p>
<pre class="chroma"><code><span class="line"><span class="cl"><span class="n">NSEvent</span><span class="p">.</span><span class="n">addLocalMonitorForEvents</span><span class="p">(</span><span class="n">matching</span><span class="p">:</span> <span class="p">.</span><span class="n">keyDown</span><span class="p">)</span> <span class="p">{</span> <span class="n">event</span> <span class="k">in</span>
</span></span><span class="line"><span class="cl">    <span class="k">switch</span> <span class="n">event</span><span class="p">.</span><span class="n">keyCode</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="k">case</span> <span class="n">kVK_ANSI_Slash</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="kd">let</span> <span class="nv">showHelp</span> <span class="p">=</span> <span class="n">UserDefaults</span><span class="p">.</span><span class="n">standard</span><span class="p">.</span><span class="n">bool</span><span class="p">(</span><span class="n">forKey</span><span class="p">:</span> <span class="s">&#34;showHelp&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">            <span class="n">UserDefaults</span><span class="p">.</span><span class="n">standard</span><span class="p">.</span><span class="kr">set</span><span class="p">(</span><span class="o">!</span><span class="n">showHelp</span><span class="p">,</span> <span class="n">forKey</span><span class="p">:</span> <span class="s">&#34;showHelp&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="c1">// case NUMBERS:</span>
</span></span><span class="line"><span class="cl">        <span class="c1">//     changeDay()</span>
</span></span><span class="line"><span class="cl">        <span class="c1">// case LETTERS:</span>
</span></span><span class="line"><span class="cl">        <span class="c1">//     changeMonth()</span>
</span></span><span class="line"><span class="cl">        <span class="k">default</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="k">return</span> <span class="n">event</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kd">struct</span> <span class="nc">ContentView</span><span class="p">:</span> <span class="n">View</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="p">@</span><span class="n">AppStorage</span><span class="p">(</span><span class="s">&#34;showHelp&#34;</span><span class="p">)</span> <span class="kd">var</span> <span class="nv">showHelp</span> <span class="p">=</span> <span class="kc">false</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="kd">var</span> <span class="nv">body</span><span class="p">:</span> <span class="n">some</span> <span class="n">View</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="n">MainView</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">showHelp</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="n">HelpHints</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre><figure class="video-container"><video src="https://files.alinpanaitiu.com/grila-keyboard-changing-day-month.mp4" autoplay loop muted playsinline disablepictureinpicture></video><figcaption>Grila changing day and month</figcaption></figure>
<hr>
<h2 id="performance-pitfalls">Performance pitfalls</h2>
<p>I never create apps <em>just for other people</em>, I have to have a need for that app myself so that I understand both the problem and the solution. Also solutions usually need to evolve with the world and the constant technology changes, otherwise the app becomes obsolete fast.</p>
<p>I also never release apps as soon as they're <em>done</em>. I use them myself for weeks, so I can notice all the creaks and rattles in real world usage before getting them in the hands of people.</p>
<p>So after that week of building, exactly in the first day after, a friend calls to tell me they'd like to visit from another city in about a month. <em>When's the best time?</em></p>
<p>Nice, exactly what I made Grila for! No more need to launch Alfred or Spotlight, type &quot;calendar&quot;, hit enter, look bedazzled at the &quot;<em>Week view? what the heck is this for?&quot;</em>, figure out how to switch to the month view, oh wait, I have to constantly switch between November and December.. <em>&quot;maybe the year view is better?&quot;</em> nope, no events are visible there.</p>
<p>Now I just press <code>Right Option</code>+<code>\</code> and have both November and December and events in my face instantly!</p>
<p>Well not really.</p>
<figure class="video-container"><video src="https://files.alinpanaitiu.com/grila-janky-animation.mp4" autoplay loop muted playsinline disablepictureinpicture></video><figcaption>Janky animation when toggling Help in Grila</figcaption></figure>
<p>It took about two seconds and a cringing janky animation to get Grila rendered on the screen. Every key press made me slow down my thinking because there was a 1 second latency until the action changed something in the UI. It made me feel like I went back to 2008 on my Core 2 Duo PC running Windows Vista.</p>
<p>What was different? This time I had Low Power Mode active on my M1 Max MacBook.</p>
<p>Even with LPM, this CPU is still incredibly fast. If Grila struggles so much on it, I can't even imagine how bad it would behave on older Intel Macs.</p>
<h3 id="instruments-for-investigation">Instruments for investigation</h3>
<p>macOS has a powerful troubleshooting utility for apps called Instruments.</p>
<p><img src="https://notes.alinpanaitiu.com/attachments/9CBCEEF9-359E-436A-90E2-C50385A11BE9.png" alt="image"></p>
<p>Attaching its Time Profiler to Grila showed that the heaviest stack trace was in some SwiftUI graph updating code. Look at this thing!</p>
<p>Click on it, I dare you.</p>
<a href="https://files.alinpanaitiu.com/grila-heavy-stack-trace-scroll.webp">
    <img class="round2" src="https://files.alinpanaitiu.com/grila-heavy-stack-trace-scroll-small.webp" alt="grila heavy stack trace">
</a>
<p>Yes, that's how much I understood from it as well.</p>
<p>Since the stack trace is of no use, I tried the SwiftUI profiling template from Instruments.</p>
<p><img src="https://notes.alinpanaitiu.com/attachments/28EA4B0A-4BA0-4ADE-970B-FA3D77BE67C3.png" alt="image"></p>
<p>What looks unusual is the huge number of updates on my custom views. Some updates also take 5ms or more which when called on the main thread, will block any UI rendering and animations.</p>
<p>Maybe I was sloppy and too liberal with my view states, I should improve on this.</p>
<h3 id="optimisations-and-wacky-workarounds">Optimisations (and wacky workarounds)</h3>
<p>The slowest thing was the year view. Because it had to render more than 365 views, each small change was excruciatingly slow.</p>
<p>I lowered the number of views by consolidating each month's day views into a single, carefully aligned text string.</p>
<p>So this:</p>
<pre class="chroma"><code><span class="line"><span class="cl"><span class="kd">struct</span> <span class="nc">WeekRow</span><span class="p">:</span> <span class="n">View</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="p">@</span><span class="n">State</span> <span class="kd">var</span> <span class="nv">week</span><span class="p">:</span> <span class="nb">Int</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="kd">var</span> <span class="nv">body</span><span class="p">:</span> <span class="n">some</span> <span class="n">View</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="n">ForEach</span><span class="p">(</span><span class="n">days</span><span class="p">(</span><span class="n">week</span><span class="p">))</span> <span class="p">{</span> <span class="n">day</span> <span class="k">in</span>
</span></span><span class="line"><span class="cl">            <span class="n">Text</span><span class="p">(</span><span class="n">day</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kd">struct</span> <span class="nc">YearMonth</span><span class="p">:</span> <span class="n">View</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="kd">var</span> <span class="nv">body</span><span class="p">:</span> <span class="n">some</span> <span class="n">View</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="n">VStack</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="n">WeekRow</span><span class="p">(</span><span class="n">week</span><span class="p">:</span> <span class="mi">1</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">            <span class="n">WeekRow</span><span class="p">(</span><span class="n">week</span><span class="p">:</span> <span class="mi">2</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">            <span class="n">WeekRow</span><span class="p">(</span><span class="n">week</span><span class="p">:</span> <span class="mi">3</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">            <span class="n">WeekRow</span><span class="p">(</span><span class="n">week</span><span class="p">:</span> <span class="mi">4</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">            <span class="n">WeekRow</span><span class="p">(</span><span class="n">week</span><span class="p">:</span> <span class="mi">5</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre><p>Became this:</p>
<pre class="chroma"><code><span class="line"><span class="cl"><span class="kd">struct</span> <span class="nc">YearMonth</span><span class="p">:</span> <span class="n">View</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="kd">var</span> <span class="nv">body</span><span class="p">:</span> <span class="n">some</span> <span class="n">View</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="n">Text</span><span class="p">(</span><span class="n">generateMonthText</span><span class="p">())</span>
</span></span><span class="line"><span class="cl">            <span class="p">.</span><span class="n">font</span><span class="p">(.</span><span class="n">body</span><span class="p">.</span><span class="n">monospaced</span><span class="p">())</span>
</span></span><span class="line"><span class="cl">            <span class="c1">// Using the `monospaced` font makes</span>
</span></span><span class="line"><span class="cl">            <span class="c1">// aligning text columns much easier</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre><p>Year view was now much faster, but partly neutered as I had no way of showing events or other types of information under each day. Oh well, that doesn't mean much anyway if I can't even make this app usable.</p>
<hr>
<p>The 3-months view had no obvious low hanging fruit. The <strong>View Properties Updates</strong> profiler showed that there were a ton of updates causing the <code>body</code> of the view to be called many times, but it was impossible to figure out what mattered.</p>
<p>Somehow in my tireless internet sleuthing, I stumbled upon a <a href="https://www.hackingwithswift.com/quick-start/swiftui/how-to-find-which-data-change-is-causing-a-swiftui-view-to-update">SwiftUI private function</a> that prints the changes which caused a <code>body</code> update: <code>Self._printChanges()</code></p>
<p>So I could now insert that line into my DayView and work my way up to the MultiMonthView until I figured out what changes are unnecessary.</p>
<h4 id="states-and-bindings">States and Bindings</h4>
<p>The words that you'll stumble upon the most in SwiftUI are <code>@State</code> and <code>@Binding</code>, which are property wrappers for defining local state and two-way bindings.</p>
<p>You see, there's this thing that happens a lot when creating SwiftUI views with deeper hierarchies:</p>
<ul>
<li>You create <code>BigContainingView</code> with <code>@State var someProp</code></li>
<li>Inside you call <code>SmallView(prop: someProp)</code></li>
<li>You change <code>someProp</code> outside</li>
<li><em>Why isn't <code>SmallView</code> updating?</em></li>
<li>You add <code>@Binding var prop</code> into <code>SmallView</code>
<ul>
<li>and call it using <code>SmallView(prop: $someProp)</code> <em>(notice the added <code>$</code>)</em></li>
</ul>
</li>
<li><em>Ah nice, the view is now updating</em></li>
</ul>
<p>Because of this issue, I had created a shortcut for passing one-way bindings, and because of this shortcut, my list continued with:</p>
<ul>
<li>Performance issues..</li>
<li><em>Why is <code>SmallView</code> NOW CONSTANTLY UPDATING?</em></li>
</ul>
<hr>
<p>In the following example, I needed a way to highlight the selected day, but passing <code>selected</code> as <code>@State</code> wouldn't propagate through the view graph. So I made it a binding, which caused <strong>all</strong> the DayViews to update when selecting another day (instead of just the two days that actually changed):</p>
<pre class="chroma"><code><span class="line"><span class="cl"><span class="kd">extension</span> <span class="nc">Binding</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="kd">static</span> <span class="kd">func</span> <span class="nf">oneway</span><span class="p">(</span><span class="n">getter</span><span class="p">:</span> <span class="p">@</span><span class="n">escaping</span> <span class="p">()</span> <span class="p">-&gt;</span> <span class="n">Value</span><span class="p">)</span> <span class="p">-&gt;</span> <span class="n">Binding</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="n">Binding</span><span class="p">(</span><span class="kr">get</span><span class="p">:</span> <span class="n">getter</span><span class="p">,</span> <span class="kr">set</span><span class="p">:</span> <span class="p">{</span> <span class="kc">_</span> <span class="k">in</span> <span class="p">})</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kd">struct</span> <span class="nc">DayView</span><span class="p">:</span> <span class="n">View</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="p">@</span><span class="n">State</span> <span class="kd">var</span> <span class="nv">date</span><span class="p">:</span> <span class="n">Date</span>
</span></span><span class="line"><span class="cl">    <span class="p">@</span><span class="n">Binding</span> <span class="kd">var</span> <span class="nv">selected</span><span class="p">:</span> <span class="nb">Bool</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="p">...</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kd">struct</span> <span class="nc">MonthView</span><span class="p">:</span> <span class="n">View</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="p">@</span><span class="n">State</span> <span class="kd">var</span> <span class="nv">selectedDate</span><span class="p">:</span> <span class="n">Date</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="kd">var</span> <span class="nv">body</span><span class="p">:</span> <span class="n">some</span> <span class="n">View</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="n">ForEach</span><span class="p">(</span><span class="n">dates</span><span class="p">)</span> <span class="p">{</span> <span class="n">date</span> <span class="k">in</span>
</span></span><span class="line"><span class="cl">            <span class="n">DayView</span><span class="p">(</span><span class="n">date</span><span class="p">:</span> <span class="n">date</span><span class="p">,</span> <span class="n">selected</span><span class="p">:</span> <span class="p">.</span><span class="n">oneway</span> <span class="p">{</span> <span class="n">date</span> <span class="p">==</span> <span class="n">selectedDate</span> <span class="p">})</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="err">￼</span><span class="o">----------------------------------</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1">// Logs from Self._printChanges()</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">DayView</span><span class="p">:</span> <span class="n">_selected</span> <span class="n">changed</span><span class="p">.</span>
</span></span><span class="line"><span class="cl"><span class="n">DayView</span><span class="p">:</span> <span class="n">_selected</span> <span class="n">changed</span><span class="p">.</span>
</span></span><span class="line"><span class="cl"><span class="n">DayView</span><span class="p">:</span> <span class="n">_selected</span> <span class="n">changed</span><span class="p">.</span>
</span></span><span class="line"><span class="cl"><span class="c1">// ... repeated 77 times</span>
</span></span></code></pre><p>The solution I found for this was to turn the Binding back into a State and manually add an <code>.id()</code> to the DayView that factors in the <code>selected</code> property.</p>
<p>That way, the graph algorithm will compare the new id with the old one, and if <code>selected</code> is different, the view will get redrawn.</p>
<pre class="chroma"><code><span class="line"><span class="cl"><span class="kd">struct</span> <span class="nc">MonthView</span><span class="p">:</span> <span class="n">View</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="p">@</span><span class="n">State</span> <span class="kd">var</span> <span class="nv">selectedDate</span><span class="p">:</span> <span class="n">Date</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="kd">var</span> <span class="nv">body</span><span class="p">:</span> <span class="n">some</span> <span class="n">View</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="n">ForEach</span><span class="p">(</span><span class="n">dates</span><span class="p">)</span> <span class="p">{</span> <span class="n">date</span> <span class="k">in</span>
</span></span><span class="line"><span class="cl">            <span class="kd">let</span> <span class="nv">isSelected</span> <span class="p">=</span> <span class="n">date</span> <span class="p">==</span> <span class="n">selectedDate</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">            <span class="n">DayView</span><span class="p">(</span><span class="n">date</span><span class="p">:</span> <span class="n">date</span><span class="p">,</span> <span class="n">selected</span><span class="p">:</span> <span class="n">isSelected</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">                <span class="p">.</span><span class="n">id</span><span class="p">(</span><span class="s">&#34;</span><span class="si">\(</span><span class="n">date</span><span class="si">)</span><span class="s">-</span><span class="si">\(</span><span class="n">isSelected</span><span class="si">)</span><span class="s">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre><p>This change alone decreased the number of unnecessary view updates to 1/4 of what it was when I started debugging.</p>
<h4 id="observableobject">ObservableObject</h4>
<p>Another pattern I tend to overuse is having a global object with <code>@Published</code> properties that I can change from <code>AppDelegate</code> and have their changes seen by SwiftUI views.</p>
<pre class="chroma"><code><span class="line"><span class="cl"><span class="kd">class</span> <span class="nc">SomeGlobalObject</span><span class="p">:</span> <span class="n">ObservableObject</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="p">@</span><span class="n">Published</span> <span class="kd">var</span> <span class="nv">fooDayView</span> <span class="p">=</span> <span class="kc">true</span>
</span></span><span class="line"><span class="cl">    <span class="p">@</span><span class="n">Published</span> <span class="kd">var</span> <span class="nv">barHintView</span> <span class="p">=</span> <span class="kc">false</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kd">let</span> <span class="nv">GLOBAL</span> <span class="p">=</span> <span class="n">SomeGlobalObject</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kd">struct</span> <span class="nc">DayView</span><span class="p">:</span> <span class="n">View</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="p">@</span><span class="n">ObservedObject</span> <span class="kd">var</span> <span class="nv">global</span> <span class="p">=</span> <span class="n">GLOBAL</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1">// ... uses fooDayView somewhere</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kd">struct</span> <span class="nc">HintView</span><span class="p">:</span> <span class="n">View</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="p">@</span><span class="n">ObservedObject</span> <span class="kd">var</span> <span class="nv">global</span> <span class="p">=</span> <span class="n">GLOBAL</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1">// ... uses barHintView somewhere</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1">// somewhere in AppDelegate</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">barHintView</span><span class="p">.</span><span class="n">toggle</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="err">￼</span><span class="o">----------------------------------</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1">// Logs from Self._printChanges()</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">DayView</span><span class="p">:</span> <span class="n">_global</span> <span class="n">changed</span><span class="p">.</span>
</span></span><span class="line"><span class="cl"><span class="n">DayView</span><span class="p">:</span> <span class="n">_global</span> <span class="n">changed</span><span class="p">.</span>
</span></span><span class="line"><span class="cl"><span class="n">DayView</span><span class="p">:</span> <span class="n">_global</span> <span class="n">changed</span><span class="p">.</span>
</span></span><span class="line"><span class="cl"><span class="p">...</span>
</span></span><span class="line"><span class="cl"><span class="n">HintView</span><span class="p">:</span> <span class="n">_global</span> <span class="n">changed</span><span class="p">.</span>
</span></span></code></pre><p>DayView doesn't use the changed property, so all those updates could have been skipped entirely.
In this case, I had to get over my tendency to not repeat myself, and just pass the properties as parameters.</p>
<p>There are other ways to fix this, each with its own quirks:</p>
<ul>
<li>Store those properties in <code>UserDefaults</code>
<ul>
<li>This makes them persistent on disk which might not be desirable</li>
</ul>
</li>
<li>Define multiple, more specific global objects with less properties
<ul>
<li>It becomes hard to remember which object had which property</li>
</ul>
</li>
<li>Pass the values as <code>EnvironmentValues</code>
<ul>
<li>This needs even more boilerplate</li>
<li>Helpful if the property is needed deep in the view hierarchy</li>
</ul>
</li>
</ul>
<p>Here's the <code>EnvironmentValues</code> alternative as an example:</p>
<pre class="chroma"><code><span class="line"><span class="cl"><span class="kd">struct</span> <span class="nc">SelectedDateKey</span><span class="p">:</span> <span class="n">EnvironmentKey</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="kd">static</span> <span class="kd">let</span> <span class="nv">defaultValue</span> <span class="p">=</span> <span class="n">Date</span><span class="p">()</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kd">extension</span> <span class="nc">EnvironmentValues</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="kd">var</span> <span class="nv">selectedDate</span><span class="p">:</span> <span class="n">Date</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="kr">get</span> <span class="p">{</span> <span class="kc">self</span><span class="p">[</span><span class="n">SelectedDateKey</span><span class="p">.</span><span class="kc">self</span><span class="p">]</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">        <span class="kr">set</span> <span class="p">{</span> <span class="kc">self</span><span class="p">[</span><span class="n">SelectedDateKey</span><span class="p">.</span><span class="kc">self</span><span class="p">]</span> <span class="p">=</span> <span class="n">newValue</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kd">extension</span> <span class="nc">View</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="kd">func</span> <span class="nf">selectedDate</span><span class="p">(</span><span class="kc">_</span> <span class="n">selectedDate</span><span class="p">:</span> <span class="n">Date</span><span class="p">)</span> <span class="p">-&gt;</span> <span class="n">some</span> <span class="n">View</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="n">environment</span><span class="p">(</span><span class="err">\</span><span class="p">.</span><span class="n">selectedDate</span><span class="p">,</span> <span class="n">selectedDate</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kd">struct</span> <span class="nc">ContentView</span><span class="p">:</span> <span class="n">View</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="kd">var</span> <span class="nv">body</span><span class="p">:</span> <span class="n">some</span> <span class="n">View</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="n">MainView</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">            <span class="p">.</span><span class="n">selectedDate</span><span class="p">(</span><span class="n">globalSelectedDate</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kd">struct</span> <span class="nc">SomeViewDeepDownInTheGraph</span><span class="p">:</span> <span class="n">View</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="p">@</span><span class="n">Environment</span><span class="p">(</span><span class="err">\</span><span class="p">.</span><span class="n">selectedDate</span><span class="p">)</span> <span class="kd">var</span> <span class="nv">selectedDate</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="kd">var</span> <span class="nv">body</span><span class="p">:</span> <span class="n">some</span> <span class="n">View</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="n">Text</span><span class="p">(</span><span class="n">selectedDate</span><span class="p">.</span><span class="n">formatted</span><span class="p">())</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre><h4 id="animations">Animations</h4>
<p>When they’re done correctly, animations can convey information that would otherwise be missing. Like where an element was moved after a layout change, or how a view was resized when more elements appeared inside it.</p>
<p>SwiftUI makes this specific case easy to implement using <code>.matchedGeometryEffect</code>:</p>
<pre class="chroma"><code><span class="line"><span class="cl"><span class="kd">struct</span> <span class="nc">ContentView</span><span class="p">:</span> <span class="n">View</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="p">@</span><span class="n">Namespace</span> <span class="kd">var</span> <span class="nv">namespace</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="kd">var</span> <span class="nv">body</span><span class="p">:</span> <span class="n">some</span> <span class="n">View</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="n">HStack</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="k">if</span> <span class="n">showHelp</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">                <span class="n">PinWindowButton</span><span class="p">(</span><span class="n">showHint</span><span class="p">:</span> <span class="kc">true</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">                    <span class="p">.</span><span class="n">matchedGeometryEffect</span><span class="p">(</span><span class="n">id</span><span class="p">:</span> <span class="s">&#34;pinWindowButton&#34;</span><span class="p">,</span> <span class="k">in</span><span class="p">:</span> <span class="n">namespace</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">                <span class="n">Spacer</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">                <span class="n">HelpHints</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">            <span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">                <span class="n">PinWindowButton</span><span class="p">(</span><span class="n">showHint</span><span class="p">:</span> <span class="kc">false</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">                    <span class="p">.</span><span class="n">matchedGeometryEffect</span><span class="p">(</span><span class="n">id</span><span class="p">:</span> <span class="s">&#34;pinWindowButton&#34;</span><span class="p">,</span> <span class="k">in</span><span class="p">:</span> <span class="n">namespace</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">            <span class="p">}</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="n">MainView</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre><p>Animations don’t always have to be practical though. They can be added solely for their whimsical effect.</p>
<p>I myself love it when I’m mesmerized by an user interface that appears organic and closer to real life movement. Those interfaces feel much more personal.</p>
<p>Springs in SwiftUI can create that appearance without having to tune any parameter:</p>
<pre class="chroma"><code><span class="line"><span class="cl"><span class="n">Button</span><span class="p">(</span><span class="s">&#34;Toggle Help Hints&#34;</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">withAnimation</span><span class="p">(.</span><span class="n">spring</span><span class="p">())</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="n">showHelp</span><span class="p">.</span><span class="n">toggle</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1">// Toggling showHelp will move the PinWindowButton and change its size</span>
</span></span><span class="line"><span class="cl"><span class="c1">//</span>
</span></span><span class="line"><span class="cl"><span class="c1">// SwiftUI will compute the difference between the old and new layout,</span>
</span></span><span class="line"><span class="cl"><span class="c1">// and animate that difference using the `.spring` animation</span>
</span></span></code></pre><figure class="video-container"><video src="https://files.alinpanaitiu.com/grila-pin-window-animation.mp4" autoplay loop muted playsinline disablepictureinpicture></video><figcaption>Grila pin window animation</figcaption></figure>
<p>But a lot of the time, animations are overused and can’t even be turned off.</p>
<p>I remember the first time I used a MacBook trackpad in 2015, swiping with three fingers to move through Spaces. It felt magical.</p>
<p>Less than one year later, the same animation was driving me nuts because I was switching spaces so often, that the animation was slowing me down. There’s no way to disable that animation from the system, you can only reduce it to a crossfade.</p>
<blockquote>
<p><a href="https://github.com/koekeishiya/yabai">Yabai</a> can actually disable it by altering the system window manager code in memory, but that requires disabling SIP (which I can’t do anymore because I need some iOS apps on my Mac which only work with SIP enabled).</p>
</blockquote>
<blockquote>
<p>I have since settled on using a single Space with my <a href="https://lowtechguys.com/rcmd">rcmd</a> app for instant app switching.</p>
</blockquote>
<hr>
<p>I wanted to improve this experience though, so I made animations completely configurable in Grila.</p>
<p>Because they’re computationally expensive, I disabled them by default in <strong>Low Power Mode</strong>, or when <strong>Reduce Motion</strong> is enabled in Accessibility settings.</p>
<pre class="chroma"><code><span class="line"><span class="cl"><span class="c1">// in AppDelegate</span>
</span></span><span class="line"><span class="cl"><span class="n">reduceMotionObserver</span> <span class="p">=</span> <span class="n">NotificationCenter</span><span class="p">.</span><span class="k">default</span>
</span></span><span class="line"><span class="cl">    <span class="p">.</span><span class="n">publisher</span><span class="p">(</span><span class="k">for</span><span class="p">:</span> <span class="n">NSWorkspace</span><span class="p">.</span><span class="n">accessibilityDisplayOptionsDidChangeNotification</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="p">.</span><span class="n">sink</span> <span class="p">{</span> <span class="kc">_</span> <span class="k">in</span> <span class="n">reduceMotion</span> <span class="p">=</span> <span class="n">NSWorkspace</span><span class="p">.</span><span class="n">shared</span><span class="p">.</span><span class="n">accessibilityDisplayShouldReduceMotion</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">lowPowerModeObserver</span> <span class="p">=</span> <span class="n">NotificationCenter</span><span class="p">.</span><span class="k">default</span>
</span></span><span class="line"><span class="cl">    <span class="p">.</span><span class="n">publisher</span><span class="p">(</span><span class="k">for</span><span class="p">:</span> <span class="p">.</span><span class="n">NSProcessInfoPowerStateDidChange</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="p">.</span><span class="n">sink</span> <span class="p">{</span> <span class="kc">_</span> <span class="k">in</span> <span class="n">lowPowerMode</span> <span class="p">=</span> <span class="n">ProcessInfo</span><span class="p">.</span><span class="n">processInfo</span><span class="p">.</span><span class="n">isLowPowerModeEnabled</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1">// in SwiftUI</span>
</span></span><span class="line"><span class="cl"><span class="n">withAnimation</span><span class="p">((</span><span class="n">reduceMotion</span> <span class="o">||</span> <span class="n">lowPowerMode</span><span class="p">)</span> <span class="p">?</span> <span class="p">.</span><span class="n">linear</span><span class="p">(</span><span class="n">duration</span><span class="p">:</span> <span class="mi">0</span><span class="p">)</span> <span class="p">:</span> <span class="p">.</span><span class="n">spring</span><span class="p">())</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">actionProducingLayoutChange</span><span class="p">()</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre><p>I also gave the user the choice to enable them back, disable them completely and even change their speed and springiness.</p>
<figure class="video-container"><video src="https://files.alinpanaitiu.com/grila-animation-settings.mp4" autoplay loop muted playsinline disablepictureinpicture></video><figcaption>Grila animation settings</figcaption></figure>
<h2 id="is-it-fast-yet">Is it fast yet?</h2>
<p>Two months later, after exhausting all the optimization possibilities, the app finally felt usable.
It was plenty fast for the actions that were most often used so I finally released it.</p>
<p>But it could be better.</p>
<p>I’m not entirely sure if there’s still a lot of performance left on the table for the SwiftUI engineers, but it kinda looks that way from my side. There are some places where the framework seems to be doing a lot of unnecessary work.</p>
<p>Taking the <strong>3-months view</strong> for example, it renders 11 rows of days, of which only half change when going to the next month. But I’m still seeing all 11 x 7 views being scrapped and rendered from scratch.</p>
<p>In the AppKit world I would have had the day views as <code>class</code> instead of <code>struct</code>, and I could have updated their day number and weekday in place. It wouldn’t be easy, but it’s an optimization that would have made all the difference.</p>
<p>The <strong>year view</strong> is an even more dramatic example, where the 365 day views are made up of only 31 stylised text views, cloned and placed on different coordinates. Those 31 views can even be drawn beforehand and cached forever. I found no way of letting SwiftUI know about this fact, and have it used for optimization.</p>
<h3 id="is-there-hope">Is there hope?</h3>
<p>Obviously, I have no idea of the underlying architecture of SwiftUI, so what I'm saying might not even be possible. Or, when implemented, it would open up a can of edge cases and causing bugs that would be even worse than not having it in the first place. Or maybe my specific use case is caused by my own inability to use SwiftUI <em>properly</em>.</p>
<p>Maybe I'm just not holding it right.</p>
<p>But sooner or later, other app devs will try the same things I tried. The chaos of this world guarantees it.</p>
<p>And it's not a big deal, people could have lived without <em>yet another calendar app</em>. I could do other things to help my brother. The sun would still shine the next day <em>(until it implodes 5 billion years in the future)</em>.</p>
<p>But ever since leaving the AppKit and UIKit world, I felt that these Apple engineers have created a magical tool. I felt like going from hammering a piece of metal to a CNC precision cutter. I'm using less force, moving much faster, and things end up even closer to how I wanted them to look.</p>
<p>In the last 3 years, I had the feeling that the ultimate goal for SwiftUI is to reach feature parity with the other established system UI frameworks, and we're incredibly close to that.</p>
<p>My hope is that after this, those Apple engineers will realise what a great thing they created, and continue honing it until we'll no longer have the need to compare the new with the old.</p>
<hr>
<h3 id="edit-a-simple-way-to-speed-this-up">EDIT: A simple way to speed this up</h3>
<p>Two years later, I finally found the largest culprit of the slowness, and wrote about it here: <a href="https://notes.alinpanaitiu.com/How-I-made-my-SwiftUI-calendar-app-3x-faster">How I made my SwiftUI calendar app 3x faster</a></p>
]]>
                </description></item>
            <item>
              <title>Making macOS apps uninstallable</title>
              <link>https://notes.alinpanaitiu.com/Making-macOS-apps-uninstallable</link>
              
              <pubDate>Sat, 18 Feb 2023 00:00:00 +0000</pubDate>
              
              <author>alin@panaitiu.com (Alin Panaitiu)</author>
              <guid>https://notes.alinpanaitiu.com/Making-macOS-apps-uninstallable</guid><description>
                  <![CDATA[<h1 id="making-macos-apps-uninstallable">Making macOS apps uninstallable</h1>
<figure class="video-container"><video src="https://files.alinpanaitiu.com/wiggly-launchpad.mp4" autoplay loop muted playsinline disablepictureinpicture></video><figcaption>Wiggling apps in Launchpad</figcaption></figure>
<p>There's this thing called <strong>Launchpad</strong> on macOS which shows you all the apps on your Mac in an iOS like manner.</p>
<p>You may already have it in the Dock, but you can also launch it as any other app. There's even a nifty trackpad gesture for it where you <em>pinch with thumb and three fingers</em>.</p>
<div class="video-container">
      <video autoplay loop muted playsinline disablepictureinpicture>
         <source src="https://files.alinpanaitiu.com/launchpad-trackpad-h264.mp4" type='video/mp4'>
      </video>
</div>
<p>When holding <code>Option</code>, it wiggles the app icons and shows an <code>X</code> button for apps installed from Mac App Store <em>(MAS for short)</em>. Clicking on that button removes the app, along with its preferences and other files it created.</p>
<hr>
<p>But hold on, why is <a href="https://lunar.fyi/">Lunar</a> having an <code>X</code> button in the first video though? Could it be that..</p>
<figure class="video-container"><video src="https://files.alinpanaitiu.com/wiggly-lunar.mp4" autoplay loop muted playsinline disablepictureinpicture></video><figcaption>Holding Option to wiggle Lunar in Launchpad</figcaption></figure>
<p>No no, it's not on the App Store, it would never have a chance in hell getting accepted there given the plethora of private APIs that it uses.</p>
<p>Turns out, there's a simple way to have that button show up for non-MAS apps as well.</p>
<pre class="chroma"><code><span class="line"><span class="cl">mkdir /Applications/Lunar.app/Contents/_MASReceipt
</span></span><span class="line"><span class="cl">touch /Applications/Lunar.app/Contents/_MASReceipt/receipt
</span></span></code></pre><p>Having an empty MAS receipt tricks the system to think the app was installed from App Store.</p>
<p>So what happens when I click that symbol now?</p>
<pre class="chroma"><code><span class="line"><span class="cl"><span class="o">&gt;</span> <span class="n">fsevent_watch</span> <span class="o">-</span><span class="n">F</span> <span class="o">/</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="mi">215781385</span>   <span class="mh">0x00020200</span><span class="p">=[</span><span class="n">removed</span><span class="p">]</span>    <span class="o">/</span><span class="n">Applications</span><span class="o">/</span><span class="n">Lunar</span><span class="p">.</span><span class="n">app</span>
</span></span><span class="line"><span class="cl"><span class="mi">215781406</span>   <span class="mh">0x00020200</span><span class="p">=[</span><span class="n">removed</span><span class="p">]</span>    <span class="o">/</span><span class="n">Users</span><span class="o">/</span><span class="n">alin</span><span class="o">/</span><span class="n">Library</span><span class="o">/</span><span class="n">Application</span> <span class="n">Support</span><span class="o">/</span><span class="n">Lunar</span>
</span></span><span class="line"><span class="cl"><span class="mi">215781409</span>   <span class="mh">0x00010200</span><span class="p">=[</span><span class="n">removed</span><span class="p">]</span>    <span class="o">/</span><span class="n">Users</span><span class="o">/</span><span class="n">alin</span><span class="o">/</span><span class="n">Library</span><span class="o">/</span><span class="n">Preferences</span><span class="o">/</span><span class="n">fyi</span><span class="p">.</span><span class="n">lunar</span><span class="p">.</span><span class="n">Lunar</span><span class="p">.</span><span class="n">plist</span>
</span></span></code></pre><p>Well, looks like we got a working uninstaller with a good user experience for free.</p>
<hr>
<p>For people looking for ways to remove files from apps that are already uninstalled, I'll leave below some possible methods:</p>
<h3 id="brew">Brew</h3>
<p>If a <a href="https://formulae.brew.sh/cask/">Homebrew Formulae</a> exists for that app, you can run the following terminal command to remove app files left behind:</p>
<pre class="chroma"><code><span class="line"><span class="cl">brew uninstall --cask --force --zap
</span></span></code></pre><p>This works because most app developers will declare the paths that can be safely deleted inside the formula. One can view that using the <code>brew cat &lt;app-name&gt;</code> command.</p>
<p><img src="https://notes.alinpanaitiu.com/attachments/AA26273D-2453-4AB2-BB0B-67D5B88BC679.png" alt="image"></p>
<h3 id="cleaner-apps">Cleaner apps</h3>
<ol>
<li><a href="https://apps.tempel.org/FindAnyFile/">FindAnyFile</a> can look for files belonging to a specific app, even if it was uninstalled</li>
<li><a href="https://freemacsoft.net/appcleaner/">AppCleaner</a> can uninstall apps and delete its files at the same time</li>
</ol>
]]>
                </description></item>
            <item>
              <title>Decoding monitor EDID on macOS</title>
              <link>https://notes.alinpanaitiu.com/Decoding-monitor-EDID-on-macOS</link>
              
              <pubDate>Tue, 28 Feb 2023 00:00:00 +0000</pubDate>
              
              <author>alin@panaitiu.com (Alin Panaitiu)</author>
              <guid>https://notes.alinpanaitiu.com/Decoding-monitor-EDID-on-macOS</guid><description>
                  <![CDATA[<h1 id="decoding-monitor-edid-on-macos">Decoding monitor EDID on macOS</h1>
<h2 id="using-lunars-clihttpslunarfyicli">Using Lunar's <a href="https://lunar.fyi/#cli">CLI</a></h2>
<p>If you are already using <strong><a href="https://lunar.fyi/">Lunar</a></strong>, I added a simple command-line action in <code>v6</code> that can do that on both Apple Silicon and Intel.</p>
<pre class="chroma"><code><span class="line"><span class="cl"><span class="c1"># Install CLI if not already installed</span>
</span></span><span class="line"><span class="cl">/Applications/Lunar.app/Contents/MacOS/Lunar install-cli
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Read and decode EDID from all monitors</span>
</span></span><span class="line"><span class="cl">lunar edid
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Read and decode EDID from a specific monitor</span>
</span></span><span class="line"><span class="cl">lunar edid dell
</span></span></code></pre><p>You'll get a nice human readable structure like this:</p>
<pre class="chroma"><code><span class="line"><span class="cl"><span class="n">LG</span> <span class="n">Ultra</span> <span class="n">HD</span> <span class="p">[</span><span class="n">ID</span> <span class="mi">5</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">    <span class="n">edid</span><span class="o">-</span><span class="n">decode</span> <span class="p">(</span><span class="n">hex</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="mi">00</span> <span class="n">ff</span> <span class="n">ff</span> <span class="n">ff</span> <span class="n">ff</span> <span class="n">ff</span> <span class="n">ff</span> <span class="mi">00</span> <span class="mi">1</span><span class="n">e</span> <span class="mi">6</span><span class="n">d</span> <span class="mi">09</span> <span class="mi">5</span><span class="n">b</span> <span class="n">b9</span> <span class="n">ca</span> <span class="mi">04</span> <span class="mi">00</span>
</span></span><span class="line"><span class="cl">    <span class="mi">08</span> <span class="mi">1</span><span class="n">b</span> <span class="mi">01</span> <span class="mi">04</span> <span class="n">b5</span> <span class="mi">3</span><span class="n">c</span> <span class="mi">22</span> <span class="mi">78</span> <span class="mi">9</span><span class="n">e</span> <span class="mi">30</span> <span class="mi">35</span> <span class="n">a7</span> <span class="mi">55</span> <span class="mi">4</span><span class="n">e</span> <span class="n">a3</span> <span class="mi">26</span>
</span></span><span class="line"><span class="cl">    <span class="mi">0</span><span class="n">f</span> <span class="mi">50</span> <span class="mi">54</span> <span class="mi">21</span> <span class="mi">08</span> <span class="mi">00</span> <span class="mi">71</span> <span class="mi">40</span> <span class="mi">81</span> <span class="mi">80</span> <span class="mi">81</span> <span class="n">c0</span> <span class="n">a9</span> <span class="n">c0</span> <span class="n">d1</span> <span class="n">c0</span>
</span></span><span class="line"><span class="cl">    <span class="mi">81</span> <span class="mi">00</span> <span class="mi">01</span> <span class="mi">01</span> <span class="mi">01</span> <span class="mi">01</span> <span class="mi">4</span><span class="n">d</span> <span class="n">d0</span> <span class="mi">00</span> <span class="n">a0</span> <span class="n">f0</span> <span class="mi">70</span> <span class="mi">3</span><span class="n">e</span> <span class="mi">80</span> <span class="mi">30</span> <span class="mi">20</span>
</span></span><span class="line"><span class="cl">    <span class="mi">65</span> <span class="mi">0</span><span class="n">c</span> <span class="mi">58</span> <span class="mi">54</span> <span class="mi">21</span> <span class="mi">00</span> <span class="mi">00</span> <span class="mi">1</span><span class="n">a</span> <span class="mi">28</span> <span class="mi">68</span> <span class="mi">00</span> <span class="n">a0</span> <span class="n">f0</span> <span class="mi">70</span> <span class="mi">3</span><span class="n">e</span> <span class="mi">80</span>
</span></span><span class="line"><span class="cl">    <span class="mi">08</span> <span class="mi">90</span> <span class="mi">65</span> <span class="mi">0</span><span class="n">c</span> <span class="mi">58</span> <span class="mi">54</span> <span class="mi">21</span> <span class="mi">00</span> <span class="mi">00</span> <span class="mi">1</span><span class="n">a</span> <span class="mi">00</span> <span class="mi">00</span> <span class="mi">00</span> <span class="n">fd</span> <span class="mi">00</span> <span class="mi">38</span>
</span></span><span class="line"><span class="cl">    <span class="mi">3</span><span class="n">d</span> <span class="mi">1</span><span class="n">e</span> <span class="mi">87</span> <span class="mi">38</span> <span class="mi">00</span> <span class="mi">0</span><span class="n">a</span> <span class="mi">20</span> <span class="mi">20</span> <span class="mi">20</span> <span class="mi">20</span> <span class="mi">20</span> <span class="mi">20</span> <span class="mi">00</span> <span class="mi">00</span> <span class="mi">00</span> <span class="n">fc</span>
</span></span><span class="line"><span class="cl">    <span class="mi">00</span> <span class="mi">4</span><span class="n">c</span> <span class="mi">47</span> <span class="mi">20</span> <span class="mi">55</span> <span class="mi">6</span><span class="n">c</span> <span class="mi">74</span> <span class="mi">72</span> <span class="mi">61</span> <span class="mi">20</span> <span class="mi">48</span> <span class="mi">44</span> <span class="mi">0</span><span class="n">a</span> <span class="mi">20</span> <span class="mi">01</span> <span class="mi">11</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="mi">02</span> <span class="mi">03</span> <span class="mi">11</span> <span class="mi">71</span> <span class="mi">44</span> <span class="mi">90</span> <span class="mi">04</span> <span class="mi">03</span> <span class="mi">01</span> <span class="mi">23</span> <span class="mi">09</span> <span class="mi">07</span> <span class="mi">07</span> <span class="mi">83</span> <span class="mi">01</span> <span class="mi">00</span>
</span></span><span class="line"><span class="cl">    <span class="mi">00</span> <span class="mi">02</span> <span class="mi">3</span><span class="n">a</span> <span class="mi">80</span> <span class="mi">18</span> <span class="mi">71</span> <span class="mi">38</span> <span class="mi">2</span><span class="n">d</span> <span class="mi">40</span> <span class="mi">58</span> <span class="mi">2</span><span class="n">c</span> <span class="mi">45</span> <span class="mi">00</span> <span class="mi">58</span> <span class="mi">54</span> <span class="mi">21</span>
</span></span><span class="line"><span class="cl">    <span class="mi">00</span> <span class="mi">00</span> <span class="mi">1</span><span class="n">e</span> <span class="mi">56</span> <span class="mi">5</span><span class="n">e</span> <span class="mi">00</span> <span class="n">a0</span> <span class="n">a0</span> <span class="n">a0</span> <span class="mi">29</span> <span class="mi">50</span> <span class="mi">30</span> <span class="mi">20</span> <span class="mi">35</span> <span class="mi">00</span> <span class="mi">58</span>
</span></span><span class="line"><span class="cl">    <span class="mi">54</span> <span class="mi">21</span> <span class="mi">00</span> <span class="mi">00</span> <span class="mi">1</span><span class="n">a</span> <span class="mi">00</span> <span class="mi">00</span> <span class="mi">00</span> <span class="mi">00</span> <span class="mi">00</span> <span class="mi">00</span> <span class="mi">00</span> <span class="mi">00</span> <span class="mi">00</span> <span class="mi">00</span> <span class="mi">00</span>
</span></span><span class="line"><span class="cl">    <span class="mi">00</span> <span class="mi">00</span> <span class="mi">00</span> <span class="mi">00</span> <span class="mi">00</span> <span class="mi">00</span> <span class="mi">00</span> <span class="mi">00</span> <span class="mi">00</span> <span class="mi">00</span> <span class="mi">00</span> <span class="mi">00</span> <span class="mi">00</span> <span class="mi">00</span> <span class="mi">00</span> <span class="mi">00</span>
</span></span><span class="line"><span class="cl">    <span class="mi">00</span> <span class="mi">00</span> <span class="mi">00</span> <span class="mi">00</span> <span class="mi">00</span> <span class="mi">00</span> <span class="mi">00</span> <span class="mi">00</span> <span class="mi">00</span> <span class="mi">00</span> <span class="mi">00</span> <span class="mi">00</span> <span class="mi">00</span> <span class="mi">00</span> <span class="mi">00</span> <span class="mi">00</span>
</span></span><span class="line"><span class="cl">    <span class="mi">00</span> <span class="mi">00</span> <span class="mi">00</span> <span class="mi">00</span> <span class="mi">00</span> <span class="mi">00</span> <span class="mi">00</span> <span class="mi">00</span> <span class="mi">00</span> <span class="mi">00</span> <span class="mi">00</span> <span class="mi">00</span> <span class="mi">00</span> <span class="mi">00</span> <span class="mi">00</span> <span class="mi">00</span>
</span></span><span class="line"><span class="cl">    <span class="mi">00</span> <span class="mi">00</span> <span class="mi">00</span> <span class="mi">00</span> <span class="mi">00</span> <span class="mi">00</span> <span class="mi">00</span> <span class="mi">00</span> <span class="mi">00</span> <span class="mi">00</span> <span class="mi">00</span> <span class="mi">00</span> <span class="mi">00</span> <span class="mi">00</span> <span class="mi">00</span> <span class="n">c8</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="o">----------------</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">Block</span> <span class="mi">0</span><span class="p">,</span> <span class="n">Base</span> <span class="n">EDID</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">     <span class="n">EDID</span> <span class="n">Structure</span> <span class="n">Version</span> <span class="o">&amp;</span> <span class="n">Revision</span><span class="p">:</span> <span class="mf">1.4</span>
</span></span><span class="line"><span class="cl">     <span class="n">Vendor</span> <span class="o">&amp;</span> <span class="n">Product</span> <span class="n">Identification</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">       <span class="n">Manufacturer</span><span class="p">:</span> <span class="n">GSM</span>
</span></span><span class="line"><span class="cl">       <span class="n">Model</span><span class="p">:</span> <span class="mi">23305</span>
</span></span><span class="line"><span class="cl">       <span class="n">Serial</span> <span class="n">Number</span><span class="p">:</span> <span class="mi">314041</span>
</span></span><span class="line"><span class="cl">       <span class="n">Made</span> <span class="k">in</span><span class="p">:</span> <span class="n">week</span> <span class="mi">8</span> <span class="n">of</span> <span class="mi">2017</span>
</span></span><span class="line"><span class="cl">     <span class="n">Basic</span> <span class="n">Display</span> <span class="n">Parameters</span> <span class="o">&amp;</span> <span class="n">Features</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">       <span class="n">Digital</span> <span class="n">display</span>
</span></span><span class="line"><span class="cl">       <span class="n">Bits</span> <span class="n">per</span> <span class="n">primary</span> <span class="n">color</span> <span class="n">channel</span><span class="p">:</span> <span class="mi">10</span>
</span></span><span class="line"><span class="cl">       <span class="n">DisplayPort</span> <span class="n">interface</span>
</span></span><span class="line"><span class="cl">       <span class="n">Maximum</span> <span class="n">image</span> <span class="n">size</span><span class="p">:</span> <span class="mi">60</span> <span class="n">cm</span> <span class="n">x</span> <span class="mi">34</span> <span class="n">cm</span>
</span></span><span class="line"><span class="cl">       <span class="n">Gamma</span><span class="p">:</span> <span class="mf">2.20</span>
</span></span><span class="line"><span class="cl">       <span class="n">DPMS</span> <span class="n">levels</span><span class="p">:</span> <span class="n">Standby</span>
</span></span><span class="line"><span class="cl">       <span class="n">Supported</span> <span class="n">color</span> <span class="n">formats</span><span class="p">:</span> <span class="n">RGB</span> <span class="mi">4</span><span class="p">:</span><span class="mi">4</span><span class="p">:</span><span class="mi">4</span><span class="p">,</span> <span class="n">YCrCb</span> <span class="mi">4</span><span class="p">:</span><span class="mi">4</span><span class="p">:</span><span class="mi">4</span><span class="p">,</span> <span class="n">YCrCb</span> <span class="mi">4</span><span class="p">:</span><span class="mi">2</span><span class="p">:</span><span class="mi">2</span>
</span></span><span class="line"><span class="cl">       <span class="n">Default</span> <span class="p">(</span><span class="n">sRGB</span><span class="p">)</span> <span class="n">color</span> <span class="n">space</span> <span class="k">is</span> <span class="n">primary</span> <span class="n">color</span> <span class="n">space</span>
</span></span><span class="line"><span class="cl">       <span class="n">First</span> <span class="n">detailed</span> <span class="n">timing</span> <span class="n">includes</span> <span class="n">the</span> <span class="n">native</span> <span class="n">pixel</span> <span class="n">format</span> <span class="n">and</span> <span class="n">preferred</span> <span class="n">refresh</span> <span class="n">rate</span>
</span></span><span class="line"><span class="cl">     <span class="n">Color</span> <span class="n">Characteristics</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">       <span class="n">Red</span>  <span class="p">:</span> <span class="mf">0.6523</span><span class="p">,</span> <span class="mf">0.3349</span>
</span></span><span class="line"><span class="cl">       <span class="n">Green</span><span class="p">:</span> <span class="mf">0.3046</span><span class="p">,</span> <span class="mf">0.6367</span>
</span></span><span class="line"><span class="cl">       <span class="n">Blue</span> <span class="p">:</span> <span class="mf">0.1484</span><span class="p">,</span> <span class="mf">0.0615</span>
</span></span><span class="line"><span class="cl">       <span class="n">White</span><span class="p">:</span> <span class="mf">0.3134</span><span class="p">,</span> <span class="mf">0.3291</span>
</span></span><span class="line"><span class="cl">     <span class="n">Established</span> <span class="n">Timings</span> <span class="n">I</span> <span class="o">&amp;</span> <span class="n">II</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">       <span class="n">DMT</span> <span class="mh">0x04</span><span class="p">:</span>   <span class="mi">640</span><span class="n">x480</span>    <span class="mf">59.940476</span> <span class="n">Hz</span>   <span class="mi">4</span><span class="p">:</span><span class="mi">3</span>     <span class="mf">31.469</span> <span class="n">kHz</span>     <span class="mf">25.175000</span> <span class="n">MHz</span>
</span></span><span class="line"><span class="cl">       <span class="n">DMT</span> <span class="mh">0x09</span><span class="p">:</span>   <span class="mi">800</span><span class="n">x600</span>    <span class="mf">60.316541</span> <span class="n">Hz</span>   <span class="mi">4</span><span class="p">:</span><span class="mi">3</span>     <span class="mf">37.879</span> <span class="n">kHz</span>     <span class="mf">40.000000</span> <span class="n">MHz</span>
</span></span><span class="line"><span class="cl">       <span class="n">DMT</span> <span class="mh">0x10</span><span class="p">:</span>  <span class="mi">1024</span><span class="n">x768</span>    <span class="mf">60.003840</span> <span class="n">Hz</span>   <span class="mi">4</span><span class="p">:</span><span class="mi">3</span>     <span class="mf">48.363</span> <span class="n">kHz</span>     <span class="mf">65.000000</span> <span class="n">MHz</span>
</span></span><span class="line"><span class="cl">     <span class="n">Standard</span> <span class="n">Timings</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">       <span class="n">GTF</span>     <span class="p">:</span>  <span class="mi">1152</span><span class="n">x864</span>    <span class="mf">60.000000</span> <span class="n">Hz</span>   <span class="mi">4</span><span class="p">:</span><span class="mi">3</span>     <span class="mf">53.700</span> <span class="n">kHz</span>     <span class="mf">81.624000</span> <span class="n">MHz</span>
</span></span><span class="line"><span class="cl">       <span class="n">DMT</span> <span class="mh">0x23</span><span class="p">:</span>  <span class="mi">1280</span><span class="n">x1024</span>   <span class="mf">60.019740</span> <span class="n">Hz</span>   <span class="mi">5</span><span class="p">:</span><span class="mi">4</span>     <span class="mf">63.981</span> <span class="n">kHz</span>    <span class="mf">108.000000</span> <span class="n">MHz</span>
</span></span><span class="line"><span class="cl">       <span class="n">DMT</span> <span class="mh">0x55</span><span class="p">:</span>  <span class="mi">1280</span><span class="n">x720</span>    <span class="mf">60.000000</span> <span class="n">Hz</span>  <span class="mi">16</span><span class="p">:</span><span class="mi">9</span>     <span class="mf">45.000</span> <span class="n">kHz</span>     <span class="mf">74.250000</span> <span class="n">MHz</span>
</span></span><span class="line"><span class="cl">       <span class="n">DMT</span> <span class="mh">0x53</span><span class="p">:</span>  <span class="mi">1600</span><span class="n">x900</span>    <span class="mf">60.000000</span> <span class="n">Hz</span>  <span class="mi">16</span><span class="p">:</span><span class="mi">9</span>     <span class="mf">60.000</span> <span class="n">kHz</span>    <span class="mf">108.000000</span> <span class="n">MHz</span> <span class="p">(</span><span class="n">RB</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">       <span class="n">DMT</span> <span class="mh">0x52</span><span class="p">:</span>  <span class="mi">1920</span><span class="n">x1080</span>   <span class="mf">60.000000</span> <span class="n">Hz</span>  <span class="mi">16</span><span class="p">:</span><span class="mi">9</span>     <span class="mf">67.500</span> <span class="n">kHz</span>    <span class="mf">148.500000</span> <span class="n">MHz</span>
</span></span><span class="line"><span class="cl">       <span class="n">DMT</span> <span class="mh">0x1c</span><span class="p">:</span>  <span class="mi">1280</span><span class="n">x800</span>    <span class="mf">59.810326</span> <span class="n">Hz</span>  <span class="mi">16</span><span class="p">:</span><span class="mi">10</span>    <span class="mf">49.702</span> <span class="n">kHz</span>     <span class="mf">83.500000</span> <span class="n">MHz</span>
</span></span><span class="line"><span class="cl">     <span class="n">Detailed</span> <span class="n">Timing</span> <span class="n">Descriptors</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">       <span class="n">DTD</span> <span class="mi">1</span><span class="p">:</span>  <span class="mi">3840</span><span class="n">x2160</span>   <span class="mf">59.996625</span> <span class="n">Hz</span>  <span class="mi">16</span><span class="p">:</span><span class="mi">9</span>    <span class="mf">133.312</span> <span class="n">kHz</span>    <span class="mf">533.250000</span> <span class="n">MHz</span> <span class="p">(</span><span class="mi">600</span> <span class="n">mm</span> <span class="n">x</span> <span class="mi">340</span> <span class="n">mm</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">                    <span class="n">Hfront</span>   <span class="mi">48</span> <span class="n">Hsync</span>  <span class="mi">32</span> <span class="n">Hback</span>   <span class="mi">80</span> <span class="n">Hpol</span> <span class="n">P</span>
</span></span><span class="line"><span class="cl">                    <span class="n">Vfront</span>   <span class="mi">54</span> <span class="n">Vsync</span>   <span class="mi">5</span> <span class="n">Vback</span>    <span class="mi">3</span> <span class="n">Vpol</span> <span class="n">N</span>
</span></span><span class="line"><span class="cl">       <span class="n">DTD</span> <span class="mi">2</span><span class="p">:</span>  <span class="mi">3840</span><span class="n">x2160</span>   <span class="mf">30.000000</span> <span class="n">Hz</span>  <span class="mi">16</span><span class="p">:</span><span class="mi">9</span>     <span class="mf">66.660</span> <span class="n">kHz</span>    <span class="mf">266.640000</span> <span class="n">MHz</span> <span class="p">(</span><span class="mi">600</span> <span class="n">mm</span> <span class="n">x</span> <span class="mi">340</span> <span class="n">mm</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">                    <span class="n">Hfront</span>    <span class="mi">8</span> <span class="n">Hsync</span> <span class="mi">144</span> <span class="n">Hback</span>    <span class="mi">8</span> <span class="n">Hpol</span> <span class="n">P</span>
</span></span><span class="line"><span class="cl">                    <span class="n">Vfront</span>   <span class="mi">54</span> <span class="n">Vsync</span>   <span class="mi">5</span> <span class="n">Vback</span>    <span class="mi">3</span> <span class="n">Vpol</span> <span class="n">N</span>
</span></span><span class="line"><span class="cl">       <span class="n">Display</span> <span class="nb">Range</span> <span class="n">Limits</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">         <span class="n">Monitor</span> <span class="n">ranges</span> <span class="p">(</span><span class="n">GTF</span><span class="p">):</span> <span class="mi">56</span><span class="o">-</span><span class="mi">61</span> <span class="n">Hz</span> <span class="n">V</span><span class="p">,</span> <span class="mi">30</span><span class="o">-</span><span class="mi">135</span> <span class="n">kHz</span> <span class="n">H</span><span class="p">,</span> <span class="bp">max</span> <span class="n">dotclock</span> <span class="mi">560</span> <span class="n">MHz</span>
</span></span><span class="line"><span class="cl">       <span class="n">Display</span> <span class="n">Product</span> <span class="n">Name</span><span class="p">:</span> <span class="err">&#39;</span><span class="n">LG</span> <span class="n">Ultra</span> <span class="n">HD</span><span class="err">&#39;</span>
</span></span><span class="line"><span class="cl">     <span class="n">Extension</span> <span class="n">blocks</span><span class="p">:</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl">    <span class="n">Checksum</span><span class="p">:</span> <span class="mh">0x11</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="o">----------------</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">Block</span> <span class="mi">1</span><span class="p">,</span> <span class="n">CTA</span><span class="o">-</span><span class="mi">861</span> <span class="n">Extension</span> <span class="n">Block</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">     <span class="n">Revision</span><span class="p">:</span> <span class="mi">3</span>
</span></span><span class="line"><span class="cl">     <span class="n">Basic</span> <span class="n">audio</span> <span class="n">support</span>
</span></span><span class="line"><span class="cl">     <span class="n">Supports</span> <span class="n">YCbCr</span> <span class="mi">4</span><span class="p">:</span><span class="mi">4</span><span class="p">:</span><span class="mi">4</span>
</span></span><span class="line"><span class="cl">     <span class="n">Supports</span> <span class="n">YCbCr</span> <span class="mi">4</span><span class="p">:</span><span class="mi">2</span><span class="p">:</span><span class="mi">2</span>
</span></span><span class="line"><span class="cl">     <span class="n">Native</span> <span class="n">detailed</span> <span class="n">modes</span><span class="p">:</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl">     <span class="n">Video</span> <span class="n">Data</span> <span class="n">Block</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">       <span class="n">VIC</span>  <span class="mi">16</span><span class="p">:</span>  <span class="mi">1920</span><span class="n">x1080</span>   <span class="mf">60.000000</span> <span class="n">Hz</span>  <span class="mi">16</span><span class="p">:</span><span class="mi">9</span>     <span class="mf">67.500</span> <span class="n">kHz</span>    <span class="mf">148.500000</span> <span class="n">MHz</span> <span class="p">(</span><span class="n">native</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">       <span class="n">VIC</span>   <span class="mi">4</span><span class="p">:</span>  <span class="mi">1280</span><span class="n">x720</span>    <span class="mf">60.000000</span> <span class="n">Hz</span>  <span class="mi">16</span><span class="p">:</span><span class="mi">9</span>     <span class="mf">45.000</span> <span class="n">kHz</span>     <span class="mf">74.250000</span> <span class="n">MHz</span>
</span></span><span class="line"><span class="cl">       <span class="n">VIC</span>   <span class="mi">3</span><span class="p">:</span>   <span class="mi">720</span><span class="n">x480</span>    <span class="mf">59.940060</span> <span class="n">Hz</span>  <span class="mi">16</span><span class="p">:</span><span class="mi">9</span>     <span class="mf">31.469</span> <span class="n">kHz</span>     <span class="mf">27.000000</span> <span class="n">MHz</span>
</span></span><span class="line"><span class="cl">       <span class="n">VIC</span>   <span class="mi">1</span><span class="p">:</span>   <span class="mi">640</span><span class="n">x480</span>    <span class="mf">59.940476</span> <span class="n">Hz</span>   <span class="mi">4</span><span class="p">:</span><span class="mi">3</span>     <span class="mf">31.469</span> <span class="n">kHz</span>     <span class="mf">25.175000</span> <span class="n">MHz</span>
</span></span><span class="line"><span class="cl">     <span class="n">Audio</span> <span class="n">Data</span> <span class="n">Block</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">       <span class="n">Linear</span> <span class="n">PCM</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">         <span class="n">Max</span> <span class="n">channels</span><span class="p">:</span> <span class="mi">2</span>
</span></span><span class="line"><span class="cl">         <span class="n">Supported</span> <span class="n">sample</span> <span class="n">rates</span> <span class="p">(</span><span class="n">kHz</span><span class="p">):</span> <span class="mi">48</span> <span class="mf">44.1</span> <span class="mi">32</span>
</span></span><span class="line"><span class="cl">         <span class="n">Supported</span> <span class="n">sample</span> <span class="n">sizes</span> <span class="p">(</span><span class="n">bits</span><span class="p">):</span> <span class="mi">24</span> <span class="mi">20</span> <span class="mi">16</span>
</span></span><span class="line"><span class="cl">     <span class="n">Speaker</span> <span class="n">Allocation</span> <span class="n">Data</span> <span class="n">Block</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">       <span class="n">FL</span><span class="o">/</span><span class="n">FR</span> <span class="o">-</span> <span class="n">Front</span> <span class="n">Left</span><span class="o">/</span><span class="n">Right</span>
</span></span><span class="line"><span class="cl">     <span class="n">Detailed</span> <span class="n">Timing</span> <span class="n">Descriptors</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">       <span class="n">DTD</span> <span class="mi">3</span><span class="p">:</span>  <span class="mi">1920</span><span class="n">x1080</span>   <span class="mf">60.000000</span> <span class="n">Hz</span>  <span class="mi">16</span><span class="p">:</span><span class="mi">9</span>     <span class="mf">67.500</span> <span class="n">kHz</span>    <span class="mf">148.500000</span> <span class="n">MHz</span> <span class="p">(</span><span class="mi">600</span> <span class="n">mm</span> <span class="n">x</span> <span class="mi">340</span> <span class="n">mm</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">                    <span class="n">Hfront</span>   <span class="mi">88</span> <span class="n">Hsync</span>  <span class="mi">44</span> <span class="n">Hback</span>  <span class="mi">148</span> <span class="n">Hpol</span> <span class="n">P</span>
</span></span><span class="line"><span class="cl">                    <span class="n">Vfront</span>    <span class="mi">4</span> <span class="n">Vsync</span>   <span class="mi">5</span> <span class="n">Vback</span>   <span class="mi">36</span> <span class="n">Vpol</span> <span class="n">P</span>
</span></span><span class="line"><span class="cl">       <span class="n">DTD</span> <span class="mi">4</span><span class="p">:</span>  <span class="mi">2560</span><span class="n">x1440</span>   <span class="mf">59.950550</span> <span class="n">Hz</span>  <span class="mi">16</span><span class="p">:</span><span class="mi">9</span>     <span class="mf">88.787</span> <span class="n">kHz</span>    <span class="mf">241.500000</span> <span class="n">MHz</span> <span class="p">(</span><span class="mi">600</span> <span class="n">mm</span> <span class="n">x</span> <span class="mi">340</span> <span class="n">mm</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">                    <span class="n">Hfront</span>   <span class="mi">48</span> <span class="n">Hsync</span>  <span class="mi">32</span> <span class="n">Hback</span>   <span class="mi">80</span> <span class="n">Hpol</span> <span class="n">P</span>
</span></span><span class="line"><span class="cl">                    <span class="n">Vfront</span>    <span class="mi">3</span> <span class="n">Vsync</span>   <span class="mi">5</span> <span class="n">Vback</span>   <span class="mi">33</span> <span class="n">Vpol</span> <span class="n">N</span>
</span></span><span class="line"><span class="cl">    <span class="n">Checksum</span><span class="p">:</span> <span class="mh">0xc8</span>  <span class="n">Unused</span> <span class="n">space</span> <span class="k">in</span> <span class="n">Extension</span> <span class="n">Block</span><span class="p">:</span> <span class="mi">74</span> <span class="n">bytes</span>
</span></span></code></pre><p>I only coded the logic for reading the EDID using DDC. The actual decoding part is done using an embedded universal binary of <a href="https://github.com/ValZapod/edid-decode">edid-decode</a>.</p>
<h2 id="coding-it-from-scratch">Coding it from scratch</h2>
<p>Here I'll lay out how EDID data can be read from a monitor using DDC. We'll continue to use <code>edid-decode</code> for decoding it.</p>
<p>If you want to skip compiling <code>edid-decode</code>, you can find it for download here: <a href="https://github.com/alin23/Lunar/blob/master/bin/edid-decode">github.com/alin23/Lunar - bin/edid-decode</a></p>
<h3 id="apple-silicon">Apple Silicon</h3>
<p>Communicating over DDC is different for each Mac architecture.</p>
<p>On Apple Silicon, macOS provides us with a handy private function inside IOKit:</p>
<pre class="chroma"><code><span class="line"><span class="cl"><span class="n">IOReturn</span> <span class="n">IOAVServiceCopyEDID</span><span class="p">(</span><span class="n">IOAVServiceRef</span> <span class="n">service</span><span class="p">,</span> <span class="n">CFDataRef</span><span class="o">*</span> <span class="n">edidData</span><span class="p">)</span>
</span></span></code></pre><p>For each connected monitor, there will be a <code>DCPAVServiceProxy</code> present in the hardware tree. From that proxy we can construct an <code>IOAVService</code> instance and call <code>IOAVServiceCopyEDID</code> on.</p>
<blockquote>
<p>DCP stands for Display CoProcessor, a separate chip with its own firmware, that handles display communication without loading the CPU.</p>
<p>Read more in <a href="https://asahilinux.org/2021/08/progress-report-august-2021/#reverse-engineering-dcp">Asahi Linux Progress Report: August 2021 - Reverse Engineering DCP</a></p>
</blockquote>
<p>We'll do the following steps:</p>
<ul>
<li>iterate on each <code>DCPAVServiceProxy</code></li>
<li>check its <code>Location</code> property to see if it's <code>External</code>
<ul>
<li>This skips the built-in screen of the MacBook or iMac</li>
</ul>
</li>
<li>create an <code>IOAVService</code> from the proxy</li>
<li>call <code>IOAVServiceCopyEDID</code> on the <code>IOAVService</code></li>
</ul>
<p><em>edid.m</em></p>
<pre class="chroma"><code><span class="line"><span class="cl"><span class="p">@</span><span class="n">import</span> <span class="n">Foundation</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="p">@</span><span class="n">import</span> <span class="n">IOKit</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">typedef</span> <span class="n">CFTypeRef</span> <span class="n">IOAVServiceRef</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="k">extern</span> <span class="n">IOAVServiceRef</span> <span class="nf">IOAVServiceCreateWithService</span><span class="p">(</span><span class="n">CFAllocatorRef</span> <span class="n">allocator</span><span class="p">,</span> <span class="n">io_service_t</span> <span class="n">service</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="k">extern</span> <span class="n">IOReturn</span> <span class="nf">IOAVServiceCopyEDID</span><span class="p">(</span><span class="n">IOAVServiceRef</span> <span class="n">service</span><span class="p">,</span> <span class="n">CFDataRef</span><span class="o">*</span> <span class="n">edidData</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kt">int</span> <span class="nf">main</span><span class="p">(</span><span class="kt">int</span> <span class="n">argc</span><span class="p">,</span> <span class="kt">char</span><span class="o">**</span> <span class="n">argv</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">io_iterator_t</span> <span class="n">iterator</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="n">kern_return_t</span> <span class="n">result</span> <span class="o">=</span> <span class="n">IOServiceGetMatchingServices</span><span class="p">(</span><span class="n">kIOMasterPortDefault</span><span class="p">,</span> <span class="n">IOServiceMatching</span><span class="p">(</span><span class="s">&#34;DCPAVServiceProxy&#34;</span><span class="p">),</span> <span class="o">&amp;</span><span class="n">iterator</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="n">result</span> <span class="o">!=</span> <span class="n">KERN_SUCCESS</span><span class="p">)</span> <span class="k">return</span> <span class="mi">1</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">io_service_t</span> <span class="n">service</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="k">while</span> <span class="p">((</span><span class="n">service</span> <span class="o">=</span> <span class="n">IOIteratorNext</span><span class="p">(</span><span class="n">iterator</span><span class="p">))</span> <span class="o">!=</span> <span class="n">IO_OBJECT_NULL</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="n">CFStringRef</span> <span class="n">location</span> <span class="o">=</span> <span class="n">IORegistryEntrySearchCFProperty</span><span class="p">(</span><span class="n">service</span><span class="p">,</span> <span class="n">kIOServicePlane</span><span class="p">,</span> <span class="n">CFSTR</span><span class="p">(</span><span class="s">&#34;Location&#34;</span><span class="p">),</span> <span class="n">kCFAllocatorDefault</span><span class="p">,</span> <span class="n">kIORegistryIterateRecursively</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="p">(</span><span class="n">location</span> <span class="o">==</span> <span class="nb">NULL</span> <span class="o">||</span> <span class="n">CFStringCompare</span><span class="p">(</span><span class="n">CFSTR</span><span class="p">(</span><span class="s">&#34;External&#34;</span><span class="p">),</span> <span class="n">location</span><span class="p">,</span> <span class="mi">0</span><span class="p">)</span> <span class="o">!=</span> <span class="mi">0</span><span class="p">)</span> <span class="k">continue</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="n">IOAVServiceRef</span> <span class="n">avService</span> <span class="o">=</span> <span class="n">IOAVServiceCreateWithService</span><span class="p">(</span><span class="n">kCFAllocatorDefault</span><span class="p">,</span> <span class="n">service</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="p">(</span><span class="n">avService</span> <span class="o">==</span> <span class="nb">NULL</span><span class="p">)</span> <span class="k">continue</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="n">CFDataRef</span> <span class="n">edidData</span> <span class="o">=</span> <span class="nb">NULL</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">        <span class="n">IOReturn</span> <span class="n">edidResult</span> <span class="o">=</span> <span class="n">IOAVServiceCopyEDID</span><span class="p">(</span><span class="n">avService</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">edidData</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="p">(</span><span class="n">edidResult</span> <span class="o">!=</span> <span class="n">kIOReturnSuccess</span> <span class="o">||</span> <span class="n">edidData</span> <span class="o">==</span> <span class="nb">NULL</span><span class="p">)</span> <span class="k">continue</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="n">NSData</span><span class="o">*</span> <span class="n">nsEdidData</span> <span class="o">=</span> <span class="p">[</span><span class="n">NSData</span> <span class="nl">dataWithBytes</span><span class="p">:</span><span class="n">CFDataGetBytePtr</span><span class="p">(</span><span class="n">edidData</span><span class="p">)</span> <span class="nl">length</span><span class="p">:</span><span class="n">CFDataGetLength</span><span class="p">(</span><span class="n">edidData</span><span class="p">)];</span>
</span></span><span class="line"><span class="cl">        <span class="kt">UInt8</span> <span class="o">*</span><span class="n">bytes</span> <span class="o">=</span> <span class="n">nsEdidData</span><span class="p">.</span><span class="n">bytes</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="k">for</span> <span class="p">(</span><span class="kt">int</span> <span class="n">i</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">i</span> <span class="o">&lt;</span> <span class="n">nsEdidData</span><span class="p">.</span><span class="n">length</span><span class="p">;</span> <span class="n">i</span><span class="o">++</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">            <span class="n">printf</span><span class="p">(</span><span class="s">&#34;%02X &#34;</span><span class="p">,</span> <span class="n">bytes</span><span class="p">[</span><span class="n">i</span><span class="p">]);</span>
</span></span><span class="line"><span class="cl">        <span class="n">printf</span><span class="p">(</span><span class="s">&#34;</span><span class="se">\n</span><span class="s">&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="mi">0</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre><p>We can then compile and run it using the following commands:</p>
<pre class="chroma"><code><span class="line"><span class="cl">clang -fmodules -o edid edid.m
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">./edid <span class="p">|</span> <span class="k">while</span> <span class="nb">read</span> line<span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span> <span class="nv">$line</span> <span class="p">|</span> edid-decode
</span></span><span class="line"><span class="cl"><span class="k">done</span>
</span></span></code></pre><h3 id="intel">Intel</h3>
<p>On Intel Macs, we don't have the convenience of <code>IOAVServiceCopyEDID</code> so we need to craft the I²C request ourselves and send it to the monitor.</p>
<p>Based on the <a href="http://boichat.ch/nicolas/ddcci/specs.html">DDC/CI specifications</a>, the request will need to be sent to address <code>0x50</code>.</p>
<p>But because IOKit needs the <a href="https://en.wikipedia.org/wiki/Bit_numbering">least-significant byte (LSB)</a> for other things, we need to use the <code>0xA0</code> address which is <code>0x50 &lt;&lt; 1</code>.</p>
<p>We'll do the following steps:</p>
<ul>
<li>iterate over <code>NSScreen.screens</code></li>
<li>get the internal macOS display ID</li>
<li>get the monitor framebuffer using <code>CGSServiceForDisplayNumber</code></li>
<li>send an <code>IOI2CRequest</code> to the first interface that responds to <code>IOI2CInterfaceOpen</code></li>
</ul>
<p><em>edid.m</em></p>
<pre class="chroma"><code><span class="line"><span class="cl"><span class="p">@</span><span class="n">import</span> <span class="n">Cocoa</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="p">@</span><span class="n">import</span> <span class="n">IOKit</span><span class="p">.</span><span class="n">i2c</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">extern</span> <span class="kt">void</span> <span class="nf">CGSServiceForDisplayNumber</span><span class="p">(</span><span class="n">CGDirectDisplayID</span> <span class="n">display</span><span class="p">,</span> <span class="n">io_service_t</span><span class="o">*</span> <span class="n">service</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kt">bool</span> <span class="nf">FramebufferI2CRequest</span><span class="p">(</span><span class="n">io_service_t</span> <span class="n">framebuffer</span><span class="p">,</span> <span class="n">IOI2CRequest</span><span class="o">*</span> <span class="n">request</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">IOItemCount</span> <span class="n">busCount</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="n">IOFBGetI2CInterfaceCount</span><span class="p">(</span><span class="n">framebuffer</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">busCount</span><span class="p">)</span> <span class="o">!=</span> <span class="n">KERN_SUCCESS</span><span class="p">)</span> <span class="k">return</span> <span class="nb">false</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">IOOptionBits</span> <span class="n">bus</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="k">while</span> <span class="p">(</span><span class="n">bus</span> <span class="o">&lt;</span> <span class="n">busCount</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="n">io_service_t</span> <span class="n">interface</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="p">(</span><span class="n">IOFBCopyI2CInterfaceForBus</span><span class="p">(</span><span class="n">framebuffer</span><span class="p">,</span> <span class="n">bus</span><span class="o">++</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">interface</span><span class="p">)</span> <span class="o">!=</span> <span class="n">KERN_SUCCESS</span><span class="p">)</span> <span class="k">continue</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="n">IOI2CConnectRef</span> <span class="n">connect</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="p">(</span><span class="n">IOI2CInterfaceOpen</span><span class="p">(</span><span class="n">interface</span><span class="p">,</span> <span class="n">kNilOptions</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">connect</span><span class="p">)</span> <span class="o">!=</span> <span class="n">KERN_SUCCESS</span><span class="p">)</span> <span class="k">continue</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="p">(</span><span class="n">IOI2CSendRequest</span><span class="p">(</span><span class="n">connect</span><span class="p">,</span> <span class="n">kNilOptions</span><span class="p">,</span> <span class="n">request</span><span class="p">)</span> <span class="o">==</span> <span class="n">KERN_SUCCESS</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="n">IOI2CInterfaceClose</span><span class="p">(</span><span class="n">connect</span><span class="p">,</span> <span class="n">kNilOptions</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">            <span class="k">return</span> <span class="nb">true</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="nb">false</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kt">int</span> <span class="nf">main</span><span class="p">(</span><span class="kt">int</span> <span class="n">argc</span><span class="p">,</span> <span class="kt">char</span><span class="o">**</span> <span class="n">argv</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="p">(</span><span class="n">NSScreen</span><span class="o">*</span> <span class="n">screen</span> <span class="k">in</span> <span class="n">NSScreen</span><span class="p">.</span><span class="n">screens</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="n">CGDirectDisplayID</span> <span class="n">displayID</span> <span class="o">=</span> <span class="p">[[</span><span class="n">screen</span><span class="p">.</span><span class="n">deviceDescription</span> <span class="nl">objectForKey</span><span class="p">:</span><span class="s">@&#34;NSScreenNumber&#34;</span><span class="p">]</span> <span class="n">unsignedIntValue</span><span class="p">];</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="p">(</span><span class="n">CGDisplayIsBuiltin</span><span class="p">(</span><span class="n">displayID</span><span class="p">))</span> <span class="k">continue</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="n">io_service_t</span> <span class="n">framebuffer</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">        <span class="n">CGSServiceForDisplayNumber</span><span class="p">(</span><span class="n">displayID</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">framebuffer</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="p">(</span><span class="n">framebuffer</span> <span class="o">==</span> <span class="mi">0</span><span class="p">)</span> <span class="k">return</span> <span class="mi">1</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="n">IOI2CRequest</span> <span class="n">request</span> <span class="o">=</span> <span class="p">{};</span>
</span></span><span class="line"><span class="cl">        <span class="kt">UInt8</span> <span class="n">data</span><span class="p">[</span><span class="mi">256</span><span class="p">]</span> <span class="o">=</span> <span class="p">{};</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="n">request</span><span class="p">.</span><span class="n">sendAddress</span> <span class="o">=</span> <span class="mh">0xA0</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">        <span class="n">request</span><span class="p">.</span><span class="n">sendTransactionType</span> <span class="o">=</span> <span class="n">kIOI2CSimpleTransactionType</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">        <span class="n">request</span><span class="p">.</span><span class="n">sendBuffer</span> <span class="o">=</span> <span class="p">(</span><span class="n">vm_address_t</span><span class="p">)</span><span class="n">data</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">        <span class="n">request</span><span class="p">.</span><span class="n">sendBytes</span> <span class="o">=</span> <span class="mh">0x01</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="n">data</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span> <span class="o">=</span> <span class="mh">0x00</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">        <span class="n">request</span><span class="p">.</span><span class="n">replyAddress</span> <span class="o">=</span> <span class="mh">0xA1</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">        <span class="n">request</span><span class="p">.</span><span class="n">replyTransactionType</span> <span class="o">=</span> <span class="n">kIOI2CSimpleTransactionType</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">        <span class="n">request</span><span class="p">.</span><span class="n">replyBuffer</span> <span class="o">=</span> <span class="p">(</span><span class="n">vm_address_t</span><span class="p">)</span><span class="n">data</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">        <span class="n">request</span><span class="p">.</span><span class="n">replyBytes</span> <span class="o">=</span> <span class="k">sizeof</span><span class="p">(</span><span class="n">data</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="n">FramebufferI2CRequest</span><span class="p">(</span><span class="n">framebuffer</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">request</span><span class="p">))</span> <span class="k">return</span> <span class="mi">1</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="k">for</span> <span class="p">(</span><span class="kt">int</span> <span class="n">i</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">i</span> <span class="o">&lt;</span> <span class="mi">256</span><span class="p">;</span> <span class="o">++</span><span class="n">i</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="n">printf</span><span class="p">(</span><span class="s">&#34;%02X &#34;</span><span class="p">,</span> <span class="n">data</span><span class="p">[</span><span class="n">i</span><span class="p">]);</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">        <span class="n">printf</span><span class="p">(</span><span class="s">&#34;</span><span class="se">\n</span><span class="s">&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="mi">0</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre><p>Again, we can read and decode the EDID using the following command:</p>
<pre class="chroma"><code><span class="line"><span class="cl">clang -fmodules -o edid edid.m
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">./edid <span class="p">|</span> <span class="k">while</span> <span class="nb">read</span> line<span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span> <span class="nv">$line</span> <span class="p">|</span> edid-decode
</span></span><span class="line"><span class="cl"><span class="k">done</span>
</span></span></code></pre><hr>
<h2 id="writing-edid-to-monitors">Writing EDID to monitors</h2>
<p>Not exactly the topic of this note, but wanted to write down my findings for posterity.</p>
<p>Reading the EDID binary from a monitor, editing it and then writing it back to the monitor memory is something that one would rarely need to do.</p>
<p>But there is <a href="https://notes.alinpanaitiu.com//Weird%20monitor%20bugs">this annoying practice of monitor vendors</a> that annoys the hell out of a large number of people and it's only fixable by editing EDIDs: they write the same serial number for all their monitors in a batch.</p>
<p>This leads to monitors getting swapped around in a multi monitor setup, and people having to re-arrange them on every reconnection. That's because from the OS side, all monitors look identical and there's no way to know which was left and which was right after they reconnect.</p>
<p>Technically, simply changing one byte of the EDID serial number should fix this. This is how I would do it:</p>
<ul>
<li>read the EDID from the monitor</li>
<li>assign a random byte to <code>EDID[12]</code> <em>(serial number is stored as a 32bit number on bytes 12-16)</em></li>
<li>write the EDID back to monitor memory through I²C</li>
</ul>
<p>And here's the code that should do that:</p>
<pre class="chroma"><code><span class="line"><span class="cl"><span class="p">@</span><span class="n">import</span> <span class="n">Foundation</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="p">@</span><span class="n">import</span> <span class="n">IOKit</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">typedef</span> <span class="n">CFTypeRef</span> <span class="n">IOAVServiceRef</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="k">extern</span> <span class="n">IOAVServiceRef</span> <span class="nf">IOAVServiceCreateWithService</span><span class="p">(</span><span class="n">CFAllocatorRef</span> <span class="n">allocator</span><span class="p">,</span> <span class="n">io_service_t</span> <span class="n">service</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="k">extern</span> <span class="n">IOReturn</span> <span class="nf">IOAVServiceCopyEDID</span><span class="p">(</span><span class="n">IOAVServiceRef</span> <span class="n">service</span><span class="p">,</span> <span class="n">CFDataRef</span><span class="o">*</span> <span class="n">edidData</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="k">extern</span> <span class="n">IOReturn</span> <span class="nf">IOAVServiceWriteI2C</span><span class="p">(</span><span class="n">IOAVServiceRef</span> <span class="n">service</span><span class="p">,</span> <span class="n">uint32_t</span> <span class="n">chipAddress</span><span class="p">,</span> <span class="n">uint32_t</span> <span class="n">dataAddress</span><span class="p">,</span> <span class="kt">void</span><span class="o">*</span> <span class="n">inputBuffer</span><span class="p">,</span> <span class="n">uint32_t</span> <span class="n">inputBufferSize</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kt">void</span> <span class="nf">edid_decode</span><span class="p">(</span><span class="n">NSData</span> <span class="o">*</span><span class="n">nsEdidData</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">NSTask</span><span class="o">*</span> <span class="n">task</span> <span class="o">=</span> <span class="p">[[</span><span class="n">NSTask</span> <span class="n">alloc</span><span class="p">]</span> <span class="n">init</span><span class="p">];</span>
</span></span><span class="line"><span class="cl">    <span class="p">[</span><span class="n">task</span> <span class="nl">setLaunchPath</span><span class="p">:</span><span class="s">@&#34;/usr/local/bin/edid-decode&#34;</span><span class="p">];</span>
</span></span><span class="line"><span class="cl">    <span class="p">[</span><span class="n">task</span> <span class="nl">setArguments</span><span class="p">:</span><span class="l">@[</span><span class="s">@&#34;-&#34;</span><span class="l">]</span><span class="p">];</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">NSPipe</span><span class="o">*</span> <span class="n">inputPipe</span> <span class="o">=</span> <span class="p">[</span><span class="n">NSPipe</span> <span class="n">pipe</span><span class="p">];</span>
</span></span><span class="line"><span class="cl">    <span class="p">[</span><span class="n">task</span> <span class="nl">setStandardInput</span><span class="p">:</span><span class="n">inputPipe</span><span class="p">];</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="p">[</span><span class="n">task</span> <span class="n">launch</span><span class="p">];</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="p">[[</span><span class="n">inputPipe</span> <span class="n">fileHandleForWriting</span><span class="p">]</span> <span class="nl">writeData</span><span class="p">:</span><span class="n">nsEdidData</span><span class="p">];</span>
</span></span><span class="line"><span class="cl">    <span class="p">[[</span><span class="n">inputPipe</span> <span class="n">fileHandleForWriting</span><span class="p">]</span> <span class="n">closeFile</span><span class="p">];</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="p">[</span><span class="n">task</span> <span class="n">waitUntilExit</span><span class="p">];</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kt">int</span> <span class="nf">main</span><span class="p">(</span><span class="kt">int</span> <span class="n">argc</span><span class="p">,</span> <span class="kt">char</span><span class="o">**</span> <span class="n">argv</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">io_iterator_t</span> <span class="n">iterator</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="n">kern_return_t</span> <span class="n">result</span> <span class="o">=</span> <span class="n">IOServiceGetMatchingServices</span><span class="p">(</span><span class="n">kIOMasterPortDefault</span><span class="p">,</span> <span class="n">IOServiceMatching</span><span class="p">(</span><span class="s">&#34;DCPAVServiceProxy&#34;</span><span class="p">),</span> <span class="o">&amp;</span><span class="n">iterator</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="n">result</span> <span class="o">!=</span> <span class="n">KERN_SUCCESS</span><span class="p">)</span> <span class="k">return</span> <span class="mi">1</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">io_service_t</span> <span class="n">service</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="k">while</span> <span class="p">((</span><span class="n">service</span> <span class="o">=</span> <span class="n">IOIteratorNext</span><span class="p">(</span><span class="n">iterator</span><span class="p">))</span> <span class="o">!=</span> <span class="n">IO_OBJECT_NULL</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="n">CFStringRef</span> <span class="n">location</span> <span class="o">=</span> <span class="n">IORegistryEntrySearchCFProperty</span><span class="p">(</span><span class="n">service</span><span class="p">,</span> <span class="n">kIOServicePlane</span><span class="p">,</span> <span class="n">CFSTR</span><span class="p">(</span><span class="s">&#34;Location&#34;</span><span class="p">),</span> <span class="n">kCFAllocatorDefault</span><span class="p">,</span> <span class="n">kIORegistryIterateRecursively</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="p">(</span><span class="n">location</span> <span class="o">==</span> <span class="nb">NULL</span> <span class="o">||</span> <span class="n">CFStringCompare</span><span class="p">(</span><span class="n">CFSTR</span><span class="p">(</span><span class="s">&#34;External&#34;</span><span class="p">),</span> <span class="n">location</span><span class="p">,</span> <span class="mi">0</span><span class="p">)</span> <span class="o">!=</span> <span class="mi">0</span><span class="p">)</span> <span class="k">continue</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="n">IOAVServiceRef</span> <span class="n">avService</span> <span class="o">=</span> <span class="n">IOAVServiceCreateWithService</span><span class="p">(</span><span class="n">kCFAllocatorDefault</span><span class="p">,</span> <span class="n">service</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="p">(</span><span class="n">avService</span> <span class="o">==</span> <span class="nb">NULL</span><span class="p">)</span> <span class="k">continue</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="n">CFDataRef</span> <span class="n">edidData</span> <span class="o">=</span> <span class="nb">NULL</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">        <span class="n">IOReturn</span> <span class="n">edidResult</span> <span class="o">=</span> <span class="n">IOAVServiceCopyEDID</span><span class="p">(</span><span class="n">avService</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">edidData</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="p">(</span><span class="n">edidResult</span> <span class="o">!=</span> <span class="n">kIOReturnSuccess</span> <span class="o">||</span> <span class="n">edidData</span> <span class="o">==</span> <span class="nb">NULL</span><span class="p">)</span> <span class="k">continue</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="n">NSMutableData</span><span class="o">*</span> <span class="n">nsEdidData</span> <span class="o">=</span> <span class="p">[</span><span class="n">NSMutableData</span> <span class="nl">dataWithBytes</span><span class="p">:</span><span class="n">CFDataGetBytePtr</span><span class="p">(</span><span class="n">edidData</span><span class="p">)</span> <span class="nl">length</span><span class="p">:</span><span class="n">CFDataGetLength</span><span class="p">(</span><span class="n">edidData</span><span class="p">)];</span>
</span></span><span class="line"><span class="cl">        <span class="kt">UInt8</span> <span class="o">*</span><span class="n">bytes</span> <span class="o">=</span> <span class="n">nsEdidData</span><span class="p">.</span><span class="n">mutableBytes</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="n">printf</span><span class="p">(</span><span class="s">&#34;Old EDID:</span><span class="se">\n</span><span class="s">&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">        <span class="n">edid_decode</span><span class="p">(</span><span class="n">nsEdidData</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="n">bytes</span><span class="p">[</span><span class="mi">12</span><span class="p">]</span> <span class="o">=</span> <span class="n">arc4random_uniform</span><span class="p">(</span><span class="mi">256</span><span class="p">);</span> <span class="c1">// Set first serial number byte to a random value
</span></span></span><span class="line"><span class="cl">        <span class="kt">UInt8</span> <span class="n">checksum</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">        <span class="k">for</span> <span class="p">(</span><span class="kt">int</span> <span class="n">i</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">i</span> <span class="o">&lt;</span> <span class="mi">127</span><span class="p">;</span> <span class="n">i</span><span class="o">++</span><span class="p">)</span> <span class="n">checksum</span> <span class="o">+=</span> <span class="n">bytes</span><span class="p">[</span><span class="n">i</span><span class="p">];</span>
</span></span><span class="line"><span class="cl">        <span class="n">bytes</span><span class="p">[</span><span class="mi">127</span><span class="p">]</span> <span class="o">=</span> <span class="mi">256</span> <span class="o">-</span> <span class="n">checksum</span><span class="p">;</span> <span class="c1">// Recompute checksum
</span></span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="n">printf</span><span class="p">(</span><span class="s">&#34;</span><span class="se">\n\n</span><span class="s">New EDID:</span><span class="se">\n</span><span class="s">&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">        <span class="n">edid_decode</span><span class="p">(</span><span class="n">nsEdidData</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="kt">char</span> <span class="n">input</span><span class="p">[</span><span class="mi">10</span><span class="p">];</span>
</span></span><span class="line"><span class="cl">        <span class="n">printf</span><span class="p">(</span><span class="s">&#34;</span><span class="se">\n\n</span><span class="s">Do you want to write the new EDID to the monitor? (y/n): &#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">        <span class="n">fgets</span><span class="p">(</span><span class="n">input</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="n">stdin</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="p">(</span><span class="n">strcmp</span><span class="p">(</span><span class="n">input</span><span class="p">,</span> <span class="s">&#34;y</span><span class="se">\n</span><span class="s">&#34;</span><span class="p">)</span> <span class="o">!=</span> <span class="mi">0</span><span class="p">)</span> <span class="k">continue</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="n">printf</span><span class="p">(</span><span class="s">&#34;</span><span class="se">\n\n</span><span class="s">Writing new EDID: &#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">        <span class="n">IOReturn</span> <span class="n">err</span> <span class="o">=</span> <span class="n">IOAVServiceWriteI2C</span><span class="p">(</span><span class="n">avService</span><span class="p">,</span> <span class="mh">0x50</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="n">bytes</span><span class="p">,</span> <span class="n">nsEdidData</span><span class="p">.</span><span class="n">length</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="p">(</span><span class="n">err</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="n">printf</span><span class="p">(</span><span class="s">&#34;error %s</span><span class="se">\n</span><span class="s">&#34;</span><span class="p">,</span> <span class="n">mach_error_string</span><span class="p">(</span><span class="n">err</span><span class="p">));</span>
</span></span><span class="line"><span class="cl">            <span class="k">return</span> <span class="mi">1</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">        <span class="n">printf</span><span class="p">(</span><span class="s">&#34;OK</span><span class="se">\n</span><span class="s">&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="mi">0</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre><p>Provided you have the <code>edid-decode</code> binary in <code>/usr/local/bin/</code>, the code can be run with:</p>
<pre class="chroma"><code><span class="line"><span class="cl">clang -fmodules -o edid-rewrite edid-rewrite.m <span class="o">&amp;&amp;</span> ./edid-rewrite
</span></span></code></pre><h3 id="why-its-not-a-good-solution">Why it's not a good solution</h3>
<p>The problem is that most monitors have write-protected EEPROMs and the write operation will just fail or discard the data.</p>
<p>I tested this on my LG 27UD88-W 4K monitor, and on an older 55&quot; LG TV and neither accepted the EDID rewrite.</p>
<p>Some monitors might have a <em>service menu</em> which has an option for enabling EDID writes, some might only accept the write on a specific port like <code>HDMI1</code>, but most will just ignore the operation.</p>
]]>
                </description></item>
            <item>
              <title>Can we hide the orange dot without disabling SIP?</title>
              <link>https://notes.alinpanaitiu.com/Can-we-hide-the-orange-dot-without-disabling-SIP</link>
              
              <pubDate>Tue, 28 Mar 2023 00:00:00 +0000</pubDate>
              
              <author>alin@panaitiu.com (Alin Panaitiu)</author>
              <guid>https://notes.alinpanaitiu.com/Can-we-hide-the-orange-dot-without-disabling-SIP</guid><description>
                  <![CDATA[<h1 id="can-we-hide-the-orange-dot-without-disabling-sip">Can we hide the orange dot without disabling SIP?</h1>
<p>A bit of background.</p>
<p>When macOS Monterey was announced, Apple added an orange dot indicator that appears on top of everything whenever the microphone is in use.</p>
<p>This has made a lot of people very angry and been widely regarded as a bad move.</p>
<div class="video-container"><video width=300 src="https://files.alinpanaitiu.com/mic-dot-indicator-appearing-h264.mp4" autoplay loop muted playsinline disablepictureinpicture></video></div>
<p>Kidding, it was quite a nice privacy addition actually. We could finally see in realtime when an app used the microphone, and what app that is.</p>
<p>But this wasn't something that everyone wanted.</p>
<p>For example, <a href="https://cdm.link/2021/12/apple-added-an-orange-dot-thats-a-showstopper-for-live-visuals-and-it-needs-a-fix/">when projecting live visuals</a> on a large screen, you might want to make the screen background black to give the impression of floating effects. That vibrant colored dot in the corner breaks that illusion.</p>
<p>It can also be a distraction for a specific group of people struggling with attention deficit.</p>
<p>Oh, and it also appears in screen recordings, which annoys me as well whenever I want to demo something.</p>
<h2 id="first-solution">First solution</h2>
<p>After news got out, <strong><a href="https://s4y.us/">Sydney San Martin</a></strong> posted a command-line implementation in a <a href="https://news.ycombinator.com/item?id=29627382">HackerNews thread</a> called <a href="https://github.com/s4y/undot">undot</a> which could grab the dot and move it off screen, since it was just a normal window.</p>
<p>And I decided to make an easy to use app for it called <a href="https://lowtechguys.com/yellowdot/">YellowDot</a> <em>(in my eyes, it looks yellow ¯\_(ツ)_/¯)</em></p>
<h2 id="macos-122">macOS 12.2</h2>
<p>Apple took note of this, saw it as a vulnerability, and promptly updated their code to disable this method of hiding the dot.</p>
<p>Indeed, it is a vulnerability. Any app with Accessibility <em>and</em> Microphone Permissions could record audio without your knowledge.</p>
<p>The problem was that there was no way for the user to opt out of this.</p>
<blockquote>
<p>A <strong>Privacy Indicator</strong> toggle that asks for password or biometric authentication could solve this problem for people that want a Mac to be a tool that should work for them as they want.</p>
</blockquote>
<h3 id="second-solution">Second solution</h3>
<p>Tyshawn Cormier, another developer annoyed by this, came up with a different solution a few days after macOS 12.2 was released: <a href="https://github.com/cormiertyshawn895/RecordingIndicatorUtility/tree/1.3">RecordingIndicatorUtility</a>.</p>
<p>It needed disabling System Integrity Protection (SIP) because it injected code inside the <strong>WindowServer</strong> and <strong>ControlCenter</strong> processes. There, it had access to methods that handled the drawing of this privacy indicator, and it could just <a href="https://github.com/cormiertyshawn895/RecordingIndicatorUtility/blob/1.3/RecordingIndicatorInjection/NSObject%2BRecordingIndicatorInjection.m#L139">set its opacity to 0</a> to hide it.</p>
<p>Quite a clever solution, but a bit too convoluted and apparently risky for most people.</p>
<p>Anyway, with no better idea in hand, I updated the YellowDot page to point people to Tyshawn's app and proceeded to forget about it.</p>
<p><img src="https://notes.alinpanaitiu.com/attachments/01E62CBF-DC4B-4E96-98CD-B5DEBB9DF260.png" alt="image"></p>
<h3 id="still-no-real-solution">Still no real solution...</h3>
<p>After a while I noticed something odd in my Plausible Analytics dashboard:</p>
<p><img src="https://notes.alinpanaitiu.com/attachments/566C56CE-A4A1-4C1E-8797-5E6E188FEE49.png" alt="image"></p>
<p>Even if the app was no longer working, and I wasn't making any noise about it, it was still the second most accessed page on <a href="https://lowtechguys.com/">The low-tech guys</a>.</p>
<p>And most traffic was coming from Google. People were constantly looking for a way to hide this dot.</p>
<p>I couldn't stop thinking that maybe there is a way to do this with SIP enabled.</p>
<hr>
<h2 id="what-the-dot">What the dot?</h2>
<p>So if it was no longer a simple window, how was this dot drawn on the screen?</p>
<p>Was it drawn on the GPU using Metal? Was it pixels pushed directly into the framebuffer?</p>
<p>No, it was still a damn window.</p>
<pre class="chroma"><code><span class="line"><span class="cl"><span class="c1">// WindowInfo.swift</span>
</span></span><span class="line"><span class="cl"><span class="kd">import</span> <span class="nc">Foundation</span>
</span></span><span class="line"><span class="cl"><span class="kd">import</span> <span class="nc">Cocoa</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kd">let</span> <span class="nv">info</span> <span class="p">=</span> <span class="n">CGWindowListCopyWindowInfo</span><span class="p">(.</span><span class="n">optionOnScreenOnly</span><span class="p">,</span> <span class="n">kCGNullWindowID</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="bp">print</span><span class="p">(</span><span class="n">info</span> <span class="k">as</span><span class="p">!</span> <span class="p">[[</span><span class="nb">String</span><span class="p">:</span> <span class="nb">AnyObject</span><span class="p">]])</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="err">￼</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="err">❯</span> <span class="n">swift</span> <span class="n">WindowInfo</span><span class="p">.</span><span class="n">swift</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="p">[</span>
</span></span><span class="line"><span class="cl">    <span class="s">&#34;kCGWindowName&#34;</span><span class="p">:</span> <span class="n">StatusIndicator</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s">&#34;kCGWindowOwnerName&#34;</span><span class="p">:</span> <span class="n">Window</span> <span class="n">Server</span>
</span></span><span class="line"><span class="cl">    <span class="s">&#34;kCGWindowBounds&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="n">Height</span> <span class="p">=</span> <span class="mi">16</span><span class="p">;</span> <span class="n">Width</span> <span class="p">=</span> <span class="mi">8</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">        <span class="n">X</span> <span class="p">=</span> <span class="mi">1953</span><span class="p">;</span> <span class="n">Y</span> <span class="p">=</span> <span class="mi">16</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="s">&#34;kCGWindowOwnerPID&#34;</span><span class="p">:</span> <span class="mi">403</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s">&#34;kCGWindowNumber&#34;</span><span class="p">:</span> <span class="mi">713</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s">&#34;kCGWindowMemoryUsage&#34;</span><span class="p">:</span> <span class="mi">2288</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s">&#34;kCGWindowStoreType&#34;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s">&#34;kCGWindowAlpha&#34;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s">&#34;kCGWindowSharingState&#34;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s">&#34;kCGWindowLayer&#34;</span><span class="p">:</span> <span class="mi">0</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s">&#34;kCGWindowIsOnscreen&#34;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span>
</span></span><span class="line"><span class="cl"><span class="p">]</span>
</span></span></code></pre><p>But this time, it was <em>a special kind of window</em>. Accessibility APIs could no longer find it, and thus there was no way to move the window around.</p>
<p>Looking for the <code>&quot;StatusIndicator&quot;</code> string inside <strong>SkyLight.framework</strong> points us to:</p>
<pre class="chroma"><code><span class="line"><span class="cl"><span class="kt">int</span> <span class="nf">system_status_indicator_get_window</span><span class="p">(</span><span class="n">PKGSystemStatusIndicator_t</span><span class="o">*</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="c1">// ...
</span></span></span><span class="line"><span class="cl">    <span class="n">_WSWindowSetProperty</span><span class="p">(</span><span class="n">r20</span><span class="p">,</span> <span class="s">@&#34;kCGSWindowTitle&#34;</span><span class="p">,</span> <span class="s">@&#34;StatusIndicator&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="c1">// ...
</span></span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre><p><img src="https://notes.alinpanaitiu.com/attachments/18C9ADFB-3AD1-4CBF-B608-5D4EFAC5EC40.png" alt="image"></p>
<p>Looks like the window is created with an internal method called <code>WSWindowCreate</code>. This creates the window but doesn’t insert it in the <code>CGSLocalWindows</code> hashmap.</p>
<p>This prevents user facing APIs from seeing the window, as they do a <code>CGSWindowGetMapped</code> to fetch the window object, which looks inside that hashmap.</p>
<p>I was wondering if we could call these methods directly. They’re not exported so we can’t link them in Swift, but we can call them directly by address.</p>
<p><code>system_status_indicator_set_shape</code> looks interesting, it expects a CGRect, a CGPoint and a double. These are most likely the <em>draw region</em>, the <em>origin</em> and the <em>size</em> of the dot.</p>
<p>Let’s see if we can move the dot offscreen by altering the <code>x</code> coordinate. I’m intercepting the method call using <a href="https://alinpanaitiu.com/blog/turn-off-macbook-display-clamshell/#no-more-amfi">Frida</a> and setting the <code>d0</code> register to <code>9999999</code>.</p>
<blockquote>
<p>Because structures like <code>CGRect</code> are passed by value, <code>d0</code> corresponds to the first <code>double</code> field of the first non-pointer argument: that is the <code>x</code> coordinate of the drawing region</p>
</blockquote>
<p><em>yellowdot.js</em></p>
<pre class="chroma"><code><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">symbols</span> <span class="o">=</span> <span class="nx">Module</span><span class="p">.</span><span class="nx">enumerateSymbols</span><span class="p">(</span><span class="s1">&#39;SkyLight&#39;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="kd">function</span> <span class="nx">addr</span><span class="p">(</span><span class="nx">symname</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="k">return</span> <span class="nx">ptr</span><span class="p">(</span><span class="nx">symbols</span><span class="p">.</span><span class="nx">find</span><span class="p">((</span><span class="nx">sym</span><span class="p">)</span> <span class="p">=&gt;</span> <span class="nx">sym</span><span class="p">.</span><span class="nx">name</span> <span class="o">==</span> <span class="nx">symname</span><span class="p">).</span><span class="nx">address</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">system_status_indicator_set_shape</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">NativeFunction</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">  <span class="nx">addr</span><span class="p">(</span><span class="s1">&#39;_ZL33system_status_indicator_set_shapeP26PKGSystemStatusIndicator_t6CGRect7CGPointd&#39;</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">  <span class="s1">&#39;int&#39;</span><span class="p">,</span> <span class="p">[</span><span class="s1">&#39;pointer&#39;</span><span class="p">,</span> <span class="p">[</span><span class="s1">&#39;double&#39;</span><span class="p">,</span> <span class="s1">&#39;double&#39;</span><span class="p">,</span> <span class="s1">&#39;double&#39;</span><span class="p">,</span> <span class="s1">&#39;double&#39;</span><span class="p">],</span> <span class="p">[</span><span class="s1">&#39;double&#39;</span><span class="p">,</span> <span class="s1">&#39;double&#39;</span><span class="p">],</span> <span class="s1">&#39;double&#39;</span><span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nx">Interceptor</span><span class="p">.</span><span class="nx">attach</span><span class="p">(</span><span class="nx">system_status_indicator_set_shape</span><span class="p">,</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nx">onEnter</span><span class="o">:</span> <span class="kd">function</span> <span class="p">(</span><span class="nx">args</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">      <span class="sb">`system_status_indicator_set_shape(</span><span class="si">${</span><span class="nx">args</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span><span class="si">}</span><span class="sb">, rx: </span><span class="si">${</span><span class="k">this</span><span class="p">.</span><span class="nx">context</span><span class="p">.</span><span class="nx">d0</span><span class="si">}</span><span class="sb">, ry: </span><span class="si">${</span><span class="k">this</span><span class="p">.</span><span class="nx">context</span><span class="p">.</span><span class="nx">d1</span><span class="si">}</span><span class="sb">, rw: </span><span class="si">${</span><span class="k">this</span><span class="p">.</span><span class="nx">context</span><span class="p">.</span><span class="nx">d2</span><span class="si">}</span><span class="sb">, rh: </span><span class="si">${</span><span class="k">this</span><span class="p">.</span><span class="nx">context</span><span class="p">.</span><span class="nx">d3</span><span class="si">}</span><span class="sb">, x: </span><span class="si">${</span><span class="k">this</span><span class="p">.</span><span class="nx">context</span><span class="p">.</span><span class="nx">d4</span><span class="si">}</span><span class="sb">, y: </span><span class="si">${</span><span class="k">this</span><span class="p">.</span><span class="nx">context</span><span class="p">.</span><span class="nx">d5</span><span class="si">}</span><span class="sb">, size: </span><span class="si">${</span><span class="k">this</span><span class="p">.</span><span class="nx">context</span><span class="p">.</span><span class="nx">d6</span><span class="si">}</span><span class="sb">)`</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">	<span class="c1">// Move the dot offscreen
</span></span></span><span class="line"><span class="cl">    <span class="k">this</span><span class="p">.</span><span class="nx">context</span><span class="p">.</span><span class="nx">d0</span> <span class="o">=</span> <span class="mi">9999999</span>
</span></span><span class="line"><span class="cl">  <span class="p">},</span>
</span></span><span class="line"><span class="cl"><span class="p">})</span>
</span></span></code></pre><p>Load the script, attach to WindowServer and start an audio recording:</p>
<pre class="chroma"><code><span class="line"><span class="cl">&gt; sudo frida --load yellowdot.js WindowServer
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">// Dot in ControlCenter icon
</span></span><span class="line"><span class="cl">system_status_indicator_set_shape<span class="o">(</span>0x600002f28540, rx: 1953, ry: 16, rw: 8, rh: 16, x: 1957, y: 24, size: 3<span class="o">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">// Dot in Full Screen
</span></span><span class="line"><span class="cl">system_status_indicator_set_shape<span class="o">(</span>0x600002f28540, rx: 0, ry: 0, rw: 0, rh: 0, x: 2010, y: 16, size: 4<span class="o">)</span>
</span></span></code></pre><div class="video-container"><video width=300 src="https://files.alinpanaitiu.com/indicator-set-shape-h264.mp4" autoplay loop muted playsinline disablepictureinpicture></video></div>
<p>Ok looks promising.</p>
<p>To call the function we would need a pointer to a <code>PKGSystemStatusIndicator</code> struct which we don’t know the layout of. I asked ChatGPT about it and came up with a good enough answer:</p>
<p><img src="https://notes.alinpanaitiu.com/attachments/F23C6B3C-ED3C-42B8-9B4D-3A28457CABBC.png" alt="image"></p>
<p>Now, how would one call a function by address? Isn’t <a href="https://en.m.wikipedia.org/wiki/Address_space_layout_randomization">Address space layout randomization</a> a problem here?</p>
<p>The dyld cache (where SkyLight resides) gets loaded at a random address at startup, but thankfully that stays the same until restart.</p>
<p>We can use <code>_dyld_get_image_vmaddr_slide</code> to get the base memory address of a specific module, then add to that the address of the symbol that we got from disassembling SkyLight.</p>
<p>Then we cast that address to a function using <code>unsafeBitCast</code> and just call it with the right parameters.</p>
<p><em>yellowdot.swift</em></p>
<pre class="chroma"><code><span class="line"><span class="cl"><span class="kd">import</span> <span class="nc">Cocoa</span>
</span></span><span class="line"><span class="cl"><span class="kd">import</span> <span class="nc">Foundation</span>
</span></span><span class="line"><span class="cl"><span class="kd">import</span> <span class="nc">MachO</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kd">func</span> <span class="nf">getDotWindowID</span><span class="p">()</span> <span class="p">-&gt;</span> <span class="n">CGWindowID</span><span class="p">?</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="kd">let</span> <span class="nv">info</span> <span class="p">=</span> <span class="n">CGWindowListCopyWindowInfo</span><span class="p">(.</span><span class="n">optionOnScreenOnly</span><span class="p">,</span> <span class="n">kCGNullWindowID</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">guard</span> <span class="kd">let</span> <span class="nv">windows</span> <span class="p">=</span> <span class="n">info</span> <span class="k">as</span><span class="p">?</span> <span class="p">[[</span><span class="nb">String</span><span class="p">:</span> <span class="nb">AnyObject</span><span class="p">]],</span>
</span></span><span class="line"><span class="cl">          <span class="kd">let</span> <span class="nv">window</span> <span class="p">=</span> <span class="n">windows</span><span class="p">.</span><span class="bp">first</span><span class="p">(</span><span class="k">where</span><span class="p">:</span> <span class="p">{</span> <span class="p">(</span><span class="nv">$0</span><span class="p">[</span><span class="s">&#34;kCGWindowName&#34;</span><span class="p">]</span> <span class="k">as</span><span class="p">?</span> <span class="nb">String</span><span class="p">)</span> <span class="p">==</span> <span class="s">&#34;StatusIndicator&#34;</span> <span class="p">})</span>
</span></span><span class="line"><span class="cl">    <span class="k">else</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">window</span><span class="p">[</span><span class="s">&#34;kCGWindowNumber&#34;</span><span class="p">]</span> <span class="k">as</span><span class="p">?</span> <span class="n">CGWindowID</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kd">struct</span> <span class="nc">PKGSystemStatusIndicator</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="kd">var</span> <span class="nv">windowID</span><span class="p">:</span> <span class="n">CGWindowID</span>
</span></span><span class="line"><span class="cl">    <span class="kd">var</span> <span class="nv">field_78</span><span class="p">:</span> <span class="nb">UInt32</span>
</span></span><span class="line"><span class="cl">    <span class="kd">var</span> <span class="nv">field_unk1</span><span class="p">:</span> <span class="nb">UnsafeMutablePointer</span><span class="p">&lt;</span><span class="nb">UInt64</span><span class="p">&gt;?</span>
</span></span><span class="line"><span class="cl">    <span class="kd">var</span> <span class="nv">field_unk2</span><span class="p">:</span> <span class="nb">UnsafeMutablePointer</span><span class="p">&lt;</span><span class="nb">Any</span><span class="p">&gt;?</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">guard</span> <span class="kd">let</span> <span class="nv">id</span> <span class="p">=</span> <span class="n">getDotWindowID</span><span class="p">()</span> <span class="k">else</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">exit</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="kd">var</span> <span class="nv">pkg</span> <span class="p">=</span> <span class="n">PKGSystemStatusIndicator</span><span class="p">(</span><span class="n">windowID</span><span class="p">:</span> <span class="n">id</span><span class="p">,</span> <span class="n">field_78</span><span class="p">:</span> <span class="mi">0</span><span class="p">,</span> <span class="n">field_unk1</span><span class="p">:</span> <span class="kc">nil</span><span class="p">,</span> <span class="n">field_unk2</span><span class="p">:</span> <span class="kc">nil</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kd">let</span> <span class="nv">libraryName</span> <span class="p">=</span> <span class="s">&#34;/System/Library/PrivateFrameworks/SkyLight.framework/Versions/A/SkyLight&#34;</span>
</span></span><span class="line"><span class="cl"><span class="kd">var</span> <span class="nv">slide</span><span class="p">:</span> <span class="nb">Int</span> <span class="p">=</span> <span class="mi">0</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kd">let</span> <span class="nv">imageCount</span> <span class="p">=</span> <span class="n">_dyld_image_count</span><span class="p">()</span>
</span></span><span class="line"><span class="cl"><span class="kd">let</span> <span class="nv">skyLightIndex</span> <span class="p">=</span> <span class="p">(</span><span class="mi">0</span> <span class="p">..</span><span class="o">&lt;</span> <span class="n">imageCount</span><span class="p">).</span><span class="bp">first</span> <span class="p">{</span> <span class="n">index</span> <span class="k">in</span>
</span></span><span class="line"><span class="cl">    <span class="n">_dyld_get_image_name</span><span class="p">(</span><span class="n">index</span><span class="p">)</span> <span class="p">==</span> <span class="n">libraryName</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="k">guard</span> <span class="kd">let</span> <span class="nv">skyLightIndex</span><span class="p">,</span> <span class="n">_dyld_get_image_vmaddr_slide</span><span class="p">(</span><span class="n">skyLightIndex</span><span class="p">)</span> <span class="o">!=</span> <span class="mi">0</span> <span class="k">else</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="bp">print</span><span class="p">(</span><span class="s">&#34;The </span><span class="si">\(</span><span class="n">libraryName</span><span class="si">)</span><span class="s"> library is not loaded in the dyld cache&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">exit</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kd">let</span> <span class="nv">slide</span> <span class="p">=</span> <span class="n">_dyld_get_image_vmaddr_slide</span><span class="p">(</span><span class="n">skyLightIndex</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="bp">print</span><span class="p">(</span><span class="s">&#34;The slide of the </span><span class="si">\(</span><span class="n">libraryName</span><span class="si">)</span><span class="s"> library is 0x</span><span class="si">\(</span><span class="nb">String</span><span class="si">(</span><span class="n">slide</span><span class="p">,</span> <span class="n">radix</span><span class="p">:</span> <span class="mi">16</span><span class="si">))</span><span class="s">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kd">typealias</span> <span class="n">SystemStatusIndicatorSetShapeFunction</span> <span class="p">=</span> <span class="p">@</span><span class="n">convention</span><span class="p">(</span><span class="n">c</span><span class="p">)</span> <span class="p">(</span><span class="n">UnsafeMutableRawPointer</span><span class="p">?,</span> <span class="n">CGRect</span><span class="p">,</span> <span class="n">CGPoint</span><span class="p">,</span> <span class="nb">Double</span><span class="p">)</span> <span class="p">-&gt;</span> <span class="nb">Void</span>
</span></span><span class="line"><span class="cl"><span class="kd">let</span> <span class="nv">systemStatusIndicatorSetShape</span> <span class="p">=</span> <span class="bp">unsafeBitCast</span><span class="p">(</span><span class="mh">0x18514d130</span> <span class="o">+</span> <span class="n">slide</span><span class="p">,</span> <span class="n">to</span><span class="p">:</span> <span class="n">SystemStatusIndicatorSetShapeFunction</span><span class="p">.</span><span class="kc">self</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">systemStatusIndicatorSetShape</span><span class="p">(&amp;</span><span class="n">pkg</span><span class="p">,</span> <span class="p">.</span><span class="n">zero</span><span class="p">,</span> <span class="n">CGPoint</span><span class="p">(</span><span class="n">x</span><span class="p">:</span> <span class="mi">999999</span><span class="p">,</span> <span class="n">y</span><span class="p">:</span> <span class="mi">16</span><span class="p">),</span> <span class="mi">4</span><span class="p">)</span>
</span></span></code></pre><pre class="chroma"><code><span class="line"><span class="cl">&gt; swift yellowdot.swift
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">The slide of the /System/Library/PrivateFrameworks/SkyLight.framework/Versions/A/SkyLight library is 0x1c98c000
</span></span><span class="line"><span class="cl">fish: Job 1, <span class="s1">&#39;sudo ./yellowdot&#39;</span> terminated by signal SIGSEGV <span class="o">(</span>Address boundary error<span class="o">)</span>
</span></span></code></pre><p>Hmm ok, it’s not so easy.</p>
<p>Debugging this with <strong>lldb</strong> drops us into <code>WSWindowCreate</code> where we are trying to load something from the <code>0x0</code> address. We reach that address because the internal SkyLight code expects to find a data structure filled with windows at a specific address in memory.</p>
<p>Obviously, we don’t have such a thing, that data only exists inside the WindowServer process and we have no access to it.</p>
<h3 id="systemstatusd">systemstatusd</h3>
<p>The “call by address” idea doesn’t work. But something else jumped out to me when I was trying to find a rogue LaunchDaemon.</p>
<pre class="chroma"><code><span class="line"><span class="cl">/System/Library/LaunchDaemons/com.apple.systemstatusd.plist
</span></span></code></pre><p>This seems to run some kind of server for handling these indicators. What happens if we just disable it?</p>
<pre class="chroma"><code><span class="line"><span class="cl">❯ sudo launchctl bootout system/com.apple.systemstatusd
</span></span></code></pre><p>That worked! Starting an audio recording no longer shows the dot!</p>
<p><em>It even stops showing the newly added blue location dot when I open the Weather app. Not exactly what I wanted.</em></p>
<p>But does it work with SIP enabled?</p>
<pre class="chroma"><code><span class="line"><span class="cl">❯ sudo launchctl bootout system/com.apple.systemstatusd
</span></span><span class="line"><span class="cl">Boot-out failed: 150: Operation not permitted <span class="k">while</span> System Integrity Protection is engaged
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">❯ sudo killall systemstatusd
</span></span><span class="line"><span class="cl">killall: warning: <span class="nb">kill</span> -term 448: Operation not permitted
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">❯ sudo rm /System/Library/LaunchDaemons/com.apple.systemstatusd.plist
</span></span><span class="line"><span class="cl">override rw-r--r-- root/wheel restricted,compressed <span class="k">for</span> /System/Library/LaunchDaemons/com.apple.systemstatusd.plist? y
</span></span><span class="line"><span class="cl">rm: /System/Library/LaunchDaemons/com.apple.systemstatusd.plist: Operation not permitted
</span></span></code></pre><p>Nope, they thought of everything.</p>
<p>Looks like with SIP enabled, these software rendered colored dots are just as effective as the green hardware-wired webcam LED.</p>
<p>Without a 0-day exploit, an attacker has no way of recording audio or location without you noticing it. But it also means you are stuck with this dot on your screen even when you fully trust the software.</p>
<p>The only solution is still disabling SIP, and using <a href="https://github.com/cormiertyshawn895/RecordingIndicatorUtility/tree/1.3">RecordingIndicatorUtility</a>. Looks like Tyshawn just replaced the code injection method with toggling this <code>systemstatusd</code> server instead, so it is more likely to survive macOS updates.</p>
]]>
                </description></item>
            <item>
              <title>Fullscreen apps above the MacBook notch</title>
              <link>https://notes.alinpanaitiu.com/Fullscreen-apps-above-the-MacBook-notch</link>
              
              <pubDate>Wed, 12 Apr 2023 00:00:00 +0000</pubDate>
              
              <author>alin@panaitiu.com (Alin Panaitiu)</author>
              <guid>https://notes.alinpanaitiu.com/Fullscreen-apps-above-the-MacBook-notch</guid><description>
                  <![CDATA[<h1 id="fullscreen-apps-above-the-macbook-notch">Fullscreen apps above the MacBook notch</h1>
<p><em>NOTE: This is not a tutorial. If you don't understand this article in detail, please don't attempt to do what's written in it.</em></p>
<p>On MacBook laptops with a notch, the area above the notch is reserved for the menu bar for most applications. That is good for normal window usage, as the menubar no longer takes precious vertical space that could be used for the content.</p>
<p><img src="https://notes.alinpanaitiu.com/attachments/01E8D99D-A0A1-4D5D-ADD7-8332E29CB972.png" alt="image"></p>
<p>Going fullscreen though, only makes that area black <em>(so as to hide the notch)</em> but apps will still render below the notch, leaving about 100k pixels useless.</p>
<p><img src="https://notes.alinpanaitiu.com/attachments/9D6E9F55-05E2-4F96-A749-A82D7EBE0323.png" alt="image"></p>
<p>So here are some instructions on how to fullscreen a window above the notch in the most hackish way possible.</p>
<p><em>By the way, if you prefer that old MacBook without a notch design, my <a href="https://lunar.fyi">Lunar</a> app can disable the notch by applying a hidden resolution.</em></p>
<figure class="video-container"><video src="https://files.lunar.fyi/lunar-notch-small.mp4" autoplay loop muted playsinline disablepictureinpicture></video><figcaption>Lunar notch button</figcaption></figure>
<hr>
<h2 id="old-style-fullscreen">Old-style fullscreen</h2>
<p>Every macOS window is actually a <code>NSWindow</code>, and that class has a <code>toggleFullScreen()</code> method.</p>
<p>But what that does is move the window to its own special Mission Control <strong>Space</strong> and render it fullscreen below the notch. Not what I want.</p>
<p>There's a thing called <em>old-style fullscreen</em> which some apps support natively (Sublime Text, <a href="https://sw.kovidgoyal.net/kitty">kitty</a>, <a href="https://iina.io/">IINA</a> etc.).</p>
<p>Instead of calling <code>toggleFullScreen()</code>, this method does the following:</p>
<ul>
<li>makes the window <em>borderless</em></li>
<li>sets up the app to auto-hide the menubar and dock</li>
<li>sets the window frame equal to the screen frame</li>
</ul>
<p>Here's how IINA does it for example: <a href="https://github.com/iina/iina/blob/fc66b27d50d0e98b056205867055f462e87828c9/iina/MainWindowController.swift#L1401-L1423">iina/MainWindowController.swift · iina/iina · GitHub</a></p>
<p>The problem is that, while apps can change their own windows as they please, there's no way for an app to change those attributes on windows of other apps.</p>
<h2 id="injecting-code">Injecting code</h2>
<p>Using <a href="https://frida.re/">Frida</a> we can inject and run code inside the context of any running process.</p>
<p>We first need to <strong><a href="https://developer.apple.com/documentation/security/disabling_and_enabling_system_integrity_protection">disable SIP</a></strong> though, as code injection is restricted by default for obvious security reasons.</p>
<p>Below we define our Frida script, which we can save in <strong>~/Documents/OverNotch.js</strong> for later usage.</p>
<p><em>OverNotch.js</em></p>
<pre class="chroma"><code><span class="line"><span class="cl"><span class="kd">var</span> <span class="nx">NSApplicationPresentationAutoHideMenuBar</span> <span class="o">=</span> <span class="mi">1</span> <span class="o">&lt;&lt;</span> <span class="mi">2</span>
</span></span><span class="line"><span class="cl"><span class="kd">var</span> <span class="nx">NSApplicationPresentationAutoHideDock</span> <span class="o">=</span> <span class="mi">1</span> <span class="o">&lt;&lt;</span> <span class="mi">0</span>
</span></span><span class="line"><span class="cl"><span class="kd">var</span> <span class="nx">NSWindowStyleMaskBorderless</span> <span class="o">=</span> <span class="mi">0</span>
</span></span><span class="line"><span class="cl"><span class="kd">var</span> <span class="nx">NSApplicationPresentationDefault</span> <span class="o">=</span> <span class="mi">0</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nx">global</span><span class="p">.</span><span class="nx">app</span> <span class="o">=</span> <span class="nx">ObjC</span><span class="p">.</span><span class="nx">classes</span><span class="p">.</span><span class="nx">NSApplication</span><span class="p">.</span><span class="nx">sharedApplication</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nx">global</span><span class="p">.</span><span class="nx">arrayFromNSArray</span> <span class="o">=</span> <span class="p">(</span><span class="nx">nsArray</span><span class="p">)</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="kd">var</span> <span class="nx">jsArray</span> <span class="o">=</span> <span class="p">[]</span>
</span></span><span class="line"><span class="cl">    <span class="kd">var</span> <span class="nx">count</span> <span class="o">=</span> <span class="nx">nsArray</span><span class="p">.</span><span class="nx">count</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="p">(</span><span class="kd">var</span> <span class="nx">i</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="nx">i</span> <span class="o">&lt;</span> <span class="nx">count</span><span class="p">;</span> <span class="nx">i</span><span class="o">++</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="nx">jsArray</span><span class="p">[</span><span class="nx">i</span><span class="p">]</span> <span class="o">=</span> <span class="nx">nsArray</span><span class="p">.</span><span class="nx">objectAtIndex_</span><span class="p">(</span><span class="nx">i</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="nx">jsArray</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nx">global</span><span class="p">.</span><span class="nx">mainWindow</span> <span class="o">=</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="nx">global</span><span class="p">.</span><span class="nx">app</span><span class="p">.</span><span class="nx">mainWindow</span><span class="p">())</span> <span class="p">{</span> <span class="k">return</span> <span class="nx">global</span><span class="p">.</span><span class="nx">app</span><span class="p">.</span><span class="nx">mainWindow</span><span class="p">()</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="nx">global</span><span class="p">.</span><span class="nx">app</span><span class="p">.</span><span class="nx">keyWindow</span><span class="p">())</span> <span class="p">{</span> <span class="k">return</span> <span class="nx">global</span><span class="p">.</span><span class="nx">app</span><span class="p">.</span><span class="nx">keyWindow</span><span class="p">()</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="kd">var</span> <span class="nx">windows</span> <span class="o">=</span> <span class="nx">arrayFromNSArray</span><span class="p">(</span><span class="nx">global</span><span class="p">.</span><span class="nx">app</span><span class="p">.</span><span class="nx">windows</span><span class="p">())</span>
</span></span><span class="line"><span class="cl">    <span class="kd">var</span> <span class="nx">index</span> <span class="o">=</span> <span class="nb">Math</span><span class="p">.</span><span class="nx">min</span><span class="p">(...</span><span class="nx">windows</span><span class="p">.</span><span class="nx">map</span><span class="p">((</span><span class="nx">w</span><span class="p">)</span> <span class="p">=&gt;</span> <span class="nx">w</span><span class="p">.</span><span class="nx">orderedIndex</span><span class="p">()))</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="nx">windows</span><span class="p">.</span><span class="nx">find</span><span class="p">((</span><span class="nx">w</span><span class="p">)</span> <span class="p">=&gt;</span> <span class="nx">w</span><span class="p">.</span><span class="nx">orderedIndex</span><span class="p">()</span> <span class="o">==</span> <span class="nx">index</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nx">global</span><span class="p">.</span><span class="nx">w</span> <span class="o">=</span> <span class="nx">mainWindow</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nx">global</span><span class="p">.</span><span class="nx">toggleFullScreen</span> <span class="o">=</span> <span class="p">(</span><span class="nx">windowNum</span><span class="p">)</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">windowNum</span><span class="p">)</span> <span class="p">{</span> <span class="nx">windowNum</span> <span class="o">=</span> <span class="nx">global</span><span class="p">.</span><span class="nx">w</span><span class="p">.</span><span class="nx">windowNumber</span><span class="p">()</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="kd">var</span> <span class="nx">dict</span> <span class="o">=</span> <span class="nx">ObjC</span><span class="p">.</span><span class="nx">classes</span><span class="p">.</span><span class="nx">NSThread</span><span class="p">.</span><span class="nx">mainThread</span><span class="p">().</span><span class="nx">threadDictionary</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="nx">dict</span><span class="p">.</span><span class="nx">valueForKey_</span><span class="p">(</span><span class="nx">windowNum</span><span class="p">.</span><span class="nx">toString</span><span class="p">()))</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="nx">stopFullScreen</span><span class="p">(</span><span class="nx">windowNum</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="nx">makeFullScreen</span><span class="p">(</span><span class="nx">windowNum</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nx">global</span><span class="p">.</span><span class="nx">stopFullScreen</span> <span class="o">=</span> <span class="p">(</span><span class="nx">windowNum</span><span class="p">)</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nx">Interceptor</span><span class="p">.</span><span class="nx">revert</span><span class="p">(</span><span class="nx">app</span><span class="p">.</span><span class="nx">setPresentationOptions_</span><span class="p">.</span><span class="nx">implementation</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="nx">ObjC</span><span class="p">.</span><span class="nx">schedule</span><span class="p">(</span><span class="nx">ObjC</span><span class="p">.</span><span class="nx">mainQueue</span><span class="p">,</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="nx">global</span><span class="p">.</span><span class="nx">app</span><span class="p">.</span><span class="nx">setPresentationOptions_</span><span class="p">(</span><span class="nx">NSApplicationPresentationDefault</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="p">})</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="kd">var</span> <span class="nb">window</span> <span class="o">=</span> <span class="nx">windowNum</span> <span class="o">?</span> <span class="nx">global</span><span class="p">.</span><span class="nx">app</span><span class="p">.</span><span class="nx">windowWithWindowNumber_</span><span class="p">(</span><span class="nx">windowNum</span><span class="p">)</span> <span class="o">:</span> <span class="nx">global</span><span class="p">.</span><span class="nx">w</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nb">window</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="s1">&#39;Window not found&#39;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="nx">ObjC</span><span class="p">.</span><span class="nx">schedule</span><span class="p">(</span><span class="nx">ObjC</span><span class="p">.</span><span class="nx">mainQueue</span><span class="p">,</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="kd">var</span> <span class="nx">key</span> <span class="o">=</span> <span class="nb">window</span><span class="p">.</span><span class="nx">windowNumber</span><span class="p">().</span><span class="nx">toString</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">        <span class="kd">var</span> <span class="nx">dict</span> <span class="o">=</span> <span class="nx">ObjC</span><span class="p">.</span><span class="nx">classes</span><span class="p">.</span><span class="nx">NSThread</span><span class="p">.</span><span class="nx">mainThread</span><span class="p">().</span><span class="nx">threadDictionary</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="p">(</span><span class="nx">dict</span><span class="p">.</span><span class="nx">valueForKey_</span><span class="p">(</span><span class="nx">key</span><span class="p">))</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="nb">window</span><span class="p">.</span><span class="nx">setStyleMask_</span><span class="p">(</span><span class="nx">dict</span><span class="p">.</span><span class="nx">valueForKey_</span><span class="p">(</span><span class="nx">key</span><span class="p">).</span><span class="nx">unsignedLongLongValue</span><span class="p">())</span>
</span></span><span class="line"><span class="cl">            <span class="nx">dict</span><span class="p">.</span><span class="nx">removeObjectForKey_</span><span class="p">(</span><span class="nx">key</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="nb">window</span><span class="p">.</span><span class="nx">setFrameUsingName_</span><span class="p">(</span><span class="nx">key</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="nb">window</span><span class="p">.</span><span class="nx">setIsMovable_</span><span class="p">(</span><span class="kc">true</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="nx">Interceptor</span><span class="p">.</span><span class="nx">revert</span><span class="p">(</span><span class="nx">app</span><span class="p">.</span><span class="nx">setPresentationOptions_</span><span class="p">.</span><span class="nx">implementation</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="p">})</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nx">global</span><span class="p">.</span><span class="nx">makeFullScreen</span> <span class="o">=</span> <span class="p">(</span><span class="nx">windowNum</span><span class="p">)</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="kd">var</span> <span class="nb">window</span> <span class="o">=</span> <span class="nx">windowNum</span> <span class="o">?</span> <span class="nx">global</span><span class="p">.</span><span class="nx">app</span><span class="p">.</span><span class="nx">windowWithWindowNumber_</span><span class="p">(</span><span class="nx">windowNum</span><span class="p">)</span> <span class="o">:</span> <span class="nx">global</span><span class="p">.</span><span class="nx">w</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nb">window</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="s1">&#39;Window not found&#39;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="nx">ObjC</span><span class="p">.</span><span class="nx">schedule</span><span class="p">(</span><span class="nx">ObjC</span><span class="p">.</span><span class="nx">mainQueue</span><span class="p">,</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="kd">var</span> <span class="nx">key</span> <span class="o">=</span> <span class="nb">window</span><span class="p">.</span><span class="nx">windowNumber</span><span class="p">().</span><span class="nx">toString</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">        <span class="kd">var</span> <span class="nx">dict</span> <span class="o">=</span> <span class="nx">ObjC</span><span class="p">.</span><span class="nx">classes</span><span class="p">.</span><span class="nx">NSThread</span><span class="p">.</span><span class="nx">mainThread</span><span class="p">().</span><span class="nx">threadDictionary</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">        <span class="kd">var</span> <span class="nx">value</span> <span class="o">=</span> <span class="nx">ObjC</span><span class="p">.</span><span class="nx">classes</span><span class="p">.</span><span class="nx">NSNumber</span><span class="p">.</span><span class="nx">numberWithUnsignedLongLong_</span><span class="p">(</span><span class="nb">window</span><span class="p">.</span><span class="nx">styleMask</span><span class="p">())</span>
</span></span><span class="line"><span class="cl">        <span class="nx">dict</span><span class="p">.</span><span class="nx">setValue_forKey_</span><span class="p">(</span><span class="nx">value</span><span class="p">,</span> <span class="nx">key</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="nb">window</span><span class="p">.</span><span class="nx">saveFrameUsingName_</span><span class="p">(</span><span class="nx">key</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="nx">global</span><span class="p">.</span><span class="nx">app</span><span class="p">.</span><span class="nx">setPresentationOptions_</span><span class="p">(</span><span class="nx">NSApplicationPresentationAutoHideDock</span> <span class="o">|</span> <span class="nx">NSApplicationPresentationAutoHideMenuBar</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="nb">window</span><span class="p">.</span><span class="nx">setStyleMask_</span><span class="p">(</span><span class="nx">NSWindowStyleMaskBorderless</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="nb">window</span><span class="p">.</span><span class="nx">setFrame_display_</span><span class="p">(</span><span class="nb">window</span><span class="p">.</span><span class="nx">screen</span><span class="p">().</span><span class="nx">frame</span><span class="p">(),</span> <span class="kc">true</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="nb">window</span><span class="p">.</span><span class="nx">setIsMovable_</span><span class="p">(</span><span class="kc">false</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="nx">Interceptor</span><span class="p">.</span><span class="nx">replace</span><span class="p">(</span><span class="nx">app</span><span class="p">.</span><span class="nx">setPresentationOptions_</span><span class="p">.</span><span class="nx">implementation</span><span class="p">,</span> <span class="k">new</span> <span class="nx">NativeCallback</span><span class="p">((</span><span class="nx">mask</span><span class="p">)</span> <span class="p">=&gt;</span> <span class="p">{}))</span>
</span></span><span class="line"><span class="cl">    <span class="p">})</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre><p>Then as a test, have a Safari window open, and run the following to make it fullscreen:</p>
<pre class="chroma"><code><span class="line"><span class="cl">sudo frida -q -l ~/Documents/OverNotch.js -e <span class="s2">&#34;toggleFullScreen()&#34;</span> Safari
</span></span></code></pre><p><em>Note that this doesn't work with all windows, and some apps will crash when their window <code>styleMask</code> is changed.</em></p>
<h3 id="hotkey">Hotkey</h3>
<p>I prefer to bind <code>fn - a</code> to <em>fullscreen the current window</em> using <a href="https://github.com/koekeishiya/skhd">skhd</a>.</p>
<blockquote>
<p>I would usually bind <code>rcmd - a</code> as it's a bit easier to press, but since I surrendered the <code>⌘ Right Command</code> key to my <strong><a href="https://lowtechguys.com/rcmd">rcmd app switcher</a></strong>, I'm using <code>fn</code> for these system-wide hotkeys.</p>
</blockquote>
<p>Here's what I added in <code>~/.skhdrc</code> to do that:</p>
<pre class="chroma"><code><span class="line"><span class="cl"><span class="n">fn</span> <span class="o">-</span> <span class="n">a</span>  <span class="p">:</span> <span class="n">sudo</span> <span class="n">frida</span> <span class="o">-</span><span class="n">q</span> <span class="o">-</span><span class="n">l</span> <span class="o">~/</span><span class="n">Documents</span><span class="o">/</span><span class="n">OverNotch</span><span class="p">.</span><span class="n">js</span> <span class="o">-</span><span class="n">e</span> <span class="s">&#34;toggleFullScreen()&#34;</span> <span class="err">$</span><span class="p">(</span><span class="n">osascript</span> <span class="o">-</span><span class="n">e</span> <span class="err">&#39;</span><span class="n">tell</span> <span class="n">application</span> <span class="s">&#34;System Events&#34;</span> <span class="n">to</span> <span class="kr">get</span> <span class="n">unix</span> <span class="n">id</span> <span class="n">of</span> <span class="bp">first</span> <span class="n">application</span> <span class="n">process</span> <span class="n">whose</span> <span class="n">frontmost</span> <span class="k">is</span> <span class="kc">true</span><span class="err">&#39;</span><span class="p">)</span>
</span></span></code></pre><hr>
<h2 id="why-would-you-need-such-a-thing">Why would you need such a thing?</h2>
<p>I personally wanted it for having <strong>Windows 11</strong> look as natively as possible through <a href="https://www.parallels.com/eu/">Parallels Desktop</a>. I still need it from time to time for hardware that only provides a Windows configuration program.</p>
<figure class="video-container"><video src="https://files.alinpanaitiu.com/parallels-full-notch.mp4" autoplay loop muted playsinline disablepictureinpicture></video><figcaption>Windows 11 full screen</figcaption></figure>
<p>You can also use it to get an immersive view of specific webpages. Here's <a href="https://incoherency.co.uk/nightdrive/">NightDrive</a> for example:</p>
<figure class="video-container"><video src="https://files.alinpanaitiu.com/safari-full-notch.mp4" autoplay loop muted playsinline disablepictureinpicture></video><figcaption>Safari NightDrive full screen</figcaption></figure>
<p>Or maybe have a more focused zen-mode on note taking apps. Here's <a href="https://noteplan.co/">NotePlan</a> in my case:</p>
<figure class="video-container"><video src="https://files.alinpanaitiu.com/noteplan-full-notch.mp4" autoplay loop muted playsinline disablepictureinpicture></video><figcaption>NotePlan zen mode</figcaption></figure>
<h2 id="sip-disabled-how-about-no">SIP disabled? How about no?</h2>
<p>Frida and other debuggers like lldb and gdb use the <code>task_for_pid</code> API for attaching to a running process.</p>
<p>That doesn't necessarily need SIP being disabled. For <code>task_for_pid</code> to succeed on a process, that process needs to meet one of the following requirements:</p>
<ul>
<li>be <strong>unsigned</strong></li>
<li>be signed <strong>without Hardened Runtime</strong></li>
<li>include the <code>com.apple.security.get-task-allow</code> entitlement</li>
</ul>
<p>Fortunately, we can alter the signature of any non-system app without disabling SIP.</p>
<p>Unfortunately that's not very straightforward because we need to ensure that we sign all the additional bundles inside the app, and we need to take care of not removing existing entitlements.</p>
<p>Because I'm using <a href="https://fishshell.com/">fish shell</a>, here's how I approach this task:</p>
<pre class="chroma"><code><span class="line"><span class="cl"><span class="k">function</span> <span class="nf">resign</span>-bundle <span class="na">-a</span> bundle
</span></span><span class="line"><span class="cl">    <span class="c"># Dump existing entitlements as a PLIST
</span></span></span><span class="line"><span class="cl">    <span class="nf">codesign</span> <span class="na">-d</span> <span class="na">--entitlements</span> <span class="na">- --xml</span> <span class="s2">&#34;</span><span class="nv">$bundle</span><span class="s2">&#34;</span> <span class="o">&gt;</span>/tmp/entitlements.xml
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="k">not</span> <span class="k">test</span> <span class="na">-s</span> /tmp/entitlements.xml
</span></span><span class="line"><span class="cl">        <span class="c"># Sign without Hardened Runtime
</span></span></span><span class="line"><span class="cl">        <span class="nf">codesign</span> <span class="na">-fs</span> <span class="nv">$CODESIGN_CERT</span> <span class="s2">&#34;</span><span class="nv">$bundle</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c"># Add `get-task-allow` to the entitlements to allow debuggers
</span></span></span><span class="line"><span class="cl">    /usr/libexec/PlistBuddy <span class="na">-c</span> <span class="s2">&#34;Add :com.apple.security.get-task-allow bool true&#34;</span> /tmp/entitlements.xml
</span></span><span class="line"><span class="cl">    <span class="c"># Sign with Hardened Runtime and `get-task-allow`
</span></span></span><span class="line"><span class="cl">    <span class="nf">codesign</span> <span class="na">-fs</span> <span class="nv">$CODESIGN_CERT</span> <span class="na">-o</span> runtime <span class="na">--timestamp</span> <span class="na">--entitlements</span> /tmp/entitlements.xml <span class="s2">&#34;</span><span class="nv">$bundle</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">function</span> <span class="nf">resign</span>-app <span class="na">-a</span> app
</span></span><span class="line"><span class="cl">    <span class="c"># Backup existing app
</span></span></span><span class="line"><span class="cl">    <span class="nf">mkdir</span> <span class="na">-p</span> ~/.cache/resign-app-backups/
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="k">not</span> <span class="k">test</span> <span class="na">-d</span> <span class="s2">&#34;~/.cache/resign-app-backups/</span><span class="nv">$(</span><span class="s2">basename &#34;</span><span class="nv">$app</span><span class="s2">&#34;)&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="nf">rsync</span> <span class="na">-avz</span> <span class="s2">&#34;</span><span class="nv">$app</span><span class="s2">&#34;</span> ~/.cache/resign-app-backups/
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c"># Resign bundles inside the app
</span></span></span><span class="line"><span class="cl">    <span class="nf">fd</span> <span class="na">-uu</span> <span class="s1">&#39;\.(app|framework|dylib|xpc|appex)$&#39;</span> <span class="s2">&#34;</span><span class="nv">$app</span><span class="s2">&#34;</span> <span class="na">-j</span> <span class="m">1</span> <span class="na">-x</span> <span class="nb">fish</span> <span class="na">-c</span> <span class="s1">&#39;resign-bundle {}&#39;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c"># Resign the app itself
</span></span></span><span class="line"><span class="cl">    <span class="nf">resign-bundle</span> <span class="s2">&#34;</span><span class="nv">$app</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span></code></pre><p>Testing this on RealVNC Viewer:</p>
<pre class="chroma"><code><span class="line"><span class="cl"><span class="o">&gt;</span> resign-app <span class="s2">&#34;/Applications/VNC Viewer.app&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">Executable</span><span class="o">=</span>/Applications/VNC <span class="nf">Viewer</span>.app/Contents/MacOS/vncviewer
</span></span><span class="line"><span class="cl">/Applications/VNC Viewer.app: replacing existing signature
</span></span></code></pre><figure class="video-container"><video src="https://files.alinpanaitiu.com/vnc-full-notch.mp4" autoplay loop muted playsinline disablepictureinpicture></video><figcaption>showing how native fullscreen differs from legacy fullscreen in VNC Viewer</figcaption></figure>
<p>I also tried testing this on <a href="https://github.com/insidegui/VirtualBuddy">VirtualBuddy</a> but because it has a required entitlement (<code>com.apple.vm.networking</code>) and an embedded provisioning profile tied to the developer certificate, this got too complicated to warrant the effort.</p>
<p>System apps also can't work with this approach because some of them live in immutable volumes called Cryptexes. For example here's where Safari can be found on macOS Ventura:</p>
<pre class="chroma"><code><span class="line"><span class="cl"><span class="c1"># Safari lives at /System/Volumes/Preboot/Cryptexes/App/System/Applications/Safari.app</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">&gt; codesign --remove-signature <span class="s1">&#39;/System/Volumes/Preboot/Cryptexes/App/System/Applications/Safari.app&#39;</span>
</span></span><span class="line"><span class="cl">/System/Volumes/Preboot/Cryptexes/App/System/Applications/Safari.app: internal error in Code Signing subsystem
</span></span></code></pre>]]>
                </description></item>
            <item>
              <title>Keyboard tricks from a macOS app dev</title>
              <link>https://notes.alinpanaitiu.com/Keyboard-tricks-from-a-macOS-app-dev</link>
              
              <pubDate>Fri, 21 Apr 2023 00:00:00 +0000</pubDate>
              
              <author>alin@panaitiu.com (Alin Panaitiu)</author>
              <guid>https://notes.alinpanaitiu.com/Keyboard-tricks-from-a-macOS-app-dev</guid><description>
                  <![CDATA[<h1 id="keyboard-tricks-from-a-macos-app-dev">Keyboard tricks from a macOS app dev</h1>
<p>This is a collection of keyboard and trackpad workflows that I accumulated	over the last 7 years of using a MacBook.</p>
<p>Here are some important abbreviations I am using in this article:</p>
<table>
<thead>
<tr>
<th>Term</th>
<th>Meaning</th>
<th>Explanation</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>l</strong>cmd</td>
<td>⌘ Left Command</td>
<td><em>The Command key on the <strong>left</strong> side of the spacebar</em></td>
</tr>
<tr>
<td><strong>r</strong>cmd</td>
<td>⌘ Right Command</td>
<td><em>The Command key on the <strong>right</strong> side of the spacebar</em></td>
</tr>
<tr>
<td><strong>l</strong>alt</td>
<td>⌥ Left Option</td>
<td><em>The Option key on the <strong>left</strong> side of the spacebar</em></td>
</tr>
<tr>
<td><strong>r</strong>alt</td>
<td>⌥ Right Option</td>
<td><em>The Option key on the <strong>right</strong> side of the spacebar</em></td>
</tr>
<tr>
<td>hyper</td>
<td>^ ⌥ ⌘</td>
<td><em>A rarely used combination of modifiers</em></td>
</tr>
<tr>
<td></td>
<td></td>
<td></td>
</tr>
</tbody>
</table>
<h2 id="key-sequences">Key Sequences</h2>
<p>In 6 years of using <a href="https://folivora.ai/">BetterTouchTool</a> I had no idea this feature existed.</p>
<p>It can map any sequence of keys (even modifiers) to any action.</p>
<blockquote>
<p>Sequence: individual keys pressed one after the other, not key combos that you press at the same time</p>
</blockquote>
<p>In the below video, I map <code>rcmd</code> then <code>lcmd</code> to the macOS zoom hotkey: <code>⌘</code> <code>⌥</code> <code>8</code></p>
<figure class="video-container"><video src="https://files.alinpanaitiu.com/btt-key-sequences-zoom[1400w,30fps,noaudio].mp4" autoplay loop muted playsinline disablepictureinpicture></video><figcaption>Activating macOS zoom by pressing rcmd then lcmd</figcaption></figure>
<p>I only have 2 key sequences at the moment:</p>
<ul>
<li><code>rcmd</code> then <code>lcmd</code> → <strong>Picture-in-Picture Zoom</strong></li>
<li><code>lcmd</code> then <code>rcmd</code> → <strong>Superkey Seek</strong></li>
</ul>
<hr>
<p><a href="https://superkey.app/">Superkey</a> is the Pro version of <em><a href="https://hyperkey.app/">Hyperkey</a></em>, the app that maps <code>CapsLock</code> to the hyper key, made by <a href="https://ryanhanson.dev/">Ryan Hanson</a> <em>(developer of the popular window management app Rectangle)</em>.</p>
<p>The below video is a demonstration of how I use it to quickly find and click on things that I know they're somewhere on the screen, but I would be too slow to notice-point-and-click with my eyes and trackpad.</p>
<p>I'm pressing <code>lcmd</code> then <code>rcmd</code>, typing <em>fip</em> then pressing <code>Enter</code> which clicks on the browser tab with <em>fipple</em> in its title.</p>
<figure class="video-container"><video src="https://files.alinpanaitiu.com/btt-key-sequences-seek[1400w,30fps,noaudio].mp4" autoplay loop muted playsinline disablepictureinpicture></video><figcaption>Clicking on things by searching them with Seek</figcaption></figure>
<p>You can even hold modifiers while pressing Enter to do the same action that clicking with that modifier would do. In my browser's case I can:</p>
<ul>
<li><code>^</code> <code>Enter</code> to right click the tab and show the context menu <em>(for muting or pinning the tab, etc)</em></li>
<li><code>⌘</code> <code>Enter</code> to close the tab without trying to click on the tiny <code>X</code></li>
</ul>
<h2 id="double-tap">Double Tap</h2>
<p>Raycast added the possibility of mapping a <em>double tap modifier</em> combo to summon it.</p>
<p>I personally mapped <code>⌥</code> <code>⌥</code> and I like it because it doesn't clash with any other keys while being unique enough to remember.</p>
<p>One thing I noticed though is that it is faster and easier to press <code>lalt</code> then <code>ralt</code> to register a double tap, instead of actually pressing one Option key twice. The reverse direction (<em>right then left</em>) works as well.</p>
<p><img src="https://notes.alinpanaitiu.com/attachments/Raycast-002318-Friday-12-01.png" alt="image"></p>
<p>One could also use BTT's Key Sequences to map arbitrary double or even triple taps to any action.</p>
<p>For example here I mapped <code>^</code> <code>^</code> to BTT's GPT action and I use it to convert some ObjC code to Swift:</p>
<p><em>the <code>⌘</code> <code>C</code> and <code>⌘</code> <code>V</code> in the video are synthesised by BTT, I'm not copy-pasting anything</em></p>
<figure class="video-container"><video src="https://files.alinpanaitiu.com/btt-key-sequences-gpt[1400w,30fps,noaudio,0.9gamma,3x].mp4" autoplay loop muted playsinline disablepictureinpicture></video><figcaption>Double tapping ^ Control to summon GPT</figcaption></figure>
<h2 id="one-key-app-switching">One-key app switching</h2>
<p>When using more than 3 apps, it becomes annoying to Command Tab Tab Tab... all of the time.</p>
<p>I'd like to switch to exactly the app I need, with a single keypress. Previously I did this by manually assigning <code>rcmd</code> + <code>letter</code> to my most used apps, using Karabiner or BTT.</p>
<p>I finally wrote the <em><img src="https://alinpanaitiu.com/images/icons/rcmd_16@2x.webp" alt="image"></em><a href="https://lowtechguys.com/rcmd">rcmd</a> app to do the assigning automatically for me, while also adding nice features like <em>show/hide app by pressing the same letter twice</em>, or <em>launch app if not already running</em> etc.</p>
<figure class="video-container"><video src="https://files.alinpanaitiu.com/rcmd-demo-macbook.mp4" autoplay loop muted playsinline disablepictureinpicture></video><figcaption>Holding ⌘ Right Command while pressing letters to focus apps</figcaption></figure>
<hr>
<p>As a bonus, I was able to implement focusing and opening specific projects, documents or windows of an app using the same method.</p>
<p><a href="https://lowtechguys.com/rcmd#windows"><img src="https://files.alinpanaitiu.com/rcmd-window-focus-section.webp" alt="image"></a></p>
<p>I have many projects in my code editor and frequently need to switch between them. And when opening up such a project, I also need to open a terminal tab and <code>cd</code> to the directory of that project.</p>
<p>For some of those I also need to open a specific <a href="https://www.sketch.com/">Sketch</a> or <a href="https://www.pixelmator.com/">Pixelmator</a> file which contains the graphics of the app or website. And of course, if it's a website, I also need to open the damn webpage in my browser to see how it looks while I develop it.</p>
<p>ლ(╹◡╹ლ)</p>
<p>That's too much work. And way too many things to remember.</p>
<blockquote>
<p>And I don't always need all of them at once, I want to be able to open things selectively, so a script that does it all isn't a good choice for me</p>
</blockquote>
<p>So I dedicated the <code>ralt</code> key for opening and switching between these projects.</p>
<figure class="video-container"><video src="https://files.alinpanaitiu.com/rcmd-vscode-windows[1400w,noaudio,0.9gamma,1.5x].mp4" autoplay loop muted playsinline disablepictureinpicture></video><figcaption>Using ⌥ ralt + letters to focus windows and open projects</figcaption></figure>
<p>Configuring <em><img src="https://alinpanaitiu.com/images/icons/rcmd_16@2x.webp" alt="image"></em> <strong>rcmd</strong> to do the window focusing thingie is a bit convoluted because of the App Store <a href="https://alinpanaitiu.com/blog/window-switcher-app-store/">sandbox limitations</a>, but if you have many projects to switch between, it's worth it.</p>
<p>To give you the basics on how to do this:</p>
<ul>
<li>Install Hammerspoon and the window switching script from rcmd's Switcher menu</li>
<li>Manually open the project you want to assign <em>(e.g. open a folder in VSCode)</em></li>
<li>Press <code>ralt</code> <code>=</code> <em>(or the key before backspace)</em> to show the assignment menu</li>
<li>Press <code>K</code> and then press the letter you want to bind to that project</li>
<li>Tell rcmd how it can open that window <em>(usually you'd choose <strong>Open a file</strong>)</em></li>
</ul>
<p><img src="https://notes.alinpanaitiu.com/attachments/CleanShot-002323-Friday-19-03.jpeg" alt="image"></p>
<p><em>if the <strong>ralt</strong> hotkeys don't do anything after first installing the script, you might need to restart rcmd and Hammerspoon</em></p>
<h2 id="fn-key">Fn key</h2>
<p>It always seemed weird to me how we have this <code>Fn</code> key which only interacts with the function row of keys, backspace and arrows and just takes up keyboard space for the rest of the time.</p>
<p>So of course, after running out of keys to bind, I looked for ways to use that key.</p>
<p>I know BTT and Karabiner can use it, but I prefer <a href="https://github.com/koekeishiya/skhd">skhd</a> for its simplicity. I decided to use it mostly for running scripts, and doing window management with <a href="https://github.com/koekeishiya/yabai">yabai</a>.</p>
<p>Starting with skhd is really simple:</p>
<ul>
<li><code>brew install skhd</code></li>
<li><code>brew services start skhd</code></li>
<li>Give it the required permissions</li>
<li>Start adding your hotkeys inside <code>~/.skhdrc</code></li>
</ul>
<p>Simply editing and saving the <code>skhdrc</code> file will hot reload the changes. Much faster than clicking around in the BTT UI.</p>
<p>Here are some <em>interesting</em> hotkeys I use with skhd:</p>
<pre class="chroma"><code><span class="line"><span class="cl"><span class="c1"># Edit skhd config</span>
</span></span><span class="line"><span class="cl">fn + k : code <span class="nv">$HOME</span>/.skhdrc
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Easy to use Sleep hotkey</span>
</span></span><span class="line"><span class="cl">fn - q : pmset sleepnow
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Turn on lights to warm colors</span>
</span></span><span class="line"><span class="cl">fn - w : shortcuts run <span class="s2">&#34;Warm Blend&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Connect or disconnect Sidecar</span>
</span></span><span class="line"><span class="cl">fn - s : <span class="nv">$HOME</span>/.local/bin/lunar toggle-connection sidecar
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Search selected text on Google</span>
</span></span><span class="line"><span class="cl">fn - g : skhd -k <span class="s1">&#39;cmd - c&#39;</span><span class="p">;</span> open <span class="s2">&#34;https://www.google.ro/search?q=</span><span class="k">$(</span>pbpaste<span class="k">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="c1"># Upload selected file and copy URL to clipboard</span>
</span></span><span class="line"><span class="cl">fn - f : skhd -k <span class="s1">&#39;cmd + alt - c&#39;</span><span class="p">;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">       terminal-notifier -title <span class="s2">&#34;Uploading file&#34;</span> -message <span class="s2">&#34;</span><span class="k">$(</span>pbpaste<span class="k">)</span><span class="s2">&#34;</span> -ignoreDnD<span class="p">;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">       curl --upload-file <span class="s2">&#34;</span><span class="k">$(</span>pbpaste<span class="k">)</span><span class="s2">&#34;</span> <span class="s2">&#34;https://transfer.sh/</span><span class="k">$(</span>basename <span class="k">$(</span>pbpaste<span class="k">))</span><span class="s2">&#34;</span> <span class="p">|</span> pbcopy<span class="p">;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">       terminal-notifier -title <span class="s2">&#34;File uploaded&#34;</span> -message <span class="s2">&#34;</span><span class="k">$(</span>pbpaste<span class="k">)</span><span class="s2">&#34;</span> -ignoreDnD
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># --- Things that need SIP disabled ---</span>
</span></span><span class="line"><span class="cl"><span class="o">()</span>
</span></span><span class="line"><span class="cl"><span class="c1"># Decrease window opacity (more transparent) -- &#34;0x1B&#34; =&gt; &#34;minus (-)&#34;</span>
</span></span><span class="line"><span class="cl">fn - 0x1B : yabai -m window --opacity <span class="k">$(</span>bc -e <span class="s2">&#34;</span><span class="k">$(</span>yabai -m query --windows --window <span class="p">|</span> jq -r <span class="s1">&#39;.&#34;opacity&#34;&#39;</span><span class="k">)</span><span class="s2"> - 0.05&#34;</span><span class="k">)</span>
</span></span><span class="line"><span class="cl"><span class="c1"># Increase window opacity (less transparent) -- &#34;0x18&#34; =&gt; &#34;plus (=)&#34;,</span>
</span></span><span class="line"><span class="cl">fn - 0x18 : yabai -m window --opacity <span class="k">$(</span>bc -e <span class="s2">&#34;</span><span class="k">$(</span>yabai -m query --windows --window <span class="p">|</span> jq -r <span class="s1">&#39;.&#34;opacity&#34;&#39;</span><span class="k">)</span><span class="s2"> + 0.05&#34;</span><span class="k">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Toggle Picture-in-Picture for the current window</span>
</span></span><span class="line"><span class="cl">fn - p : yabai -m window --toggle pip
</span></span></code></pre><h2 id="text-expansions">Text expansions</h2>
<p>Everyone has these snippets of text that they type often: email address, phone number, specific emojis, URLs etc.</p>
<p>I use Raycast's Snippets feature to quickly type these things by assigning mnemonics to them.</p>
<figure class="video-container"><video src="https://files.alinpanaitiu.com/raycast-snippets-mnemonics.mp4" autoplay loop muted playsinline disablepictureinpicture></video><figcaption>Typing mnemonics to expand Raycast Snippets</figcaption></figure>
<p>I also map typos I frequently do to their correct words. Examples:</p>
<ul>
<li><em>oyu</em> -&gt; you</li>
<li><em>improt</em> -&gt; import</li>
<li><em>stirng</em> -&gt; string</li>
</ul>
<p>Adding a snippet in Raycast is as simple as copying some text, then opening its clipboard manager and pressing <code>⌘</code> <code>S</code>.</p>
<p>Here's how I have it configured in case you want to replicate my setup exactly:</p>
<p><img src="https://notes.alinpanaitiu.com/attachments/Raycast-002326-Friday-20-54.png" alt="image"></p>
<h2 id="trackpad-gestures">Trackpad gestures</h2>
<p>I've been working on MacBooks exclusively for the last 6 years, and the Trackpad has always been right there at the center.</p>
<p>It's such a good input device, with multitouch, Force Touch and great OS support. It would be a shame to neglect it.</p>
<p>I'm using BTT to configure all kinds of gestures for it, the below video shows some of my most used ones:</p>
<p><em>(contains sound)</em></p>
<figure class="video-container">
        <video playsinline disablepictureinpicture controls>
                <source src="https://files.alinpanaitiu.com/btt-gestures%5B30fps%5D-h265.mp4" type="video/mp4; codecs=hvc1">
                <source src="https://files.alinpanaitiu.com/btt-gestures%5B30fps%5D-h264.mp4" type="video/mp4">
        </video>
        <figcaption>Controlling music and tabs with the Trackpad</figcaption>
</figure>
<h2 id="remembering-key-bindings">Remembering key bindings</h2>
<p><em>How do you keep all this in mind??</em></p>
<p>I get that question often. And the truth is it's becoming harder and harder.</p>
<p>I cope by dedicating modifiers to sets of actions and by always looking at the places where these key bindings are configured when I need them.</p>
<p>Very often I forget where a hotkey was defined.</p>
<h4 id="fuzzykey">FuzzyKey</h4>
<p>About a year ago, I started writing <strong>FuzzyKey</strong>, a fuzzy search Spotlight-like UI for all types of hotkeys. There are multiple apps doing similar things, but I wanted to take things further.</p>
<p>The defining features would have been:</p>
<ul>
<li>Parsers and ingesters for user defined hotkeys in most common apps
<ul>
<li>(e.g. keeping an always up-to-date database of hotkeys from <code>skhdrc</code>, <code>keybindings.json</code> etc.)</li>
<li>this could be extensible by users who want to write their own parsers</li>
</ul>
</li>
<li>Really good fuzzy search that surfaces the keys most likely to be forgotten</li>
<li>Easily edit a key binding without having to remember where it's defined</li>
</ul>
<p>After a few months, me and 2 friends wrote the parser architecture, the key bindings change watchers, a bit of UI, a bit of CLI, and the following parsers:</p>
<ul>
<li><strong>Shells</strong>: Bash, ZSH, Fish</li>
<li><strong>Browsers</strong>: Chrome <em>(for extension hotkeys)</em></li>
<li><strong>Terminals</strong>: iTerm, Kitty</li>
<li><strong>Code Editors</strong>: Sublime Text, VSCode, XCode, JetBrains</li>
<li>Menubar <em>(basically what <a href="https://www.mediaatelier.com/CheatSheet/">CheatSheet</a> does)</em></li>
<li>skhd</li>
</ul>
<p>But there was so much more work to do, and some parsers were already getting compatibility issues from app updates, that we paused working on it.</p>
<p>I gained a lot more experience in macOS app dev, so I'm hoping to revisit this idea soon.</p>
<p><em>I'm also secretly hoping to start crafting wood flutes and composing music so that I don't have a need for remembering any hotkeys anymore.</em></p>
<p><em>Here's my first Romanian Kaval ^_^</em></p>
<p><img src="https://notes.alinpanaitiu.com/attachments/making-caval-bminor.jpeg" alt="image"></p>
]]>
                </description></item>
            <item>
              <title>Install any website as PWA on iOS</title>
              <link>https://notes.alinpanaitiu.com/Install-any-website-as-PWA-on-iOS</link>
              
              <pubDate>Wed, 31 May 2023 00:00:00 +0000</pubDate>
              
              <author>alin@panaitiu.com (Alin Panaitiu)</author>
              <guid>https://notes.alinpanaitiu.com/Install-any-website-as-PWA-on-iOS</guid><description>
                  <![CDATA[<h1 id="install-any-website-as-pwa-on-ios">Install any website as PWA on iOS</h1>
<p>I have a few websites that I need to use often on my iPhone. I want to have them as PWA (Progressive Web Apps) so I can use them as if they were native apps.</p>
<p>This helps by making those sites readily available in the App Switcher, on the home screen and in Spotlight Search. It also gives me a bit more screen real estate by hiding the browser UI.</p>
<p>Websites can be installed as PWA if they provide a <a href="https://web.dev/add-manifest/">manifest</a> in the form of:</p>
<pre class="chroma"><code><span class="line"><span class="cl"><span class="p">&lt;</span><span class="nt">link</span> <span class="na">rel</span><span class="o">=</span><span class="s">&#34;manifest&#34;</span> <span class="na">href</span><span class="o">=</span><span class="s">&#34;manifest.json&#34;</span><span class="p">&gt;</span>
</span></span></code></pre><p>This manifest file contains metadata about the app, like the name, icons, theme color, etc.</p>
<p>The websites I use don't provide this manifest file, so I found a way to add it myself.</p>
<p>I use a Safari extension called <a href="https://apps.apple.com/ro/app/modificator-mods-for-websites/id1635358022">Modificator</a> to add the manifest file to the HTML and a simple API that I cooked for generating arbitrary manifest files.</p>
<p>The API supports almost all the manifest properties and can be used as such:</p>
<pre class="chroma"><code><span class="line"><span class="cl"><span class="nx">curl</span> <span class="s1">&#39;https://api.lowtechguys.com/manif?name=Sentry&#39;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;name&#34;</span><span class="o">:</span> <span class="s2">&#34;Sentry&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;short_name&#34;</span><span class="o">:</span> <span class="s2">&#34;Sentry&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;display&#34;</span><span class="o">:</span> <span class="s2">&#34;standalone&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;orientation&#34;</span><span class="o">:</span> <span class="s2">&#34;portrait&#34;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre><h2 id="sentry">Sentry</h2>
<p>Here I'll demonstrate adding a Sentry error reporting dashboard as an app on the home screen.</p>
<p>In the Modificator app, I needed to add a <strong>JS</strong> modificator as such:</p>
<pre class="chroma"><code><span class="line"><span class="cl"><span class="nb">document</span><span class="p">.</span><span class="nx">head</span><span class="p">.</span><span class="nx">innerHTML</span> <span class="o">+=</span> <span class="s2">&#34;&lt;link rel=manifest href=https://api.lowtechguys.com/manif?name=Sentry&gt;&#34;</span>
</span></span></code></pre><p><img src="https://notes.alinpanaitiu.com/attachments/Image%20Layer.png" alt="image"></p>
<blockquote>
<p>Looks like the Modificator app doesn't properly escape single quotes so I needed to make sure I don't use them at all.</p>
</blockquote>
<p>Then I went on the Sentry website, adjusted the zoom to 75% to allow more content to be visible, pressed the <em>Share</em> button at the bottom and tapped <strong>Add to Home Screen</strong>.</p>
<p><img src="https://notes.alinpanaitiu.com/attachments/9360.png" alt="image"></p>
<hr>
<p>The app looks like this on the home screen:</p>
<p><img src="https://notes.alinpanaitiu.com/attachments/05A56B29-CC9D-49EE-961D-387C5B4B3D30.jpeg" alt="image"></p>
<p>And this is how it looks on an iPhone 12 mini:</p>
<p><img src="https://notes.alinpanaitiu.com/attachments/IMG_0258.jpeg" alt="image"></p>
<h2 id="a-more-complex-example">A more complex example</h2>
<p>In the case of Sentry, we were helped by the fact that the site defines a good quality icon and a theme color in its <code>head</code></p>
<pre class="chroma"><code><span class="line"><span class="cl"><span class="p">&lt;</span><span class="nt">link</span> <span class="na">rel</span><span class="o">=</span><span class="s">&#34;apple-touch-icon&#34;</span> <span class="na">href</span><span class="o">=</span><span class="s">&#34;https://s1.sentry-cdn.com/_static/c1d4f32cdd3bcf7e5d26c2cbf4f29f2f/sentry/images/logos/apple-touch-icon.png&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl"><span class="p">&lt;</span><span class="nt">meta</span> <span class="na">name</span><span class="o">=</span><span class="s">&#34;theme-color&#34;</span> <span class="na">content</span><span class="o">=</span><span class="s">&#34;#2f1937&#34;</span><span class="p">&gt;</span>
</span></span></code></pre><p>We would need to add a few more parameters to the API call for websites that don't have those properties defined.</p>
<p>As an example, I'll add the <a href="https://lunar.fyi/faq">FAQ website</a> of my adaptive brightness app, <a href="https://lunar.fyi">Lunar</a>. I have to reference it often when responding to support emails.</p>
<p>The modificator JS will be the following:</p>
<pre class="chroma"><code><span class="line"><span class="cl"><span class="nb">document</span><span class="p">.</span><span class="nx">head</span><span class="p">.</span><span class="nx">innerHTML</span> <span class="o">+=</span> <span class="s2">&#34;&lt;meta name=mobile-web-app-capable content=yes&gt;&#34;</span> <span class="o">+</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;&lt;link rel=manifest href=https://api.lowtechguys.com/manif?name=Lunar+FAQ&amp;start_url=https%3A%2F%2Flunar.fyi%2Ffaq&amp;icon=https%3A%2F%2Ffiles.lunar.fyi%2Flunar-icon-maskable.png&gt;&#34;</span>
</span></span></code></pre><p>Here's what we're doing this time, exemplified with a <code>curl</code> command:</p>
<pre class="chroma"><code><span class="line"><span class="cl"><span class="nx">curl</span> <span class="o">-</span><span class="nx">G</span> <span class="s1">&#39;https://api.lowtechguys.com/manif&#39;</span> <span class="o">\</span>
</span></span><span class="line"><span class="cl">    <span class="o">--</span><span class="nx">data</span><span class="o">-</span><span class="nx">urlencode</span> <span class="nx">name</span><span class="o">=</span><span class="s1">&#39;Lunar FAQ&#39;</span> <span class="o">\</span>
</span></span><span class="line"><span class="cl">    <span class="o">--</span><span class="nx">data</span><span class="o">-</span><span class="nx">urlencode</span> <span class="nx">color</span><span class="o">=</span><span class="err">#</span><span class="mi">161125</span> <span class="o">\</span>
</span></span><span class="line"><span class="cl">    <span class="o">--</span><span class="nx">data</span><span class="o">-</span><span class="nx">urlencode</span> <span class="nx">icon</span><span class="o">=</span><span class="nx">https</span><span class="o">:</span><span class="c1">//files.lunar.fyi/lunar-icon-maskable.png
</span></span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;name&#34;</span><span class="o">:</span> <span class="s2">&#34;Lunar FAQ&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;short_name&#34;</span><span class="o">:</span> <span class="s2">&#34;Lunar FAQ&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;display&#34;</span><span class="o">:</span> <span class="s2">&#34;standalone&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;orientation&#34;</span><span class="o">:</span> <span class="s2">&#34;portrait&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;icons&#34;</span><span class="o">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">        <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="s2">&#34;src&#34;</span><span class="o">:</span> <span class="s2">&#34;https://files.lunar.fyi/lunar-icon-maskable.png&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="s2">&#34;purpose&#34;</span><span class="o">:</span> <span class="s2">&#34;any maskable&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">],</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;theme_color&#34;</span><span class="o">:</span> <span class="s2">&#34;#161125&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;background_color&#34;</span><span class="o">:</span> <span class="s2">&#34;#161125&#34;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre><h2 id="showcase">Showcase</h2>
<p><img src="https://notes.alinpanaitiu.com/attachments/IMG_7518BB29ED55-1.jpeg" alt="image"></p>
<p><img src="https://notes.alinpanaitiu.com/attachments/showcase-pwa.png" alt="image"></p>
<h2 id="caveats">Caveats</h2>
<p>The method doesn't work on websites with a more restrictive <a href="https://content-security-policy.com/">Content Security Policy</a> because the manifest has to be fetched from an external host (<em>api.lowtechguys.com</em> in this case).</p>
<p>If the CSP is sent as a header, there's nothing you can do in JS to make that policy less restrictive.</p>
<p>For example, I wanted to add <a href="https://search.marginalia.nu">the Marginalia search engine</a> on the home screen in this manner, but because of its <code>default-src https://*.marginalia.nu</code> policy, this doesn't work.</p>
<p>I was determined enough to do this, so I used <a href="https://proxyman.app/">Proxyman</a> to alter the Marginalia CSP header, just enough to trick Safari into allowing the manifest URL. After adding the app on the home screen, it keeps working without any CSP alteration needed.</p>
<h3 id="statusbar">Statusbar</h3>
<p>By default, the status bar will take the color of the <code>theme-color</code> meta value.</p>
<p>If that doesn't exist, the status bar will be black with white text, which doesn't look too <em>immersive</em> on most apps.</p>
<p>You can make the status bar transparent so that it takes the background of the website using the following meta element:</p>
<pre class="chroma"><code><span class="line"><span class="cl"><span class="p">&lt;</span><span class="nt">meta</span> <span class="na">name</span><span class="o">=</span><span class="s">apple-mobile-web-app-status-bar-style</span> <span class="na">content</span><span class="o">=</span><span class="s">black-translucent</span><span class="p">&gt;</span>
</span></span></code></pre><p>Note that if the website background is light, you'll still have white text in the status bar, which will be hard to read.</p>
]]>
                </description></item>
            <item>
              <title>Restarting macOS apps automatically on crash</title>
              <link>https://notes.alinpanaitiu.com/Restarting-macOS-apps-automatically-on-crash</link>
              
              <pubDate>Fri, 01 Sep 2023 00:00:00 +0000</pubDate>
              
              <author>alin@panaitiu.com (Alin Panaitiu)</author>
              <guid>https://notes.alinpanaitiu.com/Restarting-macOS-apps-automatically-on-crash</guid><description>
                  <![CDATA[<h1 id="restarting-macos-apps-automatically-on-crash">Restarting macOS apps automatically on crash</h1>
<p>Normally apps crash because of bugs introduced by the developer, but in my experience, there are cases where crashes can be out of the developer’s control.</p>
<p>For example the app might work with hardware closely, and the expectations the system has on that hardware are not met when a power or connection cord is yanked out <em>(or if there’s any <a href="https://mastodon.social/@haeckerfelix/110272427676278609">electromagnetic interference caused by your IKEA chair</a>)</em></p>
<p>Or maybe the app works with a network shared disk which disconnects abruptly in the middle of busy I/O, or a rogue driver changes the hardware tree while the app is traversing it, or God forbid, there is a kernel bug.</p>
<p>In all those cases, the crash is just a nuisance for the end user as they now have to relaunch the app. The crash report is also of no use to the developer, and mostly serves as a time consuming confusion.</p>
<p>After we reach a certain confidence in the app stability, we can explore some ways to have the app restart automatically when the inevitable crash happens.</p>
<h2 id="the-launchd-way">The launchd way</h2>
<p><em>com.example.MyApp.restartOnCrash.plist</em></p>
<pre class="chroma"><code><span class="line"><span class="cl"><span class="cp">&lt;?xml version=&#34;1.0&#34; encoding=&#34;UTF-8&#34;?&gt;</span>
</span></span><span class="line"><span class="cl"><span class="cp">&lt;!DOCTYPE plist PUBLIC &#34;-//Apple//DTD PLIST 1.0//EN&#34; &#34;http://www.apple.com/DTDs/PropertyList-1.0.dtd&#34;&gt;</span>
</span></span><span class="line"><span class="cl"><span class="nt">&lt;plist</span> <span class="na">version=</span><span class="s">&#34;1.0&#34;</span><span class="nt">&gt;</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&lt;dict&gt;</span>
</span></span><span class="line"><span class="cl">        <span class="nt">&lt;key&gt;</span>KeepAlive<span class="nt">&lt;/key&gt;</span>
</span></span><span class="line"><span class="cl">        <span class="nt">&lt;dict&gt;</span>
</span></span><span class="line"><span class="cl">            <span class="nt">&lt;key&gt;</span>Crashed<span class="nt">&lt;/key&gt;</span>
</span></span><span class="line"><span class="cl">            <span class="nt">&lt;true/&gt;</span>
</span></span><span class="line"><span class="cl">        <span class="nt">&lt;/dict&gt;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="nt">&lt;key&gt;</span>Label<span class="nt">&lt;/key&gt;</span>
</span></span><span class="line"><span class="cl">        <span class="nt">&lt;string&gt;</span>com.example.MyApp.restartOnCrash<span class="nt">&lt;/string&gt;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="nt">&lt;key&gt;</span>ProgramArguments<span class="nt">&lt;/key&gt;</span>
</span></span><span class="line"><span class="cl">        <span class="nt">&lt;array&gt;</span>
</span></span><span class="line"><span class="cl">            <span class="nt">&lt;string&gt;</span>/usr/bin/open<span class="nt">&lt;/string&gt;</span>
</span></span><span class="line"><span class="cl">            <span class="nt">&lt;string&gt;</span>-W<span class="nt">&lt;/string&gt;</span>
</span></span><span class="line"><span class="cl">            <span class="nt">&lt;string&gt;</span>/Applications/MyApp.app<span class="nt">&lt;/string&gt;</span>
</span></span><span class="line"><span class="cl">        <span class="nt">&lt;/array&gt;</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&lt;/dict&gt;</span>
</span></span><span class="line"><span class="cl"><span class="nt">&lt;/plist&gt;</span>
</span></span></code></pre><p><a href="https://www.launchd.info">launchd</a> is the daemon that manages all macOS processes. By placing a plist file in the right place, you can have a process start at boot, run on specific times or events, be kept alive etc.</p>
<p>So with a few lines of XML, we can have launchd start our app and keep it alive on crashes.</p>
<p>The problem with this is that the first app launch needs to be a false start that registers the agent and exits. The agent is the one who will actually start the app.</p>
<pre class="chroma"><code><span class="line"><span class="cl"><span class="kd">let</span> <span class="nv">service</span> <span class="p">=</span> <span class="n">SMAppService</span><span class="p">.</span><span class="n">agent</span><span class="p">(</span><span class="n">plistName</span><span class="p">:</span> <span class="s">&#34;com.example.MyApp.restartOnCrash.plist&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">try</span><span class="p">!</span> <span class="n">service</span><span class="p">.</span><span class="n">register</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">exit</span><span class="p">(</span><span class="mi">0</span><span class="p">)</span>
</span></span></code></pre><p><em>Also this API is only <a href="https://developer.apple.com/documentation/servicemanagement/updating_your_app_package_installer_to_use_the_new_service_management_api">available since macOS 13</a>.</em></p>
<p>Older versions of macOS would need to use a <code>.pkg</code> installer to copy the plist file to <code>~/Library/LaunchAgents</code> and then start the agent using the <code>launchctl</code> command.</p>
<h2 id="the-signal-handler-way">The signal handler way</h2>
<pre class="chroma"><code><span class="line"><span class="cl"><span class="kd">func</span> <span class="nf">restart</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">guard</span> <span class="n">CommandLine</span><span class="p">.</span><span class="n">arguments</span><span class="p">.</span><span class="bp">count</span> <span class="p">==</span> <span class="mi">1</span> <span class="k">else</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="n">exit</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="kd">let</span> <span class="nv">proc</span> <span class="p">=</span> <span class="nb">Process</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="n">proc</span><span class="p">.</span><span class="n">launchPath</span> <span class="p">=</span> <span class="s">&#34;/usr/bin/open&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="n">proc</span><span class="p">.</span><span class="n">args</span> <span class="p">=</span> <span class="p">[</span><span class="s">&#34;-n&#34;</span><span class="p">,</span> <span class="n">Bundle</span><span class="p">.</span><span class="n">main</span><span class="p">.</span><span class="n">bundlePath</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">    <span class="k">try</span><span class="p">!</span> <span class="n">proc</span><span class="p">.</span><span class="n">run</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">exit</span><span class="p">(</span><span class="mi">0</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kd">func</span> <span class="nf">restartOnCrash</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">NSSetUncaughtExceptionHandler</span> <span class="p">{</span> <span class="kc">_</span> <span class="k">in</span> <span class="n">restart</span><span class="p">()</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="n">signal</span><span class="p">(</span><span class="n">SIGABRT</span><span class="p">)</span> <span class="p">{</span> <span class="kc">_</span> <span class="k">in</span> <span class="n">restart</span><span class="p">()</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="n">signal</span><span class="p">(</span><span class="n">SIGILL</span><span class="p">)</span> <span class="p">{</span> <span class="kc">_</span> <span class="k">in</span> <span class="n">restart</span><span class="p">()</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="n">signal</span><span class="p">(</span><span class="n">SIGSEGV</span><span class="p">)</span> <span class="p">{</span> <span class="kc">_</span> <span class="k">in</span> <span class="n">restart</span><span class="p">()</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="n">signal</span><span class="p">(</span><span class="n">SIGFPE</span><span class="p">)</span> <span class="p">{</span> <span class="kc">_</span> <span class="k">in</span> <span class="n">restart</span><span class="p">()</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="n">signal</span><span class="p">(</span><span class="n">SIGBUS</span><span class="p">)</span> <span class="p">{</span> <span class="kc">_</span> <span class="k">in</span> <span class="n">restart</span><span class="p">()</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="n">signal</span><span class="p">(</span><span class="n">SIGPIPE</span><span class="p">)</span> <span class="p">{</span> <span class="kc">_</span> <span class="k">in</span> <span class="n">restart</span><span class="p">()</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="n">signal</span><span class="p">(</span><span class="n">SIGTRAP</span><span class="p">)</span> <span class="p">{</span> <span class="kc">_</span> <span class="k">in</span> <span class="n">restart</span><span class="p">()</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="n">signal</span><span class="p">(</span><span class="n">SIGHUP</span><span class="p">)</span> <span class="p">{</span> <span class="kc">_</span> <span class="k">in</span> <span class="n">restart</span><span class="p">()</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre><p>Installing this is as simple as calling <code>restartOnCrash()</code> once at the start of the app lifecycle <em>(usually in didFinishLaunching)</em></p>
<p>This is the method I use in <a href="https://lunar.fyi">Lunar</a> and <a href="https://lowtechguys.com/clop/">Clop</a> and it is reliable enough while also being easy to use and compatible with old macOS versions.</p>
<blockquote>
<p>Those signals are what is emitted by various parts of system code when the app gets in a crashing state. It could be because it accessed a wrong memory address, or because a <code>panic()</code> was called etc.</p>
</blockquote>
<p>For a more complete implementation that uses <code>execve</code> for <strong>instant restart</strong> and also checks for a <strong>restart loop</strong>, check this gist: <a href="https://gist.github.com/alin23/e79fdcf09cf6c4240169eaa6de26f517">RestartOnCrash.swift</a></p>
<h3 id="bonus-restart-on-hang">Bonus: restart on hang</h3>
<p>If the app is mostly a menubar utility sitting in background, it could be useful to detect and recover from app hangs.</p>
<blockquote>
<p>An app hang is when the app does not respond to user input anymore. It might appear as <em>Not responding</em> in Activity Monitor and show the beach ball cursor when hovering the cursor over its icon.</p>
</blockquote>
<p>For example, <strong>Lunar</strong> is well suited for this because it mostly sits in the background adapting monitor brightness automatically, and responding to brightness and volume keys. It is also prone to deadlocks caused by the macOS Gamma APIs.</p>
<p>A document based app like <strong>Sketch</strong>, <strong>Pages</strong> or <strong>Pixelmator</strong> would not be suited for this as the hang might be caused by saving a file or waiting for a CPU-heavy operation. In that case it would be best to wait it out, rather that restarting and causing data corruption.</p>
<p>So how can a hang be detected? By definition, all user inputs and UI renders happen on the main thread, so an app that’s not responding will most likely have its main thread busy or locked.</p>
<p>We can periodically schedule a ping on the main thread, and if the ping is not received for <code>n</code> seconds, an app hang happened.</p>
<pre class="chroma"><code><span class="line"><span class="cl"><span class="kd">var</span> <span class="nv">lastPing</span> <span class="p">=</span> <span class="n">Date</span><span class="p">()</span>
</span></span><span class="line"><span class="cl"><span class="kd">let</span> <span class="nv">hangThreshold</span> <span class="p">=</span> <span class="mf">60.0</span>
</span></span><span class="line"><span class="cl"><span class="kd">let</span> <span class="nv">hangDetectionQueue</span> <span class="p">=</span> <span class="n">DispatchQueue</span><span class="p">(</span><span class="n">label</span><span class="p">:</span> <span class="s">&#34;hangDetectionQueue&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kd">func</span> <span class="nf">restartOnAppHang</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="kd">let</span> <span class="nv">timer</span> <span class="p">=</span> <span class="n">Timer</span><span class="p">.</span><span class="n">scheduledTimer</span><span class="p">(</span><span class="n">withTimeInterval</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span> <span class="n">repeats</span><span class="p">:</span> <span class="kc">true</span><span class="p">)</span> <span class="p">{</span> <span class="kc">_</span> <span class="k">in</span>
</span></span><span class="line"><span class="cl">        <span class="n">lastPing</span> <span class="p">=</span> <span class="n">Date</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="n">timer</span><span class="p">.</span><span class="n">tolerance</span> <span class="p">=</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl">    <span class="n">RunLoop</span><span class="p">.</span><span class="n">main</span><span class="p">.</span><span class="n">add</span><span class="p">(</span><span class="n">timer</span><span class="p">,</span> <span class="n">forMode</span><span class="p">:</span> <span class="p">.</span><span class="n">common</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">hangDetectionQueue</span><span class="p">.</span><span class="n">async</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="k">while</span> <span class="kc">true</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="n">Thread</span><span class="p">.</span><span class="n">sleep</span><span class="p">(</span><span class="n">forTimeInterval</span><span class="p">:</span> <span class="mi">1</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">            <span class="k">if</span> <span class="n">Date</span><span class="p">().</span><span class="n">timeIntervalSince</span><span class="p">(</span><span class="n">lastPing</span><span class="p">)</span> <span class="o">&gt;</span> <span class="n">hangThreshold</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">                <span class="n">restart</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">            <span class="p">}</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre><p>To make it more robust, you might need to listen to sleep/wake events, pause the detection queue while in standby and reset the ping time on wake.</p>
<p>I use <a href="https://docs.sentry.io/platforms/apple/configuration/app-hangs/">Sentry's app hang detection</a> which not only handles this already, but it also sends a traceback where the hang happened so that I can investigate and fix it in case it's an app bug causing it.</p>
<pre class="chroma"><code><span class="line"><span class="cl"><span class="n">SentrySDK</span><span class="p">.</span><span class="n">start</span> <span class="p">{</span> <span class="n">options</span> <span class="k">in</span>
</span></span><span class="line"><span class="cl">    <span class="n">options</span><span class="p">.</span><span class="n">appHangTimeoutInterval</span> <span class="p">=</span> <span class="mi">60</span>
</span></span><span class="line"><span class="cl">    <span class="n">options</span><span class="p">.</span><span class="n">beforeSend</span> <span class="p">=</span> <span class="p">{</span> <span class="n">event</span> <span class="k">in</span>
</span></span><span class="line"><span class="cl">        <span class="k">guard</span> <span class="kd">let</span> <span class="nv">exc</span> <span class="p">=</span> <span class="n">event</span><span class="p">.</span><span class="n">exceptions</span><span class="p">?.</span><span class="bp">first</span><span class="p">,</span> <span class="kd">let</span> <span class="nv">mech</span> <span class="p">=</span> <span class="n">exc</span><span class="p">.</span><span class="n">mechanism</span><span class="p">,</span> <span class="n">mech</span><span class="p">.</span><span class="n">type</span> <span class="p">==</span> <span class="s">&#34;AppHang&#34;</span> <span class="k">else</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="k">return</span> <span class="n">event</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="c1">// Allow Sentry to send the event before restarting the app</span>
</span></span><span class="line"><span class="cl">        <span class="n">DispatchQueue</span><span class="p">.</span><span class="n">global</span><span class="p">().</span><span class="n">asyncAfter</span><span class="p">(</span><span class="n">deadline</span><span class="p">:</span> <span class="p">.</span><span class="n">now</span><span class="p">()</span> <span class="o">+</span> <span class="mi">5</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="n">restart</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre>]]>
                </description></item>
            <item>
              <title>Fetching land coordinates from Romania&#39;s Geoportal</title>
              <link>https://notes.alinpanaitiu.com/Fetching-land-coordinates-from-Romania&#39;s-Geoportal</link>
              
              <pubDate>Sat, 02 Sep 2023 00:00:00 +0000</pubDate>
              
              <author>alin@panaitiu.com (Alin Panaitiu)</author>
              <guid>https://notes.alinpanaitiu.com/Fetching-land-coordinates-from-Romania&#39;s-Geoportal</guid><description>
                  <![CDATA[<h1 id="fetching-land-coordinates-from-romanias-geoportal">Fetching land coordinates from Romania's Geoportal</h1>
<p>In my quest for finding a modest house to buy, I've been asking for cadastral reference numbers from sellers, which I can then lookup on a map using the <a href="https://geoportal.ancpi.ro/geoportal/imobile/Harta.html">Imobile eTerra</a> website.</p>
<p>Sometimes I'd like to see the house or land in question on Google Maps, to see if it's close to a train track, if cycling is possible, where schools, churches and stores are in relation to it etc.</p>
<p>Let's take this land for example:
<img src="https://notes.alinpanaitiu.com/attachments/fundatica-land.jpeg" alt="image"></p>
<p><img src="https://notes.alinpanaitiu.com/attachments/fundatica-land-panorama.jpeg" alt="image">
<em>panorama at sunset</em></p>
<style type="text/css">
    .article-container img {
        max-height: 300px;
    }
</style>
<p>It's a nice plot located in Fundățica, Brașov, with cadastral reference number <code>102507</code>. Let's search for it in the Geoportal map website:</p>
<p><img src="https://notes.alinpanaitiu.com/attachments/uat-search.png" alt="image"></p>
<p>Here's what the website requests from the backend, right after we click the search button, and before it zooms in to the land in question:</p>
<p><img src="https://notes.alinpanaitiu.com/attachments/search-query-devtools.jpeg" alt="image"></p>
<p>This seems to be the <a href="https://developers.arcgis.com/rest/services-reference/enterprise/query-map-service-layer-.htm#GUID-BA3B02CD-BD10-4F19-BFB5-72A04D49D005">query service of the ArcGIS server</a>. Let's play with that by translating it into a command-line request.</p>
<h2 id="command-line-requests">Command-line requests</h2>
<p>First, for more readable command-line requests, I'll use <a href="https://github.com/ducaale/xh">xh</a> instead of the usual <code>curl</code>. It can be easily installed on macOS using:</p>
<pre class="chroma"><code><span class="line"><span class="cl">brew install xh
</span></span></code></pre><p>Translating the above request for the command-line will look like this:</p>
<pre class="chroma"><code><span class="line"><span class="cl">xhs geoportal.ancpi.ro/maps/rest/services/eterra3_publish/MapServer/1/query <span class="se">\
</span></span></span><span class="line"><span class="cl">    <span class="nv">f</span><span class="o">==</span>json <span class="nv">where</span><span class="o">==</span><span class="s2">&#34;INSPIRE_ID = &#39;RO.83.40991.102507&#39;&#34;</span>
</span></span></code></pre><p>Which gives us an ArcGIS JSON response containing the polygon that delimits the land.</p>
<pre class="chroma"><code><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;displayFieldName&#34;</span><span class="p">:</span> <span class="s2">&#34;INSPIRE_ID&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;fieldAliases&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;INSPIRE_ID&#34;</span><span class="p">:</span> <span class="s2">&#34;INSPIRE_ID&#34;</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;geometryType&#34;</span><span class="p">:</span> <span class="s2">&#34;esriGeometryPolygon&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;spatialReference&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;wkid&#34;</span><span class="p">:</span> <span class="mi">3844</span><span class="p">,</span> <span class="nt">&#34;latestWkid&#34;</span><span class="p">:</span> <span class="mi">3844</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;fields&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;name&#34;</span><span class="p">:</span> <span class="s2">&#34;INSPIRE_ID&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;type&#34;</span><span class="p">:</span> <span class="s2">&#34;esriFieldTypeString&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;alias&#34;</span><span class="p">:</span> <span class="s2">&#34;INSPIRE_ID&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;length&#34;</span><span class="p">:</span> <span class="mi">30</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="p">],</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;features&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;attributes&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;INSPIRE_ID&#34;</span><span class="p">:</span> <span class="s2">&#34;RO.83.40991.102507&#34;</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;geometry&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="nt">&#34;rings&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">          <span class="p">[</span>
</span></span><span class="line"><span class="cl">            <span class="p">[</span><span class="mf">522620.43299999833</span><span class="p">,</span> <span class="mf">435711.11600000039</span><span class="p">],</span>
</span></span><span class="line"><span class="cl">            <span class="p">[</span><span class="mf">522622.72599999979</span><span class="p">,</span> <span class="mf">435708.64699999988</span><span class="p">],</span>
</span></span><span class="line"><span class="cl">            <span class="p">[</span><span class="mf">522651.29300000146</span><span class="p">,</span> <span class="mf">435693.64299999923</span><span class="p">],</span>
</span></span><span class="line"><span class="cl">            <span class="p">[</span><span class="mf">522671.28500000015</span><span class="p">,</span> <span class="mf">435681.12900000066</span><span class="p">],</span>
</span></span><span class="line"><span class="cl">            <span class="p">[</span><span class="mf">522687.2340000011</span><span class="p">,</span> <span class="mf">435666.57200000063</span><span class="p">],</span>
</span></span><span class="line"><span class="cl">            <span class="p">[</span><span class="mf">522714.81399999931</span><span class="p">,</span> <span class="mf">435641.48099999875</span><span class="p">],</span>
</span></span><span class="line"><span class="cl">            <span class="p">[</span><span class="mf">522703.13399999961</span><span class="p">,</span> <span class="mf">435636.46799999848</span><span class="p">],</span>
</span></span><span class="line"><span class="cl">            <span class="p">[</span><span class="mf">522676.69399999827</span><span class="p">,</span> <span class="mf">435625.78599999845</span><span class="p">],</span>
</span></span><span class="line"><span class="cl">            <span class="p">[</span><span class="mf">522663.50200000033</span><span class="p">,</span> <span class="mf">435615.34299999848</span><span class="p">],</span>
</span></span><span class="line"><span class="cl">            <span class="p">[</span><span class="mf">522649.26700000092</span><span class="p">,</span> <span class="mf">435607.15300000086</span><span class="p">],</span>
</span></span><span class="line"><span class="cl">            <span class="p">[</span><span class="mf">522640.06899999827</span><span class="p">,</span> <span class="mf">435599.19900000095</span><span class="p">],</span>
</span></span><span class="line"><span class="cl">            <span class="p">[</span><span class="mf">522632.24300000072</span><span class="p">,</span> <span class="mf">435583.27199999988</span><span class="p">],</span>
</span></span><span class="line"><span class="cl">            <span class="p">[</span><span class="mf">522626.99100000039</span><span class="p">,</span> <span class="mf">435575.28000000119</span><span class="p">],</span>
</span></span><span class="line"><span class="cl">            <span class="p">[</span><span class="mf">522621.45600000024</span><span class="p">,</span> <span class="mf">435568.55700000003</span><span class="p">],</span>
</span></span><span class="line"><span class="cl">            <span class="p">[</span><span class="mf">522603.33700000122</span><span class="p">,</span> <span class="mf">435557.77800000086</span><span class="p">],</span>
</span></span><span class="line"><span class="cl">            <span class="p">[</span><span class="mf">522598.81300000101</span><span class="p">,</span> <span class="mf">435562.11800000072</span><span class="p">],</span>
</span></span><span class="line"><span class="cl">            <span class="p">[</span><span class="mf">522592.8040000014</span><span class="p">,</span> <span class="mf">435572.04699999839</span><span class="p">],</span>
</span></span><span class="line"><span class="cl">            <span class="p">[</span><span class="mf">522589.21799999848</span><span class="p">,</span> <span class="mf">435577.49799999967</span><span class="p">],</span>
</span></span><span class="line"><span class="cl">            <span class="p">[</span><span class="mf">522585.74799999967</span><span class="p">,</span> <span class="mf">435582.1259999983</span><span class="p">],</span>
</span></span><span class="line"><span class="cl">            <span class="p">[</span><span class="mf">522582.19999999925</span><span class="p">,</span> <span class="mf">435586.90399999917</span><span class="p">],</span>
</span></span><span class="line"><span class="cl">            <span class="p">[</span><span class="mf">522580.39900000021</span><span class="p">,</span> <span class="mf">435589.24399999902</span><span class="p">],</span>
</span></span><span class="line"><span class="cl">            <span class="p">[</span><span class="mf">522578.21999999881</span><span class="p">,</span> <span class="mf">435592.07200000063</span><span class="p">],</span>
</span></span><span class="line"><span class="cl">            <span class="p">[</span><span class="mf">522574.91800000146</span><span class="p">,</span> <span class="mf">435596.9140000008</span><span class="p">],</span>
</span></span><span class="line"><span class="cl">            <span class="p">[</span><span class="mf">522571.15500000119</span><span class="p">,</span> <span class="mf">435604.28599999845</span><span class="p">],</span>
</span></span><span class="line"><span class="cl">            <span class="p">[</span><span class="mf">522568.70899999887</span><span class="p">,</span> <span class="mf">435609.90300000086</span><span class="p">],</span>
</span></span><span class="line"><span class="cl">            <span class="p">[</span><span class="mf">522565.28200000152</span><span class="p">,</span> <span class="mf">435616.85000000149</span><span class="p">],</span>
</span></span><span class="line"><span class="cl">            <span class="p">[</span><span class="mf">522559.05900000036</span><span class="p">,</span> <span class="mf">435627.21400000155</span><span class="p">],</span>
</span></span><span class="line"><span class="cl">            <span class="p">[</span><span class="mf">522555.69799999893</span><span class="p">,</span> <span class="mf">435631.82400000095</span><span class="p">],</span>
</span></span><span class="line"><span class="cl">            <span class="p">[</span><span class="mf">522551.21799999848</span><span class="p">,</span> <span class="mf">435638.20600000024</span><span class="p">],</span>
</span></span><span class="line"><span class="cl">            <span class="p">[</span><span class="mf">522549.47300000116</span><span class="p">,</span> <span class="mf">435640.9990000017</span><span class="p">],</span>
</span></span><span class="line"><span class="cl">            <span class="p">[</span><span class="mf">522548.69000000134</span><span class="p">,</span> <span class="mf">435642.15399999917</span><span class="p">],</span>
</span></span><span class="line"><span class="cl">            <span class="p">[</span><span class="mf">522544.66200000048</span><span class="p">,</span> <span class="mf">435649.3909999989</span><span class="p">],</span>
</span></span><span class="line"><span class="cl">            <span class="p">[</span><span class="mf">522542.36400000006</span><span class="p">,</span> <span class="mf">435654.72300000116</span><span class="p">],</span>
</span></span><span class="line"><span class="cl">            <span class="p">[</span><span class="mf">522540.39400000125</span><span class="p">,</span> <span class="mf">435660.24100000039</span><span class="p">],</span>
</span></span><span class="line"><span class="cl">            <span class="p">[</span><span class="mf">522539.04199999943</span><span class="p">,</span> <span class="mf">435663.48200000077</span><span class="p">],</span>
</span></span><span class="line"><span class="cl">            <span class="p">[</span><span class="mf">522615.60599999875</span><span class="p">,</span> <span class="mf">435715.23999999836</span><span class="p">],</span>
</span></span><span class="line"><span class="cl">            <span class="p">[</span><span class="mf">522620.43299999833</span><span class="p">,</span> <span class="mf">435711.11600000039</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">          <span class="p">]</span>
</span></span><span class="line"><span class="cl">        <span class="p">]</span>
</span></span><span class="line"><span class="cl">      <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre><p>The polygon coordinates are local to Romania, defined in the <a href="https://ro.wikipedia.org/wiki/Proiec%C8%9Bia_Stereografic%C4%83_1970">Stereo70 projection</a>, which is not what is used by Google Maps.</p>
<blockquote>
<p>Stereo70 is a projection specific to the territory of Romania, which helps with defining coordinates without worrying about deformed angles because of the planet curvature.</p>
<p>It has a Well Known ID (WKID) of <code>3844</code>.</p>
</blockquote>
<p>World coordinates are usually defined in the <a href="https://en.wikipedia.org/wiki/World_Geodetic_System">WGS84 projection</a> where:</p>
<ul>
<li>latitude goes from <code>-90°</code> to <code>90°</code></li>
<li>longitude goes from <code>-180°</code> to <code>180°</code></li>
<li>WKID is <code>4326</code></li>
</ul>
<p>To get the same polygon in global coordinates that we can use in Google Maps, it's enough to do the same request with the <code>f==geojson</code> parameter instead of <code>f==json</code>:</p>
<pre class="chroma"><code><span class="line"><span class="cl">xhs geoportal.ancpi.ro/maps/rest/services/eterra3_publish/MapServer/1/query <span class="se">\
</span></span></span><span class="line"><span class="cl">    <span class="nv">f</span><span class="o">==</span>geojson <span class="nv">where</span><span class="o">==</span><span class="s2">&#34;INSPIRE_ID = &#39;RO.83.40991.102507&#39;&#34;</span>
</span></span></code></pre><pre class="chroma"><code><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;type&#34;</span><span class="p">:</span> <span class="s2">&#34;FeatureCollection&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;features&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">        <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="nt">&#34;type&#34;</span><span class="p">:</span> <span class="s2">&#34;Feature&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="nt">&#34;geometry&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">                <span class="nt">&#34;type&#34;</span><span class="p">:</span> <span class="s2">&#34;Polygon&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                <span class="nt">&#34;coordinates&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">                    <span class="p">[</span>
</span></span><span class="line"><span class="cl">                        <span class="p">[</span> <span class="mf">25.287531829092149</span><span class="p">,</span> <span class="mf">45.420803656524832</span> <span class="p">],</span>
</span></span><span class="line"><span class="cl">                        <span class="p">[</span> <span class="mf">25.287470332276598</span><span class="p">,</span> <span class="mf">45.420840926634618</span> <span class="p">],</span>
</span></span><span class="line"><span class="cl">                        <span class="p">[</span> <span class="mf">25.28648952561111</span><span class="p">,</span> <span class="mf">45.420377597283412</span> <span class="p">],</span>
</span></span><span class="line"><span class="cl">                        <span class="p">[</span> <span class="mf">25.286506655001162</span><span class="p">,</span> <span class="mf">45.420348386206918</span> <span class="p">],</span>
</span></span><span class="line"><span class="cl">                        <span class="p">[</span> <span class="mf">25.286531577836683</span><span class="p">,</span> <span class="mf">45.420298663249824</span> <span class="p">],</span>
</span></span><span class="line"><span class="cl">                        <span class="p">[</span> <span class="mf">25.286560700726216</span><span class="p">,</span> <span class="mf">45.420250603640277</span> <span class="p">],</span>
</span></span><span class="line"><span class="cl">                        <span class="p">[</span> <span class="mf">25.286611844506226</span><span class="p">,</span> <span class="mf">45.420185344155705</span> <span class="p">],</span>
</span></span><span class="line"><span class="cl">                        <span class="p">[</span> <span class="mf">25.286621797812103</span><span class="p">,</span> <span class="mf">45.420174924452724</span> <span class="p">],</span>
</span></span><span class="line"><span class="cl">                        <span class="p">[</span> <span class="mf">25.286643969799059</span><span class="p">,</span> <span class="mf">45.420149732494529</span> <span class="p">],</span>
</span></span><span class="line"><span class="cl">                        <span class="p">[</span> <span class="mf">25.286700928711532</span><span class="p">,</span> <span class="mf">45.420092153024541</span> <span class="p">],</span>
</span></span><span class="line"><span class="cl">                        <span class="p">[</span> <span class="mf">25.286743668675413</span><span class="p">,</span> <span class="mf">45.420050556791701</span> <span class="p">],</span>
</span></span><span class="line"><span class="cl">                        <span class="p">[</span> <span class="mf">25.28682271923968</span><span class="p">,</span> <span class="mf">45.419957084876486</span> <span class="p">],</span>
</span></span><span class="line"><span class="cl">                        <span class="p">[</span> <span class="mf">25.286866195449967</span><span class="p">,</span> <span class="mf">45.419894454519003</span> <span class="p">],</span>
</span></span><span class="line"><span class="cl">                        <span class="p">[</span> <span class="mf">25.286897196111994</span><span class="p">,</span> <span class="mf">45.419843825178624</span> <span class="p">],</span>
</span></span><span class="line"><span class="cl">                        <span class="p">[</span> <span class="mf">25.286944946481018</span><span class="p">,</span> <span class="mf">45.419777359147687</span> <span class="p">],</span>
</span></span><span class="line"><span class="cl">                        <span class="p">[</span> <span class="mf">25.28698692143271</span><span class="p">,</span> <span class="mf">45.41973367682953</span> <span class="p">],</span>
</span></span><span class="line"><span class="cl">                        <span class="p">[</span> <span class="mf">25.287014637618551</span><span class="p">,</span> <span class="mf">45.419708155816224</span> <span class="p">],</span>
</span></span><span class="line"><span class="cl">                        <span class="p">[</span> <span class="mf">25.287037545627463</span><span class="p">,</span> <span class="mf">45.419687038771002</span> <span class="p">],</span>
</span></span><span class="line"><span class="cl">                        <span class="p">[</span> <span class="mf">25.287082667032362</span><span class="p">,</span> <span class="mf">45.419643924452878</span> <span class="p">],</span>
</span></span><span class="line"><span class="cl">                        <span class="p">[</span> <span class="mf">25.287126798471856</span><span class="p">,</span> <span class="mf">45.419602162566434</span> <span class="p">],</span>
</span></span><span class="line"><span class="cl">                        <span class="p">[</span> <span class="mf">25.287172374503797</span><span class="p">,</span> <span class="mf">45.419552990288409</span> <span class="p">],</span>
</span></span><span class="line"><span class="cl">                        <span class="p">[</span> <span class="mf">25.287248708881314</span><span class="p">,</span> <span class="mf">45.419463439785865</span> <span class="p">],</span>
</span></span><span class="line"><span class="cl">                        <span class="p">[</span> <span class="mf">25.287306322385597</span><span class="p">,</span> <span class="mf">45.419424235673638</span> <span class="p">],</span>
</span></span><span class="line"><span class="cl">                        <span class="p">[</span> <span class="mf">25.287538360704634</span><span class="p">,</span> <span class="mf">45.419520656555903</span> <span class="p">],</span>
</span></span><span class="line"><span class="cl">                        <span class="p">[</span> <span class="mf">25.287609401468654</span><span class="p">,</span> <span class="mf">45.419580981651023</span> <span class="p">],</span>
</span></span><span class="line"><span class="cl">                        <span class="p">[</span> <span class="mf">25.287676884155996</span><span class="p">,</span> <span class="mf">45.419652736280781</span> <span class="p">],</span>
</span></span><span class="line"><span class="cl">                        <span class="p">[</span> <span class="mf">25.287777624892041</span><span class="p">,</span> <span class="mf">45.419795819060639</span> <span class="p">],</span>
</span></span><span class="line"><span class="cl">                        <span class="p">[</span> <span class="mf">25.287895532854858</span><span class="p">,</span> <span class="mf">45.419867103943105</span> <span class="p">],</span>
</span></span><span class="line"><span class="cl">                        <span class="p">[</span> <span class="mf">25.288077820654202</span><span class="p">,</span> <span class="mf">45.419940349537939</span> <span class="p">],</span>
</span></span><span class="line"><span class="cl">                        <span class="p">[</span> <span class="mf">25.288246883870791</span><span class="p">,</span> <span class="mf">45.420033904614328</span> <span class="p">],</span>
</span></span><span class="line"><span class="cl">                        <span class="p">[</span> <span class="mf">25.288585257686936</span><span class="p">,</span> <span class="mf">45.420129180785878</span> <span class="p">],</span>
</span></span><span class="line"><span class="cl">                        <span class="p">[</span> <span class="mf">25.288734749902495</span><span class="p">,</span> <span class="mf">45.420173916614992</span> <span class="p">],</span>
</span></span><span class="line"><span class="cl">                        <span class="p">[</span> <span class="mf">25.288383453615673</span><span class="p">,</span> <span class="mf">45.420400618735343</span> <span class="p">],</span>
</span></span><span class="line"><span class="cl">                        <span class="p">[</span> <span class="mf">25.288180306407561</span><span class="p">,</span> <span class="mf">45.420532141910058</span> <span class="p">],</span>
</span></span><span class="line"><span class="cl">                        <span class="p">[</span> <span class="mf">25.287925397519455</span><span class="p">,</span> <span class="mf">45.420645409479448</span> <span class="p">],</span>
</span></span><span class="line"><span class="cl">                        <span class="p">[</span> <span class="mf">25.287561018878264</span><span class="p">,</span> <span class="mf">45.420781362534932</span> <span class="p">],</span>
</span></span><span class="line"><span class="cl">                        <span class="p">[</span> <span class="mf">25.287531829092149</span><span class="p">,</span> <span class="mf">45.420803656524832</span> <span class="p">]</span>
</span></span><span class="line"><span class="cl">                    <span class="p">]</span>
</span></span><span class="line"><span class="cl">                <span class="p">]</span>
</span></span><span class="line"><span class="cl">            <span class="p">},</span>
</span></span><span class="line"><span class="cl">            <span class="nt">&#34;properties&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">                <span class="nt">&#34;INSPIRE_ID&#34;</span><span class="p">:</span> <span class="s2">&#34;RO.83.40991.102507&#34;</span>
</span></span><span class="line"><span class="cl">            <span class="p">}</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre><p><a href="https://geojson.org/">GeoJSON</a> is a standard format for defining geographic features, well supported by most databases and mapping libraries. Such a JSON can be pasted into <a href="https://geojson.io">GeoJSON.io</a> for previewing it on a map, or inserted into PostGIS/ArcGIS for further processing.</p>
<p>We don't need any of that though, we just need one point from the coordinates of the polygon, which we can extract using <a href="https://stedolan.github.io/jq/">jq</a>. We'll delimit the numbers with a comma and output a Google Maps link for easy access:</p>
<pre class="chroma"><code><span class="line"><span class="cl">xhs geoportal.ancpi.ro/maps/rest/services/eterra3_publish/MapServer/1/query <span class="se">\
</span></span></span><span class="line"><span class="cl">    <span class="nv">f</span><span class="o">==</span>geojson <span class="nv">where</span><span class="o">==</span><span class="s2">&#34;INSPIRE_ID = &#39;RO.83.40991.102507&#39;&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">    <span class="p">|</span> jq -r <span class="s1">&#39;.features[0].geometry.coordinates[0][0] | &#34;\(.[1]),\(.[0])&#34;&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">    <span class="p">|</span> xargs -I% open <span class="s2">&#34;https://www.google.com/maps/search/?api=1&amp;query=%&#34;</span>
</span></span></code></pre><p>Et voila, we're presented with a satellite view of Google Maps at the location of the land. The pin is placed on a corner of the land, but that's ok, enough for us to figure out where the land is and how the zone looks.</p>
<p><img src="https://notes.alinpanaitiu.com/attachments/maps-view-land.jpeg" alt="image"></p>
]]>
                </description></item>
            <item>
              <title>Integrating dynamic binaries in macOS apps</title>
              <link>https://notes.alinpanaitiu.com/Integrating-dynamic-binaries-in-macOS-apps</link>
              
              <pubDate>Fri, 20 Oct 2023 00:00:00 +0000</pubDate>
              
              <author>alin@panaitiu.com (Alin Panaitiu)</author>
              <guid>https://notes.alinpanaitiu.com/Integrating-dynamic-binaries-in-macOS-apps</guid><description>
                  <![CDATA[<h1 id="integrating-dynamic-binaries-in-macos-apps">Integrating dynamic binaries in macOS apps</h1>
<p>It's morning, 8:01am. A bit cold, as the fire has long stopped burning in the fireplace and the stone has lost its heat. Cora, the dog, is sleeping alone on the floor. Belly up as well, she seems to enjoy the cold.</p>
<p>My wife is still snoozing so I have to carefully choose my activities if I want to avoid her angry <em>“why aren’t you sleeping”</em> stare. I choose the only silent activity, I grab my phone and stare at the lockscreen.</p>
<p>Ten (10) notifications (in order of appearance):</p>
<ul>
<li>✉️ email: <em>Question about Lunar.</em> can’t wait to hear it.</li>
<li>✉️ email: <em>all settings disappear for all monitors when I toggle black out for the internal screen!</em>  damn.. my eyes are half open by now</li>
<li>✉️ email: <em>6.3.0b2 app exits when displays are asleep and doesn't restart</em> oh man, what did I break now?</li>
<li>💬 discord: <em>anyone else experiencing very weird graphical glitches?</em> “anyone else”? I’m the only one responding there, but they probably noticed that by now</li>
<li>✉️ email: <em>student discount?</em> I guess, why not?</li>
<li>✉️ email: <em>Vulnerability Detected, Remediation Required… consider offering a monetary reward as a gesture of gratitude, once the vulnerability is thoroughly validated and addressed</em> what the heck?! Is this a scam? <a href="https://www.troyhunt.com/a-scammer-tried-to-scare-me-into-buying-their-security-services-heres-how-it-went-down/">Yes it is</a>.</li>
<li>✉️ email: <em>Lunar keeps reverting to trial.</em> probably PiHole blocking paddle.com again.. this should be an FAQ</li>
<li>✉️ email: <em>Clop 2.2.0 optimizing endlessly</em> oh. that’s not good.</li>
<li>✉️ email: <em>Clop issue - app prevented by OS from functioning?</em>  by OS? hey, at least they’re not blaming me</li>
<li>💬 discord: <em>v2.2.0 not downscaling images on Intel</em> that’s.. odd</li>
</ul>
<p>There goes my quiet morning. Who needs caffeine after reading such loving and uplifting messages. I grab my laptop and sneak out as quietly as I can into the kitchen.</p>
<h2 id="what-is-clop">What is “Clop”?</h2>
<p>You might have noticed some of those messages talk about a thing called <em>Clop</em>, which, from the messages, you can infer it’s something that makes people unhappy and irritable. Not far from the truth, but let me put it better:</p>
<p><strong><a href="https://lowtechguys.com/clop">👒Clop</a></strong> is an app born out of my frustration of having to send screenshots on a coffee shop internet connection, and constantly failing to do so because the upload kept stalling at ~85%.</p>
<p>Building such an app presented some unique challenges in <em>borrowing code written by smarter people than me</em> and especially on <em>not reinventing the wheel</em>. I thought some of you might find this useful.</p>
<blockquote>
<p>I actually built the first functional version of Clop right there in that coffee shop.
Being able to build an app with a minimal UI that can auto-optimize images directly in the clipboard in a single day is what still keeps me doing macOS development for a living.</p>
</blockquote>
<p>So what’s it all about?</p>
<h2 id="sending-large-images-and-videos">Sending large images and videos</h2>
<p>While replying to these emails and chats, I tend to do a lot of:</p>
<ol>
<li>Screenshot to clipboard</li>
<li>Add some arrows and text</li>
<li>Paste in email</li>
</ol>
<p>But good luck pasting an 11MB HiDPI screenshot in chats nowadays. You’ll very soon hit some size limit, be it the <strong>5MB Discord</strong> upload limit, or the <strong>25MB email</strong> limit <em>(or the 5GB Gmail free storage limit if you do that long enough)</em>.</p>
<p>Some explanations need more than an image, so I also do screen recordings, or even iPhone videos if I need to demonstrate some multi monitor functionality. The situation is even more dire there.</p>
<p><strong>10 seconds of 4K video can take more than 100MB</strong> so there’s no chance of sending that without uploading it somewhere first. Not to mention the format which is often <code>.mov</code> and encoded as HEVC instead of the more compatible h.264-encoded <code>.mp4</code></p>
<p>I eventually got tired of running <a href="https://imageoptim.com/mac">ImageOptim</a> and <a href="https://www.ffmpeg.org/">ffmpeg</a> manually and uploading files to temporary public storage. I wanted something that would compress the images right in my clipboard, re-encode videos as soon as they’re finished and have them ready for sharing instantly.</p>
<p>But how would I do that in a macOS app? I’m not going to start implementing image and video encoders when there are perfectly fine command line solutions that do all the hard work already.</p>
<h2 id="static-and-dynamic-binaries">Static and dynamic binaries</h2>
<p>An important distinction we need to make is between static or fully self-contained binaries, and dynamic binaries which are linked to external file dependencies.</p>
<p>If I had static binaries for all the tools I needed, this would have been easy peasy, and I would have nothing to write about. I could, for example, just embed the <code>ffmpeg</code> binary into my app bundle, then execute it as a CLI from Swift.</p>
<p>But dynamic binaries will scream about <em>missing this lib!</em> and <em>can’t find that lib?!</em> when you move them out of their usual spot. And I needed quite a few of them:</p>
<ul>
<li><a href="https://github.com/kornelski/pngquant">pngquant</a> for optimizing PNG files</li>
<li><a href="https://github.com/tjko/jpegoptim">jpegoptim</a> for compressing JPEGs</li>
<li><a href="https://github.com/kohler/gifsicle">gifsicle</a> for optimizing GIFs</li>
<li><a href="https://www.ffmpeg.org/">ffmpeg</a> for video resizing and re-encoding</li>
<li><a href="https://www.libvips.org/API/current/Using-vipsthumbnail.html">vipsthumbnail</a> for resizing images</li>
<li><a href="https://github.com/ImageOptim/gifski">gifski</a> for converting videos to GIFs</li>
<li><a href="https://www.ghostscript.com/">ghostscript</a> for optimising PDFs</li>
</ul>
<p>Of all of the above, I only found ffmpeg as a static binary for both Intel and Apple Silicon on <a href="https://osxexperts.net/%20">osxexperts.net</a>. Which is great because it would have required the most effort to get working.</p>
<p>For all the rest, I had to choose between downloading their source code and all of their dependencies and finding a way to compile them statically, or…</p>
<h2 id="isolating-a-dynamic-binary">Isolating a dynamic binary</h2>
<p>What if I could just copy the binary and all the libraries it depends on into a folder and run it. Would it work?</p>
<p>Well, first, how do I find the dependencies of a binary? Since macOS uses <a href="https://blog.efiens.com/post/luibo/osx/macho/">Mach-O</a> binaries, the <a href="https://llvm.org/docs/CommandGuide/llvm-otool.html">otool</a> command seems to be what I need.</p>
<pre class="chroma"><code><span class="line"><span class="cl">&gt; otool -L <span class="k">$(</span>which pngquant<span class="k">)</span>
</span></span><span class="line"><span class="cl">/opt/homebrew/bin/pngquant:
</span></span><span class="line"><span class="cl">	/usr/lib/libz.1.dylib <span class="o">(</span>compatibility version 1.0.0, current version 1.2.11<span class="o">)</span>
</span></span><span class="line"><span class="cl">	/usr/lib/libSystem.B.dylib <span class="o">(</span>compatibility version 1.0.0, current version 1319.0.0<span class="o">)</span>
</span></span><span class="line"><span class="cl">	/opt/homebrew/opt/libpng/lib/libpng16.16.dylib <span class="o">(</span>compatibility version 56.0.0, current version 56.0.0<span class="o">)</span>
</span></span><span class="line"><span class="cl">	/opt/homebrew/opt/little-cms2/lib/liblcms2.2.dylib <span class="o">(</span>compatibility version 3.0.0, current version 3.14.0<span class="o">)</span>
</span></span></code></pre><p>We can ignore the <code>/usr/lib</code> ones since they are always provided by the system. Let's move the rest into a folder and try to run it.</p>
<pre class="chroma"><code><span class="line"><span class="cl">&gt; mkdir -p /tmp/pngquant-dynamic
</span></span><span class="line"><span class="cl">&gt; mv <span class="k">$(</span>which pngquant<span class="k">)</span> /opt/homebrew/opt/<span class="o">{</span>libpng,little-cms2<span class="o">}</span>/lib/*.dylib /tmp/pngquant-dynamic
</span></span><span class="line"><span class="cl">&gt; /tmp/pngquant-dynamic/pngquant
</span></span><span class="line"><span class="cl">dyld<span class="o">[</span>90573<span class="o">]</span>: Library not loaded: /opt/homebrew/opt/little-cms2/lib/liblcms2.2.dylib
</span></span><span class="line"><span class="cl">  Referenced from: &lt;E2E50465-97AB-3A6D-BC28-8AC0024B342C&gt; /private/tmp/pngquant-dynamic/pngquant
</span></span></code></pre><p>Of course, you did not expect that to work from the first try, did you? The binary is still looking for the libraries in their original location, because it has those paths hardcoded inside it. That is how <code>otool</code> was able to extract them in the first place.</p>
<p>Fortunately we can change those paths with the <a href="https://www.unix.com/man-page/osx/1/install_name_tool/">install_name_tool</a> command. Let's alter the binary so that it looks for the libraries in the same folder where it resides (aka <code>@executable_path</code>).</p>
<pre class="chroma"><code><span class="line"><span class="cl">&gt; install_name_tool -change /opt/homebrew/opt/libpng/lib/libpng16.16.dylib @executable_path/libpng16.16.dylib /tmp/pngquant-dynamic/pngquant
</span></span><span class="line"><span class="cl">install_name_tool: warning: changes being made to the file will invalidate the code signature in: /tmp/pngquant-dynamic/pngquant
</span></span><span class="line"><span class="cl">&gt; install_name_tool -change /opt/homebrew/opt/little-cms2/lib/liblcms2.2.dylib @executable_path/liblcms2.2.dylib /tmp/pngquant-dynamic/pngquant
</span></span><span class="line"><span class="cl">install_name_tool: warning: changes being made to the file will invalidate the code signature in: /tmp/pngquant-dynamic/pngquant
</span></span><span class="line"><span class="cl">&gt; otool -L /tmp/pngquant-dynamic/pngquant
</span></span><span class="line"><span class="cl">/tmp/pngquant-dynamic/pngquant:
</span></span><span class="line"><span class="cl">	/usr/lib/libz.1.dylib <span class="o">(</span>compatibility version 1.0.0, current version 1.2.11<span class="o">)</span>
</span></span><span class="line"><span class="cl">	/usr/lib/libSystem.B.dylib <span class="o">(</span>compatibility version 1.0.0, current version 1319.0.0<span class="o">)</span>
</span></span><span class="line"><span class="cl">	@executable_path/libpng16.16.dylib <span class="o">(</span>compatibility version 56.0.0, current version 56.0.0<span class="o">)</span>
</span></span><span class="line"><span class="cl">	@executable_path/liblcms2.2.dylib <span class="o">(</span>compatibility version 3.0.0, current version 3.14.0<span class="o">)</span>
</span></span><span class="line"><span class="cl">&gt; /tmp/pngquant-dynamic/pngquant
</span></span><span class="line"><span class="cl">pngquant, 2.18.0 <span class="o">(</span>January 2023<span class="o">)</span>, by Kornel Lesinski, Greg Roelofs.
</span></span><span class="line"><span class="cl">...
</span></span></code></pre><p>Not bad, the binary runs, and it does the right thing. I can now copy the binary and the libraries into my app bundle and run it from there. But there's one catch I did not pay enough attention to.</p>
<h2 id="codesigning">Codesigning</h2>
<p>Remember all those people talking about Clop v2.2.0 not working correctly anymore? Well, it turns out that the app was crashing on launch because I signed the binary but I forgot to sign the libraries as well.</p>
<pre class="chroma"><code><span class="line"><span class="cl"><span class="n">kernel</span> <span class="n">CODE</span> <span class="n">SIGNING</span><span class="p">:</span> <span class="n">process</span> <span class="mi">23462</span><span class="p">[</span><span class="n">pngquant</span><span class="p">]:</span> <span class="n">rejecting</span> <span class="n">invalid</span> <span class="n">page</span> <span class="n">at</span> <span class="n">address</span> <span class="mh">0x1029f8000</span> <span class="n">from</span> <span class="n">offset</span> <span class="mh">0x0</span> <span class="k">in</span> <span class="n">file</span> <span class="s">&#34;/Users/****/Library/Application Scripts/com.lowtechguys.Clop/com.lowtechguys.Clop/bin-arm64/liblcms2.2.dylib&#34;</span> <span class="p">(</span><span class="n">cs_mtime</span><span class="p">:</span><span class="mf">1695487745.0</span> <span class="p">==</span> <span class="n">mtime</span><span class="p">:</span><span class="mf">1695487745.0</span><span class="p">)</span> <span class="p">(</span><span class="n">signed</span><span class="p">:</span><span class="mi">1</span> <span class="n">validated</span><span class="p">:</span><span class="mi">1</span> <span class="n">tainted</span><span class="p">:</span><span class="mi">1</span> <span class="n">nx</span><span class="p">:</span><span class="mi">0</span> <span class="n">wpmapped</span><span class="p">:</span><span class="mi">0</span> <span class="n">dirty</span><span class="p">:</span><span class="mi">0</span> <span class="n">depth</span><span class="p">:</span><span class="mi">0</span><span class="p">)</span>
</span></span></code></pre><p>A macOS binary signed with the hardened runtime will refuse to load unsigned libraries. I had this security mechanism disabled on my machine for reverse engineering purposes, so I didn't catch this error in my tests.</p>
<p>Glad there are users eager to alert me on these issues. You know what's worse than someone telling you about a bug in your app? You finding out about a bug and thinking <em>&quot;How long was this there? Two years?? Does nobody use this thing?&quot;</em></p>
<h3 id="hardened-runtime">Hardened runtime</h3>
<p>So about this <em>hardened runtime</em> thing, it actually blocks a lot more stuff than just loading unsigned libraries.</p>
<p>It prevents code from accessing resources like the camera and microphone, disables access to location, and does quite a few more <em>security things</em> I pretend to understand in casual conversations.</p>
<p><img src="https://notes.alinpanaitiu.com/attachments/clop_2023-10-20_576.png" alt="image"></p>
<p>One of those <em>security things</em> came to bite me one more time. There was this guy on Discord telling me:</p>
<blockquote>
<p>v2.2.0 is not downscaling images on Intel</p>
</blockquote>
<p>I was using <code>vipsthumbnail</code> and I had separate binaries for Apple Silicon and Intel, both extracted from a simple <code>brew install</code>. I did test the Intel version using Rosetta and everything seemed to work. But <em>it works on my machine</em> doesn't fly these days.</p>
<p><img src="https://notes.alinpanaitiu.com/attachments/clop_2023-10-20_580.png" alt="image"></p>
<p>After about 3 failed updates, I got this user to run the exact command that was failing in his Terminal (not easy with a less technical person). This is the output that they sent me:</p>
<p><img src="https://notes.alinpanaitiu.com/attachments/clop_2023-10-20_577.png" alt="image"></p>
<p>That's a pretty self explanatory error. It even contains the solution.</p>
<p>So it looks like Just-in-Time compiling code is forbidden by default in a hardened runtime, and <code>libvips</code> was running the <a href="https://github.com/GStreamer/orc">ORC</a> JIT on Intel CPUs (but not on Intel emulators like Rosetta, most likely because JIT is slower in an emulator).</p>
<p>To enable JIT, I needed to change the way I codesign the binaries. First I needed an entitlements file, which is just a Property List file (which is just an XML with a specific schema):</p>
<p><em>bin.entitlements</em></p>
<pre class="chroma"><code><span class="line"><span class="cl"><span class="cp">&lt;?xml version=&#34;1.0&#34; encoding=&#34;UTF-8&#34;?&gt;</span>
</span></span><span class="line"><span class="cl"><span class="cp">&lt;!DOCTYPE plist PUBLIC &#34;-//Apple//DTD PLIST 1.0//EN&#34; &#34;http://www.apple.com/DTDs/PropertyList-1.0.dtd&#34;&gt;</span>
</span></span><span class="line"><span class="cl"><span class="nt">&lt;plist</span> <span class="na">version=</span><span class="s">&#34;1.0&#34;</span><span class="nt">&gt;</span>
</span></span><span class="line"><span class="cl"><span class="nt">&lt;dict&gt;</span>
</span></span><span class="line"><span class="cl">	<span class="nt">&lt;key&gt;</span>com.apple.security.cs.allow-jit<span class="nt">&lt;/key&gt;</span>
</span></span><span class="line"><span class="cl">	<span class="nt">&lt;true/&gt;</span>
</span></span><span class="line"><span class="cl">	<span class="nt">&lt;key&gt;</span>com.apple.security.cs.allow-unsigned-executable-memory<span class="nt">&lt;/key&gt;</span>
</span></span><span class="line"><span class="cl">	<span class="nt">&lt;true/&gt;</span>
</span></span><span class="line"><span class="cl"><span class="nt">&lt;/dict&gt;</span>
</span></span><span class="line"><span class="cl"><span class="nt">&lt;/plist&gt;</span>
</span></span></code></pre><p>Then I needed to codesign the binaries and libs by embedding those entitlements into the signature:</p>
<pre class="chroma"><code><span class="line"><span class="cl">codesign -fs <span class="s2">&#34;Developer ID Application: Alin Panaitiu (RDDXV84A73)&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">    --options runtime <span class="se">\
</span></span></span><span class="line"><span class="cl">    --entitlements bin.entitlements <span class="se">\
</span></span></span><span class="line"><span class="cl">    --timestamp vipsthumbnail
</span></span></code></pre><p>And of course I had to do this for every other binary just to be sure, recreate the binary archive, create a binary update scheme using checksums <em>because I’m not going to stall the app 3 seconds on every launch to decompress the binaries</em>, rebuild the app, generate an update, upload the files, deal with people that just got an update yesterday and don’t need another 90MB download and an interruption today…</p>
<p>But at least, this <em>seemed</em> to fix everything.</p>
<h2 id="end-result">End result</h2>
<p>What had to be done was done.</p>
<p>The emails and chats were replied to and solved, people were pleased, or at least less angry and the dynamic binary issues were finally dealt with.</p>
<p>The morning passed by fast with me as not even a spectator. I remember blinking a few times, finishing two espressos, hearing some laughter and vague questions, hearing myself answer them with a robot voice. I remember seeing Cora and my wife run through the house… but thinking about it now, maybe they were just walking and it was my time stretched memory creating that impression.</p>
<p>I woke up from the coding-induced trance at 8PM. I was not in the kitchen anymore, when did that happen? And I thought it was just the morning that passed by fast.</p>
<p>To be honest, it felt like a lost day. Many days feel like that nowadays. I'd rather have walked or run around the house with my family than sit in the same spot for 12 hours.</p>
<p>To make myself feel that it wasn't all lost time, I’ll leave here a <code>fish</code> script that can kind of isolate a whole binary with its dependencies, because there’s a bit more to do than what I have shown above. <em>Dylibs can have other dylib dependencies of their own, so recursivity needs to be used.</em></p>
<h3 id="the-script">The script</h3>
<p>Check out this gist: <a href="https://gist.github.com/alin23/4e1bf49e8fa01db94235dedbceb101d3">collect-bin-with-deps.fish</a></p>
<pre class="chroma"><code><span class="line"><span class="cl"><span class="c1"># Example usage for extracting `vipsthumbnail` and signing it and its dylibs</span>
</span></span><span class="line"><span class="cl">collect-bin-with-deps --sign <span class="s2">&#34;Developer ID Application: Your Name (TEAMID)&#34;</span> vipsthumbnail
</span></span></code></pre><h2 id="some-more-notes">Some more notes</h2>
<h3 id="non-binary-dependencies">Non-binary dependencies</h3>
<p>Some CLI tools have other kinds of dependencies like fonts, external scripts and other resources.</p>
<p>For example <code>exiftool</code> has a <code>lib</code> folder packed with perl modules that it needs for specific parsers. I copied that whole folder near the binary and changed line <code>33</code> to let it know it should look in the executable path for it.</p>
<p><img src="https://notes.alinpanaitiu.com/attachments/clop_2023-10-21_603.png" alt="image"></p>
<p>Another example is <code>gs</code> (GhostScript) which has fonts, color profiles, PostScript scripts etc. in a <code>share</code> folder.</p>
<p>I copied that into the executable path as well, and used the <code>GS_LIB</code> environment variable to let it know where to look for the files:</p>
<pre class="chroma"><code><span class="line"><span class="cl">&gt; <span class="nb">export</span> <span class="nv">GS_DIR</span><span class="o">=</span><span class="s2">&#34;/path/to/folder-containing-gs/&#34;</span>
</span></span><span class="line"><span class="cl">&gt; cp -R <span class="s2">&#34;</span><span class="k">$(</span>brew --prefix ghostscript<span class="k">)</span><span class="s2">/share&#34;</span> <span class="s2">&#34;</span><span class="nv">$GS_DIR</span><span class="s2">/&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">&gt; <span class="nb">export</span> <span class="nv">GS_LIB</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$GS_DIR</span><span class="s2">/share/ghostscript/10.02.0/Resource/Init&#34;</span>
</span></span><span class="line"><span class="cl">&gt; <span class="nv">$GS_DIR</span>/gs
</span></span><span class="line"><span class="cl">GPL Ghostscript 10.02.0 <span class="o">(</span>2023-09-13<span class="o">)</span>
</span></span><span class="line"><span class="cl">Copyright <span class="o">(</span>C<span class="o">)</span> <span class="m">2023</span> Artifex Software, Inc.  All rights reserved.
</span></span><span class="line"><span class="cl">This software is supplied under the GNU AGPLv3 and comes with NO WARRANTY:
</span></span><span class="line"><span class="cl">see the file COPYING <span class="k">for</span> details.
</span></span></code></pre><h3 id="compressing-binaries">Compressing binaries</h3>
<p>A macOS app bundle size can balloon pretty fast when including binaries and dylibs in this manner. To keep storage size and download bandwidth lower, I ended up archiving binaries with <code>xz</code> as it has the best compression ratio and is available out of the box on a macOS system.</p>
<p>It takes a while to compress, and spikes the CPU usage for a few seconds to decompress the binaries on the very first app start, but it's worth the trouble.</p>
<p><img src="https://notes.alinpanaitiu.com/attachments/clop_2023-10-21_604.png" alt="image"></p>
<p><em>lrzip is a bit better on compression, but it is not available out of the box.</em></p>
]]>
                </description></item>
            <item>
              <title>Costs of running a macOS app studio business</title>
              <link>https://notes.alinpanaitiu.com/Costs-of-running-a-macOS-app-studio-business</link>
              
              <pubDate>Sun, 24 Dec 2023 00:00:00 +0000</pubDate>
              
              <author>alin@panaitiu.com (Alin Panaitiu)</author>
              <guid>https://notes.alinpanaitiu.com/Costs-of-running-a-macOS-app-studio-business</guid><description>
                  <![CDATA[<h1 id="costs-of-running-a-macos-app-studio-business">Costs of running a macOS app studio business</h1>
<p>I created <a href="https://lowtechguys.com/">The low-tech guys</a>, a brand under which I develop macOS apps for a living.</p>
<p>I started this business with a good friend of mine, thinking I could help him stop working long hours at his consulting job and ease into more creative work.</p>
<p>However, getting to a state where you earn enough to live off the app sales takes time, and he did not have that time. So it's just one &quot;low-tech guy&quot; doing all this, and that is me.</p>
<p>So what are the largest costs of running such a business?</p>
<p>Well to be honest, that is <strong>time</strong>. Time I could have spent with my wife, my brother, my dog, instead of answering support emails from my first waking moment to right before sleeping. Time I could have walked outside, do some handy work in natural light, instead of sit on my butt and wreck my spine 6-12h a day.</p>
<p>But time is hard to quantify, so let's talk about money instead.</p>
<h2 id="app-store">App Store</h2>
<p>I sell 3 apps on the App Store:</p>
<ul>
<li><a href="https://lowtechguys.com/rcmd">rcmd - App switcher using the ⌘ Right Command key</a></li>
<li><a href="https://lowtechguys.com/grila/">Grila - Keyboard-centric calendar</a></li>
<li><a href="https://lowtechguys.com/gammadimmer/">Gamma Dimmer - Basic screen brightness dimmer</a></li>
</ul>
<p>rcmd makes the bulk of the sales, but mostly because I didn't have time to further develop and act on feedback for the other two.</p>
<p>Here are the numbers for 2023:</p>
<ul>
<li>Sales (how much people paid in total): <code>$14.9k</code></li>
<li>Proceeds (how much I get from the sales): <code>$11.5k</code></li>
<li>Costs: <code>$3.4k</code>
<ul>
<li>App Store commission fee (small business 15%): <code>$2.3k</code></li>
<li>VAT, Sales Tax, foreign currency exchange fees: <code>$1.1k</code></li>
</ul>
</li>
</ul>
<p><img src="https://notes.alinpanaitiu.com/attachments/app-store-revenue-2023.png" alt="image"></p>
<h2 id="paddle">Paddle</h2>
<p>I publish apps independently as well because the App Store has some strict restrictions regarding what you can do in an app <em>(no private APIs allowed, no IOKit for talking to hardware directly etc.)</em>.</p>
<p>So the apps where I can use code to tickle my curiosity and experiment more are not on the App Store.</p>
<p>I use <a href="https://paddle.com">paddle.com</a> for selling these apps because they handle all tax work for me (calculate VAT and sales tax, send it to the relevant authority, handle reverse charge and chargebacks etc.). They also provide a useful Mac SDK for licensing and checkout.</p>
<p>For most year, I only sold <a href="https://lunar.fyi/">Lunar</a> there, an app for adaptive monitor brightness. Just recently I also started selling <a href="https://lowtechguys.com/clop">Clop</a> (an image, video and PDF optimizer), but most numbers will be relative to Lunar's price.</p>
<p>Here are the numbers for 2023:</p>
<ul>
<li>Sales (how much people paid in total): <code>$93.5k</code></li>
<li>Proceeds (how much I get from the sales): <code>$73.3k</code></li>
<li>Costs: <code>$20.2k</code>
<ul>
<li>Paddle commission fee (5%): <code>$4.7k</code></li>
<li>VAT, Sales Tax, foreign currency exchange fees: <code>$15.5k</code></li>
</ul>
</li>
</ul>
<p><img src="https://notes.alinpanaitiu.com/attachments/paddle-revenue-2023.png" alt="image"></p>
<p>The disadvantage of using a Merchant of Record like Paddle is that I don't get to reclaim the VAT at the end of the year. That's because it's some kind of B2B relation: I sell them my app in gross with 0% VAT, and then they resell it and collect taxes themselves.</p>
<p>Oh well, the price of convenience and peace of mind...</p>
<h2 id="romanian-corporate-income-tax">Romanian corporate income tax</h2>
<p>So in 2023, people paid a total of <code>$108.4k</code> <em>(wow.. 108400 US dollars.. I’m amazed to see that number myself, thank you good people for helping me keep doing this)</em> and after paying fees to a bunch of states and platforms, I’m left with <code>$84.8k</code>.</p>
<p>Before taxes. Yep, it doesn’t stop here, you didn't think I'd get to spend eighty-five thousand dollars, did you?</p>
<p><strong>THE LOW-TECH GUYS S.R.L.</strong> is a business incorporated in Romania so it has to pay taxes on the income. Calculating that final tax needs applying such a chaotic and stupidly complex formula that I think I’ll spare you the details.</p>
<blockquote>
<p>If you really want to know, it’s something like: I have to pay myself a minimum salary of about $1k/month on which I pay half as taxes, then I pay 3% from the remaining sum as income tax, then I pay 8% as dividend tax, then I pay 10% of 24 minimum monthly salaries for mandatory health insurance, then I have to pay a mandatory accountant and a mandatory billing software, and a mandatory business bank account... I most surely forgot something and I'll also have to pay a mandatory fine next year</p>
</blockquote>
<p>In short, another <code>$13k</code> flies out the window to Romanian corrupt politicians to line up their greasy pockets. A small amount of those might end up in healthcare and education, so that corrupt hospital managers and inspectors can get their part as well.</p>
<p>I'm left with <code>$71.8k</code>. Before expenses.</p>
<p>Thankfully, a macOS app business doesn't have many of those. Here's what I currently have to pay:</p>
<ul>
<li>A bare metal Hetzner server: <code>$600</code>
<ul>
<li>This is where I host all my DMGs, websites, APIs, analytics, experiments etc.</li>
</ul>
</li>
<li>An IPStack subscription as a fallback for Lunar's <a href="https://lunar.fyi/#location">Location Mode</a>: <code>$140</code></li>
<li>Apple Developer Membership: <code>$100</code></li>
</ul>
<p>Depending on the app features, I might also need to buy the latest MacBook or iPhone to develop and test hardware-related features <em>(like <a href="https://alinpanaitiu.com/blog/journey-to-ddc-on-m1-macs/">DDC support for M1</a> or <a href="https://alinpanaitiu.com/blog/over-500nits-failed/">XDR Brightness for MacBook HDR screens</a>)</em>, or for example I needed to buy a bunch of varying light sensors and ESP32/ESP8266 boards to develop adaptive monitor brightness using <a href="https://lunar.fyi/sensor">wireless light sensors</a>.</p>
<p>These are not recurring so I can't count them in, just thought I'd note them here for whoever's interested.</p>
<p>I'm also a Romanian, living in Romania, earning USD but spending RON so I'll also lose a significant amount on foreign currency exchange. I calculated that to be at least <code>$1k</code></p>
<h2 id="end-result">End result</h2>
<pre class="chroma"><code><span class="line"><span class="cl">    <span class="nv">$108</span> <span class="m">400</span>
</span></span><span class="line"><span class="cl">   -  <span class="nv">$3</span> <span class="m">400</span>  <span class="o">(</span>App Store<span class="o">)</span>
</span></span><span class="line"><span class="cl">   - <span class="nv">$20</span> <span class="m">200</span>  <span class="o">(</span>Paddle<span class="o">)</span>
</span></span><span class="line"><span class="cl">   - <span class="nv">$13</span> <span class="m">000</span>  <span class="o">(</span>Romanian CIT<span class="o">)</span>
</span></span><span class="line"><span class="cl">   -    <span class="nv">$600</span>  <span class="o">(</span>Hetzner server<span class="o">)</span>
</span></span><span class="line"><span class="cl">   -    <span class="nv">$140</span>  <span class="o">(</span>IPStack API<span class="o">)</span>
</span></span><span class="line"><span class="cl">   -    <span class="nv">$100</span>  <span class="o">(</span>Apple Developer Program<span class="o">)</span>
</span></span><span class="line"><span class="cl">   -  <span class="nv">$1</span> <span class="m">000</span>  <span class="o">(</span>Foreign exchange<span class="o">)</span>
</span></span><span class="line"><span class="cl">   ---------
</span></span><span class="line"><span class="cl">     <span class="nv">$69</span> <span class="m">960</span>  <span class="o">(</span>Net profit<span class="o">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">￼
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">     <span class="nv">$108</span> <span class="m">400</span>  <span class="o">(</span>What people paid<span class="o">)</span> <span class="o">[</span>100%<span class="o">]</span>
</span></span><span class="line"><span class="cl">    - <span class="nv">$69</span> <span class="m">960</span>  <span class="o">(</span>What I get<span class="o">)</span>       <span class="o">[</span>64%<span class="o">]</span>
</span></span><span class="line"><span class="cl">    ---------
</span></span><span class="line"><span class="cl">      <span class="nv">$38</span> <span class="m">440</span>  <span class="o">(</span>What God knows how many intermediaries get just so that I am allowed to ask money <span class="k">for</span> my work<span class="o">)</span>
</span></span><span class="line"><span class="cl">                                  <span class="o">[</span>36%<span class="o">]</span>
</span></span></code></pre><p>In the end, I get the equivalent of a <code>$5800/month</code> net salary. Not bad, but very far from what I could earn as a consultant.</p>
<p>To put it in perspective, this summer I took a <code>$120/hour</code> consulting gig for a spanish MDM company because they said they need a few days worth of work for a macOS app and I said <em>why not, it's just a few days</em>. It turned out they needed a lot more and were happy to continue paying that amount because macOS skills are not easy to find.</p>
<p>But I could not fit into the same old <em>Slack chat + Zoom meetings + report</em> madness that consulting work needs. So I said &quot;no, I'm sorry, I'd like to continue building my apps&quot;.</p>
<p>At <code>$120/hour</code>, one would get <code>$120 * 8 hours * 21 days - ~12% taxes</code> → <code>$18k/month</code> before taxes. That's about <code>$214k/year</code> after taxes which is a mind boggling amount for a Romanian.But I like to believe that with that difference of <code>$12k/month</code> I'm buying time.</p>
<p>Time that I can spend as late mornings, drinking coffee quietly while playing with my dog. Time that I can spend eating a quiet meal with my wife without being in a hurry for a meeting, and then taking time after to talk about our house projects. Time for taking weeks off from being in front of a laptop without having to ask someone for my own time back.</p>
]]>
                </description></item>
            <item>
              <title>my spoon carving journey</title>
              <link>https://notes.alinpanaitiu.com/my-spoon-carving-journey</link>
              
              <pubDate>Thu, 26 Sep 2024 00:00:00 +0000</pubDate>
              
              <author>alin@panaitiu.com (Alin Panaitiu)</author>
              <guid>https://notes.alinpanaitiu.com/my-spoon-carving-journey</guid><description>
                  <![CDATA[<h1 id="my-spoon-carving-journey">my spoon carving journey</h1>
<h3 id="curvy-spoon">curvy spoon</h3>
<p><em>2024-05-06</em></p>
<p>Carved a little spoon for my espresso coffee cup, first time I’m doing spoons, but I managed to do it all from a small piece of a walnut branch and my pocket knife.</p>
<p><img src="https://notes.alinpanaitiu.com/attachments/FEDF557F-AF64-4180-B746-B5D1AE78D0CE.jpeg" alt="image"></p>
<p>I finished it with sesame oil to give it a little orange tint because the walnut branch was too white. I also carved my signature logo for the first time, kinda hard to do with a simple pocket knife.</p>
<p>Unfortunately the next day I left the spoon on the table and my dog was alone in the room with it. I heard a crack and when I came into the kitchen, there was only a pool of drool on the table. Part of the spoon handle was between Cora’s front paws and she was happily gnawing on it.</p>
<p><img src="https://notes.alinpanaitiu.com/attachments/A6E9EC19-98E8-41D9-95E6-5AF9AEF19461.jpeg" alt="image"></p>
<h3 id="chunky-spoon">chunky spoon</h3>
<p><em>2024-05-11</em></p>
<p>I started another spoon from a stick I found in the forest of the Buciumeni monastery. I believe it’s ash or hornbeam.</p>
<p>Unfortunately I made it too short so I thought I should throw it away.</p>
<p><img src="https://notes.alinpanaitiu.com/attachments/3E3E7503-5F89-4B05-9080-3DDEE864B28C.jpeg" alt="image"></p>
<p>I finally got the idea that I could glue up a pine stick to make it longer and usable because I really like the wood. I used Poxipol (some kind of epoxy resin glue) because it’s all I had around at my in-laws home. I think I could make this two-tone wood work nicely :)</p>
<p><img src="https://notes.alinpanaitiu.com/attachments/12BED862-5711-4D00-BC9A-B45D19D492F5.jpeg" alt="image"></p>
<p>And here it is, sanded and finished with Osmo Polyx clear hardwax oil. Looks like one of those cigarette holders from <em>Breakfast at Tiffany’s</em></p>
<p>I used it for the first time this morning and it feels nice for mixing sugar and milk in a small espresso glass. I like how it doesn't make any <em>cling</em> sound when it hits the glass walls. It's a bit too feathery light though, I'll try a denser wood next time to get a heavier spoon.</p>
<h3 id="something-between-a-spoon-and-a-teaspoon">something between a spoon and a teaspoon</h3>
<p><em>2024-06-18</em></p>
<p><strong>1st attempt</strong></p>
<p><img src="https://notes.alinpanaitiu.com/attachments/DFAF2FFD-B54D-4ED6-812C-31E15F2FFF2A.jpeg" alt="image">
<em>dang</em></p>
<p>This hybrid of a spoon is something my wife wanted for tasting. We went on another trip to Buciumeni to see the flowering of the linden trees and collect some flowers for tea.</p>
<p>On the way back, on the side of the road there was a linden tree with a broken branch. I tug on it and took it down and brought it home with me.</p>
<p>I started this spoon out of that linden branch, which carves like butter indeed. But it’s such a soft wood that it snapped in the weakest spot.</p>
<p>I started another spoon, this time by doing the scoop first.</p>
<p><img src="https://notes.alinpanaitiu.com/attachments/72A6FD10-1BD1-479D-B0B9-2F89B76256C9.jpeg" alt="image"></p>
<p>This one was a success, kinda. It still snapped while finishing it, but I was able to glue it back with CA glue and wrap the joint with epoxy putty to avoid snapping in the future.</p>
<p>Not the prettiest, it will probably sit in the coffee grounds jar. I’ll have to find a denser wood for my wife’s spoon.</p>
<p><img src="https://notes.alinpanaitiu.com/attachments/E6B17EE2-066A-47C9-830C-FECC09DCB297.jpeg" alt="image"></p>
<p><strong>2nd attempt</strong></p>
<p>I found an old and very dry black locust branch behind one of the dogs’ house. It didn’t look too promising but I knew locust wood is very dense so I thought I’d give it a try.</p>
<p><img src="https://notes.alinpanaitiu.com/attachments/53F781C9-D7D4-406F-A8C4-81F92462B53B.jpeg" alt="image"></p>
<p>It had some knots and cracks and shaping the wood dulled my knives very fast. The hook knife was no match for it. I eventually had to do the scoop with a power drill in a dangerous motion of pushing diagonally into the wood.</p>
<p>I slipped the drill and cut a finger pretty badly, but the spoon turned out really nice :) worth the blood loss
It is still my wife’s favorite spoon so far.
<img src="https://notes.alinpanaitiu.com/attachments/AFB725DA-152F-4541-B391-AE50343AE382.jpeg" alt="image"></p>
<h3 id="condiments-spoon">condiments spoon</h3>
<p><em>2024-07-13</em></p>
<p>I tried a new drill for making caval flutes out of branches and it turned out to be worse than the long auger bit I used before. The locust branch split after just a few centimeters of drilling.</p>
<p><img src="https://notes.alinpanaitiu.com/attachments/E9013647-6A4B-4AA1-B7FA-38636BDC94A3.jpeg" alt="image"></p>
<p>I cut the split part and thought the half hole looks kind of like those spoons for condiment jars. So I carved one of those spoons out of it.</p>
<p>The coloring of the wood is pretty wild and messy, it looks kind of dirty, not exactly to my taste. But it's a useful spoon, it will probably turn very yellow soon anyway since it will sit in the turmeric jar.</p>
<p><img src="https://notes.alinpanaitiu.com/attachments/2D1BDF7D-AF16-4642-9A23-9AE7D79903CD.jpeg" alt="image"></p>
<h3 id="snaky-spoon">snaky spoon</h3>
<p><em>2024-07-13</em></p>
<p>I had a mulberry spoon blank stashed from when I made the black locust spoon. I stored it on a low shelf where my dog was constantly finding and stealing it to gnaw on it when she was bored, so I finally started carving it.</p>
<p><img src="https://notes.alinpanaitiu.com/attachments/774C654C-862D-4DDB-9FBA-76B45DE01EFF.jpeg" alt="image"></p>
<p>The wood was denser than I expected and I had a lot of material to work with, so I had this idea of making a snaky handle.</p>
<p>Cutting those curves was incredibly hard even with a shaving sharp knife, I ended up using my Dremel with a 430 sanding band bit to finish the curves. This tool is incredibly useful for spoons, I should get one of those fine teeth carving bits.</p>
<p>I didn't really enjoy the dust and the constant noise and the vibration and heat it produced while holding the Dremel, I much prefer the calm and silent carving knife, but it sure helped.</p>
<div class="video-container"><video controls width=700 src="https://files.alinpanaitiu.com/elm-spoon-2.mp4"></video></div>
<h3 id="peach-spoons">peach spoons</h3>
<p><em>2024-08-26</em></p>
<p><img src="https://notes.alinpanaitiu.com/attachments/AF0DBBE0-C2B0-49D4-9840-80D64745C0F4.jpeg" alt="image"></p>
<p>This year the peach tree was so fruitful that a branch collapsed and broke under the weight of the peaches. I was there when it happened, so I had the chance to collect the straightest parts of the branch to make spoons out of them.</p>
<p>I had no idea how peach wood behaves but carving it while it was green was very easy, and it dried dense enough to not crack.</p>
<p>It’s the first time I was able to do the spoon cups with the hook knife only, and even get them smoother than I would do with a Dremel or by sanding.</p>
<p>The family of my wife was visiting from Italy so I carved 4 spoons, one for each child plus one for their mother. They seemed most excited about the carved letter of their name on each spoon, but I really liked how they turned up.</p>
<p>Peach wood has contrasting whites and browns which look great after finishing with walnut oil and bees wax.</p>
<p><img src="https://notes.alinpanaitiu.com/attachments/ADAF5CC6-5242-426C-A54D-1D6415ADEF35.jpeg" alt="image"></p>
<h3 id="wax-cherry-spoon">wax cherry spoon</h3>
<p><em>2024-09-25</em></p>
<p><img src="https://notes.alinpanaitiu.com/attachments/A78F735A-C9FE-4C1C-B2E8-67AE1899C210.jpeg" alt="image"></p>
<p>Wax cherry trees are very common here in Romania, it was one of the only fruits I had to eat when I was a child. We ate the fruits green with salt, we ate them sour-sweet right before riping and we still had tons of fruit remaining when they got mellow and sweet. Our dads made palinka out of the remaining fruits, a highly alcoholic clear beverage, with a yellowish tint and weird smell.</p>
<p>That’s why I’m probably sick of the fruit now, but I never thought I could do something with its wood. I had a thick branch that I got from the side of the road here in Brasov, I kept it in the corner of the room because I liked its orange accents after peeling off the bark. It also kinda looked like a deer.</p>
<p><img src="https://notes.alinpanaitiu.com/attachments/7BB11933-3736-4C2E-A17E-16E409040F19.jpeg" alt="image"></p>
<p>After a few months it cracked in every way possible so I thought about doing another spoon for my wife. She wanted an unusual design: round bottom and flat top of the stem with a sharp scoop.</p>
<p>I’m getting better and better at sharpening and using my hook knives, I was able to get a smooth scoop even with a very dry wood. The trick is to get a very low angle between the blade apex and the wood so that you only take very fine shavings, and cut across the grain to avoid tear out.</p>
<p>Sanding with 400 grit and a coat of tung oil was all this spoon needed for a nice orangey color.</p>
<p><img src="https://notes.alinpanaitiu.com/attachments/50791732-147A-407E-BCA3-4E713DDCAB69.jpeg" alt="image"></p>
<h3 id="red-leafed-maple-spoon">Red leafed maple spoon</h3>
<p><em>2024-10-23</em></p>
<p><img src="https://notes.alinpanaitiu.com/attachments/AFDAE3BB-325D-40DE-8D99-86BAA5AFB3E9.jpeg" alt="image"></p>
<p>My brother visited from Bucharest today and we went on a long walk through Brasov’s forests. On a hidden path at the foot of the university hill, we found an old large house surrounded by quince and apple trees. Right when we were walking out of the path into the main road, I saw a beautiful red leafed maple with some dry branches that I could reach.</p>
<p>I thought we could spend some of that afternoon making a spoon each so I broke those branches and got them home.</p>
<p>I got a snaky golden spoon out of it, which is more for show than utility as it has a very shallow scoop. The wood was surprisingly easy to work with and its color is a nice golden hue with very subtle brown discoloration.</p>
<h3 id="cedar-spoon">Cedar spoon</h3>
<p><em>2024-10-29</em></p>
<p><img src="https://notes.alinpanaitiu.com/attachments/313C891B-E0E8-4413-A292-15D08B5B62A2.jpeg" alt="image"></p>
<p>I cut an invasive spiky shrub at our old house a few years ago and I got a branch from it to see how the wood looks. I had no idea it’s cedar until I started shaping it with the knife and the unmistakable cedar scent started coming out of it.</p>
<p>It cut super easily and because I wasn’t expecting that, I got a smaller scoop than I wanted because I kept removing too much wood with the axe.</p>
<p>This time, my wife wanted a circle scoop and a very long handle and I got quite a nice piece. But because the scoop is too small, I have to do another one 😅 her collection of wooden spoons is getting out of hand</p>
<p><img src="https://notes.alinpanaitiu.com/attachments/2FD31541-39FB-418E-A07D-A1A0B124D179.jpeg" alt="image"></p>
<p>And here’s the second attempt: extra long, wider scoop and barely saved out of my dog’s paws</p>
<p>🐾</p>
<p><img src="https://notes.alinpanaitiu.com/attachments/7A6863D6-D8A6-456A-9DB8-BC170D50D52F.jpeg" alt="image"></p>
<p><img src="https://notes.alinpanaitiu.com/attachments/D1CF08AC-BCF7-43F6-98AA-062925DAC8F5.jpeg" alt="image"></p>
<h3 id="quince-spoons">Quince spoons</h3>
<p><em>2024-11-16</em></p>
<p><img src="https://notes.alinpanaitiu.com/attachments/7EEC4234-6403-4133-A27F-56D5279C7DA7.jpeg" alt="image"></p>
<p>This is the first time I’m trying to do a knife-only carving, without any sandpaper and power tools. The facets do look nice when you get them right, but I’m still very much a beginner at this.</p>
<p>The wood comes from the same quince tree hill where I got the red leafed cedar. This time I found a half broken large branch on a quince tree which I got home and was immediately surprised how easy it was to cut. It was very green wood for a broken branch.</p>
<p><img src="https://notes.alinpanaitiu.com/attachments/A6B34C3E-D98E-47D6-9A38-1BC0131AEA43.jpeg" alt="image"></p>
<p>I really didn’t want to get it so thin, but when cutting, large chunks of fibers would break off. Not sure if it’s not a dense enough wood, or if I should have waited for it to dry a little.</p>
<p><img src="https://notes.alinpanaitiu.com/attachments/E442FA1E-5A49-42B6-B55E-617B884BF957.jpeg" alt="image"></p>
<h4 id="2nd-attempt">2nd attempt</h4>
<p><em>2024-11-23</em></p>
<p><img src="https://notes.alinpanaitiu.com/attachments/B4F357D6-60A6-4E35-B51D-EE304CE1218F.jpeg" alt="image"></p>
<p>With the other half of the branch I tried to be slower and get a more unique shape out of it.</p>
<p>There was some black rotten wood on the side and I tried to keep some of it visible so the shape was mostly guided by where that black wood was placed.</p>
<p><img src="https://notes.alinpanaitiu.com/attachments/167BC730-1A0D-4CAF-B0E1-884BC70C0775.jpeg" alt="image"></p>
<p>I still needed to sand the spoon after finishing the shape as the wood splintered too much in the tight curves. I added some facets afterwards.</p>
<p>Also, the wood is light in color after carving, but it gets dirty very easily, and the end result looks like an old used spoon. To avoid this, I carefully sanded the dirty spots, then dipped the spoon into an espresso to darken it. Finishing with tung oil and wax should keep that color in place.</p>
<p>In the end I got quite a cute pair of quince wood spoons.</p>
<p><img src="https://notes.alinpanaitiu.com/attachments/32ACC2EB-99BE-460F-A6AF-83A7BBD038D6.jpeg" alt="image"></p>
<h3 id="spoon-rest">spoon rest</h3>
<p><em>2024-11-24</em></p>
<p><img src="https://notes.alinpanaitiu.com/attachments/3D6A9D80-7688-4E56-BF4C-0A7DBD30DFE7_1_201_a.jpeg" alt="image"></p>
<p>I've got some old beech floor slabs that have some wild grain variations and I wanted to try and use them for something. My mom asked me to make her a spoon rest when I first started carving, but that was way out of my skill set at the time. I thought I could probably handle that now.</p>
<p>Here's a video of two slabs; one is finished with my beeswax, cocoa butter and walnut oil blend and you can see just how much darker it gets.</p>
<div class="video-container"><video controls width=270 src="https://files.alinpanaitiu.com/beech-floor-slabs-wax-diff.mp4"></video></div>
<p>I glued three slabs together to make something wide enough for a spoon rest, cut the sides of the handle, then glued one of the scraps to the bottom end of the handle to make a curved and raised handle.</p>
<p>I shaped the bowl with my new Mora 163 knife which I absolutely love. It's so much easier to do push cuts with a double edged blade. <em>It's also much easier to cut my finger really deep, took me a while to find what to wrap around the blade bottom end to avoid that.</em></p>
<p><img src="https://notes.alinpanaitiu.com/attachments/45FA1113-4144-476F-91B4-A1BD3C8D84B0_1_201_a.jpeg" alt="image"></p>
<p>The spoon rest handle and general shape was made with an aggressive sanding disc, the beech is very dense and bone dry and it's really hard to shape it manually with a blade. I then sanded it with 180 grit by hand to get it smooth.</p>
<p>I stropped the Mora knife to get it as sharp as possible and did a last push cut pass inside the spoon rest scoop to get some facets. I am starting to like the carving pattern look.</p>
<p><img src="https://notes.alinpanaitiu.com/attachments/899B7B16-6456-4243-8992-51FC9879FF10_1_201_a.jpeg" alt="image"></p>
<h3 id="plum-spoons">plum spoons</h3>
<p><em>2024-12-06</em></p>
<p><img src="https://notes.alinpanaitiu.com/attachments/IMG_2020.jpeg" alt="image"></p>
<p>While exploring another hill under the university building, one morning, I noticed one of the plum trees had been struck by lightning the night before and fell down. The part of the core where the lightning struck was already rotten and mostly eaten by bugs but most of the tree was still green.</p>
<p>I found a thick broken branch near the tree and got it home. I find it amusing and heartwarming that people in this city never look weird at a guy dragging long soggy branches through main streets.</p>
<p>The wood was surprisingly dense, but weirdly, very easy to carve. It has a golden white sapwood on the sides with a dark red heartwood in the middle. I would have loved to keep the sapwood for light colored accents but the heartwood was quite thick.</p>
<h4 id="large-cooking-spoon">large cooking spoon</h4>
<p><img src="https://notes.alinpanaitiu.com/attachments/9562D586-004B-45A7-A8ED-7952CEB0C163_1_201_a.jpeg" alt="image"></p>
<p>The first thing I made from the branch was a large-ish cooking spoon. It was a crooked part of the branch with a gnarly knot, but I tried my best to follow its natural shape instead of forcing a straight spoon out of it.</p>
<p><img src="https://notes.alinpanaitiu.com/attachments/EF115DBD-FC80-48F6-9502-72CFB87C8697_1_201_a.jpeg" alt="image"></p>
<h4 id="small-comet-spoon">small comet spoon</h4>
<p><img src="https://notes.alinpanaitiu.com/attachments/22BEF6E5-5685-47F9-89EE-356DEDF68C9F_1_201_a.jpeg" alt="image"></p>
<p>From a smaller part of the branch, I got a small spoon in a shape resembling a comet.</p>
<p>The shape is mostly an accident because of a misplaced hatchet strike while roughing out the shape. But it turned out ok, and it's surprisingly heavy for the size.</p>
<p><img src="https://notes.alinpanaitiu.com/attachments/D71B7D78-964E-4143-A809-60521ECAE210_1_201_a.jpeg" alt="image"></p>
<h4 id="odd-walnut-spoon">odd walnut spoon</h4>
<p><img src="https://notes.alinpanaitiu.com/attachments/F8E26768-46CA-4363-B760-76912832B5C2_1_201_a.jpeg" alt="image"></p>
<p>This is not from the plum branch but it's the from the same hill. My dog found some odd walnuts and brought them to me to take home. She likes to play with them a bit on the carpet, then proceeds to crack them open with her teeth and eat the small bits of walnut.</p>
<p>I never saw this walnut shape before, so I thought the wood might be interesting too. I found a dry and half rotten branch to carve a spoon out of it.</p>
<p>The wood color and grain was not that interesting however, it had this dull greenish white color and rough texture no matter how much I sanded it. So I tried to compensate with the shaping.</p>
<p><img src="https://notes.alinpanaitiu.com/attachments/C348CBC3-B92F-431D-9398-670AE9B3FED7_1_201_a.jpeg" alt="image"></p>
<h3 id="olive-spoon">olive spoon</h3>
<p><em>2025-03-20</em></p>
<p><img src="https://notes.alinpanaitiu.com/attachments/olive-spoon.jpeg" alt=""></p>
<p>Last winter I went to a flea market to try and sell some of my wooden tool creations like the sharpening blocks and some hammers. I didn't sell much, I don't think I made more than what I paid on the taxi to the market and back home.</p>
<p>But at some point an experienced woodworker approached me and bought one of the sharpening blocks, and invited me to his shop so I can see this very old olive tree that he recently bought for making tables.</p>
<p>I finally found time to pay him a visit in March and he gifted me a nice block of olive wood.</p>
<p><img src="https://notes.alinpanaitiu.com/attachments/olive-wood-block.jpeg" alt=""></p>
<p>The first thing I did out of it is this olive spoon in the picture above, I really like its shape and sheen. The wood is very dense and dry so it's unworkable with knives, but I recently got some carbide carving discs for my angle grinder which shaped the wood really easy.</p>
<p>The bowl of the spoon was still done with the hook knife, it was hard but the best choice for getting a good shape and finish.</p>
<p>I had some leftover cutoffs so I cleaned up the largest of them and made a nice salt holder to keep above the stove so we can always reach for the salt when cooking.</p>
<p><img src="https://notes.alinpanaitiu.com/attachments/olive-salt.jpeg" alt=""></p>
<h3 id="plum-cooking-spoon">plum cooking spoon</h3>
<p><em>2025-04-04</em></p>
<p><img src="https://notes.alinpanaitiu.com/attachments/IMG_2635.jpeg" alt=""></p>
<p>It’s that time of year when the nearby trees are pruned and thick branches are left on the side of the road.</p>
<p>I picked some plum tree thick branches to make some coffee cups but unfortunately the wood cracks too easily and develops checks so I carved some spoons instead.</p>
<p>This is a large cooking spoon, done with the usual Mora 163 double edge hook knife and the Mora 120 carbon knife, finished with the DIY food-safe hardwax oil from <a href="https://youtu.be/9xSJpkU-hsg">Workshop Companion’s recent video</a></p>
<p>The wood is not pleasant to carve, as it’s very dense while also splitting too easily. But it does get an incredible sheen just from the knife cutting the fibers.</p>
<p><img src="https://notes.alinpanaitiu.com/attachments/IMG_2636.jpeg" alt=""></p>
<h3 id="sycamore-coffee-spoons">sycamore coffee spoons</h3>
<p><em>2025-04-05</em></p>
<p><img src="https://notes.alinpanaitiu.com/attachments/Pasted%20image%2020250406142349.jpg" alt=""></p>
<p><img src="https://notes.alinpanaitiu.com/attachments/Pasted%20image%2020250406142349%201.jpg" alt=""></p>
<p>I’m trying to open an online shop where I can sell some of my creations, and I thought to start with some of my favorite type of spoons: tiny and thin coffee mixing spoons.</p>
<p>I made these out of a sycamore branch I got from a hike last year. Lovely wood for carving, and ages into a beautiful light-gold color with warm brown accents.</p>
<p><img src="https://notes.alinpanaitiu.com/attachments/Pasted%20image%2020250406142349%202.jpg" alt=""></p>
<p><img src="https://notes.alinpanaitiu.com/attachments/Pasted%20image%2020250406174519.jpg" alt=""></p>
<p>The aged color can be best seen in these salt shakers I made last year.</p>
<p><img src="https://notes.alinpanaitiu.com/attachments/IMG_1825.jpeg" alt=""></p>
]]>
                </description></item>
            <item>
              <title>How I made my SwiftUI calendar app 3x faster</title>
              <link>https://notes.alinpanaitiu.com/How-I-made-my-SwiftUI-calendar-app-3x-faster</link>
              
              <pubDate>Sun, 20 Oct 2024 00:00:00 +0000</pubDate>
              
              <author>alin@panaitiu.com (Alin Panaitiu)</author>
              <guid>https://notes.alinpanaitiu.com/How-I-made-my-SwiftUI-calendar-app-3x-faster</guid><description>
                  <![CDATA[<h1 id="how-i-made-my-swiftui-calendar-app-3x-faster">How I made my SwiftUI calendar app 3x faster</h1>
<pre class="chroma"><code><span class="line"><span class="cl">  DayView(day: day, weekday: SHORT_WEEKDAY_SYMBOLS[wk], weekdayNum: wkNum)
</span></span><span class="line"><span class="cl">    .overlay(roundRect(radius, fill: todayColor))
</span></span><span class="line"><span class="cl"><span class="gi">+   .fixedSize()
</span></span></span></code></pre><p>That's it. Really, what you see in the above diff is all I had to do.</p>
<p>I could end the article here and it would still be useful for a bunch of people, but I'm pretty sure you'd like to know why this works, where it doesn't actually do anything, and how I reached this conclusion.</p>
<h3 id="before-and-after-fixedsize">Before and after <code>fixedSize</code></h3>
<p>Below is a video demo of me bringing up the calendar app, then moving 4 months forward then backward.</p>
<div class="video-container"><video src="https://files.alinpanaitiu.com/grila-speedup-comparison.mp4" autoplay loop muted playsinline disablepictureinpicture></video></div>
<p>You can really see just how sluggish the <em>before</em> video looks, it takes quite a long time between the time I pressed the <em>forward</em> hotkey and the time the next month renders.</p>
<p>The <em>after</em> video shows the rendering happening just a few milliseconds after the hotkey is pressed, as expected.</p>
<h3 id="how-i-found-this">How I found this</h3>
<p><a href="https://lowtechguys.com/grila">Grila</a> is the calendar app in question. It's a read-only calendar app which can be navigated fully by keyboard.</p>
<p>I built it two years ago because I needed to consult my calendar a lot when trying to make plans for meeting friends and relatives. I didn't really need to add or edit things in my calendar, by I did want to view holidays, birthdays and move to specific dates in less than 2 seconds.</p>
<p>At the time, I complained about <a href="https://notes.alinpanaitiu.com/SwiftUI-is-convenient,-but-slow">SwiftUI being convenient, but slow</a> because no matter what I tried, the app felt sluggish without obvious issues in code.</p>
<p>In that article I also included a screenshot of an attempt at profiling the UI code using Instruments.app:</p>
<blockquote>
<p>Attaching its Time Profiler to Grila showed that the heaviest stack trace was in some SwiftUI graph updating code. Look at this thing!</p>
<p>Click on it, I dare you.</p>
<a href="https://files.alinpanaitiu.com/grila-heavy-stack-trace-scroll.webp">
   <img class="round2" src="https://files.alinpanaitiu.com/grila-heavy-stack-trace-scroll-small.webp" alt="grila heavy stack trace">
</a>
<p>Yes, that's how much I understood from it as well.</p>
</blockquote>
<p>At the time, SwiftUI didn't come with debug symbols, so the stack trace was useless.</p>
<p>So I blindly tried people's recommendations like:</p>
<ul>
<li>caching rasterized views with <code>drawingGroup()</code></li>
<li>turning one-way bindings into simple <code>var</code> properties</li>
<li>breaking up large <code>ObservableObject</code> classes into smaller ones</li>
<li>removing animations</li>
<li>removing <code>.environment</code> dependencies</li>
</ul>
<p>None of those made a measurable difference, even though they seem like sensible choices for lowering view draw time.</p>
<hr>
<p>Thankfully, with macOS Sonoma's launch we also got SwiftUI symbols. So I tried my hand again at finding where most of the processing time is spent in the app.</p>
<p>Turns out, a huge chunk of time was spent in <code>sizeThatFits()</code> which was being called hundreds of times on a single render:</p>
<p><img src="https://notes.alinpanaitiu.com/attachments/grila-sizethatfits-slow.png" alt="image"></p>
<p>Unfortunately the stack trace doesn't also show what specific view causes this, but the <em>&quot;hundreds of times&quot;</em> figure led me to believe this was being called on <code>DayView</code>, because in the 3-month view we have more than 90 days being rendered.</p>
<p>From my understanding, <code>sizeThatFits()</code> gets called for dynamically sized views which get their inner dimensions from both their interior and exterior constraints. That's the default for all SwiftUI views.</p>
<p>In my case though, the <code>DayView</code> is a rectangle which needs to always have the same dimensions, dictated by the size of its inner text. So I could ignore the exterior constraints by adding <code>.fixedSize()</code> to it.</p>
<p>In the below screenshot <em>(click to view the larger version)</em>, you can see the <em>before</em> heaviest traceback <em>on the left</em> which shows <code>sizeThatFits()</code> as the most CPU intensive call and spends more than 4 seconds on rendering the month changes.</p>
<p><img src="https://notes.alinpanaitiu.com/attachments/grila-speed-up-profiler.png" alt="image"></p>
<p>The <em>after</em> traceback <em>on the right</em>, which adds the fixed size modifier, doesn't show <code>sizeThatFits()</code> at all and it spends a just little more than 1 second on the same number of renders.</p>
<p>On the bottom left of the image you can also see how the main queue is blocked for 629ms, and that is what causes our visible hangs. That's gone after adding <code>fixedSize</code>.</p>
<p>After making the view size fixed in a few more places where I knew I don't need dynamic sizing, the only size processing that remained as a heavy call was the text sizing. That's most likely because I use a lot of <code>Text()</code> elements, all with font modifiers.</p>
<p>I don't think I can optimise this further, but at least it feels fast enough to use daily.</p>
]]>
                </description></item>
            <item>
              <title>I&#39;ll quit my job and buy a farm</title>
              <link>https://notes.alinpanaitiu.com/I&#39;ll-quit-my-job-and-buy-a-farm</link>
              
              <pubDate>Fri, 26 Sep 2025 00:00:00 +0000</pubDate>
              
              <author>alin@panaitiu.com (Alin Panaitiu)</author>
              <guid>https://notes.alinpanaitiu.com/I&#39;ll-quit-my-job-and-buy-a-farm</guid><description>
                  <![CDATA[<h1 id="ill-quit-my-job-and-buy-a-farm">I'll quit my job and buy a farm</h1>
<p>You've heard the words in that title before.</p>
<p>You've probably said them yourself, when your day to day job became so absurd that you couldn’t even tell what it is that you do there.</p>
<p>Details shift; maybe you wanted to open a bar and talk to people face to face, hear their problems and make them feel listened. Or maybe you wanted to build a house with your bare hands or build furniture out of logs or become a blacksmith, pick up an old craft...</p>
<p>The same quiet hunger lies in so many of us. The need to feel some grass beneath our feet, breathe in some hay dust, soak up the heat of a sunrise through the thin skin of our eyelids.</p>
<p>The thing is, details don't matter. I’m not stuck at a job, I'm an independent <a href="https://lowtechguys.com/">macOS app developer</a>. And I don't think I’ll have the money to buy a farm anytime soon, but I do have a few square meters of grass in front of the house I’m renting. Yet I feel the same hunger.</p>
<p><img src="https://notes.alinpanaitiu.com/attachments/carving-techniques.jpeg" alt="carving spoons and coffee cups"></p>
<p>I started carving spoons and coffee cups out of green wood, at first just to take a break from the hectic feeling of always being online, later it slowly became more than that.</p>
<p>I don't see myself endlessly clacking at my keyboard for the next 30 years. So in this past year I've been cluelessly trying to build a wood carving business, and being struck by how hard all this real world work can be after living so much of my life online.</p>
<blockquote>
<p>For the impatient people, here's where my wood carvings are:</p>
<ul>
<li><a href="https://gospodaria.com">Web shop</a></li>
<li><a href="https://www.instagram.com/alin.panaitiu">Instagram</a></li>
</ul>
</blockquote>
<h2 id="whats-wrong-with-just-selling-macos-apps">What's wrong with just selling macOS apps?</h2>
<p>Not much really. It's a well paying business, fulfilling if you like using and abusing macOS, writing Swift and SwiftUI all day, doing some reverse engineering of system frameworks here and there.</p>
<p>Sure, there's a lot of fighting with Apple as well, especially if you take the App Store route, but also just because you are forced to use Xcode, send your apps to be notarized by them before publishing even independently, dealing with system bugs and deprecations every year.</p>
<p><em>The people complaining about your apps can be a source of stress too, you're shielded from that feedback when you are an employee at a larger company.</em></p>
<p>But overall, it beats everything I've done so far in my 12 years of being a software engineer.</p>
<p><strong>It's just a bit lonely.</strong></p>
<p>And it's weird seeing those words form inside my mind. I've always been a person that likes being alone with my work, I could occupy myself with programming tasks for 14 hours, sleep happily then wake up and start over.</p>
<p>But working by yourself, for yourself, is different. When I was employed in a real physical office, I would have coffee and genuine conversation with my colleagues, we could play some table tenis and vent about the things that we found annoying or absurd about our job.</p>
<p>Even when I was working remotely, I at least had colleagues as passionate as I was about the things we were working on, we could share learnings and find meaning in what we did. Or at least complain and bond over how meaningless our job is.</p>
<p>I have none of that now.</p>
<p>My pains are mine only, the things I learn become so deeply specific that in the end I have no one to share them with and the number of people complaining outweigh the people thanking me about 100-to-1 no matter how good of a job I do.</p>
<p>My parents and friends have no idea what it is that I do so I can’t talk to them about what I spend most of my waking time on without boring them to death.</p>
<p>Sure the money keeps coming, but even the rut of having to manage a business, navigating the ever changing tax laws and publishing restrictions, takes all the joy out of seeing that there are people happy to pay for my hard work.</p>
<p>There's only so much complaining my wife can handle before I start sounding like a broken record. So what do I do?</p>
<h2 id="working-with-wood">Working with wood</h2>
<p>I've written about some of this before in the following articles:</p>
<ul>
<li><a href="https://alinpanaitiu.com/blog/woodworking-escape-from-software-absurdity/">Woodworking as an escape from software absurdity</a></li>
<li><a href="https://notes.alinpanaitiu.com/my-spoon-carving-journey">my spoon carving journey</a></li>
</ul>
<p>A few years ago I started making things out of wood: <a href="https://alinpanaitiu.com/images/chess-board-on-carpet.jpg">a chess board with magnets</a>, <a href="https://alinpanaitiu.com/images/masa-danut-pocket.jpg">a laptop bed table</a>, <a href="https://alinpanaitiu.com/images/bookshelf.jpg">a bookshelf</a>. I did these mostly because I needed the things, but also because I needed a break from the virtual world. <strong>I noticed I like creating things, real things that I can feel, and especially that I can let others feel as well.</strong></p>
<div style="width: 100%; display: flex; justify-content: center">
  <figure style="width: 100%; margin: 4px">
    <img style="width: 100%; margin: 8px 0; border-radius: 8px" src="https://alinpanaitiu.com/images/chess-board-on-carpet.jpg" alt="a chess set with wooden pieces on a wooden board" />
    <img style="width: 100%; margin: 8px 0; border-radius: 8px" src="https://alinpanaitiu.com/images/bookshelf.jpg" alt="wooden bookshelf" />
  </figure>
  <figure style="width: 100%; margin: 4px">
    <img style="width: 100%; margin: 8px 0; border-radius: 8px" src="https://alinpanaitiu.com/images/masa-danut-pocket.jpg" alt="wooden laptop table" />
  </figure>
</div>
<p>One lazy morning, after a walk with my dog, I finally got tired of splashing coffee on myself and being extra careful not to break the coffee cup because my dog ran after a cat and pulled me along with the leash. <em>Yes, I'm that weird person that takes his home brewed coffee with him on morning walks.</em></p>
<p>So I thought, how hard could it be to make a wooden coffee cup that:</p>
<ul>
<li>has an internal geometry that doesn't spill out coffee</li>
<li>can't be broken if dropped on the pavement</li>
<li>I can easily pocket after finishing the coffee so that I have both my hands free</li>
</ul>
<p>I had a thick branch from the apple tree that my father had to fell because of a storm, cut off a piece of it, and since I wasn't so experienced with scooping out large amounts of wood with hook knives, I just drilled a 30mm hole into its endgrain with a forstner bit.</p>
<p><img src="https://notes.alinpanaitiu.com/attachments/forstner-endgrain.jpeg" alt="Forstner bit into endgrain"></p>
<p>This got me a rough espresso cup blank in a few minutes.</p>
<p>To make it hold coffee without spilling, I carved facets on the interior walls with a hook knife. I then sanded the exterior smooth and finished it with tung oil and beeswax to make it waterproof.</p>
<p><img src="https://notes.alinpanaitiu.com/attachments/apple-wood-coffee-cup.jpeg" alt="apple wood espresso cup"></p>
<p>It was a small cup, it couldn't hold more than two shots of espresso, but it wasn't much work and I felt such a joy using it on my morning walks that I couldn't wait to share this with others.</p>
<p>That day, I stopped seeing working with wood as just a way to make things around the house. I thought <em>&quot;I could make these things for others! I can gift them to my friends, I could probably even sell them!&quot;</em></p>
<p>Boy, was I naive.</p>
<h2 id="selling-things-you-can-hold-in-your-hand">Selling things you can hold in your hand</h2>
<p>I kept that idea in my head but didn't act on it because apps still needed my full attention.</p>
<p>In the meantime, I made more coffee cups, because I wanted to try different shapes, see how different woods look and behave, experiment with finishing oils and waxes and make some gifts for friends while I'm at it.</p>
<p>Here are some other cups I made in that time:</p>
<style>
  .gallery {
    width: 100%;
    column-width: 240px;
    column-gap: 6px;
    margin: 0 auto;
  }
  .gallery figure {
    display: inline-block;
    width: 100%;
    break-inside: avoid;
    margin: 0;
  }
  .gallery img {
    display: block;
    max-width: 100%;
    width: 100%;
    height: auto;
    border-radius: 16px;
    object-fit: contain;
    aspect-ratio: auto;
  }
  .gallery figcaption {
    text-align: center;
    font-size: 0.9em;
    color: gray;
    margin-top: -8px;
    margin-bottom: 0;
  }
</style>
<div class="gallery">
    <figure> <img src="https://notes.alinpanaitiu.com/attachments/walnut-small.jpeg" alt="small walnut wood cup" /> <figcaption>Walnut (small)</figcaption> </figure>
    <figure> <img src="https://notes.alinpanaitiu.com/attachments/lilac.jpeg" alt="lilac wood cup" /> <figcaption>Lilac</figcaption> </figure>
    <figure> <img src="https://notes.alinpanaitiu.com/attachments/cherry.jpeg" alt="cherry wood cup" /> <figcaption>Cherry</figcaption> </figure>
    <figure> <img src="https://notes.alinpanaitiu.com/attachments/ash.jpeg" alt="ash wood cappuccino cup" /> <figcaption>Ash</figcaption> </figure>
    <figure> <img src="https://notes.alinpanaitiu.com/attachments/locust.jpeg" alt=" honey locust wood cup" /> <figcaption>Honey Locust</figcaption> </figure>
    <figure> <img src="https://notes.alinpanaitiu.com/attachments/walnut.jpeg" alt="walnut wood cup" /> <figcaption>Walnut</figcaption> </figure>
    <figure> <img src="https://notes.alinpanaitiu.com/attachments/wax-cherry.jpeg" alt="wax cherry wood cup" /> <figcaption>Wax Cherry</figcaption> </figure>
    <figure> <img src="https://notes.alinpanaitiu.com/attachments/sycamore.jpeg" alt="sycamore wood cup" /> <figcaption>Sycamore</figcaption> </figure>
    <figure> <img src="https://notes.alinpanaitiu.com/attachments/spalted-ash.jpeg" alt="spalted ash wood cup" /> <figcaption>Spalted Ash</figcaption> </figure>
    <figure> <img src="https://notes.alinpanaitiu.com/attachments/olive.jpeg" alt="olive wood cup" /> <figcaption>Olive</figcaption> </figure>
</div>
<p>Spring came and warmed up my outdoor workshop enough for me to start carving again. App work lessened, so I thought I could put some effort into selling the things I'm creating.</p>
<p>I found myself with three ways to sell:</p>
<ul>
<li>showcasing my work in front of my house and sell it to whoever shows up <em>(kind of illegal unfortunately)</em></li>
<li>register for craft markets in my city and stand on my feet 3 days a week to sell them to tourists</li>
<li>open an online shop</li>
</ul>
<p>The first way was not really an option, the fines are pretty large if you get caught doing that.</p>
<p>The second, selling in person at markets, required:</p>
<ul>
<li>incorporating a company</li>
<li>getting a Point of Sale device</li>
<li>getting a cash register and learning how to operate it</li>
<li>bid for a market spot and pay more than 100€ per day to be allowed there</li>
<li>have a lot of stock available, I can't sell just a handful of products there</li>
<li>ask for a much lower price than what I value my work for since I'll mostly be selling to Romanians looking for cheap trinkets</li>
</ul>
<p>That sounded like way too much effort for something that I wasn't sure would even work.</p>
<p>I was left with the only option, opening an online shop. For that, there are a myriad of options: should I sell on <strong>Etsy</strong>, should I use that <strong>Squarespace</strong> coupon that every YouTuber advertises, should I write my own web shop since I come from a web developer career after all?</p>
<p>In the end I chose <strong>Shopify</strong>, which might seem the most expensive choice, but there are quite a few advantages to it:</p>
<ul>
<li>they offer 3 months of trial at <code>$1/month</code>, which is huge if you want enough time to see if it's worth even trying to sell online</li>
<li>the selling fees are incredibly small compared to Etsy</li>
<li>the integration with shipping providers and tax calculators is much better than Squarespace for my region</li>
<li>I don't have to bother with debugging code, I can just be outside at my workbench</li>
</ul>
<p>So I chose a visually clean theme, added my business details and opened this tiny web shop called <strong><a href="https://gospodaria.com">Gospodăria lui Alin</a></strong> which basically translates to <em>&quot;Alin's homestead&quot;</em>.</p>
<blockquote>
<p>My idea with the name was that I want to make and sell things that I myself like to use around the house, kitchen and workshop.</p>
<p>Things like coffee cups, cooking spoons, salt shakers, butter knives, sharpening blocks etc.</p>
</blockquote>
<p>I have to say I love Shopify! Adding products is a breeze, I can even do it from my phone right after the thing itself is done. Integrating with shipping carriers is as easy as installing a plugin. The UI is clean and functional, flexible but not overly complex.</p>
<p>It allowed me to skip all the physical selling pain points, but I was unknowingly diving right into the most painful of them all: <em><strong>how do I get people to view my work in the first place?</strong></em></p>
<h2 id="visibility">Visibility</h2>
<p>Getting your work in front of people online is harder than you might think. Sure, you made a website, it's now available for everyone on Earth, but how come no one sees it?</p>
<p>Well, it's not like there's an online plaza where people stroll around and stumble upon new websites. Either people search for your website specifically, or an algorithm decides to push it in front of their eyes on the social platform of the moment.</p>
<div style="display:flex; justify-content:center; width:100%; margin:1.5rem 0;">
  <figure style="max-width:600px; width:100%; text-align:center; margin:0;">
    <img src="https://notes.alinpanaitiu.com/attachments/website-plaza.jpeg" alt="website city market" style="width:100%; border-radius:8px;" />
    <figcaption style="font-size:0.9em; color:gray; max-width: 400px; margin: 4px auto 0 auto;">
      yeah yeah, it's AI generated. don't worry, if I'd have drawn it by hand, it would have been just as bad. you get the point
    </figcaption>
  </figure>
</div>
<p>That I think is the largest and most important difference between online and physical selling: <strong>people find you in a city market by chance, but online, you have to find the people and convince them to come to you</strong></p>
<p>I started with a local only presence at first, because I don't yet have the energy to figure out global shipping and taxes. I set my prices a bit lower than what I'd want so that Romanian people wouldn't scrunch their nose at them, went through all the SEO best practices, started an Instagram page.</p>
<p>After months of working on Instagram reels and photos, handing out business cards, doing some paid ads and asking friends to spread the word, I got nothing. Zero online sales, close to zero visitors.</p>
<p><em>All I got was a shadow ban on Instagram because I changed my username to fix a typo. I had to start over on my personal account.</em></p>
<p><img src="https://notes.alinpanaitiu.com/attachments/gospodaria-analytics-zero-sales.png" alt="zero sales on webshop analytics"></p>
<p><img src="https://notes.alinpanaitiu.com/attachments/gospodaria-analytics-zero-sales-2.png" alt="zero sales on webshop analytics"></p>
<h2 id="whimsical-intersections">Whimsical intersections</h2>
<p>I was out on a walk through the city historical center one day, and there was a craft market that day in the square. There's always a craft market somewhere in Brașov.</p>
<p>I struck a conversation with a lady that was doing beautifully painted wood clocks and mirror frames, and postal boxes and key hooks... I was amazed by how many things she could do by herself.</p>
<p><img src="https://notes.alinpanaitiu.com/attachments/arina-ceas-posta.jpeg" alt="">
<img src="https://notes.alinpanaitiu.com/attachments/arina-oglinda-chei.jpg" alt=""></p>
<p>Apparently, she was also working with reclaimed wood like me, and she was living two streets away from me. I was <em>unknowingly</em> walking past her house almost every day on my dog's morning walks.</p>
<p>I told her about what I make and how I'm struggling with selling and she so very kindly offered to sell some of my stuff at her market stand for free. I wouldn't have to go through the hassle of getting a cash register or PoS, she would sell my wood creations as her own and pass me the earnings. Who does that??</p>
<p>It's so rare to see someone so unafraid and trusting nowadays. She helped me a lot, I could finally see if people would really pay for my work, I could hear what they think about it, about the prices, which products cause confusion and which ones bring joy.</p>
<blockquote>
<p>Salt shakers and sharpening blocks caused a lot of confusion by the way.</p>
<p>They thought salt would cling to the wood, when in fact it does the opposite. The wood absorbs excess moisture so the salt never clumps.</p>
<p>And the sharpening blocks would be seen as luxury for people that use expensive knives. People usually spend €2-3 on a knife around here and view them as expendable.</p>
</blockquote>
<hr>
<p>I was dumbfounded to hear that the wooden spoons sold first every time, no matter the price.</p>
<p>Coffee cups were selling as well, but people bought them more for keeping the spoons inside them! I was putting in so much time in making these waterproof and people used them as spoon holders...</p>
<p>That was eye-opening to say the least.</p>
<p>You see, I like carving these minimalist spoons out of rarely used woods that I find in orchards, courtyards, the side of the road. Woods like jasmine <em>(yes, the flower)</em>, fig tree, lilac wood, wax cherry, black locust, quince wood.</p>
<div class="gallery">
    <figure> <img src="https://notes.alinpanaitiu.com/attachments/black-locust-cooking-spoon.jpeg" alt="black locust wood cooking spoon" /> <figcaption>black locust cooking spoon</figcaption> </figure>
    <figure> <img src="https://notes.alinpanaitiu.com/attachments/beech-cooking-spoon.jpeg" alt="beech wood cooking spoon" /> <figcaption>beech cooking spoon</figcaption> </figure>
    <figure> <img src="https://notes.alinpanaitiu.com/attachments/black-locust-mixing-spoon.jpeg" alt="black locust wood mixing spoon" /> <figcaption>black locust mixing spoon</figcaption> </figure>
    <figure> <img src="https://notes.alinpanaitiu.com/attachments/walnut-sap-eating-spoon.jpeg" alt="walnut wood eating spoon" /> <figcaption>walnut sap eating spoon</figcaption> </figure>
    <figure> <img src="https://notes.alinpanaitiu.com/attachments/curvy-sycamore-mixing-spoon.jpeg" alt="curvy sycamore wood mixing spoon" /> <figcaption>curvy sycamore mixing spoon</figcaption> </figure>
    <figure> <img src="https://notes.alinpanaitiu.com/attachments/elm-cooking-spoon.jpeg" alt="elm wood cooking spoon" /> <figcaption>elm cooking spoon</figcaption> </figure>
    <!--  -->
    <figure> <img src="https://notes.alinpanaitiu.com/attachments/wax-cherry-spoon.jpeg" alt="wax cherry wood spoon" /> <figcaption>wax cherry spoon</figcaption> </figure>
    <figure> <img src="https://notes.alinpanaitiu.com/attachments/lilac-cooking-spoon.jpeg" alt="lilac wood cooking spoon" /> <figcaption>lilac cooking spoon</figcaption> </figure>
    <figure> <img src="https://notes.alinpanaitiu.com/attachments/lilac-black-locust-spoons.jpeg" alt="lilac and black locust wood spoons" /> <figcaption>lilac & black locust spoons</figcaption> </figure>
    <figure> <img src="https://notes.alinpanaitiu.com/attachments/spalted-beech-cooking-spoon.jpeg" alt="spalted beech wood cooking spoon" /> <figcaption>spalted beech cooking spoon</figcaption> </figure>
    <figure> <img src="https://notes.alinpanaitiu.com/attachments/jasmine-mixing-spoons.jpeg" alt="jasmine wood mixing spoons" /> <figcaption>jasmine & quince mixing spoons</figcaption> </figure>
    <figure> <img src="https://notes.alinpanaitiu.com/attachments/lilac-mixing-spoons.jpeg" alt="lilac wood teaspoons" /> <figcaption>lilac teaspoons</figcaption> </figure>
</div>
<p>And it turns out people liked the idea of those rarely used woods.</p>
<p>They liked the idea of cooking with a spoon made out of a fig tree branch that grew in someone's yard, that someone took care of, watered, pruned, harvested fruit from it. Or mixing their tea with wood coming from a flowering bush like lilac or jasmine.</p>
<h2 id="money-and-effort">Money and effort</h2>
<p>Before I sold my first real product, I had no idea that the people who make the product end up with less than half of the money paid by the buyer. If there's a middleman, the maker could get even less than a quarter.</p>
<p>I was recently invited to sell my wood carvings at a gift shop here in Brașov. These places usually need you to lower your usual prices because they need to add a commission on top.</p>
<p>So let's say I would usually sell a cooking spoon for <code>€20</code>, I would have to lower that to <code>€15</code> there. The gift shop doubles that because they also have to pay rent, employers, taxes. So the buyer sees the <code>€30</code> final price.</p>
<p>A rough calculation leads to:</p>
<pre class="chroma"><code><span class="line"><span class="cl"><span class="err">€</span><span class="mi">30</span> <span class="p">(</span><span class="n">paid</span> <span class="n">by</span> <span class="n">the</span> <span class="n">buyer</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="o">-</span> <span class="err">€</span><span class="mi">15</span> <span class="p">(</span><span class="n">shop</span> <span class="n">commission</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="o">-</span> <span class="mi">21</span><span class="o">%</span> <span class="p">(</span><span class="n">VAT</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="o">-</span> <span class="mi">16</span><span class="o">%</span> <span class="p">(</span><span class="n">income</span> <span class="n">tax</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="o">-</span> <span class="mi">16</span><span class="o">%</span> <span class="p">(</span><span class="n">dividend</span> <span class="n">tax</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="p">=</span> <span class="err">€</span><span class="mf">8.36</span> <span class="p">(</span><span class="n">maker</span> <span class="n">earnings</span><span class="p">)</span>
</span></span></code></pre><p><strong>I get only 8 euros for the effort of hand carving, hand sanding, burnishing, oiling and waterproofing a large cooking spoon</strong>. Not to mention the preliminary efforts of finding the salvageable wood in the first place, selecting the good parts of the tree branch, fixing small cracks, axing out the excess and preparing the blank.</p>
<p>All that while the person that values my work and actually buys the spoon, pays 4x the price.</p>
<p>Of course, I'm happy to finally sell something at all, and get some much needed exposure, I'm not complaining about the gift shop.</p>
<p>I'm more like the old man shouting at the sky that the taxes are too high or something. Doesn't seem fair to me.</p>
<h2 id="the-real-world-is-messy">The real world is messy</h2>
<p>As software engineers we don’t value enough how little we need to do our job. We buy a laptop, download our tools <em>(a lot of times even for free)</em>, upload our work across the world, copy it unlimited times...</p>
<p>When we carry that laptop around, we carry the whole workshop with us, with all the tools, finished and unfinished work, space for more work and tools, AI that does the boring stuff for us and seemingly limitless knowledge on any topic.</p>
<p>There's no dust or waste accumulating, no hazardous substances to deal with, cleanup happens at the flick of a button.</p>
<p>It’s not like that in the real world.</p>
<p>In the past year I filled my small rented house, inside and outside with:</p>
<ul>
<li>carving knives, axes and all kinds of cutting tools</li>
<li>bottles of oils and cans of waxes and flammable solvents</li>
<li>a million types of abrasives and tools to hold those abrasives</li>
<li>measuring and marking tools</li>
<li>electric drilling and cutting tools</li>
<li>workbenches, axe blocks, vises</li>
<li>mallets, hammers, adzes</li>
<li>glues, resins, fillers</li>
</ul>
<p>And it’s a daily challenge keeping all that easily available without having it look like a mess.</p>
<p>At least I'm not making furniture. I can carry an axe, a folding saw and a hook knife if I want to carve a spoon in the forest or in my parents' orchard, but for anything else, I’m mostly stuck to my workbench.</p>
<p>When someone buys an app from me, I'm not even aware of it when it happens. The person gets the app on the other side of the world, I get the money, next person in line can do the same without me lifting a finger.</p>
<p>If someone buys a wooden coffee cup from me, I have to find ways to physically move it to the place where that person lives, I have to pay for that moving of goods, I have to package the cup to make it resilient to being thrown around in a van.. and then that's it, it's gone for good. If someone else wants another cup, I have to put in the effort to make another one.</p>
<h2 id="pricing-physical-goods">Pricing physical goods</h2>
<p>When that lady was helping me sell at her market stand, I had to create a lot of spoons and cups in a short amount of time. I had to do the same thing for the gift shop opening day. People like to see a stocked shelf when buying.</p>
<p>When people see just a handful of products on a shelf, they seem to think it's only for admiring.</p>
<p><img src="https://notes.alinpanaitiu.com/attachments/giftshop-stand.jpeg" alt="leather lined stand I had to make for the giftshop"></p>
<p>So for a few days, I had to enter <em>&quot;production mode&quot;</em> as I like to call it. Which means I was trying to get as many finished products in a day. I tried to carve 10 spoons in a day, or 4 coffee cups, from early morning until the last ray of sun disappeared behind the horizon.</p>
<p>I managed to do it, but there's a physical limit to how much effort I can put in before my hand muscles refuse to move the next day, before the painful cramps in my arms stop me from lifting even a light grocery bag.</p>
<p>And there's a limit to how creative my designs can be in such a short amount of time. The spoon or cup looks a specific way because the tree branch and its wood grain moves in specific directions. I can't use templates and do the same design if I have to fight the grain direction.</p>
<p>And that brings me to pricing.</p>
<hr>
<p>I remember walking through the Vienna Christmas markets about 7 years ago, and I saw people selling little pebbles for <code>€5</code> a piece!</p>
<p><img src="https://notes.alinpanaitiu.com/attachments/IMG_0214.jpeg" alt=""></p>
<p>What I remember the most actually, is a lady that was making huge crazy grained olive wood bowls, and was selling them for eye watering prices, there was nothing below <code>€500</code> on that stand.</p>
<p><img src="https://notes.alinpanaitiu.com/attachments/89645%201.jpeg" alt=""></p>
<p>I was almost angry. How could anyone ask for that much money on a wooden bowl? How could anyone afford them anyway?</p>
<p>I understand it now.</p>
<p>That physical limit on how many quality goods you can create <strong>is</strong> what should decide your price, not how much people want to pay for it. If there really is no one that will pay that much, then it's not a sustainable business and you should do something else if money is a problem.</p>
<p>I'm still figuring out my own pricing. <code>€20</code> might sound a lot for a cooking spoon, but given that I only receive half of that after taxes, and I can only make about 2 or 3 spoons max in a day of work, there's not a lot of money at the end of the month.</p>
<p>In the happiest scenario, I would make:</p>
<pre class="chroma"><code><span class="line"><span class="cl"><span class="err">€</span><span class="mi">20</span> <span class="p">(</span><span class="n">spoon</span> <span class="n">price</span><span class="p">)</span> <span class="err">×</span> <span class="mi">3</span> <span class="p">(</span><span class="n">spoons</span> <span class="n">per</span> <span class="n">day</span><span class="p">)</span> <span class="err">×</span> <span class="mi">21</span> <span class="p">(</span><span class="n">days</span> <span class="n">of</span> <span class="n">work</span> <span class="k">in</span> <span class="n">a</span> <span class="n">month</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="o">---------------------------------------------------------------------</span>  <span class="p">=</span>  <span class="err">€</span><span class="mi">630</span> <span class="n">per</span> <span class="n">month</span>
</span></span><span class="line"><span class="cl">                                  <span class="mi">2</span>
</span></span><span class="line"><span class="cl">       <span class="p">(</span><span class="n">the</span> <span class="n">half</span> <span class="n">that</span> <span class="n">I</span> <span class="n">have</span> <span class="n">to</span> <span class="n">give</span> <span class="n">to</span> <span class="n">my</span> <span class="n">corrupt</span> <span class="n">government</span><span class="p">)</span>
</span></span></code></pre><p><code>€630</code> per month... That's just a bit higher than the minimum wage here in Romania which is <code>€466</code> after taxes at the moment.</p>
<p>It's not sustainable, but I need to build trust first. People need to know that other people have heard of a maker before accepting higher prices. I have to start at the low end.</p>
<hr>
<p>And it's not all about money in the end.</p>
<p>I like carving useful wooden things, and I want to meet people that like them as well. I want to teach people to carve their own coffee cup and see it develop into the grounding piece of their morning ritual. I want to meet and be taught by other more experienced artists.</p>
<p>I want to connect to people. Get back that feeling that there's always someone I can call up for a coffee at my place, or be called to help someone fell a dead tree and see that tree transform into spoons and cups and butter knives filling the cupboards of home cooks and coffee lovers.</p>
<p>I used to feel I was robbed of that feeling when I started working in the lonely world of online work. But the thing is, I make a lot of money from that. Am I selling my need for connection for those large amounts of money? It certainly feels that way.</p>
<p>If someone told me 9 years ago, when I was just finishing college and hopeful: <em>I'll give you €8000 every month, but you have to spend more time managing a business than seeing your friends, you have to specialize in a field that no friend or family wants to hear you talk about, you have to lose physical contact with them by the age of 28 and you'll slowly develop health issues at 30 because of so much sitting on your butt.</em></p>
<p>I would have laughed all the way back to the college campus. Who in their right mind would want that?</p>
<hr>
<h2 id="status-quo-in-october-2025">Status quo in October 2025</h2>
<p>So where am I with this whole carving thing and apps business?</p>
<p>The apps are still making money and I'm still working on them. I am learning to tune out mean comments and focus on features and stability. The wood carving work has created a kind of balance in my life that allows me to keep away burnout from the software engineering work.</p>
<p>The wood carving online shop is still making no money, but I have ideas to grow it in the next year:</p>
<ul>
<li>the gift shop will display a QR code with my website alongside my products</li>
<li>I'll try to translate the website and expand to EU shipping where hopefully my prices won't be seen as too high</li>
<li>I might try opening an Etsy shop hoping that their search will also guide people to my website</li>
</ul>
<p>And who knows, in the end, I might actually buy a farm and finally stop renting houses. Build a proper workshop, some shaving horses, spoon mules and axing blocks, and open up the place for people that want to learn how to carve.</p>
<h2 id="resources">Resources</h2>
<p>If you've made it this far, I'm guessing you might be interested in the carvers and artists that inspired me.</p>
<p>Here's a short list:</p>
<ul>
<li>Samuel Alexander: <em>calm carving bulbous tea cups out of spalted walnut</em>
<ul>
<li><a href="https://www.youtube.com/shorts/4mzc7L0hArY">Youtube</a></li>
<li><a href="https://samuelalexandershapes.com/shop">Shop</a></li>
</ul>
</li>
</ul>
<p><img src="https://notes.alinpanaitiu.com/attachments/samuel-alexander-shapes.jpg" alt="Samuel Alexander Shapes"></p>
<ul>
<li>Luke Hope: <em>sculptural spoons, plates and bowls</em>
<ul>
<li><a href="https://www.instagram.com/hopeinthewoods/">Instagram</a></li>
<li><a href="https://lukehope.com/">Website</a></li>
</ul>
</li>
</ul>
<p><img src="https://notes.alinpanaitiu.com/attachments/luke-hope-sculptural-spoons.jpg" alt="Luke Hope"></p>
<ul>
<li>Oren Hetzroni: <em>teaching people how to carve at his homestead in Israel</em>
<ul>
<li><a href="https://www.instagram.com/hetzronio/">Instagram</a></li>
<li><a href="https://woodspoon.co.il/workshop">Website</a></li>
<li><a href="https://www.youtube.com/watch?v=lS7IMuS4gv4">Zed Outdoors video</a></li>
</ul>
</li>
</ul>
<p><img src="https://notes.alinpanaitiu.com/attachments/oren-hetzroni.jpeg" alt="Oren Hetzroni"></p>
<ul>
<li>NU woodworks
<ul>
<li><a href="https://www.instagram.com/nu_woodworks/">Instagram</a></li>
<li><a href="https://nuwoodworks.stores.jp/">Website</a></li>
</ul>
</li>
</ul>
<p><img src="https://notes.alinpanaitiu.com/attachments/nu-woodworks.jpeg" alt="NU woodworks"></p>
<ul>
<li>Benoit Schöni: <em>raw carvings of cups and spoons</em>
<ul>
<li><a href="https://www.instagram.com/benoit_schoeni/">Instagram</a></li>
</ul>
</li>
</ul>
<p><img src="https://notes.alinpanaitiu.com/attachments/9D898387-DCA1-40F7-865C-6ACA86F27CA8_1_201_a.jpeg" alt=""></p>
<ul>
<li>Zed Outdoors: <em>long interviews with tutorials on all things carving, sharpening, green woodworking</em>
<ul>
<li><a href="https://www.youtube.com/@Zedoutdoors">Youtube</a></li>
<li><a href="https://www.instagram.com/zedoutdoors/">Instagram</a></li>
</ul>
</li>
</ul>
]]>
                </description></item>
  </channel>
</rss>
