<?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>Forem: hiyoyo</title>
    <description>The latest articles on Forem by hiyoyo (@hiyoyok).</description>
    <link>https://forem.com/hiyoyok</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3851832%2Fa2762ba1-e687-4ae9-901d-245b96cf95d6.jpg</url>
      <title>Forem: hiyoyo</title>
      <link>https://forem.com/hiyoyok</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/hiyoyok"/>
    <language>en</language>
    <item>
      <title>Rust Error Handling in Tauri Commands — The Pattern That Actually Works</title>
      <dc:creator>hiyoyo</dc:creator>
      <pubDate>Tue, 12 May 2026 12:16:23 +0000</pubDate>
      <link>https://forem.com/hiyoyok/rust-error-handling-in-tauri-commands-the-pattern-that-actually-works-35le</link>
      <guid>https://forem.com/hiyoyok/rust-error-handling-in-tauri-commands-the-pattern-that-actually-works-35le</guid>
      <description>&lt;p&gt;All tests run on an 8-year-old MacBook Air.&lt;br&gt;
All results from shipping 7 Mac apps as a solo developer. No sponsored opinion.&lt;br&gt;
The first Tauri app I shipped had inconsistent error handling. Some commands returned strings. Some panicked. Some silently swallowed errors.&lt;br&gt;
Here's the pattern I settled on after 7 apps.&lt;/p&gt;

&lt;p&gt;The problem with naive error handling&lt;br&gt;
Tauri commands return Result where E must implement serde::Serialize. The temptation is to just return String for errors:&lt;br&gt;
rust#[tauri::command]&lt;br&gt;
fn do_something() -&amp;gt; Result {&lt;br&gt;
    some_operation().map_err(|e| e.to_string())&lt;br&gt;
}&lt;br&gt;
This works. It's also a mess at scale. The frontend gets an untyped string. You can't match on error types. Logging is inconsistent. Error messages are whatever .to_string() produces.&lt;/p&gt;

&lt;p&gt;The pattern that works&lt;br&gt;
A single app-wide error type:&lt;br&gt;
rust#[derive(Debug, thiserror::Error, serde::Serialize)]&lt;/p&gt;

&lt;h1&gt;
  
  
  [serde(tag = "kind", content = "message")]
&lt;/h1&gt;

&lt;p&gt;pub enum AppError {&lt;br&gt;
    #[error("IO error: {0}")]&lt;br&gt;
    Io(String),&lt;br&gt;
    #[error("ADB error: {0}")]&lt;br&gt;
    Adb(String),&lt;br&gt;
    #[error("Database error: {0}")]&lt;br&gt;
    Database(String),&lt;br&gt;
    #[error("Permission denied: {0}")]&lt;br&gt;
    Permission(String),&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;impl From&lt;a&gt;std::io::Error&lt;/a&gt; for AppError {&lt;br&gt;
    fn from(e: std::io::Error) -&amp;gt; Self {&lt;br&gt;
        AppError::Io(e.to_string())&lt;br&gt;
    }&lt;br&gt;
}&lt;br&gt;
Every command returns Result. The frontend receives a typed error object with kind and message fields. You can match on kind in TypeScript and show appropriate UI for each error type.&lt;/p&gt;

&lt;p&gt;The frontend side&lt;br&gt;
typescripttry {&lt;br&gt;
  await invoke('do_something')&lt;br&gt;
} catch (e: any) {&lt;br&gt;
  if (e.kind === 'Permission') {&lt;br&gt;
    showPermissionDialog()&lt;br&gt;
  } else if (e.kind === 'Adb') {&lt;br&gt;
    showAdbTroubleshooting()&lt;br&gt;
  } else {&lt;br&gt;
    showGenericError(e.message)&lt;br&gt;
  }&lt;br&gt;
}&lt;br&gt;
Typed errors on both sides. No string parsing. No guessing what went wrong.&lt;/p&gt;

&lt;p&gt;The logging layer&lt;br&gt;
Add logging at the command boundary, not scattered through business logic:&lt;br&gt;
rust#[tauri::command]&lt;br&gt;
async fn sync_files(handle: AppHandle) -&amp;gt; Result {&lt;br&gt;
    sync_files_inner(&amp;amp;handle).await.map_err(|e| {&lt;br&gt;
        log::error!("sync_files failed: {:?}", e);&lt;br&gt;
        e&lt;br&gt;
    })&lt;br&gt;
}&lt;br&gt;
One log line per command failure. Consistent format. Easy to find in production logs.&lt;/p&gt;

&lt;p&gt;The verdict&lt;br&gt;
The thiserror + tagged enum pattern is the correct default for Tauri app error handling. Set it up on day one. Retrofitting consistent error handling into a shipping app is painful.&lt;br&gt;
The String error shortcut is fine for prototypes. Not for anything users will actually run.&lt;/p&gt;

&lt;p&gt;If this was useful, a ❤️ helps more than you'd think — thanks!&lt;br&gt;
Hiyoko PDF Vault → &lt;a href="https://hiyokoko.gumroad.com/l/HiyokoPDFVault" rel="noopener noreferrer"&gt;https://hiyokoko.gumroad.com/l/HiyokoPDFVault&lt;/a&gt;&lt;br&gt;
X → &lt;a class="mentioned-user" href="https://dev.to/hiyoyok"&gt;@hiyoyok&lt;/a&gt;&lt;/p&gt;

</description>
      <category>tauri</category>
      <category>rust</category>
      <category>programming</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Building a Universal Binary with Tauri v2 — It's Easier Than You Think</title>
      <dc:creator>hiyoyo</dc:creator>
      <pubDate>Mon, 11 May 2026 01:29:43 +0000</pubDate>
      <link>https://forem.com/hiyoyok/building-a-universal-binary-with-tauri-v2-its-easier-than-you-think-1b53</link>
      <guid>https://forem.com/hiyoyok/building-a-universal-binary-with-tauri-v2-its-easier-than-you-think-1b53</guid>
      <description>&lt;p&gt;All tests run on an 8-year-old MacBook Air.&lt;br&gt;
All results from shipping 7 Mac apps as a solo developer. No sponsored opinion.&lt;/p&gt;

&lt;p&gt;Intel Mac users still exist. Apple Silicon is the future. A universal binary covers both with one DMG.&lt;/p&gt;

&lt;p&gt;Here's the actual process.&lt;/p&gt;

&lt;p&gt;Why bother with universal binary&lt;br&gt;
Rosetta 2 exists. Intel users can run Apple Silicon binaries with it. So why compile universal?&lt;/p&gt;

&lt;p&gt;Because "this app requires Rosetta" is a friction point at install time. Some users don't have it installed. Some corporate machines block it. Some users just don't know what it is.&lt;/p&gt;

&lt;p&gt;A universal binary runs natively on both architectures. No conversation about Rosetta. No support tickets.&lt;/p&gt;

&lt;p&gt;The Tauri build command&lt;br&gt;
bash&lt;br&gt;
rustup target add x86_64-apple-darwin&lt;br&gt;
rustup target add aarch64-apple-darwin&lt;/p&gt;

&lt;p&gt;npm run tauri build -- --target universal-apple-darwin&lt;br&gt;
That's it. Tauri handles the lipo step that combines both binaries. The output is a single .app that runs natively on Intel and Apple Silicon.&lt;/p&gt;

&lt;p&gt;Build time is roughly double since you're compiling for two targets. On an 8-year-old MacBook Air, plan for it.&lt;/p&gt;

&lt;p&gt;The gotchas&lt;br&gt;
Bundled binaries need universal builds too.&lt;/p&gt;

&lt;p&gt;If your Tauri app bundles external binaries — ADB, ffmpeg, anything — each one needs to be a universal binary or you need architecture-specific versions with runtime selection.&lt;/p&gt;

&lt;p&gt;For bundled ADB in my apps:&lt;/p&gt;

&lt;p&gt;rust&lt;br&gt;
let arch = if cfg!(target_arch = "x86_64") {&lt;br&gt;
    "x86_64"&lt;br&gt;
} else {&lt;br&gt;
    "aarch64"&lt;br&gt;
};&lt;br&gt;
let adb_path = resource_dir.join(format!("adb-{}", arch));&lt;br&gt;
Test on both architectures before shipping.&lt;/p&gt;

&lt;p&gt;The most common universal binary bug: it builds fine, runs fine on your machine, crashes on the other architecture because of an assumption baked into the code. If you only have one machine, use GitHub Actions with both runners.&lt;/p&gt;

&lt;p&gt;DMG size increases.&lt;/p&gt;

&lt;p&gt;Roughly doubles the binary size. For most Tauri apps this is still small — 10-20MB instead of 5-10MB. Not a real concern.&lt;/p&gt;

&lt;p&gt;The verdict&lt;br&gt;
Universal binary support in Tauri v2 is well implemented. The build command is one line. The main work is handling bundled binaries correctly.&lt;/p&gt;

&lt;p&gt;Do it before your first release. Retrofitting it after users are already on Intel-only builds creates a confusing update path.&lt;/p&gt;

&lt;p&gt;If this was useful, a ❤️ helps more than you'd think — thanks!&lt;/p&gt;

&lt;p&gt;Hiyoko PDF Vault → &lt;a href="https://hiyokoko.gumroad.com/l/HiyokoPDFVault" rel="noopener noreferrer"&gt;https://hiyokoko.gumroad.com/l/HiyokoPDFVault&lt;/a&gt;&lt;br&gt;
X → &lt;a class="mentioned-user" href="https://dev.to/hiyoyok"&gt;@hiyoyok&lt;/a&gt;&lt;/p&gt;

</description>
      <category>tauri</category>
      <category>rust</category>
      <category>programming</category>
      <category>productivity</category>
    </item>
    <item>
      <title>One Input Box, Two AI Modes — Detecting Whether the User Wants Error Help or Command Explanation</title>
      <dc:creator>hiyoyo</dc:creator>
      <pubDate>Sun, 10 May 2026 16:15:18 +0000</pubDate>
      <link>https://forem.com/hiyoyok/one-input-box-two-ai-modes-detecting-whether-the-user-wants-error-help-or-command-explanation-1ok</link>
      <guid>https://forem.com/hiyoyok/one-input-box-two-ai-modes-detecting-whether-the-user-wants-error-help-or-command-explanation-1ok</guid>
      <description>&lt;p&gt;If this is useful, a ❤️ helps others find it.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;All tests run on an 8-year-old MacBook Air.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;HiyokoHelper has one input box but handles two completely different use cases:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Error diagnosis&lt;/strong&gt; — "Why did this fail and how do I fix it?"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Command explanation&lt;/strong&gt; — "What does this command actually do?"&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The user doesn't select a mode. The app detects which one they need.&lt;/p&gt;




&lt;h2&gt;
  
  
  Detecting the mode
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;enum&lt;/span&gt; &lt;span class="n"&gt;InputMode&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;ErrorDiagnosis&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;CommandExplain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;detect_mode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;InputMode&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;trimmed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="nf"&gt;.trim&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;trimmed&lt;/span&gt;&lt;span class="nf"&gt;.lines&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.collect&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;lines&lt;/span&gt;&lt;span class="nf"&gt;.len&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;lines&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="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;command_prefixes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s"&gt;"sudo "&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"git "&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"npm "&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"yarn "&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"cargo "&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s"&gt;"brew "&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"pip "&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"docker "&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"kubectl "&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s"&gt;"ls "&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"cd "&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"mv "&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"cp "&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"chmod "&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s"&gt;"systemctl "&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"launchctl "&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="n"&gt;looks_like_command&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;command_prefixes&lt;/span&gt;&lt;span class="nf"&gt;.iter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="nf"&gt;.any&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="nf"&gt;.starts_with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="nf"&gt;.starts_with&lt;/span&gt;&lt;span class="p"&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;span class="n"&gt;line&lt;/span&gt;&lt;span class="nf"&gt;.starts_with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&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;looks_like_command&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nn"&gt;InputMode&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;CommandExplain&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="nn"&gt;InputMode&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;ErrorDiagnosis&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Different prompts for each mode
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;build_prompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mode&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;InputMode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lang&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;Lang&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lang&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="nn"&gt;InputMode&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;ErrorDiagnosis&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nn"&gt;Lang&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Japanese&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nd"&gt;format!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s"&gt;"あなたはターミナルエラーの専門家です。&lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="s"&gt;
             以下のエラーの原因を1〜2文で説明し、&lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="s"&gt;
             解決コマンドがあれば示してください。&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s"&gt;{}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;
        &lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;InputMode&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;CommandExplain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nn"&gt;Lang&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Japanese&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nd"&gt;format!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s"&gt;"あなたはターミナルコマンドの先生です。&lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="s"&gt;
             以下のコマンドが何をするか初心者向けに説明し、&lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="s"&gt;
             危険な副作用があれば警告してください。&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s"&gt;{}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;
        &lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;InputMode&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;ErrorDiagnosis&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nn"&gt;Lang&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;English&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nd"&gt;format!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s"&gt;"You are a terminal error specialist. &lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="s"&gt;
             Explain the cause in 1-2 sentences and provide a fix.&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s"&gt;{}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;
        &lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;InputMode&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;CommandExplain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nn"&gt;Lang&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;English&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nd"&gt;format!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s"&gt;"You are a terminal command teacher. &lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="s"&gt;
             Explain what this command does for a beginner. &lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="s"&gt;
             Warn about dangerous side effects.&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s"&gt;{}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;input&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;h2&gt;
  
  
  Live mode indicator in UI
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;detectMode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;inputText&lt;/span&gt;&lt;span class="p"&gt;);&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;error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;🔍 エラー診断モード&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;📖 コマンド解説モード&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Updates live as they type. No confusion about what will happen on submit.&lt;/p&gt;




&lt;p&gt;HiyokoHelper (OSS) → github.com/hiyoyok/HiyokoHelper&lt;br&gt;
X → &lt;a class="mentioned-user" href="https://dev.to/hiyoyok"&gt;@hiyoyok&lt;/a&gt;&lt;/p&gt;

</description>
      <category>gemini</category>
      <category>ai</category>
      <category>rust</category>
      <category>programming</category>
    </item>
    <item>
      <title>AI Overlay UI in Tauri — Designing the "Ask AI" Button That Doesn't Annoy Users</title>
      <dc:creator>hiyoyo</dc:creator>
      <pubDate>Thu, 07 May 2026 16:07:38 +0000</pubDate>
      <link>https://forem.com/hiyoyok/ai-overlay-ui-in-tauri-designing-the-ask-ai-button-that-doesnt-annoy-users-17i5</link>
      <guid>https://forem.com/hiyoyok/ai-overlay-ui-in-tauri-designing-the-ask-ai-button-that-doesnt-annoy-users-17i5</guid>
      <description>&lt;p&gt;All tests run on an 8-year-old MacBook Air.&lt;br&gt;
All results from shipping 7 Mac apps as a solo developer. No sponsored opinion.&lt;br&gt;
Every app has an AI button now. Most of them are annoying. Here's how I approach AI UI in my Tauri apps.&lt;/p&gt;

&lt;p&gt;The problem with most AI UI&lt;br&gt;
The bad pattern: a prominent "Ask AI" button that's always visible, always tempting users to click it, and produces results that take 3 seconds to load with a full-screen loading state.&lt;br&gt;
The result: users click once, wait, get a mediocre response, and never click again. The button becomes visual noise.&lt;/p&gt;

&lt;p&gt;What works: contextual, fast, optional&lt;br&gt;
Contextual: the AI button appears near the content it analyzes, not in a toolbar. In HiyokoLogcat, the "Diagnose" button appears next to each log entry. In HiyokoHelper, the analyze button appears next to the clipboard content. The button makes sense where it lives.&lt;br&gt;
Fast feedback: show something immediately. Even if the full response takes 2 seconds, stream the first tokens within 200ms. The user sees progress. Waiting feels shorter when something is happening.&lt;br&gt;
Optional: AI features are enhancements. The app is fully usable without clicking the AI button. Users who want it will find it. Users who don't won't be forced through it.&lt;/p&gt;

&lt;p&gt;The loading state that works&lt;br&gt;
Don't show a spinner for 2 seconds then replace everything with text. Stream the response:&lt;br&gt;
typescriptconst [response, setResponse] = useState('')&lt;br&gt;
const [isStreaming, setIsStreaming] = useState(false)&lt;/p&gt;

&lt;p&gt;// As chunks arrive from Gemini streaming&lt;br&gt;
listen('ai-chunk', (event) =&amp;gt; {&lt;br&gt;
    setResponse(prev =&amp;gt; prev + event.payload)&lt;br&gt;
})&lt;br&gt;
Text appearing character by character feels fast even when it isn't.&lt;/p&gt;

&lt;p&gt;The error state that doesn't panic users&lt;br&gt;
When Gemini returns a 429 or 503, don't show a technical error. Show:&lt;/p&gt;

&lt;p&gt;"Analysis unavailable right now. Try again in a moment."&lt;/p&gt;

&lt;p&gt;One sentence. No stack trace. No HTTP status code. The user doesn't care why it failed — they care what to do next.&lt;/p&gt;

&lt;p&gt;The API key UX&lt;br&gt;
Apps that require an API key have an onboarding problem. Users don't want to get an API key before trying your app.&lt;br&gt;
Solution: make the AI features clearly optional, show them working in screenshots/demos, and make the API key setup flow short. Three steps maximum: get key → paste key → done.&lt;/p&gt;

&lt;p&gt;The verdict&lt;br&gt;
AI UI that respects the user's attention works. Streaming responses, contextual buttons, graceful degradation on errors. The "AI" label doesn't make a feature valuable — solving a real problem does.&lt;/p&gt;

&lt;p&gt;If this was useful, a ❤️ helps more than you'd think — thanks!&lt;br&gt;
Hiyoko PDF Vault → &lt;a href="https://hiyokoko.gumroad.com/l/HiyokoPDFVault" rel="noopener noreferrer"&gt;https://hiyokoko.gumroad.com/l/HiyokoPDFVault&lt;/a&gt;&lt;br&gt;
X → &lt;a class="mentioned-user" href="https://dev.to/hiyoyok"&gt;@hiyoyok&lt;/a&gt;&lt;/p&gt;

</description>
      <category>tauri</category>
      <category>rust</category>
      <category>ai</category>
      <category>programming</category>
    </item>
    <item>
      <title>"You Got This Error Last Week" — Building an AI That Remembers Your Past Errors</title>
      <dc:creator>hiyoyo</dc:creator>
      <pubDate>Thu, 07 May 2026 14:52:21 +0000</pubDate>
      <link>https://forem.com/hiyoyok/you-got-this-error-last-week-building-an-ai-that-remembers-your-past-errors-1gmp</link>
      <guid>https://forem.com/hiyoyok/you-got-this-error-last-week-building-an-ai-that-remembers-your-past-errors-1gmp</guid>
      <description>&lt;p&gt;If this is useful, a ❤️ helps others find it.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;All tests run on an 8-year-old MacBook Air.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The same error appears twice. Most AI tools diagnose it twice — two API calls, same answer.&lt;/p&gt;

&lt;p&gt;HiyokoHelper remembers. When the same error appears again, it responds instantly from cache: "💡 先日も同じケースが発生し、〇〇で解決しました"&lt;/p&gt;

&lt;p&gt;Here's how the history cache works.&lt;/p&gt;




&lt;h2&gt;
  
  
  The data structure
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="nd"&gt;#[derive(Serialize,&lt;/span&gt; &lt;span class="nd"&gt;Deserialize,&lt;/span&gt; &lt;span class="nd"&gt;Clone)]&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;HistoryEntry&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;error_hash&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;error_preview&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;diagnosis&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;resolved&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;u64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;hit_count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;u32&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;Stored in &lt;code&gt;history.json&lt;/code&gt; via &lt;code&gt;tauri-plugin-store&lt;/code&gt;. Local only, never leaves the machine.&lt;/p&gt;




&lt;h2&gt;
  
  
  Normalizing before hashing
&lt;/h2&gt;

&lt;p&gt;Same error, different timestamps → same hash:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;normalize_error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="nf"&gt;.to_string&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;timestamp_re&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;Regex&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;r"\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.unwrap&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;timestamp_re&lt;/span&gt;&lt;span class="nf"&gt;.replace_all&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;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"[TIMESTAMP]"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.to_string&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;line_re&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;Regex&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;r"line \d+"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.unwrap&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;line_re&lt;/span&gt;&lt;span class="nf"&gt;.replace_all&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;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"line [N]"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.to_string&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;pid_re&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;Regex&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;r"\bpid[: ]\d+"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.unwrap&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pid_re&lt;/span&gt;&lt;span class="nf"&gt;.replace_all&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;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"pid [PID]"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.to_string&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="nf"&gt;.split_whitespace&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="py"&gt;.collect&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="nf"&gt;.join&lt;/span&gt;&lt;span class="p"&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 lookup flow
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;diagnose_with_history&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;history&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;HistoryCache&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;DiagnosisResult&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;error_hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;history&lt;/span&gt;&lt;span class="nf"&gt;.get&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;hash&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="py"&gt;.hit_count&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="py"&gt;.resolved&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nd"&gt;format!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"💡 先日も同じエラーが発生し、解決済みです。&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s"&gt;{}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="py"&gt;.diagnosis&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="p"&gt;{&lt;/span&gt;
            &lt;span class="nd"&gt;format!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"⚠️ このエラーは以前も発生しています（{}回目）。&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s"&gt;{}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="py"&gt;.hit_count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="py"&gt;.diagnosis&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="nn"&gt;DiagnosisResult&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;FromHistory&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;diagnosis&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="py"&gt;.diagnosis&lt;/span&gt;&lt;span class="nf"&gt;.clone&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;msg&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="n"&gt;diagnosis&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;call_gemini&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;history&lt;/span&gt;&lt;span class="nf"&gt;.insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;HistoryEntry&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;error_hash&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;hash&lt;/span&gt;&lt;span class="nf"&gt;.clone&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="n"&gt;error_preview&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="nf"&gt;.chars&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.take&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.collect&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="n"&gt;diagnosis&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;diagnosis&lt;/span&gt;&lt;span class="nf"&gt;.clone&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="n"&gt;resolved&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;unix_now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="n"&gt;hit_count&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="nn"&gt;DiagnosisResult&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Fresh&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;diagnosis&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 "resolved" button
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;mark_resolved&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;history&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;HistoryCache&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&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="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;history&lt;/span&gt;&lt;span class="nf"&gt;.get_mut&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="py"&gt;.resolved&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;history&lt;/span&gt;&lt;span class="nf"&gt;.save&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;Next time: "You had this issue and resolved it. Here's what worked."&lt;/p&gt;




&lt;h2&gt;
  
  
  Cache eviction
&lt;/h2&gt;

&lt;p&gt;Unresolved entries older than 30 days evicted. Resolved entries kept forever.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;evict_old_entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;history&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;HistoryCache&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="n"&gt;cutoff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;unix_now&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="mi"&gt;30&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;history&lt;/span&gt;&lt;span class="py"&gt;.entries&lt;/span&gt;&lt;span class="nf"&gt;.retain&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="py"&gt;.created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;cutoff&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="py"&gt;.resolved&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;HiyokoHelper (OSS) → github.com/hiyoyok/HiyokoHelper&lt;br&gt;
X → &lt;a class="mentioned-user" href="https://dev.to/hiyoyok"&gt;@hiyoyok&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>gemini</category>
      <category>rust</category>
      <category>programming</category>
    </item>
    <item>
      <title>Detecting Dangerous Terminal Commands Before Sending Them to an AI — My Safety Layer</title>
      <dc:creator>hiyoyo</dc:creator>
      <pubDate>Thu, 07 May 2026 01:14:37 +0000</pubDate>
      <link>https://forem.com/hiyoyok/detecting-dangerous-terminal-commands-before-sending-them-to-an-ai-my-safety-layer-109j</link>
      <guid>https://forem.com/hiyoyok/detecting-dangerous-terminal-commands-before-sending-them-to-an-ai-my-safety-layer-109j</guid>
      <description>&lt;p&gt;If this is useful, a ❤️ helps others find it.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;All tests run on an 8-year-old MacBook Air.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;HiyokoHelper sends terminal errors to Gemini for diagnosis. But what if the user pastes &lt;code&gt;rm -rf /&lt;/code&gt; or a fork bomb?&lt;/p&gt;

&lt;p&gt;Two problems: wasting API quota on non-errors, and potentially getting an "explanation" of a command that could destroy the system.&lt;/p&gt;

&lt;p&gt;Here's the safety layer I built.&lt;/p&gt;




&lt;h2&gt;
  
  
  Layer 1: Is it actually dangerous?
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="nd"&gt;#[derive(Debug,&lt;/span&gt; &lt;span class="nd"&gt;PartialEq)]&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;enum&lt;/span&gt; &lt;span class="n"&gt;SafetyLevel&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Safe&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nf"&gt;Warning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nf"&gt;Danger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;assess_safety&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;SafetyLevel&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;input_lower&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="nf"&gt;.to_lowercase&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;critical&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"rm -rf /"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"This deletes your entire filesystem."&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"rm -rf ~"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"This deletes your home directory."&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"rm -rf *"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"This deletes everything in the current directory."&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"mkfs"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"This formats a disk, erasing all data."&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"dd if=/dev/zero"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"This overwrites a disk with zeros."&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;":(){:|:&amp;amp;};:"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"This is a fork bomb — it will crash your system."&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"chmod -R 777 /"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"This removes all security from your filesystem."&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;];&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pattern&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;critical&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;input_lower&lt;/span&gt;&lt;span class="nf"&gt;.contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pattern&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="nn"&gt;SafetyLevel&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;Danger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="nf"&gt;.to_string&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;warnings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"sudo rm"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"This runs a deletion command as administrator."&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"sudo chmod"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"This changes file permissions as administrator."&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"kill -9"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"This forcefully terminates a process."&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"pkill"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"This kills processes by name."&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;];&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pattern&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;warnings&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;input_lower&lt;/span&gt;&lt;span class="nf"&gt;.contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pattern&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="nn"&gt;SafetyLevel&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;Warning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="nf"&gt;.to_string&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="nn"&gt;SafetyLevel&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Safe&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Layer 2: Different UI for each level
&lt;/h2&gt;

&lt;p&gt;Danger → block entirely. Warning → user decides. Safe → proceed silently.&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;SafetyBanner&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;level&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;level&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SafetyResult&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;level&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;danger&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="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;


        &lt;span class="err"&gt;⚠️&lt;/span&gt; &lt;span class="nx"&gt;危険なコマンドが含まれています&lt;/span&gt;

&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;level&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;



&lt;span class="nx"&gt;AIへの送信をブロックしました&lt;/span&gt;&lt;span class="err"&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;level&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;warning&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="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;


        &lt;span class="err"&gt;🔶&lt;/span&gt; &lt;span class="nx"&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;level&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="nx"&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;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Layer 3: sudo gets automatic explanation
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;detect_mode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;DiagnosisMode&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;trimmed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="nf"&gt;.trim&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;trimmed&lt;/span&gt;&lt;span class="nf"&gt;.starts_with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"sudo "&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;trimmed&lt;/span&gt;&lt;span class="nf"&gt;.contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sc"&gt;'\n'&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="nn"&gt;DiagnosisMode&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;CommandExplain&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="nf"&gt;looks_like_error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input&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="nn"&gt;DiagnosisMode&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;ErrorDiagnosis&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nn"&gt;DiagnosisMode&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;CommandExplain&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;sudo systemctl restart nginx&lt;/code&gt; → explains what it does, warns about administrator privileges.&lt;/p&gt;




&lt;p&gt;HiyokoHelper (OSS) → github.com/hiyoyok/HiyokoHelper&lt;br&gt;
X → &lt;a class="mentioned-user" href="https://dev.to/hiyoyok"&gt;@hiyoyok&lt;/a&gt;&lt;/p&gt;

</description>
      <category>gemini</category>
      <category>ai</category>
      <category>rust</category>
      <category>security</category>
    </item>
    <item>
      <title>Offline-First Architecture in a Tauri App — What It Actually Means</title>
      <dc:creator>hiyoyo</dc:creator>
      <pubDate>Wed, 06 May 2026 17:09:11 +0000</pubDate>
      <link>https://forem.com/hiyoyok/offline-first-architecture-in-a-tauri-app-what-it-actually-means-4gkj</link>
      <guid>https://forem.com/hiyoyok/offline-first-architecture-in-a-tauri-app-what-it-actually-means-4gkj</guid>
      <description>&lt;p&gt;All tests run on an 8-year-old MacBook Air.&lt;br&gt;
All results from shipping 7 Mac apps as a solo developer. No sponsored opinion.&lt;br&gt;
"Offline-first" gets thrown around a lot. For a Tauri desktop app, it has a specific meaning — and it's not complicated.&lt;br&gt;
Here's what I mean when I say my apps are offline-first, and how I built it.&lt;/p&gt;

&lt;p&gt;What offline-first means for a desktop app&lt;br&gt;
A desktop app is already local by default. The question is what happens when it needs external resources — AI APIs, sync endpoints, update servers.&lt;br&gt;
Offline-first means: the app is fully functional without any network connection. Network access enhances the experience; it doesn't enable it.&lt;br&gt;
For my apps:&lt;/p&gt;

&lt;p&gt;HiyokoAutoSync: syncs files when ADB device is connected. No internet required.&lt;br&gt;
PDF Vault: all PDF operations run locally. Gemini OCR is optional enhancement.&lt;br&gt;
HiyokoHelper: all history, caching, and UI runs locally. AI analysis requires network.&lt;/p&gt;

&lt;p&gt;The distinction: required vs optional network.&lt;/p&gt;

&lt;p&gt;The practical implementation&lt;br&gt;
Local-first data. Everything goes to SQLite first. Network sync (if any) happens after.&lt;br&gt;
rustasync fn process_action(input: Input) -&amp;gt; Result {&lt;br&gt;
    // Write to local DB immediately&lt;br&gt;
    db.save(&amp;amp;input).await?;&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// Try network enhancement — fail gracefully
match enhance_with_api(&amp;amp;input).await {
    Ok(enhanced) =&amp;gt; Ok(enhanced),
    Err(_) =&amp;gt; Ok(Output::from_local(&amp;amp;input)), // degrade gracefully
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;br&gt;
Graceful degradation for AI features. If Gemini is unavailable, show the raw data. Don't block the UI waiting for a network response.&lt;br&gt;
No blocking network calls on the hot path. The user's primary workflow should never wait for the network. Background sync, optional enrichment — yes. Mandatory network call before the user can do anything — no.&lt;/p&gt;

&lt;p&gt;Why it matters for desktop apps&lt;br&gt;
Desktop apps get used in planes, conferences, basements, rural areas. Users expect desktop software to work without internet. Web app expectations don't apply.&lt;br&gt;
More practically: a flaky network call that hangs the UI is the fastest way to get a bad review.&lt;/p&gt;

&lt;p&gt;The one exception&lt;br&gt;
Update checks and license validation. These require network by definition. Handle them gracefully: check in the background, don't block launch, degrade to last-known state on network failure.&lt;/p&gt;

&lt;p&gt;If this was useful, a ❤️ helps more than you'd think — thanks!&lt;br&gt;
Hiyoko PDF Vault → &lt;a href="https://hiyokoko.gumroad.com/l/HiyokoPDFVault" rel="noopener noreferrer"&gt;https://hiyokoko.gumroad.com/l/HiyokoPDFVault&lt;/a&gt;&lt;br&gt;
X → &lt;a class="mentioned-user" href="https://dev.to/hiyoyok"&gt;@hiyoyok&lt;/a&gt;&lt;/p&gt;

</description>
      <category>tauri</category>
      <category>rust</category>
      <category>programming</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How I Price My Indie Mac Apps — The Thinking Behind $7, $39, and $50</title>
      <dc:creator>hiyoyo</dc:creator>
      <pubDate>Wed, 06 May 2026 15:33:11 +0000</pubDate>
      <link>https://forem.com/hiyoyok/how-i-price-my-indie-mac-apps-the-thinking-behind-7-39-and-50-4bpk</link>
      <guid>https://forem.com/hiyoyok/how-i-price-my-indie-mac-apps-the-thinking-behind-7-39-and-50-4bpk</guid>
      <description>&lt;p&gt;If this is useful, a ❤️ helps others find it.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;All tests run on an 8-year-old MacBook Air.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I have apps priced at $7, $20, $29, $39, and $50. Each price reflects a different decision. Here's the thinking.&lt;/p&gt;




&lt;h2&gt;
  
  
  The framework I use
&lt;/h2&gt;

&lt;p&gt;Two questions:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Who is the buyer?&lt;/strong&gt; Consumer (buying for personal use, price-sensitive) vs. professional (buying for work, outcome-focused).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What pain does it solve?&lt;/strong&gt; Occasional convenience vs. recurring time savings vs. risk reduction.&lt;/p&gt;




&lt;h2&gt;
  
  
  $7 — HiyokoShot (screenshot transfer)
&lt;/h2&gt;

&lt;p&gt;Consumer buyer. Occasional use. Solves a mild inconvenience (getting screenshots from Android to Mac).&lt;/p&gt;

&lt;p&gt;At $7, the decision is instant. No comparison shopping, no deliberation. The friction of evaluation costs more than the price.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rule:&lt;/strong&gt; If the buyer will use the app once a week or less, keep it under $10.&lt;/p&gt;




&lt;h2&gt;
  
  
  $39 — HiyokoBar (menubar tool)
&lt;/h2&gt;

&lt;p&gt;Mixed buyer — some consumers, some professionals. Daily use. Saves 5-10 minutes per day for people who use it seriously.&lt;/p&gt;

&lt;p&gt;At $39, it's in "considered purchase" territory — buyers spend 2-3 minutes evaluating. The sales page needs to be clear about the value proposition.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rule:&lt;/strong&gt; Daily-use tools for a mixed audience price between $20-50.&lt;/p&gt;




&lt;h2&gt;
  
  
  $50 — HiyokoAutoSync (zero-touch Android sync)
&lt;/h2&gt;

&lt;p&gt;Professional buyer. Solves a real workflow problem (automatic sync without thinking). Time savings compound daily.&lt;/p&gt;

&lt;p&gt;At $50, buyers are outcome-focused. They're not asking "is $50 a lot?" — they're asking "does this solve my problem?" The price signals quality and commitment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rule:&lt;/strong&gt; If the app replaces a workflow that currently takes manual effort daily, $50+ is defensible.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I got wrong
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Starting too low.&lt;/strong&gt; My first app launched at $9. After 50 sales I raised it to $19. Conversion rate barely changed. The $9 price was anchoring expectations low without driving meaningfully more sales.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Underestimating professional buyers.&lt;/strong&gt; I assumed everyone was price-sensitive. Some buyers emailed asking if there was a "pro" tier. There wasn't. Left money on the table.&lt;/p&gt;




&lt;h2&gt;
  
  
  Practical notes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Odd prices ($39 not $40, $7 not $8) perform slightly better — no strong evidence why, but it's consistent in my data&lt;/li&gt;
&lt;li&gt;Offering a permanent license (not subscription) is a strong selling point for Mac utility apps — buyers are tired of subscriptions&lt;/li&gt;
&lt;li&gt;"One-time purchase" in the product title or description noticeably improves conversion for this audience&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Hiyoko PDF Vault → &lt;a href="https://hiyokoko.gumroad.com/l/HiyokoPDFVault" rel="noopener noreferrer"&gt;https://hiyokoko.gumroad.com/l/HiyokoPDFVault&lt;/a&gt;&lt;br&gt;
X → &lt;a class="mentioned-user" href="https://dev.to/hiyoyok"&gt;@hiyoyok&lt;/a&gt;&lt;/p&gt;

</description>
      <category>programming</category>
      <category>product</category>
      <category>rust</category>
      <category>tauri</category>
    </item>
    <item>
      <title>What I Learned Building HiyokoBar — A Menubar App That Does One Thing Per Click</title>
      <dc:creator>hiyoyo</dc:creator>
      <pubDate>Wed, 06 May 2026 14:30:05 +0000</pubDate>
      <link>https://forem.com/hiyoyok/what-i-learned-building-hiyokobar-a-menubar-app-that-does-one-thing-per-click-2a4h</link>
      <guid>https://forem.com/hiyoyok/what-i-learned-building-hiyokobar-a-menubar-app-that-does-one-thing-per-click-2a4h</guid>
      <description>&lt;p&gt;If this is useful, a ❤️ helps others find it.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;All tests run on an 8-year-old MacBook Air.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;HiyokoBar is a menubar app. Click the icon — panel appears. Do the thing. Click away — panel disappears.&lt;/p&gt;

&lt;p&gt;It sounds trivial. It took longer than expected to get right. Here's what I learned.&lt;/p&gt;




&lt;h2&gt;
  
  
  The constraint that shaped everything
&lt;/h2&gt;

&lt;p&gt;A menubar panel has maybe 400px of vertical space. That's it.&lt;/p&gt;

&lt;p&gt;This constraint forced decisions I wouldn't have made otherwise:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every feature had to earn its place. No "maybe someone will want this" features.&lt;/li&gt;
&lt;li&gt;Each action had to complete in one click or one step. Two-step actions don't belong in a menubar panel.&lt;/li&gt;
&lt;li&gt;Visual hierarchy matters more than in a full window — users scan, not read.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Constraints produce clarity. The limited space was the best design tool I had.&lt;/p&gt;




&lt;h2&gt;
  
  
  The technical decisions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Activation policy:&lt;/strong&gt; &lt;code&gt;accessory&lt;/code&gt; — no Dock icon, no Cmd+Tab entry.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Panel positioning:&lt;/strong&gt; calculate from tray icon position on every click — the user might have moved it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Focus behavior:&lt;/strong&gt; hide on blur, but suppress blur-hiding during native dialogs. Took 3 iterations to get right.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Launch at login:&lt;/strong&gt; LaunchAgent plist, not SMAppService — better compatibility with older macOS versions.&lt;/p&gt;




&lt;h2&gt;
  
  
  What users actually use
&lt;/h2&gt;

&lt;p&gt;I added analytics (opt-in, local only) to see which features got tapped.&lt;/p&gt;

&lt;p&gt;The top 3 features account for 80% of all interactions. The bottom 5 features combined account for under 5%.&lt;/p&gt;

&lt;p&gt;I removed two features entirely after seeing the data. The app got better immediately — less to scan, less to understand, faster to use.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The lesson:&lt;/strong&gt; ship with more features than you think users need. Then watch what they actually use. Then remove everything else.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Product Hunt launch
&lt;/h2&gt;

&lt;p&gt;HiyokoBar launched on Product Hunt on April 20, 2026.&lt;/p&gt;

&lt;p&gt;Traffic spike: yes. Sustained sales from it: modest. The real value was the comments and feedback — several feature ideas came directly from PH discussions that I'd never have thought of.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;My view on PH:&lt;/strong&gt; worth doing once per app for the feedback loop, not for the traffic.&lt;/p&gt;




&lt;h2&gt;
  
  
  The one thing that surprised me
&lt;/h2&gt;

&lt;p&gt;The users who emailed me feedback. Not bug reports — genuine "here's what this does for my workflow" messages.&lt;/p&gt;

&lt;p&gt;Menubar apps attract a specific kind of user: people who care about their tools, who customize their environment, who notice when something is done right. These are the best users to have.&lt;/p&gt;

&lt;p&gt;Build for them.&lt;/p&gt;




&lt;p&gt;Hiyoko PDF Vault → &lt;a href="https://hiyokoko.gumroad.com/l/HiyokoPDFVault" rel="noopener noreferrer"&gt;https://hiyokoko.gumroad.com/l/HiyokoPDFVault&lt;/a&gt;&lt;br&gt;
HiyokoBar → &lt;a href="https://hiyokoko.gumroad.com/l/hiyokobar" rel="noopener noreferrer"&gt;https://hiyokoko.gumroad.com/l/hiyokobar&lt;/a&gt;&lt;br&gt;
X → &lt;a class="mentioned-user" href="https://dev.to/hiyoyok"&gt;@hiyoyok&lt;/a&gt;&lt;/p&gt;

</description>
      <category>tauri</category>
      <category>rust</category>
      <category>programming</category>
      <category>product</category>
    </item>
    <item>
      <title>Bates Numbering in Rust — Automating Legal Document Stamping</title>
      <dc:creator>hiyoyo</dc:creator>
      <pubDate>Wed, 06 May 2026 13:20:00 +0000</pubDate>
      <link>https://forem.com/hiyoyok/bates-numbering-in-rust-automating-legal-document-stamping-4080</link>
      <guid>https://forem.com/hiyoyok/bates-numbering-in-rust-automating-legal-document-stamping-4080</guid>
      <description>&lt;p&gt;All tests run on an 8-year-old MacBook Air.&lt;br&gt;
All results from shipping 7 Mac apps as a solo developer. No sponsored opinion.&lt;br&gt;
Bates numbering is sequential page stamping used in legal documents. Every page gets a unique identifier: CASE-001, CASE-002, etc.&lt;br&gt;
I built this into Hiyoko PDF Vault. Here's how it works in Rust.&lt;/p&gt;

&lt;p&gt;What Bates numbering actually is&lt;br&gt;
A Bates stamp is a text label added to a fixed position on each page — usually bottom-right or bottom-left. The label increments sequentially across a document set.&lt;br&gt;
Format: [PREFIX][NUMBER][SUFFIX] where number is zero-padded to a fixed width.&lt;br&gt;
Examples: SMITH-000001, EXHIBIT_A_0042, DOC00100&lt;/p&gt;

&lt;p&gt;The implementation with lopdf&lt;br&gt;
rustuse lopdf::{Document, Object, Stream, Dictionary, content::Content};&lt;/p&gt;

&lt;p&gt;pub struct BatesConfig {&lt;br&gt;
    pub prefix: String,&lt;br&gt;
    pub suffix: String,&lt;br&gt;
    pub start_number: u64,&lt;br&gt;
    pub pad_width: usize,&lt;br&gt;
    pub position: BatesPosition,&lt;br&gt;
    pub font_size: f32,&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;pub enum BatesPosition {&lt;br&gt;
    BottomRight,&lt;br&gt;
    BottomLeft,&lt;br&gt;
    TopRight,&lt;br&gt;
    TopLeft,&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;pub fn apply_bates(doc: &amp;amp;mut Document, config: &amp;amp;BatesConfig) -&amp;gt; Result&amp;lt;(), AppError&amp;gt; {&lt;br&gt;
    let page_ids: Vec&amp;lt;_&amp;gt; = doc.page_iter().collect();&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;for (i, page_id) in page_ids.iter().enumerate() {
    let number = config.start_number + i as u64;
    let label = format!(
        "{}{}{}",
        config.prefix,
        format!("{:0&amp;gt;width$}", number, width = config.pad_width),
        config.suffix
    );

    stamp_page(doc, *page_id, &amp;amp;label, &amp;amp;config)?;
}

Ok(())
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;/p&gt;

&lt;p&gt;Stamping a page&lt;br&gt;
Adding text to a PDF page requires appending to its content stream:&lt;br&gt;
rustfn stamp_page(&lt;br&gt;
    doc: &amp;amp;mut Document,&lt;br&gt;
    page_id: (u32, u16),&lt;br&gt;
    label: &amp;amp;str,&lt;br&gt;
    config: &amp;amp;BatesConfig,&lt;br&gt;
) -&amp;gt; Result&amp;lt;(), AppError&amp;gt; {&lt;br&gt;
    let (x, y) = calculate_position(doc, page_id, &amp;amp;config.position)?;&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;let stamp_content = format!(
    "BT /F1 {} Tf {} {} Td ({}) Tj ET",
    config.font_size, x, y, label
);

// Append to existing page content
// Ensure font is available in page resources
append_content_to_page(doc, page_id, &amp;amp;stamp_content)?;

Ok(())
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;/p&gt;

&lt;p&gt;The font dependency&lt;br&gt;
PDF text rendering requires a font reference in the page's resource dictionary. If the page doesn't already have a suitable font, you need to embed one or reference a standard PDF font (Helvetica, Times, Courier — guaranteed to be available in any PDF viewer).&lt;br&gt;
For Bates stamps, Helvetica works fine and requires no font embedding.&lt;/p&gt;

&lt;p&gt;Batch processing&lt;br&gt;
The real use case is stamping hundreds of pages across multiple documents. Process sequentially with progress events back to the frontend. Don't try to parallelize PDF mutation — document state is not thread-safe with lopdf's mutable references.&lt;/p&gt;

&lt;p&gt;If this was useful, a ❤️ helps more than you'd think — thanks!&lt;br&gt;
Hiyoko PDF Vault → &lt;a href="https://hiyokoko.gumroad.com/l/HiyokoPDFVault" rel="noopener noreferrer"&gt;https://hiyokoko.gumroad.com/l/HiyokoPDFVault&lt;/a&gt;&lt;br&gt;
X → &lt;a class="mentioned-user" href="https://dev.to/hiyoyok"&gt;@hiyoyok&lt;/a&gt;&lt;/p&gt;

</description>
      <category>tarui</category>
      <category>rust</category>
      <category>programming</category>
      <category>webdev</category>
    </item>
    <item>
      <title>PDF Redaction in Rust — Why "Delete the Text" Isn't Enough</title>
      <dc:creator>hiyoyo</dc:creator>
      <pubDate>Wed, 06 May 2026 02:18:52 +0000</pubDate>
      <link>https://forem.com/hiyoyok/pdf-redaction-in-rust-why-delete-the-text-isnt-enough-2bof</link>
      <guid>https://forem.com/hiyoyok/pdf-redaction-in-rust-why-delete-the-text-isnt-enough-2bof</guid>
      <description>&lt;p&gt;All tests run on an 8-year-old MacBook Air.&lt;br&gt;
All results from shipping 7 Mac apps as a solo developer. No sponsored opinion.&lt;br&gt;
Real PDF redaction is harder than it looks. The naive approach — draw a black rectangle over text — doesn't actually remove the text from the file.&lt;br&gt;
Here's what proper redaction requires.&lt;/p&gt;

&lt;p&gt;The problem with naive redaction&lt;br&gt;
A PDF with a black rectangle drawn over sensitive text still contains that text in the file structure. Anyone with a PDF editor can remove the rectangle and read the original content.&lt;br&gt;
This has caused real security incidents. Legal documents, medical records, government reports — all leaked because someone drew a box over text and called it redacted.&lt;/p&gt;

&lt;p&gt;What actual redaction requires&lt;/p&gt;

&lt;p&gt;Identify the content to redact (text, images, or regions)&lt;br&gt;
Remove the actual content from the PDF's content streams&lt;br&gt;
Replace with a filled rectangle&lt;br&gt;
Remove any references in the document structure&lt;br&gt;
Rebuild the PDF without the redacted content in the object stream&lt;/p&gt;

&lt;p&gt;Step 2 is where naive implementations fail. Removing visible rendering is not the same as removing the data.&lt;/p&gt;

&lt;p&gt;The lopdf approach&lt;br&gt;
With lopdf, you're working directly with PDF objects. Redaction means modifying content streams:&lt;br&gt;
rustfn redact_text_in_stream(content: &amp;amp;[u8], target: &amp;amp;str) -&amp;gt; Vec {&lt;br&gt;
    // Parse PDF content stream operations&lt;br&gt;
    // Find text rendering operations containing target&lt;br&gt;
    // Replace text content with spaces or remove operations&lt;br&gt;
    // Rebuild content stream&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// This is genuinely complex — PDF content streams
// interleave text positioning and rendering commands
todo!("non-trivial implementation")
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;br&gt;
PDF content streams aren't plain text. They're a sequence of operators and operands. Text appears across multiple operators: font selection, positioning, encoding, rendering. A complete redaction implementation needs to parse all of these.&lt;/p&gt;

&lt;p&gt;What I ship in PDF Vault&lt;br&gt;
Hiyoko PDF Vault implements region-based redaction: the user selects a region, we remove all content operations that render within that region, then fill with a solid rectangle.&lt;br&gt;
It's not forensic-grade redaction. It removes content from the file structure rather than just drawing over it. For the use case — personal documents, not classified government files — it's appropriate.&lt;br&gt;
For truly sensitive documents requiring certified redaction, professional tools with documented audit trails are the right choice. I'm honest about this in the app description.&lt;/p&gt;

&lt;p&gt;The verdict&lt;br&gt;
True PDF redaction is a solved problem in professional tools. In a Rust implementation, it's achievable but requires careful PDF content stream parsing. The naive approach (draw a rectangle) should never be called redaction.&lt;br&gt;
Know what level of redaction your users actually need before deciding how to implement it.&lt;/p&gt;

&lt;p&gt;If this was useful, a ❤️ helps more than you'd think — thanks!&lt;br&gt;
Hiyoko PDF Vault → &lt;a href="https://hiyokoko.gumroad.com/l/HiyokoPDFVault" rel="noopener noreferrer"&gt;https://hiyokoko.gumroad.com/l/HiyokoPDFVault&lt;/a&gt;&lt;br&gt;
X → &lt;a class="mentioned-user" href="https://dev.to/hiyoyok"&gt;@hiyoyok&lt;/a&gt;&lt;/p&gt;

</description>
      <category>tauri</category>
      <category>rust</category>
      <category>programming</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Selling Mac Apps on Gumroad — What Works, What Doesn't, Honest Numbers</title>
      <dc:creator>hiyoyo</dc:creator>
      <pubDate>Wed, 06 May 2026 01:09:39 +0000</pubDate>
      <link>https://forem.com/hiyoyok/selling-mac-apps-on-gumroad-what-works-what-doesnt-honest-numbers-3f0m</link>
      <guid>https://forem.com/hiyoyok/selling-mac-apps-on-gumroad-what-works-what-doesnt-honest-numbers-3f0m</guid>
      <description>&lt;p&gt;If this is useful, a ❤️ helps others find it.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;All tests run on an 8-year-old MacBook Air.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I sell Mac apps on Gumroad. Not the App Store — Gumroad, direct to buyers.&lt;/p&gt;

&lt;p&gt;Here's the honest account: what the platform is like, what drives sales, and what I'd do differently.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Gumroad over the App Store
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;No code signing requirement.&lt;/strong&gt; Apple Developer Program costs $99/year. Before you've made $99, that's a high bar. Gumroad accepts unsigned apps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No 30% cut.&lt;/strong&gt; Gumroad takes 10% (or less with volume). Apple takes 30%. On a $30 app, that's $3 vs $9 per sale.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No review process.&lt;/strong&gt; Ship when it's ready. Update when you want. No waiting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The tradeoff:&lt;/strong&gt; users see a "cannot verify developer" dialog on first launch. Right-click → Open bypasses it. Some users won't do this. It costs conversions — I'd estimate 15-20% drop-off at that step.&lt;/p&gt;




&lt;h2&gt;
  
  
  What actually drives sales
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Technical articles.&lt;/strong&gt; Every spike in sales correlates with a published article. Not product announcements — articles about how I built something. "Adobe税を払うのをやめた" (I stopped paying the Adobe tax) outperformed every product post I've written.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Specific problem, specific solution.&lt;/strong&gt; "PDF tool" is too broad. "Remove metadata from PDFs before sharing" is a specific problem someone is actively searching for. The more specific the problem the app solves, the easier the sales page writes itself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Price anchoring.&lt;/strong&gt; Listing a Japanese version and English version separately at slightly different prices helps. The existence of two options makes choosing feel like a decision rather than a yes/no on buying.&lt;/p&gt;




&lt;h2&gt;
  
  
  What doesn't drive sales
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Product Hunt launches.&lt;/strong&gt; One-day spike, then nothing. Useful for social proof and backlinks, not for sustained revenue.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Generic social media posting.&lt;/strong&gt; "Check out my new app!" gets ignored. Technical content with a mention of the app at the end works better.&lt;/p&gt;




&lt;h2&gt;
  
  
  The numbers (directionally)
&lt;/h2&gt;

&lt;p&gt;Articles that perform well drive 5-15 sales in the following week. Product announcements drive 0-3. The difference in effort between writing a good technical article and a product announcement is small. The difference in result is large.&lt;/p&gt;




&lt;h2&gt;
  
  
  Practical Gumroad tips
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Offer both a Japanese and English listing for Japan-market apps — different buyers find different listings&lt;/li&gt;
&lt;li&gt;Include a clear "how to open on first launch" note in the product description (for the security dialog)&lt;/li&gt;
&lt;li&gt;Respond to every support email — Gumroad shows buyer reviews and support responsiveness matters&lt;/li&gt;
&lt;li&gt;Use the "pay what you want" minimum for free tools to collect emails&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Hiyoko PDF Vault → &lt;a href="https://hiyokoko.gumroad.com/l/HiyokoPDFVault" rel="noopener noreferrer"&gt;https://hiyokoko.gumroad.com/l/HiyokoPDFVault&lt;/a&gt;&lt;br&gt;
X → &lt;a class="mentioned-user" href="https://dev.to/hiyoyok"&gt;@hiyoyok&lt;/a&gt;&lt;/p&gt;

</description>
      <category>programming</category>
      <category>product</category>
      <category>tauri</category>
      <category>rust</category>
    </item>
  </channel>
</rss>
