<?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: Taiki Kanai</title>
    <description>The latest articles on DEV Community by Taiki Kanai (@taikikanai).</description>
    <link>https://dev.to/taikikanai</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%2F4142626%2F3325f8de-a98c-485c-bd02-11a7508664c2.png</url>
      <title>DEV Community: Taiki Kanai</title>
      <link>https://dev.to/taikikanai</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/taikikanai"/>
    <language>en</language>
    <item>
      <title>How I built a webcam posture app where video never leaves the Mac</title>
      <dc:creator>Taiki Kanai</dc:creator>
      <pubDate>Fri, 25 Sep 2026 09:45:34 +0000</pubDate>
      <link>https://dev.to/taikikanai/how-i-built-a-webcam-posture-app-where-video-never-leaves-the-mac-56ig</link>
      <guid>https://dev.to/taikikanai/how-i-built-a-webcam-posture-app-where-video-never-leaves-the-mac-56ig</guid>
      <description>&lt;p&gt;I kept catching myself slouching. Every webcam posture app I tried either wanted a subscription or left me guessing where the frames went, so I built &lt;a href="https://kamaeposture.com/" rel="noopener noreferrer"&gt;Kamae&lt;/a&gt; with one rule baked in from day one: &lt;strong&gt;video and posture data never leave the Mac&lt;/strong&gt;. This post is about the parts of that decision that were interesting to engineer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The design constraint came first
&lt;/h2&gt;

&lt;p&gt;Before writing any pose code, I wrote down what Kamae would &lt;em&gt;not&lt;/em&gt; do:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No video frames saved to disk.&lt;/li&gt;
&lt;li&gt;No raw landmark time series saved or uploaded.&lt;/li&gt;
&lt;li&gt;No telemetry, no analytics, no install IDs.&lt;/li&gt;
&lt;li&gt;No backend server to sign in to.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one is the keystone. Without a server, there is nowhere to upload frames to and nothing to leak. The whole architecture flows from it: everything happens on the Mac, the only outbound requests are license checks, update checks, and feedback the user explicitly sends.&lt;/p&gt;

&lt;p&gt;The next decision was where to put the camera logic. MediaPipe Pose runs in a WebAssembly runtime, so Electron was an easy fit. The pose model and WASM are bundled inside the app — nothing is fetched from a CDN at runtime.&lt;/p&gt;

&lt;h2&gt;
  
  
  How pose becomes three numbers
&lt;/h2&gt;

&lt;p&gt;A hidden detector window calls &lt;code&gt;getUserMedia&lt;/code&gt;, feeds each frame into MediaPipe Pose Landmarker (the lite model), and reads five landmarks: the nose, both ears, and both shoulders. From those, I compute three normalized numbers:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Calculation&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;neckDrop&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;(shoulder-midpoint Y − ear-midpoint Y) ÷ shoulder width&lt;/td&gt;
&lt;td&gt;Smaller is more hunched. Primary signal.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;leanIn&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;current shoulder width ÷ baseline shoulder width&lt;/td&gt;
&lt;td&gt;Larger means closer to the screen.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;shoulderTilt&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;angle of the line between the two shoulders (degrees)&lt;/td&gt;
&lt;td&gt;Asymmetry.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The comparison is against a baseline you set yourself — three seconds of your own good posture at the start of each session. Absolute numbers vary too much between setups, so the app compares you to &lt;em&gt;you&lt;/em&gt;. The display level follows a 60-second majority of &lt;code&gt;good / warn / bad&lt;/code&gt;, so a quick sip of coffee does not flip the menu bar red.&lt;/p&gt;

&lt;p&gt;The pose logic and the state machine live in &lt;code&gt;packages/core&lt;/code&gt; — pure TypeScript, no Electron, no DOM, no &lt;code&gt;Date.now()&lt;/code&gt;. Time comes in as milliseconds. This sounds pedantic but it paid off: every threshold and transition is unit-testable without spinning up a renderer.&lt;/p&gt;

&lt;p&gt;A simplified version of the metrics function looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// packages/core/src/metrics.ts (simplified)&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;computeMetrics&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PoseLandmarks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;minVisibility&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;DEFAULT_MIN_VISIBILITY&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Metrics&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;isPersonVisible&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;minVisibility&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;null&lt;/span&gt;  &lt;span class="c1"&gt;// nobody in frame: a gap, not a zero&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;shoulderWidth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;distance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;leftShoulder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rightShoulder&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;shoulderWidth&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;shoulderMid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;midpoint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;leftShoulder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rightShoulder&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;earMid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;midpoint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;leftEar&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rightEar&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;neckDrop&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;shoulderMid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;earMid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;shoulderWidth&lt;/span&gt;  &lt;span class="c1"&gt;// image y grows downward&lt;/span&gt;
  &lt;span class="c1"&gt;// Always measure from the shoulder on the image's left to the one on its right, so a mirrored&lt;/span&gt;
  &lt;span class="c1"&gt;// camera doesn't flip the angle to ±180 and jump across the baseline&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;lm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;leftShoulder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;lm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rightShoulder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;lm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;leftShoulder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rightShoulder&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="nx"&gt;lm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rightShoulder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;leftShoulder&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;shoulderTilt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;atan2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;180&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PI&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;neckDrop&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;shoulderWidth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;shoulderTilt&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;  &lt;span class="c1"&gt;// leanIn = shoulderWidth / baseline, computed later&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once the three numbers cross IPC into the main process, &lt;strong&gt;the frame is gone&lt;/strong&gt;. No buffer is kept, no &lt;code&gt;toDataURL&lt;/code&gt;, no upload. The only thing that gets persisted is per-minute aggregates (&lt;code&gt;good_ratio&lt;/code&gt;, &lt;code&gt;warn_ratio&lt;/code&gt;, &lt;code&gt;bad_ratio&lt;/code&gt;, average deviations) in a local SQLite file under &lt;code&gt;~/Library/Application Support/Kamae/&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The camera light was the problem I had to solve
&lt;/h2&gt;

&lt;p&gt;The first build kept the camera on continuously. It worked, but the green LED next to the webcam stayed lit all day, and that felt like surveillance even though nothing was leaving the Mac. The fix was intermittent sampling.&lt;/p&gt;

&lt;p&gt;By default Kamae opens the camera for about one second every thirty seconds:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open the camera.&lt;/li&gt;
&lt;li&gt;Wait for the first frame (timeout 2 s).&lt;/li&gt;
&lt;li&gt;Take three samples at 200 ms intervals.&lt;/li&gt;
&lt;li&gt;Close the camera.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Three samples per burst × two bursts per minute = six samples per minute, which is plenty for a 60-second majority. The camera light blinks instead of staying on, and CPU drops with it. On an M4 Mac mini running for an hour with default settings, Kamae averaged &lt;strong&gt;0.5% CPU&lt;/strong&gt; (cumulative CPU time 19.0 s over 3,655 s). The instant readings bounce between near-zero and a short spike when a burst fires.&lt;/p&gt;

&lt;p&gt;The cadence is decided by a pure function in core, so it can be tested:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// packages/core/src/intermittent.ts (simplified)&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;planBurst&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;BurstInput&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;BurstPlan&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mode&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;continuous&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;CALIBRATING&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;continuousRequested&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;continuous&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="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;periodMs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;AWAY&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;periodMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;AWAY_PERIOD_MS&lt;/span&gt;                    &lt;span class="c1"&gt;// 30 s: notice when you come back&lt;/span&gt;
  &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;displayLevel&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;warn&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;displayLevel&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bad&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;periodMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;BAD_PERIOD_MS&lt;/span&gt;  &lt;span class="c1"&gt;// 10 s&lt;/span&gt;
  &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="nx"&gt;periodMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;clampPeriodMs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;periodMs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                    &lt;span class="c1"&gt;// user setting, 10–120 s, default 30&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onBattery&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;periodMs&lt;/span&gt; &lt;span class="o"&gt;*=&lt;/span&gt; &lt;span class="nx"&gt;BATTERY_PERIOD_FACTOR&lt;/span&gt;                  &lt;span class="c1"&gt;// 2x on battery&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;burst&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;periodMs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;samples&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;BURST_SAMPLES&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;gapMs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;BURST_GAP_MS&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So while you are slouched, Kamae checks every ten seconds. While you are sitting well, every thirty. On battery, twice as long. Calibration and the live preview switch to continuous mode automatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  "We do not upload" is not a claim, it is a feature
&lt;/h2&gt;

&lt;p&gt;Saying &lt;em&gt;"no frames are uploaded"&lt;/em&gt; is cheap. Letting the user verify it is the actual product feature. Two things make that possible:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. A visible connection log.&lt;/strong&gt; Every time the main process makes a network call, it appends one row to &lt;code&gt;net_log&lt;/code&gt; — timestamp, kind (&lt;code&gt;license-activate&lt;/code&gt;, &lt;code&gt;update-check&lt;/code&gt;, &lt;code&gt;feedback&lt;/code&gt;, …), destination host, success, note. Settings → Privacy → Connection log shows the last 20 entries, plus a summary of the last 24 hours. A quiet day shows up as "0", not as a blank, so an empty log is an answer rather than a gap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. CI watchdogs that prevent regressions.&lt;/strong&gt; &lt;code&gt;scripts/check-no-egress.mjs&lt;/code&gt; scans the detector renderer and the main process for any API that could exfiltrate frames — &lt;code&gt;fetch&lt;/code&gt;, &lt;code&gt;WebSocket&lt;/code&gt;, &lt;code&gt;XMLHttpRequest&lt;/code&gt;, &lt;code&gt;fs.write*&lt;/code&gt;, &lt;code&gt;MediaRecorder&lt;/code&gt;, &lt;code&gt;canvas.toDataURL&lt;/code&gt;, and so on — and fails the build if any of them appear where frames are handled. &lt;code&gt;scripts/check-net-log.mjs&lt;/code&gt; is its twin: every file that calls &lt;code&gt;fetch(&lt;/code&gt; or imports &lt;code&gt;electron-updater&lt;/code&gt; has to also reference &lt;code&gt;netLog.record&lt;/code&gt;, so no outbound traffic can sneak in unlogged.&lt;/p&gt;

&lt;p&gt;The watchdogs are dumb on purpose. They do not understand intent; they just refuse certain symbols from appearing in certain places. That is the kind of test you can leave running for years.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things I was careful about in Electron
&lt;/h2&gt;

&lt;p&gt;Electron is easy to make insecure by accident. The configuration I checked twice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;contextIsolation: true&lt;/code&gt; and &lt;code&gt;nodeIntegration: false&lt;/code&gt; in every renderer.&lt;/li&gt;
&lt;li&gt;Every IPC entry point declared in &lt;code&gt;src/preload/index.ts&lt;/code&gt;. Nothing else is reachable from the renderer.&lt;/li&gt;
&lt;li&gt;The production renderer is served from a custom &lt;code&gt;kamae://&lt;/code&gt; scheme registered in &lt;code&gt;src/main/protocol.ts&lt;/code&gt;, not from &lt;code&gt;file://&lt;/code&gt;, so a renderer never has a path into arbitrary local files.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The other thing I would flag is bundle hygiene. The MediaPipe WASM, the &lt;code&gt;.task&lt;/code&gt; model, and the language files all ship inside the app. There is no runtime CDN call. That decision predates the privacy story — it is also the difference between an installer that works offline and one that doesn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I gave up on purpose
&lt;/h2&gt;

&lt;p&gt;A few honest trade-offs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Apple silicon only.&lt;/strong&gt; macOS 11 Big Sur or later on M1 and newer. I would rather test one architecture properly than two halfway, and a universal build would roughly double a DMG that is already about 140 MB for everyone on an M-series Mac.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It is not a medical device.&lt;/strong&gt; Kamae is a habit tool that helps you notice how you sit. I deliberately avoided the kind of language that would imply a medical effect. If you have pain, see a professional.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No analytics.&lt;/strong&gt; Instead of an event pipeline I added an opt-in one-question survey on day 7 and day 13 of the trial. The data only moves when the user presses the button.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;Kamae is $19 one-time (a year of updates included) with a 14-day free trial and every feature unlocked. One license covers up to two Macs. Sales are handled by a Merchant of Record so tax and VAT are not my problem. The full pricing breakdown and the download are at &lt;a href="https://kamaeposture.com/" rel="noopener noreferrer"&gt;kamaeposture.com&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you are evaluating any webcam posture app, the checklist I wrote at &lt;a href="https://kamaeposture.com/guides/posture-app-privacy/" rel="noopener noreferrer"&gt;kamaeposture.com/guides/posture-app-privacy/&lt;/a&gt; — read the privacy screen, watch the camera light, watch the network, look for an in-app connection log — applies to any of them, not just mine.&lt;/p&gt;

&lt;p&gt;If you'd like to try it on the early-bird price: the first 100 buyers get 20% off until Oct 31 — &lt;a href="https://buy.polar.sh/polar_cl_SHRn2QFrDF3mttSDCHvcJwIvFsZQUGWbexb2G1vIzEN?discount_code=EARLYBIRD" rel="noopener noreferrer"&gt;https://buy.polar.sh/polar_cl_SHRn2QFrDF3mttSDCHvcJwIvFsZQUGWbexb2G1vIzEN?discount_code=EARLYBIRD&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Built by &lt;a href="https://funbrew.tech/" rel="noopener noreferrer"&gt;FUNBREW&lt;/a&gt;. Feedback welcome — the in-app Feedback button is the fastest way to reach me.&lt;/p&gt;

</description>
      <category>electron</category>
      <category>privacy</category>
      <category>macos</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
