<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Paul Contreras</title>
    <description>The latest articles on DEV Community by Paul Contreras (@paulcontr_).</description>
    <link>https://dev.to/paulcontr_</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1270579%2Ff5fc33eb-f21b-4e4c-a59d-0c60365864df.jpg</url>
      <title>DEV Community: Paul Contreras</title>
      <link>https://dev.to/paulcontr_</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/paulcontr_"/>
    <language>en</language>
    <item>
      <title>I built Observe: a system monitor for your terminal</title>
      <dc:creator>Paul Contreras</dc:creator>
      <pubDate>Mon, 13 Jul 2026 09:54:32 +0000</pubDate>
      <link>https://dev.to/paulcontr_/i-built-observe-a-system-monitor-for-your-terminal-2doa</link>
      <guid>https://dev.to/paulcontr_/i-built-observe-a-system-monitor-for-your-terminal-2doa</guid>
      <description>&lt;p&gt;&lt;strong&gt;A live terminal monitoring cockpit for CPU, memory, disk, network, ports, and busy processes.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn58dghigxb2ob50relvk.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn58dghigxb2ob50relvk.gif" alt="observe" width="800" height="456"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;When something slows down on my machine, I usually open several commands to check CPU, memory, disk usage, network activity, ports, and running processes.&lt;/p&gt;

&lt;p&gt;I wanted a simpler way to see everything in one place, so I built &lt;strong&gt;Observe&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Observe is an open-source terminal dashboard that shows what your machine is doing in real time.&lt;/p&gt;

&lt;p&gt;It includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;CPU, memory, disk, and network usage&lt;/li&gt;
&lt;li&gt;Processes using the most resources&lt;/li&gt;
&lt;li&gt;Listening ports&lt;/li&gt;
&lt;li&gt;Disk I/O and swap pressure&lt;/li&gt;
&lt;li&gt;Docker container activity&lt;/li&gt;
&lt;li&gt;Simple warnings for possible bottlenecks&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There is no account, database, agent, or initial configuration. Everything runs locally, and your system metrics are not uploaded anywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install
&lt;/h2&gt;

&lt;p&gt;Using Homebrew:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;pol-cova/homebrew-tap/observe
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or using Go:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;go &lt;span class="nb"&gt;install &lt;/span&gt;github.com/pol-cova/observe@latest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;observe
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can also monitor a remote machine over SSH:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;observe &lt;span class="nt"&gt;--ssh&lt;/span&gt; user@host
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or save a diagnostic snapshot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;observe snapshot &lt;span class="nt"&gt;--output&lt;/span&gt; diagnostics.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Observe is written in Go and released under the MIT license.&lt;/p&gt;

&lt;p&gt;You can find the source code here: &lt;a href="https://github.com/pol-cova/observe" rel="noopener noreferrer"&gt;https://github.com/pol-cova/observe&lt;br&gt;
&lt;/a&gt;&lt;br&gt;
Feedback, issues, and contributions are welcome.&lt;/p&gt;

</description>
      <category>programming</category>
      <category>opensource</category>
      <category>showdev</category>
      <category>learning</category>
    </item>
    <item>
      <title>What if your little moments became a song? I built Boppi with Gemini + Strudel 🎵</title>
      <dc:creator>Paul Contreras</dc:creator>
      <pubDate>Sun, 12 Jul 2026 10:49:10 +0000</pubDate>
      <link>https://dev.to/paulcontr_/what-if-your-little-moments-became-a-song-i-built-boppi-with-gemini-strudel-23d</link>
      <guid>https://dev.to/paulcontr_/what-if-your-little-moments-became-a-song-i-built-boppi-with-gemini-strudel-23d</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/challenges/weekend-2026-07-09"&gt;Weekend Challenge: Passion Edition&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Boppi turns everyday moments into tiny songs.&lt;/p&gt;

&lt;p&gt;Add a photo, a short caption, and a vibe. Gemini understands the feeling behind the moment and creates a musical plan with melody, bass, and rhythm. Strudel then turns that plan into a song you can hear and share.&lt;/p&gt;

&lt;p&gt;You can create a Bop alone or invite friends into a private room and combine your moments into one song.&lt;/p&gt;

&lt;p&gt;Built with Next.js, Bun, Convex, Gemini, Strudel, and Vercel.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Try Boppi → &lt;a href="https://boppi.vercel.app" rel="noopener noreferrer"&gt;https://boppi.vercel.app&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

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

&lt;p&gt;What was one little thing you loved today?&lt;br&gt;
&lt;em&gt;Maybe it was finally making a good coffee.&lt;/em&gt;&lt;br&gt;
&lt;strong&gt;Maybe it was the rain while you were coding.&lt;/strong&gt;&lt;br&gt;
Maybe it was playing a game with your friends, seeing a really nice sunset, or finding a song you could not stop replaying.&lt;/p&gt;

&lt;p&gt;When I saw the theme of this challenge, I first thought about building a normal music generator. You write a prompt, AI generates a song, and that is it.&lt;/p&gt;

&lt;p&gt;But I wanted to make something more personal and social.&lt;/p&gt;

&lt;p&gt;I also wanted it to be small enough that someone could understand it immediately and actually enjoy using it.&lt;/p&gt;

&lt;p&gt;That became &lt;strong&gt;Boppi&lt;/strong&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Little moments. One tiny song.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Boppi lets you turn a real moment from your day into a short song called a &lt;strong&gt;Bop&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;You add:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One photo&lt;/li&gt;
&lt;li&gt;A short caption&lt;/li&gt;
&lt;li&gt;A vibe&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Photo: my desk while it rains outside
Caption: late night coding
Vibe: calm
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Gemini looks at the photo, reads the caption, and understands the feeling behind it.&lt;/p&gt;

&lt;p&gt;It then creates a musical direction for the moment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Mood: focused and calm
Tempo: 88 BPM
Melody: soft and repetitive
Bass: warm and slow
Rhythm: light keyboard-like percussion
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Strudel uses that direction to perform the final song directly in the browser.&lt;/p&gt;

&lt;p&gt;The result is a short, playful soundtrack made from something that actually happened to you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The aha moment
&lt;/h2&gt;

&lt;p&gt;The main experience is not just pressing a button and waiting for an AI song.&lt;/p&gt;

&lt;p&gt;Boppi shows how your moment is becoming music.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Your rainy window became the atmosphere.

Your keyboard became the rhythm.

Your late-night energy became the bass.

Your caption inspired the melody.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each layer appears and starts playing one by one.&lt;/p&gt;

&lt;p&gt;Then everything joins together into the final Bop.&lt;/p&gt;

&lt;p&gt;That reveal is the part I built the product around.&lt;/p&gt;

&lt;p&gt;The song does not need to be perfect or three minutes long. It only needs to create the feeling:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“Wait, this actually sounds like my moment.”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Create a Bop alone
&lt;/h2&gt;

&lt;p&gt;Sometimes you just want to capture something for yourself.&lt;/p&gt;

&lt;p&gt;In Solo mode, you add one photo, caption, and vibe. Boppi turns it into a personal tiny song.&lt;/p&gt;

&lt;p&gt;You can then share it using a public page:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;RAINY DEBUGGING

a tiny song by Paul

calm · focused · 88 BPM

[ Play Bop ]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It is a different way to share a moment.&lt;/p&gt;

&lt;p&gt;Instead of only saying:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“This was my day.”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;You can say:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“This is what my day sounded like.”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Create a Bop with friends
&lt;/h2&gt;

&lt;p&gt;Boppi also has private rooms.&lt;/p&gt;

&lt;p&gt;You create a room, send the invite link to your friends, and everyone contributes one moment.&lt;/p&gt;

&lt;p&gt;There are no followers, public profiles, likes, or infinite feeds.&lt;/p&gt;

&lt;p&gt;Just one small room and one shared song.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Paul
late-night coding

Andrea
morning coffee

Mateo
football with my dad

Sofia
the sky looked unreal
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Gemini analyzes the group as a whole and gives every moment a musical role:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Paul’s coding became the rhythm.

Andrea’s coffee became the bass.

Mateo’s football match became the percussion.

Sofia’s sunset became the melody.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Strudel combines all four parts into one shared Bop.&lt;/p&gt;

&lt;p&gt;The final result is something that only exists because those specific people shared those specific moments.&lt;/p&gt;

&lt;p&gt;That is what makes the social part interesting to me.&lt;/p&gt;

&lt;p&gt;You are not posting for strangers or trying to get likes. You are making a tiny creative memory with people you care about.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this fits the Passion Edition
&lt;/h2&gt;

&lt;p&gt;When we talk about passion, we normally think about big things.&lt;/p&gt;

&lt;p&gt;The project you spend every night building.&lt;/p&gt;

&lt;p&gt;The team you always support.&lt;/p&gt;

&lt;p&gt;The hobby you have practiced for years.&lt;/p&gt;

&lt;p&gt;But I think passion also appears in smaller moments.&lt;/p&gt;

&lt;p&gt;It is the coffee you keep trying to improve.&lt;/p&gt;

&lt;p&gt;The game you always play with the same friends.&lt;/p&gt;

&lt;p&gt;The music you listen to while working.&lt;/p&gt;

&lt;p&gt;The sunset that makes you stop walking for a second.&lt;/p&gt;

&lt;p&gt;The random thing that made your day a little better.&lt;/p&gt;

&lt;p&gt;Boppi is about noticing those moments and turning them into something you can keep and share.&lt;/p&gt;

&lt;p&gt;It does not ask:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“What is the most important passion in your life?”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It asks something much simpler:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;“What did you love today?”&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Try the live app → &lt;a href="https://boppi.vercel.app" rel="noopener noreferrer"&gt;https://boppi.vercel.app&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy6ggq9ylcbguwmt0isqg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy6ggq9ylcbguwmt0isqg.png" alt="demo-home" width="800" height="813"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Solo Bop
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Add a photo.&lt;/li&gt;
&lt;li&gt;Write a short caption.&lt;/li&gt;
&lt;li&gt;Choose a vibe.&lt;/li&gt;
&lt;li&gt;Watch the musical layers appear.&lt;/li&gt;
&lt;li&gt;Listen to your Bop.&lt;/li&gt;
&lt;li&gt;Share it.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Room Bop
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Create a private room.&lt;/li&gt;
&lt;li&gt;Invite your friends.&lt;/li&gt;
&lt;li&gt;Everyone adds one moment.&lt;/li&gt;
&lt;li&gt;Gemini finds the connection between them.&lt;/li&gt;
&lt;li&gt;Every moment receives a musical role.&lt;/li&gt;
&lt;li&gt;Strudel performs the shared song.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;At end you can see the archives of your bops&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd1y2y8lo2bofln39urcs.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd1y2y8lo2bofln39urcs.png" alt="archive-demo" width="800" height="368"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;The source code is available on GitHub:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Repository → &lt;a href="https://github.com/pol-cova/boppi" rel="noopener noreferrer"&gt;https://github.com/pol-cova/boppi&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;p&gt;Boppi uses:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Next.js&lt;/strong&gt; for the web application and UI&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bun&lt;/strong&gt; for package management and local development&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Convex&lt;/strong&gt; for private rooms, moments, and real-time updates&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gemini&lt;/strong&gt; for multimodal understanding and composition planning&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Strudel&lt;/strong&gt; for generating and performing the music&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vercel&lt;/strong&gt; for deployment&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The basic pipeline looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;photo + caption + vibe
          ↓
       Gemini
          ↓
structured musical plan
          ↓
validated Strudel patterns
          ↓
       tiny song
          ↓
    shareable Bop
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Understanding the moment with Gemini
&lt;/h3&gt;

&lt;p&gt;Gemini receives the user’s photo, caption, and selected vibe.&lt;/p&gt;

&lt;p&gt;Instead of asking it to return random music code, I ask it to produce a structured composition plan.&lt;/p&gt;

&lt;p&gt;A simplified response looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Rainy Debugging"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"A calm and focused late-night moment"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"bpm"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;88&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"scale"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"D minor"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"energy"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.35&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mood"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"calm"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"template"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"cozy"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"layers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"melody"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"density"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"low"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"movement"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"repetitive"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"bass"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"style"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"warm"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"movement"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"slow"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"rhythm"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"density"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"light"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"texture"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"digital"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This was an important decision.&lt;/p&gt;

&lt;p&gt;Letting a model generate unrestricted executable Strudel code would make the result less reliable and harder to control.&lt;/p&gt;

&lt;p&gt;Instead, Gemini makes the creative decisions while Boppi maps those decisions into tested musical building blocks.&lt;/p&gt;

&lt;p&gt;That gives me a balance between AI creativity and consistent output.&lt;/p&gt;

&lt;h3&gt;
  
  
  Creating the song with Strudel
&lt;/h3&gt;

&lt;p&gt;Boppi contains a small set of handcrafted song templates.&lt;/p&gt;

&lt;p&gt;For the first version, I focused on three:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Cozy&lt;/li&gt;
&lt;li&gt;Playful&lt;/li&gt;
&lt;li&gt;Energetic&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each template has slots for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Melody&lt;/li&gt;
&lt;li&gt;Bass&lt;/li&gt;
&lt;li&gt;Rhythm&lt;/li&gt;
&lt;li&gt;Atmosphere&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Gemini selects the template and controls values such as tempo, density, note movement, and intensity.&lt;/p&gt;

&lt;p&gt;Boppi then generates the final Strudel pattern.&lt;/p&gt;

&lt;p&gt;A simplified example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;stack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nf"&gt;note&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;d4 f4 a4 c5&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sound&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;triangle&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;

  &lt;span class="nf"&gt;note&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;d2 d2 f2 a2&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sound&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sawtooth&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lpf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;700&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;

  &lt;span class="nf"&gt;s&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bd ~ hh ~&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;gain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.7&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cpm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;88&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Strudel performs everything in the browser, which also makes the reveal feel alive.&lt;/p&gt;

&lt;p&gt;The user can see the layers being constructed while hearing them enter the song.&lt;/p&gt;

&lt;h3&gt;
  
  
  Combining moments inside a room
&lt;/h3&gt;

&lt;p&gt;Convex manages the room state and keeps contributions synchronized.&lt;/p&gt;

&lt;p&gt;Each room contains:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;room
├── invite code
├── members
├── moments
└── generated Bop
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When someone contributes, the room updates immediately for everyone else.&lt;/p&gt;

&lt;p&gt;Once the group is ready, Gemini receives all the moments together.&lt;/p&gt;

&lt;p&gt;It does not create four unrelated songs. It decides how each contribution can become part of one coherent composition.&lt;/p&gt;

&lt;p&gt;One moment might provide the melody.&lt;/p&gt;

&lt;p&gt;Another might control the rhythm.&lt;/p&gt;

&lt;p&gt;Another might define the atmosphere.&lt;/p&gt;

&lt;p&gt;This is what turns the room into a collaboration instead of a simple gallery.&lt;/p&gt;

&lt;h2&gt;
  
  
  Interesting decisions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  No full music editor
&lt;/h3&gt;

&lt;p&gt;I did not want Boppi to become a DAW or a complicated music tool.&lt;/p&gt;

&lt;p&gt;The user only chooses a vibe.&lt;/p&gt;

&lt;p&gt;The rest of the experience is about discovery.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tiny songs instead of full songs
&lt;/h3&gt;

&lt;p&gt;A Bop is intentionally short.&lt;/p&gt;

&lt;p&gt;It is meant to feel like a musical snapshot, not a finished commercial track.&lt;/p&gt;

&lt;p&gt;This also makes it easier to listen to, generate, and share.&lt;/p&gt;

&lt;h3&gt;
  
  
  No traditional social feed
&lt;/h3&gt;

&lt;p&gt;I wanted something social without recreating another feed.&lt;/p&gt;

&lt;p&gt;Private rooms are enough for the main idea.&lt;/p&gt;

&lt;p&gt;The song is the social object.&lt;/p&gt;

&lt;h3&gt;
  
  
  Gemini creates meaning, Strudel creates sound
&lt;/h3&gt;

&lt;p&gt;Gemini does not directly generate the final audio.&lt;/p&gt;

&lt;p&gt;It understands the moment and creates the musical intention.&lt;/p&gt;

&lt;p&gt;Strudel turns that intention into something you can hear.&lt;/p&gt;

&lt;p&gt;Both tools have a clear role in the product.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Best Use of Google AI
&lt;/h3&gt;

&lt;p&gt;I am submitting Boppi for &lt;strong&gt;Best Use of Google AI&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Gemini is not used only to generate a title or add a chatbot to the app.&lt;/p&gt;

&lt;p&gt;It powers the central transformation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;real moment → emotional meaning → musical composition
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Gemini:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Understands the uploaded photo&lt;/li&gt;
&lt;li&gt;Connects the image with the caption&lt;/li&gt;
&lt;li&gt;Interprets the selected vibe&lt;/li&gt;
&lt;li&gt;Creates the title and description&lt;/li&gt;
&lt;li&gt;Selects the tempo, mood, and scale&lt;/li&gt;
&lt;li&gt;Plans the melody, bass, and rhythm&lt;/li&gt;
&lt;li&gt;Assigns musical roles in friend rooms&lt;/li&gt;
&lt;li&gt;Finds a shared direction between different moments&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Without Gemini, Boppi would only be a collection of photos.&lt;/p&gt;

&lt;p&gt;Gemini is what turns those moments into music.&lt;/p&gt;

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

&lt;p&gt;The hardest part was not generating more music.&lt;/p&gt;

&lt;p&gt;It was deciding how little control the product actually needed.&lt;/p&gt;

&lt;p&gt;My first ideas included editors, timelines, custom instruments, public profiles, reactions, and many other features.&lt;/p&gt;

&lt;p&gt;But the more things I added, the less clear the product became.&lt;/p&gt;

&lt;p&gt;The final experience is much smaller:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;add a moment
hear its song
share it
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That constraint made Boppi feel more like a real product and less like a collection of hackathon features.&lt;/p&gt;

&lt;h2&gt;
  
  
  What comes next
&lt;/h2&gt;

&lt;p&gt;There are a few ideas I would like to explore after the challenge:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Recording a short real-world sound with every photo&lt;/li&gt;
&lt;li&gt;Weekly mixtapes made from previous Bops&lt;/li&gt;
&lt;li&gt;More musical styles&lt;/li&gt;
&lt;li&gt;Collaborative reactions that add a tiny sound&lt;/li&gt;
&lt;li&gt;Animated share cards&lt;/li&gt;
&lt;li&gt;Daily prompts for friend rooms&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For now, I wanted to keep the first version focused on one simple feeling:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A normal moment can become something special when you hear it differently.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

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

&lt;p&gt;&lt;strong&gt;Try Boppi → &lt;a href="https://boppi.vercel.app" rel="noopener noreferrer"&gt;https://boppi.vercel.app&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;View the code → &lt;a href="https://github.com/pol-cova/boppi" rel="noopener noreferrer"&gt;https://github.com/pol-cova/boppi&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I would love to know what your first Bop would be.&lt;/p&gt;

&lt;p&gt;What was one little thing you loved today? 🎵&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>weekendchallenge</category>
      <category>webdev</category>
      <category>discuss</category>
    </item>
    <item>
      <title>I built gessi: playful OS-style interfaces with just HTML</title>
      <dc:creator>Paul Contreras</dc:creator>
      <pubDate>Thu, 25 Jun 2026 19:58:38 +0000</pubDate>
      <link>https://dev.to/paulcontr_/i-built-gessi-playful-os-style-interfaces-with-just-html-9p0</link>
      <guid>https://dev.to/paulcontr_/i-built-gessi-playful-os-style-interfaces-with-just-html-9p0</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Meet gessi, a dependency-free CSS and Web Component library for building retro desktops, draggable windows, and expressive interfaces without framework lock-in.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8bcwvrkytwd46o80st6r.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8bcwvrkytwd46o80st6r.jpeg" alt="gess-o" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I’ve been working on &lt;strong&gt;Gessi&lt;/strong&gt;, an open-source CSS and Web Component library for building playful web interfaces with plain HTML.&lt;/p&gt;

&lt;p&gt;The idea behind it is simple:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Websites do not always have to feel like landing pages.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;They can feel like desktops.&lt;br&gt;
They can feel like tiny operating systems.&lt;br&gt;
They can feel like archives, control panels, old computers, personal desks, or interactive product demos.&lt;/p&gt;

&lt;p&gt;That is the kind of web I wanted to make easier to build.&lt;/p&gt;

&lt;p&gt;So I made &lt;strong&gt;Gessi&lt;/strong&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  A tiny desktop in HTML
&lt;/h2&gt;

&lt;p&gt;Here is the smallest example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;script
  &lt;/span&gt;&lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"module"&lt;/span&gt;
  &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://cdn.jsdelivr.net/npm/@pol-cova/gessi/dist/gessi.js"&lt;/span&gt;
&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;gessi-desktop&lt;/span&gt; &lt;span class="na"&gt;menu=&lt;/span&gt;&lt;span class="s"&gt;"◆ My OS,File,View,Help"&lt;/span&gt; &lt;span class="na"&gt;clock=&lt;/span&gt;&lt;span class="s"&gt;"09:41"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;gessi-window&lt;/span&gt; &lt;span class="na"&gt;title=&lt;/span&gt;&lt;span class="s"&gt;"hello.html"&lt;/span&gt; &lt;span class="na"&gt;width=&lt;/span&gt;&lt;span class="s"&gt;"38rem"&lt;/span&gt; &lt;span class="na"&gt;active&lt;/span&gt; &lt;span class="na"&gt;draggable&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;h1&amp;gt;&lt;/span&gt;Hello from Gessi&lt;span class="nt"&gt;&amp;lt;/h1&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;This is normal HTML inside a draggable window.&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/gessi-window&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/gessi-desktop&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is a complete working page.&lt;/p&gt;

&lt;p&gt;The script registers the components and loads the styles automatically.&lt;/p&gt;

&lt;p&gt;No React runtime.&lt;br&gt;
No build setup.&lt;br&gt;
No package installation.&lt;br&gt;
No framework required.&lt;/p&gt;

&lt;p&gt;Just HTML.&lt;/p&gt;
&lt;h2&gt;
  
  
  What Gessi helps you build
&lt;/h2&gt;

&lt;p&gt;Gessi is made for interfaces that want to feel more expressive than a normal page.&lt;/p&gt;

&lt;p&gt;You can use it to build things like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a personal website that feels like a desktop&lt;/li&gt;
&lt;li&gt;a portfolio that opens projects inside windows&lt;/li&gt;
&lt;li&gt;a retro product demo&lt;/li&gt;
&lt;li&gt;a media archive with folders and panels&lt;/li&gt;
&lt;li&gt;an experimental blog layout&lt;/li&gt;
&lt;li&gt;a tiny browser-based operating system&lt;/li&gt;
&lt;li&gt;a playful docs or internal tool interface&lt;/li&gt;
&lt;li&gt;a landing page that feels more like an app&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The goal is not to replace your whole stack.&lt;/p&gt;

&lt;p&gt;The goal is to give you a set of reusable interface pieces so you can build something more memorable without starting from zero.&lt;/p&gt;
&lt;h2&gt;
  
  
  Components included
&lt;/h2&gt;

&lt;p&gt;Gessi includes the common pieces needed for small desktop-like interfaces:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;desktops&lt;/li&gt;
&lt;li&gt;draggable windows&lt;/li&gt;
&lt;li&gt;minimizable and zoomable windows&lt;/li&gt;
&lt;li&gt;dialogs&lt;/li&gt;
&lt;li&gt;nested child windows&lt;/li&gt;
&lt;li&gt;menus&lt;/li&gt;
&lt;li&gt;toolbars&lt;/li&gt;
&lt;li&gt;docks&lt;/li&gt;
&lt;li&gt;tabs&lt;/li&gt;
&lt;li&gt;trees&lt;/li&gt;
&lt;li&gt;desktop icons&lt;/li&gt;
&lt;li&gt;control panels&lt;/li&gt;
&lt;li&gt;alerts&lt;/li&gt;
&lt;li&gt;notifications&lt;/li&gt;
&lt;li&gt;tooltips&lt;/li&gt;
&lt;li&gt;meters&lt;/li&gt;
&lt;li&gt;responsive maps&lt;/li&gt;
&lt;li&gt;carousels&lt;/li&gt;
&lt;li&gt;image effects&lt;/li&gt;
&lt;li&gt;pixel patterns&lt;/li&gt;
&lt;li&gt;classic OS-inspired themes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can combine these pieces to create different kinds of browser-based environments.&lt;/p&gt;

&lt;p&gt;For example, the docs include complete examples like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Product OS&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Classic OS&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Media OS&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each one uses the same idea: normal HTML content wrapped inside reusable Gessi components.&lt;/p&gt;
&lt;h2&gt;
  
  
  Multiple visual systems
&lt;/h2&gt;

&lt;p&gt;Gessi currently includes five visual systems:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;neo
minimal
retro
old-tech
classic-os
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The idea is that your structure can stay mostly the same while the visual style changes.&lt;/p&gt;

&lt;p&gt;You can start with something clean and minimal, then move into a more nostalgic or old-tech look without rebuilding the whole interface.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I made it framework-independent
&lt;/h2&gt;

&lt;p&gt;I wanted Gessi to work in as many projects as possible.&lt;/p&gt;

&lt;p&gt;You can use it with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a single static HTML file&lt;/li&gt;
&lt;li&gt;Astro&lt;/li&gt;
&lt;li&gt;Eleventy&lt;/li&gt;
&lt;li&gt;Hugo&lt;/li&gt;
&lt;li&gt;Jekyll&lt;/li&gt;
&lt;li&gt;server-rendered templates&lt;/li&gt;
&lt;li&gt;React&lt;/li&gt;
&lt;li&gt;Vue&lt;/li&gt;
&lt;li&gt;Svelte&lt;/li&gt;
&lt;li&gt;any project that only wants the CSS layer&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The components are built with Web Components and use light DOM.&lt;/p&gt;

&lt;p&gt;That means your content remains normal HTML. It exists before JavaScript runs, stays available to your own CSS, and still has a readable fallback if the interactive layer does not load.&lt;/p&gt;

&lt;p&gt;Gessi handles the reusable interface behavior and visual chrome.&lt;/p&gt;

&lt;p&gt;Your project still owns the content, layout, and personality.&lt;/p&gt;

&lt;h2&gt;
  
  
  It also works with npm
&lt;/h2&gt;

&lt;p&gt;You can install it from npm:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; @pol-cova/gessi
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then import the CSS and components:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@pol-cova/gessi/css&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@pol-cova/gessi/components&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The component module is safe to import during SSR, and it does not inject CSS when used through a bundler.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this exists
&lt;/h2&gt;

&lt;p&gt;A lot of modern websites are starting to feel very similar.&lt;/p&gt;

&lt;p&gt;Same cards.&lt;br&gt;
Same dashboards.&lt;br&gt;
Same layouts.&lt;br&gt;
Same generic sections.&lt;/p&gt;

&lt;p&gt;There is nothing wrong with that. Those patterns are useful, and I use them too.&lt;/p&gt;

&lt;p&gt;But I also think the web is more fun when interfaces have personality.&lt;/p&gt;

&lt;p&gt;I wanted a small library that makes it easier to build websites that feel unusual, nostalgic, playful, or simply less generic.&lt;/p&gt;

&lt;p&gt;That is what Gessi is trying to become.&lt;/p&gt;

&lt;h2&gt;
  
  
  The project is early
&lt;/h2&gt;

&lt;p&gt;Gessi already has working components, examples, and documentation, but it is still early.&lt;/p&gt;

&lt;p&gt;There is a lot of room to improve the project.&lt;/p&gt;

&lt;p&gt;Some areas I would love help with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;new components&lt;/li&gt;
&lt;li&gt;better component APIs&lt;/li&gt;
&lt;li&gt;Markdown and MDX support&lt;/li&gt;
&lt;li&gt;a first-party pixel icon set&lt;/li&gt;
&lt;li&gt;more themes&lt;/li&gt;
&lt;li&gt;a public custom-theme API&lt;/li&gt;
&lt;li&gt;accessibility and keyboard testing&lt;/li&gt;
&lt;li&gt;Firefox and WebKit testing&lt;/li&gt;
&lt;li&gt;examples with real static-site generators&lt;/li&gt;
&lt;li&gt;documentation improvements&lt;/li&gt;
&lt;li&gt;design feedback&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You do not need to contribute a huge feature.&lt;/p&gt;

&lt;p&gt;A small bug report, browser test, accessibility fix, documentation correction, or example of something you built would already be valuable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try Gessi
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Documentation: &lt;a href="https://pol-cova.github.io/gessi/" rel="noopener noreferrer"&gt;https://pol-cova.github.io/gessi/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;GitHub: &lt;a href="https://github.com/pol-cova/gessi" rel="noopener noreferrer"&gt;https://github.com/pol-cova/gessi&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;npm: &lt;a href="https://www.npmjs.com/package/@pol-cova/gessi" rel="noopener noreferrer"&gt;https://www.npmjs.com/package/@pol-cova/gessi&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If Gessi helps you build something weird, nostalgic, playful, or simply less generic, I would genuinely love to see it.&lt;/p&gt;

&lt;p&gt;Feel free to try it, open an issue, share feedback, or contribute to the project.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>showdev</category>
      <category>css</category>
      <category>frontend</category>
    </item>
    <item>
      <title>Why I Redesigned StrictBlock to Make Focus Feel Easier</title>
      <dc:creator>Paul Contreras</dc:creator>
      <pubDate>Sun, 21 Jun 2026 06:43:55 +0000</pubDate>
      <link>https://dev.to/paulcontr_/why-i-redesigned-strictblock-to-make-focus-feel-easier-50al</link>
      <guid>https://dev.to/paulcontr_/why-i-redesigned-strictblock-to-make-focus-feel-easier-50al</guid>
      <description>&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/0jAXUn527r4"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;I rebuilt StrictBlock (my app) from the ground up.&lt;/p&gt;

&lt;p&gt;StrictBlock is an iPhone app blocker and focus app designed to help people stop procrastinating, protect deep work, and build better focus habits.&lt;/p&gt;

&lt;p&gt;For this relaunch, I did not want to just “refresh the UI.” I wanted to redesign the full product experience around one question:&lt;/p&gt;

&lt;p&gt;How can I make starting a focus session feel simple, strict, and useful?&lt;/p&gt;

&lt;p&gt;The new version focuses on reducing friction. Users can create focus profiles for study, work, sleep, deep work, or Pomodoro sessions, then start blocking distracting apps and websites with less setup.&lt;/p&gt;

&lt;p&gt;I also redesigned the app around accountability. StrictBlock now includes streaks, trophies, weekly reports, widgets, session journaling, and consequences for ending sessions early.&lt;/p&gt;

&lt;p&gt;As a developer, this redesign was a good reminder that productivity apps are not only about features. They are about behavior. The UI, the flow, the defaults, and the friction all shape whether someone actually stays focused.&lt;/p&gt;

&lt;p&gt;StrictBlock is now live with a complete redesign.&lt;/p&gt;

&lt;p&gt;Would love feedback from other builders, iOS devs, and product engineers.&lt;/p&gt;

&lt;p&gt;Try it here: &lt;a href="https://apps.apple.com/app/apple-store/id6749779711?pt=127882070&amp;amp;ct=peerlist&amp;amp;mt=8" rel="noopener noreferrer"&gt;Download strictblock on appstore&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>showdev</category>
      <category>writing</category>
      <category>mobile</category>
    </item>
    <item>
      <title>Can You Beat Gemini Before Sunset? I Built a Solstice Puzzle Duel</title>
      <dc:creator>Paul Contreras</dc:creator>
      <pubDate>Sun, 21 Jun 2026 05:17:03 +0000</pubDate>
      <link>https://dev.to/paulcontr_/can-you-beat-gemini-before-sunset-i-built-a-solstice-puzzle-duel-3622</link>
      <guid>https://dev.to/paulcontr_/can-you-beat-gemini-before-sunset-i-built-a-solstice-puzzle-duel-3622</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/june-game-jam-2026-06-03"&gt;June Solstice Game Jam&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;I built &lt;strong&gt;Color Queens: Solstice Duel&lt;/strong&gt;, a small logic puzzle game where you race Gemini before the sun sets.&lt;/p&gt;

&lt;p&gt;The idea started with one simple question:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can human logic beat an AI in a puzzle duel?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Many games let players compete against bots, but I had rarely seen a game where the opponent is actually a LLM. That curiosity became the starting point for this project. I wanted to see what would happen if an LLM was not just powering dialogue behind the scenes, but actively participating in the gameplay as a rival.&lt;/p&gt;

&lt;p&gt;In the game, you and Gemini receive the same board. Your goal is to place queens across colored regions while following the rules: one queen per color, no attacks, no row conflicts, no column conflicts, and no diagonal conflicts.&lt;/p&gt;

&lt;p&gt;For the June Solstice theme, I added a light and shadow layer. The board is not only about placing queens correctly, it is also about balancing daylight before sunset. As time passes, the mood shifts from warm daylight into night, so the solstice becomes part of the pressure of the game.&lt;/p&gt;

&lt;p&gt;I also added Pride-inspired boards because Color Queens is already about color, identity, and pattern. I wanted those boards to feel joyful and intentional, not just decorative.&lt;/p&gt;

&lt;p&gt;The main goal was to make something that feels simple to understand, but still has a strong hook:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You are not just solving a puzzle. You are dueling Gemini.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa30kh1h1uu9ggxy6hode.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa30kh1h1uu9ggxy6hode.png" alt="landing-demo" width="800" height="564"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Video Demo
&lt;/h2&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/h31tXgx1rZw"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;The project is built as a web game so anyone can try it without installing anything.&lt;/p&gt;

&lt;p&gt;Live demo:&lt;br&gt;
&lt;a href="//colorqueens.xyz"&gt;Try now! colorqueens.xyz&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Source code: &lt;a href="https://github.com/pol-cova/colorqueens-game" rel="noopener noreferrer"&gt;https://github.com/pol-cova/colorqueens-game&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;p&gt;I built the game around a local puzzle engine.&lt;/p&gt;

&lt;p&gt;The most important decision was this:&lt;br&gt;
&lt;strong&gt;Gemini is a player, not the judge.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Gemini can suggest a move, but the game validates that move locally using the same rules that apply to the human player. If Gemini makes an illegal move, the board calls it out.&lt;/p&gt;

&lt;p&gt;That made the duel feel more interesting, because the AI is not treated as magically correct. It has to play by the rules too.&lt;/p&gt;

&lt;p&gt;The main systems are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A Color Queens board renderer&lt;/li&gt;
&lt;li&gt;A local move validator&lt;/li&gt;
&lt;li&gt;A light and shadow solstice rule&lt;/li&gt;
&lt;li&gt;A Gemini opponent mode&lt;/li&gt;
&lt;li&gt;A backtracking solver&lt;/li&gt;
&lt;li&gt;A Turing Tape replay system&lt;/li&gt;
&lt;li&gt;A small binary mini-game called Bombe Calibration&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;strong&gt;Turing Tape&lt;/strong&gt; was one of my favorite parts to build. After a puzzle, it shows how the solver thinks step by step: scanning regions, trying cells, rejecting conflicts, accepting candidates, and backtracking when a branch fails.&lt;/p&gt;

&lt;p&gt;I wanted the algorithm to feel visible, almost like watching the machine think.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;Bombe Calibration&lt;/strong&gt; mini-game is a short binary challenge. Before unlocking a hint, the player taps a quick 0 and 1 sequence. It adds a little tension, sound, and movement while keeping the main game focused on logic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prize Category
&lt;/h2&gt;

&lt;p&gt;I am submitting this for both additional prize categories.&lt;/p&gt;

&lt;h3&gt;
  
  
  Best Google AI Usage
&lt;/h3&gt;

&lt;p&gt;Gemini is used directly inside the game as an opponent.&lt;br&gt;
Instead of only using AI to generate text or assets, I wanted Gemini to become part of the core game loop. It receives the current board state, proposes a queen placement, and then the local validator checks whether that move is legal.&lt;/p&gt;

&lt;p&gt;That means Gemini can win, fail, recover, or get rejected by the rules.&lt;/p&gt;

&lt;p&gt;I liked this approach because it makes the AI interaction playable. It is not just a chatbot on the side. It is part of the duel.&lt;/p&gt;

&lt;h3&gt;
  
  
  Best Ode to Alan Turing
&lt;/h3&gt;

&lt;p&gt;The game connects to Alan Turing through logic, algorithms, and machine reasoning.&lt;/p&gt;

&lt;p&gt;The Turing Tape shows a backtracking solver working through the puzzle one step at a time. It checks constraints, rejects invalid cells, and searches for a valid solution.&lt;/p&gt;

&lt;p&gt;The Bombe Calibration mini-game is also a small nod to code-breaking and binary logic.&lt;/p&gt;

&lt;p&gt;I wanted the Turing tribute to live inside the mechanics, not just in the story text.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final Thoughts
&lt;/h2&gt;

&lt;p&gt;This was a fun project because it mixed a few things I really care about: polished puzzle design, AI as part of the experience, and a simple idea that is easy to explain.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can you beat Gemini before sunset?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That is the whole challenge.&lt;/p&gt;

&lt;p&gt;Try the game, race the machine, and let me know who wins. Coming soon on the App Store. &lt;a href="//colorqueens.xyz"&gt;Join to the waitlist&lt;/a&gt;&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>gamechallenge</category>
      <category>gamedev</category>
      <category>ai</category>
    </item>
    <item>
      <title>Button Remapping: Knowing When to Stop Reverse-Engineering</title>
      <dc:creator>Paul Contreras</dc:creator>
      <pubDate>Sun, 14 Jun 2026 10:18:59 +0000</pubDate>
      <link>https://dev.to/paulcontr_/button-remapping-knowing-when-to-stop-reverse-engineering-17b0</link>
      <guid>https://dev.to/paulcontr_/button-remapping-knowing-when-to-stop-reverse-engineering-17b0</guid>
      <description>&lt;p&gt;&lt;em&gt;Part 5 of a series on building a native Razer mouse controller for macOS.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffhpmnvzoi1rez3wkd4pw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffhpmnvzoi1rez3wkd4pw.png" alt="razer control ui" width="419" height="617"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;Last feature: remap the two side buttons. Make the forward one a left click, or copy, or whatever. Synapse does it, so the mouse can do it, so I just need the command. Right?&lt;/p&gt;

&lt;p&gt;This is the part where I spent an evening getting the device to say yes and do nothing, then threw the whole approach away and solved it in a completely different place. I think the second half is the more useful lesson.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Firmware Hunt
&lt;/h2&gt;

&lt;p&gt;There's no public table for "remap a DeathAdder V2 button."  So I built a probe tool: send an arbitrary class/id with arbitrary args, print the full response. Then I swept ranges of commands looking for anything that responded &lt;code&gt;0x02&lt;/code&gt; (OK) and looked button-shaped.&lt;/p&gt;

&lt;p&gt;I found the profile system. Class &lt;code&gt;0x05&lt;/code&gt; and &lt;code&gt;0x06&lt;/code&gt; commands all answered. There's a per-button read at &lt;code&gt;0x05 / 0x8A&lt;/code&gt; that echoes back which button you asked about. There's a write at &lt;code&gt;0x05 / 0x0A&lt;/code&gt;. Driver mode, profile slots, the works. It all looked exactly like where button bindings would live.&lt;/p&gt;

&lt;p&gt;So I tried the obvious write: button 4, action type mouse, parameter left-click. Sent it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;OK&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pressed the button. Nothing changed. Read it back. Unchanged.&lt;/p&gt;

&lt;p&gt;Tried it in driver mode first. OK. Nothing. Tried selecting the profile slot before writing. OK. Nothing. Tried five different argument layouts. Every single one came back &lt;code&gt;0x02&lt;/code&gt;. Every single one did nothing.&lt;/p&gt;

&lt;p&gt;If you read part 3, you know exactly what this is. &lt;code&gt;0x02&lt;/code&gt; means the packet was well-formed and understood as &lt;em&gt;a&lt;/em&gt; command. It does not mean it did what I wanted. I was writing into a void the firmware was politely accepting.&lt;/p&gt;

&lt;p&gt;It got worse. One of my "reset to default" attempts used action type &lt;code&gt;0x00&lt;/code&gt;, which I assumed meant "firmware default." It actually means &lt;strong&gt;disabled&lt;/strong&gt;. I turned my own side button off and didn't realize for a minute why pressing it did nothing at all. (Fix: clear the profile slot entirely and let the firmware take back over. Or just unplug and replug.)&lt;/p&gt;

&lt;p&gt;After an evening of this I had a pile of commands that all returned OK and a mouse whose buttons were exactly as unremapped as when I started. The firmware path was a wall. Without capturing real Synapse USB traffic on Windows to see the exact bytes, I was guessing, and the device's "OK" gave me no signal to guess better.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Pivot
&lt;/h2&gt;

&lt;p&gt;Here's the reframe that fixed it. I don't actually need the &lt;em&gt;mouse&lt;/em&gt; to remap the button. I need the button press to &lt;em&gt;do something different on my Mac.&lt;/em&gt; Those aren't the same problem, and the second one doesn't involve Razer at all.&lt;/p&gt;

&lt;p&gt;macOS lets you intercept input events with a &lt;code&gt;CGEventTap&lt;/code&gt;. You watch for mouse button events, and when the one you care about fires, you swallow it and post a different event instead. This is how the commercial Mac mouse utilities do it. None of them reprogram firmware. They all sit in the event stream.&lt;/p&gt;

&lt;p&gt;First I just needed to know which button number the OS sees for each side button. A listen-only tap that logs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getIntegerValueField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mouseEventButtonNumber&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"otherMouseDown buttonNumber=&lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pressed both side buttons. The forward one (near the wheel) is &lt;code&gt;#4&lt;/code&gt;, the back one is &lt;code&gt;#3&lt;/code&gt;. Now I can target them.&lt;/p&gt;




&lt;h2&gt;
  
  
  Actually Remapping
&lt;/h2&gt;

&lt;p&gt;To &lt;em&gt;change&lt;/em&gt; an event, not just watch it, the tap has to be active (&lt;code&gt;.defaultTap&lt;/code&gt;), and that needs Accessibility permission, not Input Monitoring. Different permission, different System Settings pane. Worth knowing up front: a listen-only tap needs Input Monitoring, a tap that modifies or drops events needs Accessibility.&lt;/p&gt;

&lt;p&gt;The handler: if it's a button I manage and it's mapped to something, swallow the original and synthesize the replacement.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;nonisolated&lt;/span&gt; &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;CGEventType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;CGEvent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;Unmanaged&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;CGEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;button&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getIntegerValueField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mouseEventButtonNumber&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;MouseAction&lt;/span&gt;
    &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="n"&gt;button&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;forwardAction&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;backAction&lt;/span&gt;
    &lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kt"&gt;Unmanaged&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;passUnretained&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;// not ours&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;passthrough&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kt"&gt;Unmanaged&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;passUnretained&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;type&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;otherMouseDown&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;perform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;location&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;   &lt;span class="c1"&gt;// swallow the original&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;perform&lt;/code&gt; posts whatever you mapped to: a left click is a synthesized &lt;code&gt;leftMouseDown&lt;/code&gt;/&lt;code&gt;leftMouseUp&lt;/code&gt;, copy is &lt;code&gt;⌘C&lt;/code&gt; as a key event, and so on. Press the side button, the click happens where your cursor is. It just works, immediately, the first time, with no guessing about byte layouts.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Tradeoff, Honestly
&lt;/h2&gt;

&lt;p&gt;Software remapping isn't strictly better than firmware remapping. The differences are real:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It only works while my app is running. Firmware remapping would persist on the mouse, even on another computer or in a BIOS screen.&lt;/li&gt;
&lt;li&gt;It's Mac-only. The mouse's own memory doesn't know anything changed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a menu bar app that launches at login, "only while the app runs" is basically always, so the tradeoff barely bites. And the firmware version had one fatal flaw the software version doesn't: it didn't work.&lt;/p&gt;

&lt;p&gt;That's the actual lesson from this whole feature. I spent an evening determined to solve the problem at the layer I'd decided it lived at. The device kept telling me OK and doing nothing, and I kept reading that as "almost there." The fix wasn't a better byte sequence. It was stepping up one layer to where the problem was tractable and the feedback was honest: press button, thing happens, or it doesn't.&lt;/p&gt;

&lt;p&gt;Reverse-engineering is fun right up until the point where it's just you and a device that says yes to everything. Knowing when to stop is part of the skill.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where This Lands
&lt;/h2&gt;

&lt;p&gt;Five parts in, the app does what Synapse does for the things I care about: DPI with presets, static and animated lighting, side button remapping, all from a menu bar icon, no Razer account, no background service phoning home. The protocol work was the interesting half. The honest half was admitting one feature didn't belong in the protocol at all.&lt;/p&gt;

&lt;p&gt;The full code is at &lt;a href="//github.com/pol-cova/RazerControl"&gt;github.com/pol-cova/RazerControl&lt;/a&gt;&lt;/p&gt;

</description>
      <category>tutorial</category>
      <category>opensource</category>
      <category>learning</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Wrapping It in a Menu Bar App (and Three Things That Broke)</title>
      <dc:creator>Paul Contreras</dc:creator>
      <pubDate>Wed, 10 Jun 2026 07:16:26 +0000</pubDate>
      <link>https://dev.to/paulcontr_/wrapping-it-in-a-menu-bar-app-and-three-things-that-broke-55lc</link>
      <guid>https://dev.to/paulcontr_/wrapping-it-in-a-menu-bar-app-and-three-things-that-broke-55lc</guid>
      <description>&lt;p&gt;&lt;em&gt;Part 4 of a series on building a native Razer mouse controller for macOS.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;The protocol works from a command line tool. Now I want it living in the menu bar: a little mouse icon, click it, set DPI and color, no Terminal. SwiftUI has &lt;code&gt;MenuBarExtra&lt;/code&gt; for exactly this, so it should be quick.&lt;/p&gt;

&lt;p&gt;It was not quick. Three separate things broke, none of them about the mouse. This is the part where the OS fights you.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Crash Before Anything Renders
&lt;/h2&gt;

&lt;p&gt;The starting point. An accessory app (menu bar only, no Dock icon) usually sets that with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;@main&lt;/span&gt;
&lt;span class="kd"&gt;struct&lt;/span&gt; &lt;span class="kt"&gt;RazerControlApp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;App&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;NSApp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setActivationPolicy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;accessory&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kd"&gt;some&lt;/span&gt; &lt;span class="kt"&gt;Scene&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Launched it. Instant crash:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Fatal error: Unexpectedly found nil while implicitly unwrapping an Optional value
RazerControlApp.swift:15
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;NSApp&lt;/code&gt; is &lt;code&gt;NSApplication!&lt;/code&gt;, an implicitly unwrapped optional. It's nil this early. The app object's &lt;code&gt;init()&lt;/code&gt; runs before the shared application is set up, so &lt;code&gt;NSApp&lt;/code&gt; is still nothing and the force-unwrap explodes.&lt;/p&gt;

&lt;p&gt;This bites harder on newer projects because the default actor isolation is &lt;code&gt;MainActor&lt;/code&gt;, and the timing of &lt;code&gt;init()&lt;/code&gt; lands before &lt;code&gt;NSApp&lt;/code&gt; exists. You can fight the lifecycle, or you can not use code for this at all.&lt;/p&gt;

&lt;p&gt;The fix is a plist key. &lt;code&gt;LSUIElement = YES&lt;/code&gt; (in build settings, &lt;code&gt;INFOPLIST_KEY_LSUIElement&lt;/code&gt;). It's read by the launch machinery before your &lt;code&gt;App&lt;/code&gt; struct is ever created, so there's no race and no &lt;code&gt;NSApp&lt;/code&gt; to unwrap. Delete the &lt;code&gt;init()&lt;/code&gt; entirely.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;INFOPLIST_KEY_LSUIElement&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;YES&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No code, no crash, no Dock icon. The thing I was writing code to do was a setting the whole time.&lt;/p&gt;




&lt;h2&gt;
  
  
  macOS Won't Let the App Touch the Mouse
&lt;/h2&gt;

&lt;p&gt;App launches now. Connects to the device. Immediately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;TCC deny IOHIDDeviceOpen
RazerControl: device not found
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;IOHIDDeviceOpen&lt;/code&gt; on a mouse needs Input Monitoring permission. The command line tool worked the whole time because it ran from Terminal, and Terminal already had Input Monitoring. A fresh signed &lt;code&gt;.app&lt;/code&gt; has nothing.&lt;/p&gt;

&lt;p&gt;macOS won't pop the permission dialog on its own. You have to ask, explicitly, with the right call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;access&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;IOHIDCheckAccess&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kIOHIDRequestTypeListenEvent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;access&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;kIOHIDAccessTypeUnknown&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;IOHIDRequestAccess&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kIOHIDRequestTypeListenEvent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;access&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;kIOHIDAccessTypeDenied&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// user said no earlier — send them to System Settings&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;kIOHIDRequestTypeListenEvent&lt;/code&gt; is imported as a plain constant, not a Swift enum case, so it's &lt;code&gt;kIOHIDRequestTypeListenEvent&lt;/code&gt; and not &lt;code&gt;.listenEvent&lt;/code&gt;. That cost me a compile error and a confused minute.&lt;/p&gt;

&lt;p&gt;First launch now shows the system prompt. If the user denied it before, the prompt won't come back, so the app needs to detect that case and point them at System Settings rather than silently failing.&lt;/p&gt;




&lt;h2&gt;
  
  
  MenuBarExtra Has Two Personalities
&lt;/h2&gt;

&lt;p&gt;The popover opened, but the layout was wrong. Everything stacked vertically like a dropdown menu. My &lt;code&gt;HStack&lt;/code&gt; of buttons rendered as separate rows. The color picker wouldn't open. Frame widths were ignored.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;MenuBarExtra&lt;/code&gt; has two render styles. The default is &lt;code&gt;.menu&lt;/code&gt;, which puts your content inside a real &lt;code&gt;NSMenu&lt;/code&gt;, so every view becomes a menu item and normal layout doesn't apply. The other is &lt;code&gt;.window&lt;/code&gt;, which renders your SwiftUI in an actual floating panel where layout works like everywhere else.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kt"&gt;MenuBarExtra&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"RazerControl"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;systemImage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"computermouse.fill"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;MenuBarView&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;menuBarExtraStyle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;window&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One modifier. Suddenly HStacks are horizontal, widths apply, the popover looks like a popover. If your menu bar layout is behaving strangely, this is almost certainly why.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Color Picker That Wouldn't Open
&lt;/h2&gt;

&lt;p&gt;Last one. SwiftUI's &lt;code&gt;ColorPicker&lt;/code&gt; renders a little well; click it and the system color panel opens. Except in my app, clicking did nothing.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ColorPicker&lt;/code&gt; relies on &lt;code&gt;NSColorPanel&lt;/code&gt;. An accessory app (the &lt;code&gt;LSUIElement&lt;/code&gt; thing from earlier) can't reliably bring up that panel, because it isn't allowed to become active the normal way, and the menu bar window dismisses the moment focus moves. So the panel either opens behind everything or never shows. The "selection" never happens, so the color never changes. One root cause, two symptoms.&lt;/p&gt;

&lt;p&gt;I could have fought the activation policy. Instead I asked what a menu bar RGB control actually needs, and the answer is not a full color wheel. It's a handful of good colors, one tap.&lt;/p&gt;

&lt;p&gt;So I replaced the picker with a row of preset swatches:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;palette&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;Color&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;red&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;orange&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;yellow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;green&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cyan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;blue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;purple&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pink&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;white&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="c1"&gt;// tap a swatch -&amp;gt; apply immediately, ring the selected one&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No &lt;code&gt;NSColorPanel&lt;/code&gt;, no activation problem, and honestly better for a quick menu. Tap red, the logo's red. Done. The "fix" was less code and a nicer interaction than the thing that was broken.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;Part 5 is the button remapping, which is the part I was most sure would be easy and turned out to be the hardest. I spent an evening trying to reverse-engineer the firmware command for it, got the device to accept my writes, and watched nothing happen. Then I gave up on the firmware entirely and solved it a different way. That story is its own post.&lt;/p&gt;

</description>
      <category>programming</category>
      <category>tutorial</category>
      <category>opensource</category>
      <category>showdev</category>
    </item>
    <item>
      <title>DPI, Color, and Why "OK" Doesn't Mean It Worked</title>
      <dc:creator>Paul Contreras</dc:creator>
      <pubDate>Mon, 08 Jun 2026 22:51:45 +0000</pubDate>
      <link>https://dev.to/paulcontr_/dpi-color-and-why-ok-doesnt-mean-it-worked-2nc</link>
      <guid>https://dev.to/paulcontr_/dpi-color-and-why-ok-doesnt-mean-it-worked-2nc</guid>
      <description>&lt;p&gt;&lt;em&gt;Part 3 of a series on building a native Razer mouse controller for macOS.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;We have the envelope from part 2. Now the commands that actually do something: DPI and lighting. The DPI one is easy. The color one taught me the most useful lesson in this whole project, which is that the device telling you &lt;code&gt;0x02&lt;/code&gt; (OK) is not the device telling you it did what you wanted.&lt;/p&gt;




&lt;h2&gt;
  
  
  Setting DPI
&lt;/h2&gt;

&lt;p&gt;Command class &lt;code&gt;0x04&lt;/code&gt;, id &lt;code&gt;0x05&lt;/code&gt;, data_size &lt;code&gt;0x07&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The value is the raw DPI, not divided by anything. 800 DPI is &lt;code&gt;0x0320&lt;/code&gt;, split into a high byte and a low byte. The mouse stores X and Y sensitivity separately, so you send the pair twice.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;dpiReport&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="nv"&gt;dpi&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;UInt8&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;UInt16&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dpi&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;UInt8&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="nv"&gt;repeating&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mh"&gt;0x3F&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mh"&gt;0x07&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mh"&gt;0x04&lt;/span&gt;          &lt;span class="c1"&gt;// class: DPI&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mh"&gt;0x05&lt;/span&gt;          &lt;span class="c1"&gt;// id: set DPI&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mh"&gt;0x00&lt;/span&gt;          &lt;span class="c1"&gt;// stage&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;UInt8&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// X high&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;UInt8&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mh"&gt;0xFF&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// X low&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;11&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;UInt8&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// Y high&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;UInt8&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mh"&gt;0xFF&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// Y low&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;88&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="mi"&gt;87&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;^&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This one is fire-and-forget. You don't need to read a response. Set it, feel the cursor speed change.&lt;/p&gt;




&lt;h2&gt;
  
  
  Reading DPI Back
&lt;/h2&gt;

&lt;p&gt;There's a matching read command, class &lt;code&gt;0x04&lt;/code&gt;, id &lt;code&gt;0x85&lt;/code&gt;. Same idea, the id just has the high bit set, which is the convention for "get" versions of Razer commands.&lt;/p&gt;

&lt;p&gt;I didn't think I needed this until the GUI app shipped and immediately created a bug: every time you opened the app it reset your DPI to 800. The app had a default of 800 and pushed it on connect, stomping whatever you'd actually set.&lt;/p&gt;

&lt;p&gt;The fix was to read the device's real DPI on connect instead of forcing a default:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;getDPI&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;Int&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// send class 0x04 id 0x85, read response&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;return&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kt"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The DPI sits in the same byte positions in the response as you'd send it. Read first, then the UI reflects reality instead of overwriting it. Obvious in hindsight. Most of these bugs are.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Color Command That Lied to Me
&lt;/h2&gt;

&lt;p&gt;This is the part worth reading.&lt;/p&gt;

&lt;p&gt;I wanted to set the logo and scroll wheel to a static color. I found a command that looked right, class &lt;code&gt;0x03&lt;/code&gt;, id &lt;code&gt;0x03&lt;/code&gt;, packed in the zone and an RGB triple, sent it. Got &lt;code&gt;0x02&lt;/code&gt; back. OK. Accepted.&lt;/p&gt;

&lt;p&gt;Nothing happened. The LED didn't change.&lt;/p&gt;

&lt;p&gt;I checked the CRC. Fine. Checked the transaction ID. Fine. Checked the bytes ten times. The device kept saying &lt;code&gt;0x02&lt;/code&gt;. Accepted, accepted, accepted, and the logo stayed the color it already was.&lt;/p&gt;

&lt;p&gt;The problem: &lt;code&gt;0x03 / 0x03&lt;/code&gt; is not "set color." It's "set LED brightness." I was setting the brightness to a value, the device was happily setting the brightness to that value, and reporting success. It just wasn't a color command at all. I'd been sending a perfectly valid command for the wrong thing.&lt;/p&gt;

&lt;p&gt;The real static color command for this device lives in a different family. Class &lt;code&gt;0x0F&lt;/code&gt;, id &lt;code&gt;0x02&lt;/code&gt;, the "extended matrix effect" commands. The layout:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="n"&gt;data_size&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mh"&gt;0x09&lt;/span&gt;
&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mh"&gt;0x01&lt;/span&gt;        &lt;span class="c1"&gt;// varstore&lt;/span&gt;
&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;led_id&lt;/span&gt;      &lt;span class="c1"&gt;// scroll = 0x01, logo = 0x04&lt;/span&gt;
&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mh"&gt;0x01&lt;/span&gt;        &lt;span class="c1"&gt;// effect: static&lt;/span&gt;
&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mh"&gt;0x00&lt;/span&gt;
&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mh"&gt;0x00&lt;/span&gt;
&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mh"&gt;0x01&lt;/span&gt;
&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;
&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;
&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sent that. Logo turned green. First time the lighting actually moved.&lt;/p&gt;

&lt;p&gt;The lesson stuck with me: a status of &lt;code&gt;0x02&lt;/code&gt; only tells you the packet was well-formed and the device understood it as &lt;em&gt;a&lt;/em&gt; command. It says nothing about whether it was the command you meant. A valid brightness-set and a valid color-set both return &lt;code&gt;0x02&lt;/code&gt;. If you're debugging by status code alone, you can burn an hour sending textbook-correct commands that do the wrong job.&lt;/p&gt;




&lt;h2&gt;
  
  
  Effects Come for Free
&lt;/h2&gt;

&lt;p&gt;Once you're in the &lt;code&gt;0x0F / 0x02&lt;/code&gt; family, the other lighting effects are just a different effect byte at &lt;code&gt;args[2]&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Effect&lt;/th&gt;
&lt;th&gt;args[2]&lt;/th&gt;
&lt;th&gt;data_size&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Off&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0x00&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Static&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0x01&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;9 (+ rgb)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Breathing&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0x02&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;9 (+ rgb)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Spectrum&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0x03&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Spectrum is the rainbow cycle. You set it once and the mouse firmware animates it on its own, no polling from your app. Breathing pulses a single color. Off goes dark. Same envelope, one byte different. After the static color fight, adding three more effects took about ten minutes.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;Part 4 leaves the protocol behind and wraps all this in a SwiftUI menu bar app. That sounds like the easy part. It was not. There's a crash that only happens because of a build setting, a permission macOS won't grant without being asked in a specific way, and a color picker that refuses to open. The protocol was more honest than the UI framework.&lt;/p&gt;

</description>
      <category>discuss</category>
      <category>learning</category>
      <category>sideprojects</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>The Packet Format: 90 Bytes and One Cursed Byte</title>
      <dc:creator>Paul Contreras</dc:creator>
      <pubDate>Sun, 07 Jun 2026 06:35:11 +0000</pubDate>
      <link>https://dev.to/paulcontr_/the-packet-format-90-bytes-and-one-cursed-byte-55pe</link>
      <guid>https://dev.to/paulcontr_/the-packet-format-90-bytes-and-one-cursed-byte-55pe</guid>
      <description>&lt;p&gt;&lt;em&gt;Part 2 of a series on building a native Razer mouse controller for macOS.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;In part 1 we found the right HID interface and learned how to push a feature report at it. The report was just 90 zero bytes, which does nothing. This part is about what actually goes in those 90 bytes.&lt;/p&gt;

&lt;p&gt;Every Razer config command uses the same fixed layout. Once you have the layout, every feature (DPI, lighting, polling rate) is just different values in the same envelope.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Layout
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[0]      status
[1]      transaction_id
[2-3]    remaining_packets
[4]      protocol_type
[5]      data_size
[6]      command_class
[7]      command_id
[8-87]   arguments (80 bytes)
[88]     CRC
[89]     reserved
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When you send a command, byte &lt;code&gt;[0]&lt;/code&gt; (status) is 0. When the device replies, it writes a status code back into byte &lt;code&gt;[0]&lt;/code&gt;. Same buffer shape both ways.&lt;/p&gt;

&lt;p&gt;The two bytes that pick what the command does are &lt;code&gt;command_class&lt;/code&gt; (&lt;code&gt;[6]&lt;/code&gt;) and &lt;code&gt;command_id&lt;/code&gt; (&lt;code&gt;[7]&lt;/code&gt;). Think of class as the subsystem (DPI, lighting, profiles) and id as the specific action inside it. Everything from byte 8 onward is arguments, and what they mean depends entirely on the class/id pair.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;data_size&lt;/code&gt; (&lt;code&gt;[5]&lt;/code&gt;) is how many of the argument bytes are meaningful. If your command uses 3 argument bytes, data_size is 3. Get this wrong and the device usually ignores you.&lt;/p&gt;




&lt;h2&gt;
  
  
  The CRC
&lt;/h2&gt;

&lt;p&gt;Byte &lt;code&gt;[88]&lt;/code&gt; is a checksum. It's an XOR of every byte from index 2 through 87, inclusive.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;88&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="mi"&gt;87&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;^&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole thing. No polynomial, no lookup table, just XOR. If the CRC is wrong the device rejects the packet, and you'll get a status that isn't &lt;code&gt;0x02&lt;/code&gt; or no response at all.&lt;/p&gt;

&lt;p&gt;The range matters. It's &lt;code&gt;2...87&lt;/code&gt;, not &lt;code&gt;0...89&lt;/code&gt;. The status and transaction ID at the front are excluded, and so are the CRC and reserved bytes at the end. I got this wrong the first time by including byte 1 and spent a while convinced my command bytes were the problem.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Transaction ID
&lt;/h2&gt;

&lt;p&gt;Byte &lt;code&gt;[1]&lt;/code&gt; is the transaction ID, and it is the single most annoying byte in the whole format.&lt;/p&gt;

&lt;p&gt;It's not part of the command. It doesn't change behavior. It's a tag the device firmware checks before it will even look at the rest of the packet. If it's wrong, the device acts like you said nothing.&lt;/p&gt;

&lt;p&gt;The catch: the correct value is different across Razer devices, and nobody documents it per model. For the DeathAdder V2 it's &lt;code&gt;0x3F&lt;/code&gt;. Other devices use &lt;code&gt;0xFF&lt;/code&gt; or &lt;code&gt;0x1F&lt;/code&gt;. There's no command to ask the device what its transaction ID is. You either know it or you guess.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mh"&gt;0x3F&lt;/span&gt;   &lt;span class="c1"&gt;// DeathAdder V2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you're porting this to another Razer mouse and nothing works even though your command bytes look right, this is the first thing to change. Try &lt;code&gt;0xFF&lt;/code&gt;, then &lt;code&gt;0x1F&lt;/code&gt;, then &lt;code&gt;0x3F&lt;/code&gt;. One of them will start returning &lt;code&gt;0x02&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I hardcoded it. There's no reason to make it configurable in the app, because a wrong value just means a dead command.&lt;/p&gt;




&lt;h2&gt;
  
  
  A Real Command
&lt;/h2&gt;

&lt;p&gt;Here's the firmware version request, which is the simplest useful command and a good first target because it returns data you can sanity-check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;report&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;UInt8&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="nv"&gt;repeating&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mh"&gt;0x3F&lt;/span&gt;   &lt;span class="c1"&gt;// transaction id&lt;/span&gt;
&lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mh"&gt;0x02&lt;/span&gt;   &lt;span class="c1"&gt;// data_size&lt;/span&gt;
&lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mh"&gt;0x00&lt;/span&gt;   &lt;span class="c1"&gt;// command_class: standard&lt;/span&gt;
&lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mh"&gt;0x81&lt;/span&gt;   &lt;span class="c1"&gt;// command_id: get firmware&lt;/span&gt;
&lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;88&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="mi"&gt;87&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;^&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Send it, wait 300ms, read the response. If the stack is working, byte &lt;code&gt;[0]&lt;/code&gt; of the response comes back &lt;code&gt;0x02&lt;/code&gt;, and the firmware version sits in bytes &lt;code&gt;[9]&lt;/code&gt; and &lt;code&gt;[10]&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"firmware: &lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;// firmware: 2.0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Seeing &lt;code&gt;2.0&lt;/code&gt; print instead of &lt;code&gt;0.0&lt;/code&gt; is the moment you know the whole chain works: right interface, right transaction ID, right CRC, right delay. Everything after this is just changing class, id, and arguments.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;Part 3 builds the real commands on top of this envelope: setting DPI, reading it back, and the static color command that I got wrong twice before it worked. There's a good lesson buried in that one about why "command accepted" doesn't mean "command did anything."&lt;/p&gt;

</description>
      <category>discuss</category>
      <category>learning</category>
      <category>opensource</category>
      <category>programming</category>
    </item>
    <item>
      <title>USB HID on macOS: Talking to Devices with IOKit</title>
      <dc:creator>Paul Contreras</dc:creator>
      <pubDate>Mon, 01 Jun 2026 07:44:16 +0000</pubDate>
      <link>https://dev.to/paulcontr_/usb-hid-on-macos-talking-to-devices-with-iokit-3g2n</link>
      <guid>https://dev.to/paulcontr_/usb-hid-on-macos-talking-to-devices-with-iokit-3g2n</guid>
      <description>&lt;p&gt;&lt;em&gt;Part 1 of a series on building a native Razer mouse controller for macOS.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;USB HID (Human Interface Device) is the protocol your mouse, keyboard, and gamepad use to talk to the OS. Most of the time it's invisible; The OS handles it and your app gets abstracted events.&lt;/p&gt;

&lt;p&gt;But HID also has &lt;strong&gt;feature reports&lt;/strong&gt;: raw byte arrays you can send directly to a device outside of normal input events. Manufacturers use these for configuration. That's how Razer Synapse sets DPI and RGB — and it's what we need to replace it.&lt;/p&gt;

&lt;p&gt;On macOS, the API for this is &lt;code&gt;IOHIDManager&lt;/code&gt; inside &lt;code&gt;IOKit&lt;/code&gt;. No kernel extension required.&lt;/p&gt;




&lt;h2&gt;
  
  
  Setup
&lt;/h2&gt;

&lt;p&gt;Command Line Tool target in Xcode, macOS, Swift. No sandbox  add &lt;code&gt;App Sandbox&lt;/code&gt; to entitlements and immediately remove it, or just confirm it's not there. IOKit feature reports fail silently with sandbox on.&lt;/p&gt;

&lt;p&gt;Link &lt;code&gt;IOKit.framework&lt;/code&gt; under target → General → Frameworks and Libraries.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;import&lt;/span&gt; &lt;span class="kt"&gt;IOKit&lt;/span&gt;
&lt;span class="kd"&gt;import&lt;/span&gt; &lt;span class="kt"&gt;IOKit&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hid&lt;/span&gt;
&lt;span class="kd"&gt;import&lt;/span&gt; &lt;span class="kt"&gt;Foundation&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Finding a Device
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;IOHIDManager&lt;/code&gt; takes a matching dictionary. At minimum, vendor ID and product ID.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;manager&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;IOHIDManagerCreate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kCFAllocatorDefault&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;IOOptionBits&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kIOHIDOptionsTypeNone&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="kt"&gt;IOHIDManagerSetDeviceMatching&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;manager&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nv"&gt;kIOHIDVendorIDKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="mh"&gt;0x1532&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nv"&gt;kIOHIDProductIDKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mh"&gt;0x0084&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kt"&gt;CFDictionary&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="kt"&gt;IOHIDManagerOpen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;manager&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;IOOptionBits&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kIOHIDOptionsTypeNone&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;kIOReturnSuccess&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"failed to open manager"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;devices&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;IOHIDManagerCopyDevices&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;manager&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as?&lt;/span&gt; &lt;span class="kt"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;IOHIDDevice&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;devices&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isEmpty&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"device not found"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you don't know your device's PID yet, filter by vendor ID only and print everything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kt"&gt;IOHIDManagerSetDeviceMatching&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;manager&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;kIOHIDVendorIDKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mh"&gt;0x1532&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kt"&gt;CFDictionary&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;// ... open manager ...&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;devices&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;IOHIDDeviceGetProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kIOHIDProductKey&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kt"&gt;CFString&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as?&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="s"&gt;"?"&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;pid&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;IOHIDDeviceGetProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kIOHIDProductIDKey&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kt"&gt;CFString&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as?&lt;/span&gt; &lt;span class="kt"&gt;Int&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="s"&gt;: 0x&lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;radix&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  The Multiple Interfaces Problem
&lt;/h2&gt;

&lt;p&gt;One physical USB device often enumerates as multiple HID interfaces. A Razer DeathAdder V2 shows up as four:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;page: 0x1 | usage: 0x6 | maxFeatureReport: 1
page: 0x1 | usage: 0x6 | maxFeatureReport: 0
page: 0x59 | usage: 0x1 | maxFeatureReport: 64
page: 0x1 | usage: 0x2 | maxFeatureReport: 90
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;IOHIDManagerCopyDevices&lt;/code&gt; returns a &lt;code&gt;Set&lt;/code&gt;. &lt;code&gt;devices.first&lt;/code&gt; is non-deterministic  you have a 1-in-4 chance of hitting the right interface. The wrong interfaces accept your data and do nothing with it. The send call returns &lt;code&gt;0&lt;/code&gt; (success) and the device response is all zeros.&lt;/p&gt;

&lt;p&gt;Print all interfaces first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;devices&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;usagePage&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;IOHIDDeviceGetProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kIOHIDPrimaryUsagePageKey&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kt"&gt;CFString&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as?&lt;/span&gt; &lt;span class="kt"&gt;Int&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;usage&lt;/span&gt;      &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;IOHIDDeviceGetProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kIOHIDPrimaryUsageKey&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kt"&gt;CFString&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as?&lt;/span&gt; &lt;span class="kt"&gt;Int&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;maxFeature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;IOHIDDeviceGetProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kIOHIDMaxFeatureReportSizeKey&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kt"&gt;CFString&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as?&lt;/span&gt; &lt;span class="kt"&gt;Int&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"page: 0x&lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;usagePage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;radix&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="s"&gt; | usage: 0x&lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;radix&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="s"&gt; | maxFeatureReport: &lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="n"&gt;maxFeature&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The interface you want for Razer configuration commands is the one with &lt;code&gt;maxFeatureReport: 90&lt;/code&gt; — that matches the 90-byte packet format Razer uses. Filter on it explicitly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;device&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;devices&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="nf"&gt;in&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;IOHIDDeviceGetProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kIOHIDMaxFeatureReportSizeKey&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kt"&gt;CFString&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as?&lt;/span&gt; &lt;span class="kt"&gt;Int&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"control interface not found"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For other devices, the right interface depends on the protocol. &lt;code&gt;maxFeatureReport&lt;/code&gt; size is usually the clearest signal.&lt;/p&gt;




&lt;h2&gt;
  
  
  Opening and Sending
&lt;/h2&gt;

&lt;p&gt;After filtering, open the specific interface and send a feature report:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="kt"&gt;IOHIDDeviceOpen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;device&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;IOOptionBits&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kIOHIDOptionsTypeNone&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;kIOReturnSuccess&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"could not open device"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;report&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;UInt8&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="nv"&gt;repeating&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;// ... fill report bytes ...&lt;/span&gt;

&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;IOHIDDeviceSetReport&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;device&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kIOHIDReportTypeFeature&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things that caused me problems here:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Buffer size.&lt;/strong&gt; &lt;code&gt;IOHIDDeviceSetReport&lt;/code&gt; takes the report ID as a separate parameter (the &lt;code&gt;0&lt;/code&gt; above). Do not prepend it to the buffer. If &lt;code&gt;maxFeatureReport&lt;/code&gt; is 90, your buffer should be exactly 90 bytes. I was sending 91 and getting error &lt;code&gt;-536850432&lt;/code&gt; (&lt;code&gt;0xE0005000&lt;/code&gt;) back from IOKit with no further explanation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reading the response.&lt;/strong&gt; The response comes back in a separate &lt;code&gt;IOHIDDeviceGetReport&lt;/code&gt; call. The device needs a small delay between send and read — the amount varies, but 200–300ms works reliably:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kt"&gt;Thread&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;forTimeInterval&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;UInt8&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="nv"&gt;repeating&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;responseLen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;CFIndex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kt"&gt;IOHIDDeviceGetReport&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;device&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kIOHIDReportTypeFeature&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;responseLen&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use a fresh buffer for the response so you can tell whether the device wrote anything into it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Interpreting the Response
&lt;/h2&gt;

&lt;p&gt;For Razer devices, byte &lt;code&gt;[0]&lt;/code&gt; of the response is a status code:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;0x00&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No response / read stale data&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;0x01&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Device busy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;0x02&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Command accepted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;0x05&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Command not understood&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;0x02&lt;/code&gt; means the full stack is working. &lt;code&gt;0x05&lt;/code&gt; usually means wrong command bytes or wrong transaction ID for the device family.&lt;/p&gt;

&lt;p&gt;If you get all zeros back, you either read too fast, hit the wrong interface, or the buffer size was wrong.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;Part 2 covers the actual Razer packet format — what goes in those 90 bytes, how the CRC is computed, and what the transaction ID is and why it matters.&lt;/p&gt;

</description>
      <category>swift</category>
      <category>opensource</category>
      <category>showdev</category>
      <category>learning</category>
    </item>
    <item>
      <title>LaunchNotes: Simple What's New Screens for SwiftUI Apps</title>
      <dc:creator>Paul Contreras</dc:creator>
      <pubDate>Mon, 01 Jun 2026 00:54:33 +0000</pubDate>
      <link>https://dev.to/paulcontr_/launchnotes-simple-whats-new-screens-for-swiftui-apps-2ahh</link>
      <guid>https://dev.to/paulcontr_/launchnotes-simple-whats-new-screens-for-swiftui-apps-2ahh</guid>
      <description>&lt;h2&gt;
  
  
  Show What's New in Your SwiftUI App with LaunchNotes
&lt;/h2&gt;

&lt;p&gt;Somewhere in the third app I shipped, I got tired of rebuilding the same "What's New" screen.&lt;br&gt;
It's never quite the same codebase, so copy-pasting doesn't work. And it's not interesting&lt;br&gt;
enough to engineer properly each time.&lt;/p&gt;

&lt;p&gt;I built &lt;strong&gt;LaunchNotes&lt;/strong&gt; to stop thinking about it.&lt;/p&gt;

&lt;p&gt;One view modifier. It compares the current bundle version to what's saved in &lt;code&gt;UserDefaults&lt;/code&gt;&lt;br&gt;
and shows a sheet if something changed.&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="kd"&gt;struct&lt;/span&gt; &lt;span class="kt"&gt;ContentView&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;View&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kd"&gt;some&lt;/span&gt; &lt;span class="kt"&gt;View&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;HomeView&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;launchNotes&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="kt"&gt;LaunchNote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="s"&gt;"New Stats"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="s"&gt;"Track your progress with cleaner charts."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="nv"&gt;systemImage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"chart.line.uptrend.xyaxis"&lt;/span&gt;
                &lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="kt"&gt;LaunchNote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="s"&gt;"Smoother Animations"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="s"&gt;"The app feels faster now."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="nv"&gt;systemImage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"sparkles"&lt;/span&gt;
                &lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can also keep all release history in one place:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;launchNotes&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;LaunchNotesRelease&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"1.2.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"What's New in 1.2"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;LaunchNote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"New Stats"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Cleaner progress charts."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;systemImage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"chart.line.uptrend.xyaxis"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="kt"&gt;LaunchNotesRelease&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"1.1.0"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;LaunchNote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Search"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Find previous sessions faster."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;systemImage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"magnifyingglass"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's also full-screen mode, style presets, custom accent colors, footer actions, and a&lt;br&gt;
manual trigger if you don't want the automatic behavior.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;launchNotes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;presentation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fullScreen&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;style&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prominent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;LaunchNote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"New Stats"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Cleaner progress charts."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;systemImage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"chart.line.uptrend.xyaxis"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Package is at &lt;a href="https://github.com/pol-cova/LaunchNotes" rel="noopener noreferrer"&gt;https://github.com/pol-cova/LaunchNotes&lt;/a&gt;. Still early — feedback and PRs welcome.&lt;/p&gt;

</description>
      <category>ios</category>
      <category>swift</category>
      <category>mobile</category>
      <category>opensource</category>
    </item>
    <item>
      <title>The 5 MB Homework Limit That Turned Me Into an App-Builder</title>
      <dc:creator>Paul Contreras</dc:creator>
      <pubDate>Fri, 01 Aug 2025 22:13:33 +0000</pubDate>
      <link>https://dev.to/paulcontr_/the-5-mb-homework-limit-that-turned-me-into-an-app-builder-222a</link>
      <guid>https://dev.to/paulcontr_/the-5-mb-homework-limit-that-turned-me-into-an-app-builder-222a</guid>
      <description>&lt;p&gt;This semester I snapped a neat, high-res photo of my handwritten math assignment and went to upload it to my university platform.&lt;/p&gt;

&lt;p&gt;Error: &lt;strong&gt;“File must be under 5 MB.”&lt;/strong&gt;&lt;br&gt;
I tried again with a different angle—same error. Then the portal complained about the HEIC format my iPhone uses by default. &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I tried next&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Google results sent me to web compressors that wanted my personal photos on their servers (nope).&lt;/li&gt;
&lt;li&gt;The first “free” iOS app threw three ads at me before I could pick a file.&lt;/li&gt;
&lt;li&gt;A second app asked for $4.99 per month, just to shrink a picture!&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I ended up opening Preview on my Mac, exporting, tweaking quality sliders, re-sending to my phone, and finally hitting “Submit” with seconds to spare. The whole thing took longer than writing the homework.&lt;/p&gt;

&lt;p&gt;That night I wrote Crunch, an offline photo compressor that fixes the exact pain I felt.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What Crunch does now&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Shrinks JPEG, HEIC, PNG images by up to 70 %, totally on-device (no uploads)&lt;/li&gt;
&lt;li&gt;Converts formats so HEIC becomes JPG or PDF in one tap&lt;/li&gt;
&lt;li&gt;Cleans out location + EXIF data before you share a file&lt;/li&gt;
&lt;li&gt;Free: 10 photos a day. Pro: one-time $3.99—no subscriptions, ever&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Try Crunch for iOS now&lt;/strong&gt;: &lt;a href="https://apps.apple.com/app/id6748700457" rel="noopener noreferrer"&gt;Crunch on AppStore&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Follow me on x where I’m sharing progress, mistakes, your feedback will shape the next update—features, pricing, UX, anything.&lt;br&gt;
&lt;a href="https://x.com/3nginuity" rel="noopener noreferrer"&gt;@3nginuity&lt;/a&gt;&lt;/p&gt;

</description>
      <category>programming</category>
      <category>mobile</category>
      <category>showdev</category>
      <category>buildinpublic</category>
    </item>
  </channel>
</rss>
