<?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: engnzdl</title>
    <description>The latest articles on DEV Community by engnzdl (@engnzdl).</description>
    <link>https://dev.to/engnzdl</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%2F368665%2Fcbf2e341-63bb-4341-8e4e-f3cfbf10f558.png</url>
      <title>DEV Community: engnzdl</title>
      <link>https://dev.to/engnzdl</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/engnzdl"/>
    <language>en</language>
    <item>
      <title>Two real bugs in aiograpi: a race condition and a dropped auth token</title>
      <dc:creator>engnzdl</dc:creator>
      <pubDate>Thu, 15 Oct 2026 15:34:00 +0000</pubDate>
      <link>https://dev.to/engnzdl/two-real-bugs-in-aiograpi-a-race-condition-and-a-dropped-auth-token-20l4</link>
      <guid>https://dev.to/engnzdl/two-real-bugs-in-aiograpi-a-race-condition-and-a-dropped-auth-token-20l4</guid>
      <description>&lt;p&gt;aiograpi is the async Instagram private-API library (475★ on GitHub). A version bump of our vendored copy surfaced two real bugs. Both are fixed, tested, and PR'd upstream.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. photo_upload extracts media from the wrong response
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;photo_upload&lt;/code&gt; retries &lt;code&gt;photo_configure&lt;/code&gt; up to 10 times, then pulls the uploaded media out of &lt;code&gt;self.last_json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;photo_configure&lt;/span&gt;&lt;span class="p"&gt;(...):&lt;/span&gt;
    &lt;span class="n"&gt;media&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_extract_configured_media_or_recent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;last_json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;# shared mutable state
&lt;/span&gt;        &lt;span class="bp"&gt;...&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;self.last_json&lt;/code&gt; is the client's shared "last response" field. In a single-upload flow it happens to equal the configure response. Run two uploads concurrently and another coroutine's response can land in &lt;code&gt;self.last_json&lt;/code&gt; between the configure call and the read — you extract media from the wrong request, or fail outright.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;photo_configure&lt;/code&gt; already returns the configured dict. The retry loop was discarding it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;configure_result&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;photo_configure&lt;/span&gt;&lt;span class="p"&gt;(...):&lt;/span&gt;
    &lt;span class="n"&gt;media&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_extract_configured_media_or_recent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;configure_result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="bp"&gt;...&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Race gone.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Instagram's token rotation gets silently dropped
&lt;/h2&gt;

&lt;p&gt;Instagram rotates the bearer token mid-session and returns it in the &lt;code&gt;ig-set-authorization&lt;/code&gt; response header. aiograpi parsed that header in exactly one place — the login flow. A rotation returned on any other private request was dropped, so &lt;code&gt;authorization_data&lt;/code&gt; went stale and every later request rebuilt a stale &lt;code&gt;Authorization&lt;/code&gt; header.&lt;/p&gt;

&lt;p&gt;The fix moves the parse into the shared private-request handler, so any response carrying the header updates the client:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;authorization&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ig-set-authorization&lt;/span&gt;&lt;span class="sh"&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;authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;authorization_data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse_authorization&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;authorization&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Shipped with tests
&lt;/h2&gt;

&lt;p&gt;Each fix has a regression test (mocked transport / mocked configure) and passes the project's ruff + pytest gates.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Race fix: &lt;a href="https://github.com/subzeroid/aiograpi/pull/511" rel="noopener noreferrer"&gt;https://github.com/subzeroid/aiograpi/pull/511&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Auth fix: &lt;a href="https://github.com/subzeroid/aiograpi/pull/512" rel="noopener noreferrer"&gt;https://github.com/subzeroid/aiograpi/pull/512&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>python</category>
      <category>debugging</category>
      <category>opensource</category>
      <category>instagram</category>
    </item>
    <item>
      <title>Upgrading a vendored anti-bot library across a major version: the patch ledger</title>
      <dc:creator>engnzdl</dc:creator>
      <pubDate>Thu, 15 Oct 2026 14:14:51 +0000</pubDate>
      <link>https://dev.to/engnzdl/upgrading-a-vendored-anti-bot-library-across-a-major-version-the-patch-ledger-2ekf</link>
      <guid>https://dev.to/engnzdl/upgrading-a-vendored-anti-bot-library-across-a-major-version-the-patch-ledger-2ekf</guid>
      <description>&lt;p&gt;I run the Instagram side of a social-automation platform, and it depends on aiograpi — the async port of instagrapi, the library people use to talk to Instagram's private API without the official Graph API. We vendor it (we have to, because we patch it), and recently we took it from 0.9.6 to 2.0.15.&lt;/p&gt;

&lt;p&gt;That's not a minor bump. 2.0.15 is the instagrapi 3.x lineage — a rewrite underneath. And when you've been patching a vendored library for months, a major upgrade isn't &lt;code&gt;pip install&lt;/code&gt;. It's an accounting exercise: which of my local patches does upstream now have, and which are still mine to carry forward by hand?&lt;/p&gt;

&lt;h2&gt;
  
  
  The patch ledger
&lt;/h2&gt;

&lt;p&gt;Over the life of our 0.9.6 fork, five local patches had accumulated. Going in, the job was to sort them into two buckets.&lt;/p&gt;

&lt;p&gt;Three had been absorbed upstream. The XDT media types (XDTGraphImage / XDTGraphVideo / XDTGraphSidecar — Instagram's newer Graph response shapes), a null guard on empty &lt;code&gt;video_versions&lt;/code&gt;, a normalize on carousel-children pk extraction. Those just disappear on upgrade.&lt;/p&gt;

&lt;p&gt;Two hadn't. Those are the ones worth describing, because they're the kind of bug that only surfaces in production, and only in an async client.&lt;/p&gt;

&lt;h2&gt;
  
  
  Patch one: the walrus-operator fix
&lt;/h2&gt;

&lt;p&gt;The photo upload path retries its configure step up to ten times. The original code checked success by reading &lt;code&gt;self.last_json&lt;/code&gt; — a shared field the client overwrites on every response. In a synchronous client that's already fragile. In an async client, where another coroutine's response can land between your configure call and your read, it's actively wrong: you're checking whether your upload succeeded by looking at some &lt;em&gt;other&lt;/em&gt; request's response.&lt;/p&gt;

&lt;p&gt;The fix is one line, and it's the whole lesson:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nf"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;configure_result&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;photo_configure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;upload_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;height&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;caption&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...)):&lt;/span&gt;
    &lt;span class="n"&gt;media&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_extract_configured_media_or_recent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;configure_result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The walrus captures configure's return value into a local, so you test the response you actually received — not whatever last touched the shared field. This is the bug that makes photo uploads flake intermittently in ways that are miserable to reproduce: it only bites when two requests race.&lt;/p&gt;

&lt;h2&gt;
  
  
  Patch two: the authorization header Instagram sends you
&lt;/h2&gt;

&lt;p&gt;Instagram hands the session's auth token back in a response header, &lt;code&gt;ig-set-authorization&lt;/code&gt;. The code parsed its sibling — &lt;code&gt;ig-set-x-mid&lt;/code&gt;, the machine id — but never parsed the authorization header into the client's &lt;code&gt;authorization_data&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So the session looked logged in while the client quietly worked with an empty authorization, and the first privileged request failed in a way that pointed at everything except the real cause. The fix is a straight header parse:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;auth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ig-set-authorization&lt;/span&gt;&lt;span class="sh"&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;auth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;authorization_data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse_authorization&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It took a failing login to trace it there, because the symptom — "auth required" on the first action — is indistinguishable from a dozen other auth problems.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug the upgrade didn't cause, but the test run caught
&lt;/h2&gt;

&lt;p&gt;None of this counts until it's exercised against a real account. The end-to-end run was: session login, reads (user info, search, friendship status), then writes — follow, like, unlike, unfollow.&lt;/p&gt;

&lt;p&gt;The unlike action 404'd with "not-logged-in". It turned out &lt;code&gt;_action_unlike&lt;/code&gt; only called the &lt;em&gt;web&lt;/em&gt; client's &lt;code&gt;unlike()&lt;/code&gt;, while the session we were holding was a mobile session — so the web client had no cookie and got a clean 404. The like path already had the right shape (mobile first, web fallback); the unlike path didn't. Fixed by mirroring it: try &lt;code&gt;media_unlike&lt;/code&gt; on the mobile client, fall back to web.&lt;/p&gt;

&lt;p&gt;After that, every write verified against the real account — follow reflected as &lt;code&gt;following: true&lt;/code&gt; on Instagram, like / unlike / unfollow all clean.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I'm writing this
&lt;/h2&gt;

&lt;p&gt;Because this is the unglamorous reality of building on top of reverse-engineered, unofficial APIs: the library is one platform change away from breaking, so you vendor it, you patch it, and when upstream finally catches up you run the ledger — what's absorbed, what's still yours, and what only shows up when you run it against a live account.&lt;/p&gt;

&lt;p&gt;If your product sits on a private-API library and you're tired of the treadmill — the upgrades, the patch accounting, the 404s that mean something else entirely — this is the work I do. DM me on X (&lt;a href="https://x.com/engnzdl" rel="noopener noreferrer"&gt;@engnzdl&lt;/a&gt;) or reach me at &lt;a href="https://enginozdal.com" rel="noopener noreferrer"&gt;enginozdal.com&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>reverseengineering</category>
      <category>python</category>
      <category>api</category>
      <category>instagram</category>
    </item>
    <item>
      <title>I reverse-engineered TikTok's x-argus anti-bot signature</title>
      <dc:creator>engnzdl</dc:creator>
      <pubDate>Thu, 15 Oct 2026 12:57:18 +0000</pubDate>
      <link>https://dev.to/engnzdl/i-reverse-engineered-tiktoks-x-argus-anti-bot-signature-194p</link>
      <guid>https://dev.to/engnzdl/i-reverse-engineered-tiktoks-x-argus-anti-bot-signature-194p</guid>
      <description>&lt;p&gt;Anti-bot signatures are the modern CAPTCHA. Every serious mobile app signs its API requests with a token only the app itself can produce, and TikTok's is among the hardest to break. I've spent the past weeks taking it apart, and I want to share what's inside a protected native signer like this and what it takes to work with one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The signature, structurally
&lt;/h2&gt;

&lt;p&gt;The x-argus header is a 728-character token. Under the base64 it's a 546-byte blob with a clean layout: an 8-byte nonce, a 32-byte SM3 digest, and a 506-byte AES-CBC ciphertext.&lt;/p&gt;

&lt;p&gt;The pipeline: a protobuf payload (device id, install id, app version, timestamp, and the SM3 hash of the request body) is hashed with SM3, encrypted through SIMON-128/256 (72 rounds), then AES-128-CBC, then base64. The companion headers — x-gorgon, x-ladon, x-khronos — come out of the same pass.&lt;/p&gt;

&lt;p&gt;This logic doesn't live in the APK's Java. It's inside &lt;code&gt;libmetasec_ov.so&lt;/code&gt;, a native library built to resist analysis: OLLVM control-flow flattening, a custom bytecode VM, and JIT-generated code. Static disassembly of a signer like this just folds back on itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Emulating, not disassembling
&lt;/h2&gt;

&lt;p&gt;The approach that actually produced results was to stop reading the binary and start running it. unidbg executes an Android .so on a desktop JVM; once the native entry point was mapped and fed the right inputs, the library produced valid signatures under emulation.&lt;/p&gt;

&lt;p&gt;That's the inversion worth internalizing: against obfuscation at this level, you don't beat it — you give the binary the environment it expects and let it sign for you.&lt;/p&gt;

&lt;p&gt;Dynamic analysis had its own traps. The signer's entry function takes its arguments in a non-obvious way — the request URL isn't passed where you'd assume. Calling it the intuitive way throws an "argument types do not match" from the JNI layer; it only signs correctly when you pass the headers array instead of the URL string. Details like that are the difference between "I looked at it" and "I ran it."&lt;/p&gt;

&lt;h2&gt;
  
  
  What the client actually returns
&lt;/h2&gt;

&lt;p&gt;The result isn't a paper analysis — it's a working Python client that speaks the API natively. Profile lookups (&lt;code&gt;/tiktok/user/profile/other/v1&lt;/code&gt;) return real account data — uid, avatar, follower counts. The feed endpoint (&lt;code&gt;/aweme/v2/feed/&lt;/code&gt;) returns fresh content with the full &lt;code&gt;aweme_id&lt;/code&gt; / &lt;code&gt;sec_uid&lt;/code&gt; shape. Search works across all three surfaces — users, videos, hashtags — through &lt;code&gt;/aweme/v2/search/general/single/&lt;/code&gt;, returning live user lists with real ids and follower counts.&lt;/p&gt;

&lt;p&gt;A couple of non-obvious details that fall out of doing this for real. The API routes each request to a different host based on what it is — one host for profile, another for feed, a third for search — and sending a request to the wrong one fails even with a valid signature. Gzipped bodies need an extra &lt;code&gt;x-ss-stub&lt;/code&gt; header that's just the MD5 of the plaintext, folded into the signature with the data field left empty. These are the small things a headless browser never shows you, and exactly what breaks when you guess.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two layers you don't expect
&lt;/h2&gt;

&lt;p&gt;Two non-crypto layers make this genuinely hard in practice. The first is transport: the API only accepts a client presenting Android's TLS fingerprint (OkHttp3_13), so everything has to ride on a TLS stack that mimics the device. The second was the bigger surprise — the signature binds a device fingerprint, read live from the property trie at &lt;code&gt;/dev/__properties__&lt;/code&gt;. The crypto is portable; the device identity isn't.&lt;/p&gt;

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

&lt;p&gt;If you build anything that talks to a mobile API — data collection, growth tooling, automation, market research — you eventually hit this wall. The only real question is whether the team can actually get through it, or just throws a headless browser at the problem and hopes.&lt;/p&gt;

&lt;p&gt;That's the work I do: mobile reverse engineering, signature and anti-bot bypass, TLS fingerprinting, and building clients that speak these protocols natively.&lt;/p&gt;

&lt;p&gt;If your product is stuck at one of these walls, let's talk — DM me on X (&lt;a href="https://x.com/engnzdl" rel="noopener noreferrer"&gt;@engnzdl&lt;/a&gt;) or reach me at &lt;a href="https://enginozdal.com" rel="noopener noreferrer"&gt;enginozdal.com&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The full research notes — signing chain, endpoint catalog, dynamic-analysis hooks — are on GitHub: &lt;a href="https://github.com/engnzdl/tiktok-x-argus-research" rel="noopener noreferrer"&gt;github.com/engnzdl/tiktok-x-argus-research&lt;/a&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Educational security research. This describes a publicly documented signature scheme at a structural level — no working code, keys, or implementation details are published.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>reverseengineering</category>
      <category>security</category>
      <category>android</category>
      <category>mobile</category>
    </item>
    <item>
      <title>I built a cross-platform Android emulator manager with Tauri 2 + Rust — no Android Studio required</title>
      <dc:creator>engnzdl</dc:creator>
      <pubDate>Thu, 15 Oct 2026 11:55:38 +0000</pubDate>
      <link>https://dev.to/engnzdl/i-built-a-cross-platform-android-emulator-manager-with-tauri-2-rust-no-android-studio-required-52c5</link>
      <guid>https://dev.to/engnzdl/i-built-a-cross-platform-android-emulator-manager-with-tauri-2-rust-no-android-studio-required-52c5</guid>
      <description>&lt;p&gt;I got tired of three things at once: the Android emulator CLI is tedious, Android Studio is a multi-gigabyte install for just running AVDs, and every decent alternative is either closed-source or expensive. So I built &lt;a href="https://github.com/engnzdl/enmulator" rel="noopener noreferrer"&gt;Enmulator&lt;/a&gt; — a desktop app that manages Android Virtual Devices end to end, ships on Windows/macOS/Linux, and doesn't need Android Studio installed.&lt;/p&gt;

&lt;p&gt;It's Tauri 2 on the backend (Rust, 13 modules), React 18 on the frontend, and it's GPLv3.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it actually does
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Create / start / stop / clone / delete AVDs, plus batch operations across multiple devices&lt;/li&gt;
&lt;li&gt;66 pre-loaded device profiles across 16 brands (Pixel, Samsung, Xiaomi, OnePlus, realme…)&lt;/li&gt;
&lt;li&gt;Device identity spoofing: IMEI, operator, phone number, WiFi MAC, timezone, locale&lt;/li&gt;
&lt;li&gt;Root: &lt;code&gt;adb root&lt;/code&gt; for google_apis images, and Magisk via rootAVD for everything else&lt;/li&gt;
&lt;li&gt;Per-device proxy, GPS mocking, screen recording, clipboard sync, live logcat, a split-panel file explorer&lt;/li&gt;
&lt;li&gt;A headless REST API (Actix-web) so you can drive it from CI or a script&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The whole point was to make emulator setup feel like a product, not a shell-script chore.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Tauri instead of Electron
&lt;/h2&gt;

&lt;p&gt;A tool that spawns emulator processes shouldn't eat 300 MB of RAM on its own. Tauri uses the OS webview instead of bundling Chromium, so the installer is small and idle memory is low. The backend being Rust mattered more than I expected: adb calls are blocking &lt;code&gt;Command&lt;/code&gt; invocations, and being able to move them off the main thread with plain &lt;code&gt;std::thread&lt;/code&gt; — without async ceremony — kept the UI responsive for free.&lt;/p&gt;

&lt;p&gt;The tradeoff is real too: the macOS/Windows/Linux build matrix means three toolchains and three sets of platform quirks (more on that below).&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture
&lt;/h2&gt;

&lt;p&gt;The Rust side is a flat set of modules, each owning one concern:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;sdk.rs&lt;/code&gt; — locate the Android SDK, &lt;code&gt;adb&lt;/code&gt;, &lt;code&gt;emulator&lt;/code&gt;, &lt;code&gt;avdmanager&lt;/code&gt; binaries&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;avd_manager.rs&lt;/code&gt; — create/delete AVDs, write &lt;code&gt;config.ini&lt;/code&gt; (resolution, DPI, hardware keyboard)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;emulator.rs&lt;/code&gt; — process management + port allocation (each emulator gets a console port)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;adb_bridge.rs&lt;/code&gt; — the &lt;code&gt;adb shell / install / push / pull&lt;/code&gt; wrappers&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;fingerprint.rs&lt;/code&gt; — the identity-spoofing engine&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;bypass.rs&lt;/code&gt; — build.prop patching for detection bypass&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;extras.rs&lt;/code&gt; — GPS, clipboard, screen record, logcat streams&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;api_server.rs&lt;/code&gt; — the REST layer&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The frontend is a few focused components: a create wizard, per-device cards, an identity panel, the file explorer, quick actions. Nothing clever — state lives in the backend, the UI just calls commands over Tauri's IPC.&lt;/p&gt;

&lt;h2&gt;
  
  
  The parts that were actually hard
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Fingerprint spoofing is a moving target
&lt;/h3&gt;

&lt;p&gt;The naive version is &lt;code&gt;adb shell setprop&lt;/code&gt; with the IMEI/operator values. That works for &lt;em&gt;some&lt;/em&gt; properties and silently fails for others — and "silently fails" is the worst outcome because the user thinks they're spoofed when they aren't.&lt;/p&gt;

&lt;p&gt;The reality on modern Android:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ro.product.*&lt;/code&gt; (brand, model, manufacturer) are &lt;strong&gt;immutable&lt;/strong&gt; — set at boot by the system image, &lt;code&gt;setprop&lt;/code&gt; can't touch them.&lt;/li&gt;
&lt;li&gt;IMEI/SIM fields (&lt;code&gt;persist.radio.imei&lt;/code&gt;, etc.) stopped being settable via &lt;code&gt;setprop&lt;/code&gt; around API 29.&lt;/li&gt;
&lt;li&gt;The reliable route for read-only props is &lt;strong&gt;Magisk's &lt;code&gt;resetprop&lt;/code&gt;&lt;/strong&gt;, which patches the property store in memory.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the identity engine is now Magisk-aware: if the device is rooted with Magisk, it uses &lt;code&gt;resetprop&lt;/code&gt; for &lt;code&gt;ro.product.*&lt;/code&gt; and &lt;code&gt;ro.build.fingerprint&lt;/code&gt;; otherwise it falls back to &lt;code&gt;setprop&lt;/code&gt; for the fields that still allow it. The app also reports &lt;em&gt;which fields applied and which failed&lt;/em&gt;, instead of one big "success" that might be a lie.&lt;/p&gt;

&lt;h3&gt;
  
  
  Magisk integration without Android Studio
&lt;/h3&gt;

&lt;p&gt;Rooting an emulator normally means patching the ramdisk. Enmulator bundles &lt;a href="https://gitlab.com/newbit/rootAVD" rel="noopener noreferrer"&gt;rootAVD&lt;/a&gt;, which does exactly that: it grabs &lt;code&gt;ramdisk.img&lt;/code&gt; from the system image, patches it with Magisk, and pushes it back.&lt;/p&gt;

&lt;p&gt;The gotcha I learned: &lt;strong&gt;the ramdisk is shared per system image, not per AVD&lt;/strong&gt;. Root one device on a given image and every AVD using that image is rooted. That's worth documenting loud, because it surprises people.&lt;/p&gt;

&lt;p&gt;Also &lt;code&gt;adb root&lt;/code&gt; only works on &lt;code&gt;google_apis&lt;/code&gt; (non-Play-Store) images — Play Store images reject it outright. So the app offers both paths and tells you which one applies to your image.&lt;/p&gt;

&lt;h3&gt;
  
  
  Windows wants to show you console windows
&lt;/h3&gt;

&lt;p&gt;Every &lt;code&gt;adb&lt;/code&gt; / &lt;code&gt;emulator&lt;/code&gt; spawn on Windows popped a black console window. The fix is &lt;code&gt;CREATE_NO_WINDOW&lt;/code&gt; (0x08000000) in the process creation flags — one line, but it has to wrap &lt;em&gt;every&lt;/em&gt; command invocation, so it went into a shared &lt;code&gt;sdk::new_command()&lt;/code&gt; helper used everywhere.&lt;/p&gt;

&lt;p&gt;Windows also likes to prepend &lt;code&gt;\\?\&lt;/code&gt; to long paths, which broke resolving the rootAVD submodule. &lt;code&gt;dunce::canonicalize&lt;/code&gt; fixed it by stripping the prefix.&lt;/p&gt;

&lt;h3&gt;
  
  
  Shipping three platforms from one repo
&lt;/h3&gt;

&lt;p&gt;The release pipeline is a GitHub Actions matrix (ubuntu / windows / macos) that produces a &lt;code&gt;.deb&lt;/code&gt;, &lt;code&gt;.AppImage&lt;/code&gt;, &lt;code&gt;.msi&lt;/code&gt;, and &lt;code&gt;.dmg&lt;/code&gt; with stable filenames, then uploads them to the release. The thing that bit me: the default &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; can't &lt;em&gt;update&lt;/em&gt; a release — it needs &lt;code&gt;permissions: contents: write&lt;/code&gt; in the workflow. Took a failed run to learn that.&lt;/p&gt;

&lt;p&gt;I also did a git history cleanup before open-sourcing: the repo's &lt;code&gt;.git&lt;/code&gt; was 483 MB because old &lt;code&gt;target/&lt;/code&gt; and &lt;code&gt;node_modules&lt;/code&gt; blobs were stuck in history. &lt;code&gt;git filter-repo&lt;/code&gt; brought it to 6.7 MB. Same code, 72× smaller clone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest limitations
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;ARM64 system images only run natively on Apple Silicon — x86_64 everywhere else.&lt;/li&gt;
&lt;li&gt;The detection-bypass and fingerprint features are for testing on software you own/are authorized to test. There's a disclaimer in the README for a reason.&lt;/li&gt;
&lt;li&gt;This is a solo project, so some edges (snapshot management, exotic device configs) are rougher than a team-built tool.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;Repo: &lt;a href="https://github.com/engnzdl/enmulator" rel="noopener noreferrer"&gt;github.com/engnzdl/enmulator&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Installers for macOS / Windows / Linux are on the releases page. If you poke at it and hit something, open an issue — and if it's useful, a star helps others find it.&lt;/p&gt;

&lt;p&gt;Built with Tauri 2, Rust, and React. GPLv3.&lt;/p&gt;

</description>
      <category>rust</category>
      <category>tauri</category>
      <category>react</category>
      <category>android</category>
    </item>
  </channel>
</rss>
