<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[OlegChe]]></title><description><![CDATA[OlegChe]]></description><link>https://olegche.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Thu, 05 Nov 2026 00:33:27 GMT</lastBuildDate><atom:link href="https://olegche.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[A one-click local LLM installer for Windows: Python, pywebview, Ollama and hardware-based model picking]]></title><description><![CDATA[How a free Windows app installs Ollama and picks an LLM for your hardware: pywebview UI, resumable downloads, no WMI, safe uninstall.]]></description><link>https://olegche.hashnode.dev/local-llm-installer-windows-python-ollama</link><guid isPermaLink="true">https://olegche.hashnode.dev/local-llm-installer-windows-python-ollama</guid><category><![CDATA[Python]]></category><category><![CDATA[Windows]]></category><category><![CDATA[Open Source]]></category><category><![CDATA[AI]]></category><category><![CDATA[ollama]]></category><dc:creator><![CDATA[Oleg Che]]></dc:creator><pubDate>Tue, 03 Nov 2026 07:27:52 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ae35677ab46857a53df3d49/f92ff05f-5dc2-4cf6-aa2d-6506f240e997.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Running a local LLM is easy to do once and annoying to explain: install Ollama, work out which model fits your GPU, get the tag right, wait for gigabytes, find a UI. People kept asking me to set this up for them, so I wrote <strong>Local AI</strong>: it looks at the hardware, picks a model, installs Ollama and the model with one click, and opens a normal chat. Here is how it works and where it got tricky.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6ae35677ab46857a53df3d49/36295acf-119a-4b94-b73f-04809e4b95df.png" alt="Local AI: chat with a local model" style="display:block;margin:0 auto" />

<h2>What it is</h2>
<p>One <code>LocalAI.exe</code> (about 42 MB, no Python needed), a catalogue of 34 models (Qwen3, Gemma 3/3n, Llama 3.1/3.2, Mistral, Phi-4, DeepSeek-R1, Qwen2.5-Coder and -VL, LLaVA, gpt-oss), a streaming chat with a "think" mode and attachments, model moving, updates and a full uninstall. MIT licence. Chats never leave the PC: the app talks to Ollama on <code>127.0.0.1</code>, and the network is only used for downloads and update checks.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6ae35677ab46857a53df3d49/a4ea6e30-e71a-46fb-b331-24b40a578f47.png" alt="Welcome screen: hardware and recommended model" style="display:block;margin:0 auto" />

<h2>Structure</h2>
<p>I needed a small window and logic that digs into the system (registry, processes, installers), so: Python, with a plain HTML/CSS/JS interface and no bundler.</p>
<ul>
<li><code>main.py</code> starts a local HTTP server on <code>127.0.0.1</code> and opens a <a href="https://pywebview.flowrl.com/">pywebview</a> window (WebView2). If that fails, it falls back to Edge or Chrome with <code>--app</code>.</li>
<li><code>server.py</code> is the API for the UI: state, install, chat, updates, paths. Requests are protected by a session token (<code>X-Token</code>) and a <code>Host</code> check, so a page in your browser cannot call the local server.</li>
<li><code>hw.py</code> detects hardware, <code>catalog.py</code> holds the model catalogue and the recommendation, <code>engine.py</code> does everything about Ollama.</li>
<li><code>ui/</code> is the page, styles and <code>app.js</code> with a tiny Markdown renderer. Strings live in <code>i18n.js</code> in two languages.</li>
</ul>
<h2>Hardware without WMI</h2>
<p>WMI is broken on my PC, and that happens elsewhere too, so detection avoids it:</p>
<ul>
<li>NVIDIA: <code>nvidia-smi --query-gpu=name,memory.total</code>;</li>
<li>other GPUs: the display-class registry key (<code>DriverDesc</code>, <code>HardwareInformation.qwMemorySize</code>); the value can be negative from a 32-bit overflow and has to be corrected;</li>
<li>RAM: <code>GlobalMemoryStatusEx</code> via <code>ctypes</code>.</li>
</ul>
<p>Free space is measured on the drive of the models folder, not on C:, because the user can move it.</p>
<h2>Picking a model</h2>
<p>Each entry has a size in GiB and its capabilities. A model needs more than its file size (context, buffers), so the estimate is rough but simple:</p>
<pre><code class="language-python">OVERHEAD = 1.2   # multiplier for context and buffers
EXTRA = 0.6      # GB of headroom

def _need(size):
    return size * OVERHEAD + EXTRA

def rate(model, hw):
    need = _need(model["size"])
    vram, ram = hw["vram_gb"], hw["ram_gb"]
    if vram and need &lt;= vram * 0.95:
        return "gpu"        # fully in VRAM, fast
    if need &lt;= ram * 0.7:
        return "mixed" if vram else "cpu"
    return "no"             # does not fit
</code></pre>
<p>The recommendation is the largest suitable "main" model (Qwen3 or Gemma 3) that fits in VRAM. With no discrete GPU it is a light model of at most 5 GB, because bigger dense models are too slow on a CPU. The picker only lists models that fit the PC by memory and by disk.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6ae35677ab46857a53df3d49/4736811f-2b35-47e9-87df-2f6b509829c7.png" alt="Catalogue: only models that fit this PC" style="display:block;margin:0 auto" />

<h2>Installing Ollama</h2>
<p>The installer is about 1.6 GB, so the download resumes: it lives in <code>%TEMP%\LocalAI</code> as a <code>.part</code> file and continues with a <code>Range</code> header:</p>
<pre><code class="language-python">done = os.path.getsize(part) if os.path.exists(part) else 0
headers = dict(UA, **({"Range": f"bytes={done}-"} if done else {}))
resp = urllib.request.urlopen(urllib.request.Request(SETUP_URL, headers=headers), timeout=30)
if resp.status != 206:   # server ignored Range, start over
    done = 0
</code></pre>
<p>Before running it, the Authenticode signature is checked (status <code>Valid</code>, signer <code>O=Ollama</code>), otherwise the file is deleted. The install is silent (<code>/VERYSILENT /NORESTART /SUPPRESSMSGBOXES</code>, <code>/DIR="…"</code> for a custom folder) and goes into the user profile, so no administrator rights. Then <code>/api/pull</code> downloads the model with progress; its read timeout is long because Ollama goes quiet while verifying sha256.</p>
<h2>Things that went wrong</h2>
<p><strong>Ollama's own window on top of ours.</strong> The installer launches <code>ollama app.exe</code>, which opens a window over the app. Local AI finds it and sends <code>WM_CLOSE</code>: the app goes to the tray and the engine keeps running.</p>
<p><strong>Black console windows.</strong> <code>ollama serve</code> must be started with <code>CREATE_NO_WINDOW</code> and <em>without</em> <code>DETACHED_PROCESS</code>. A process with no console makes every child (<code>ollama.exe</code>, the <code>llama-server.exe</code> runner) open its own black console. I checked with a window watcher (<code>EnumWindows</code> every 10 ms): after the fix there are no new windows except "Local AI".</p>
<p><strong>Slow port check.</strong> On Windows a plain <code>connect</code> to a closed <code>127.0.0.1:11434</code> fails only after a couple of seconds because of SYN retries, which made <code>/api/state</code> take 3-5 seconds. Now a quick <code>socket.create_connection</code> with a 0.3 s timeout goes first, and the HTTP request only if the port is open.</p>
<p><strong>The first reply looks instant.</strong> Ollama is stopped on exit, so the first answer waits for the model to load into memory (about 3 seconds on an RTX 3070). It looked like the whole answer appeared at once. Now the server sends <code>{"status":"loading"}</code> first if the model is not in <code>/api/ps</code>, and the bubble shows "Loading the model into memory…".</p>
<p><strong>Moving models.</strong> The folder is set with <code>OLLAMA_MODELS</code> in <code>HKCU\Environment</code> plus a <code>WM_SETTINGCHANGE</code> broadcast. In Ollama 0.40 models live not only in <code>blobs</code> and <code>manifests</code> but also in <code>manifests-v2</code> and <code>metadata</code>; move all of them or the model is invisible in the new folder. On the same drive it is a <code>rename</code>, otherwise a copy with progress; cancel or an error rolls everything back.</p>
<p><strong>An uninstall that deletes itself.</strong> Ollama's uninstaller wipes <code>~/.ollama/models</code>, so the models folder is renamed for the duration and put back. The Inno uninstaller re-launches itself from <code>%TEMP%</code> and returns immediately, so the app waits for <code>ollama.exe</code> to disappear and for the registry entry to go. The app's own exe is removed by a hidden PowerShell that waits for our processes (the PyInstaller onefile loader and its child), then deletes the exe, <code>%APPDATA%\LocalAI</code>, the unpacked <code>_MEI*</code> folder and finally itself.</p>
<p><strong>localStorage and the port.</strong> Chats live in <code>localStorage</code>, which is tied to the origin including the port, and pywebview opens windows in private mode by default. So the window starts with <code>private_mode=False</code> and its own storage folder, and the server port is fixed (28765); if it is taken, any free port is used, but that run has its own chats.</p>
<p><strong>No <code>requestAnimationFrame</code> while streaming.</strong> In a covered or hidden window frames freeze and the answer would arrive all at once, so the bubble is redrawn on a timer and only its <code>innerHTML</code> changes.</p>
<h2>What I tested, and what I did not</h2>
<p>On an RTX 3070 (8 GB) with 32 GB of RAM: a real install of Ollama 0.40.13 (silent install in about a minute), <code>qwen3:8b</code> in chat with and without <code>think</code>, about 236 tokens/s on <code>gemma3:1b</code>, moving models back and forth on one drive, and closing the window shutting Ollama down. The uninstall was tested in demo mode and on a copy of the exe with substituted <code>APPDATA</code> and <code>TEMP</code>; I did not run the real Ollama uninstaller. Not tested live: the system folder picker and reinstalling Ollama to another folder (demo only), and a real Ollama update. For UI work there is a demo mode: <code>LOCALAI_DEMO=1</code> and <code>python app/dev.py</code>.</p>
<h2>Limits and download</h2>
<p>Windows 10/11 only. Ollama has no image or video generation, so those capability icons are always grey. The app is not code-signed, so SmartScreen may warn ("More info" → "Run anyway"), and some antivirus tools dislike PyInstaller executables; the source is open, so you can build it yourself.</p>
<ul>
<li><strong>Download (about 43 MB):</strong> <a href="https://github.com/OlegChe77/local-ai/releases/latest/download/LocalAI.zip">https://github.com/OlegChe77/local-ai/releases/latest/download/LocalAI.zip</a></li>
<li><strong>Source and README (MIT):</strong> <a href="https://github.com/OlegChe77/local-ai">https://github.com/OlegChe77/local-ai</a></li>
</ul>
<p>If your approach to picking models differs, or Ollama behaves differently on your setup, tell me in the comments or in Issues.</p>
]]></content:encoded></item><item><title><![CDATA[A Markdown notes app for Windows without a WebView: C#, Avalonia and AvaloniaEdit, a one-second start]]></title><description><![CDATA[How a free Markdown notes app for Windows is built in C# and Avalonia: live preview in AvaloniaEdit, link fixing on rename, PDF export via SkiaSharp.]]></description><link>https://olegche.hashnode.dev/markdown-notes-app-windows-avalonia-csharp</link><guid isPermaLink="true">https://olegche.hashnode.dev/markdown-notes-app-windows-avalonia-csharp</guid><category><![CDATA[C#]]></category><category><![CDATA[dotnet]]></category><category><![CDATA[Windows]]></category><category><![CDATA[open source]]></category><category><![CDATA[markdown]]></category><dc:creator><![CDATA[Oleg Che]]></dc:creator><pubDate>Sat, 31 Oct 2026 04:22:12 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ae35677ab46857a53df3d49/ac96f9e2-1292-48c2-89fb-bade01d04f87.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>I wanted a notes app where notes are plain <code>.md</code> files in a normal folder, with <code>[[links]]</code>, backlinks and search, and where the text already looks formatted while I type, without a separate preview pane. It also had to start fast, not carry a whole browser in memory, and not ask for an account. Almost every ready-made option is built on a WebView or Electron, so I wrote my own: <strong>Leaflet</strong>, in C# and Avalonia, with no web page inside. Here is how it works and where it got tricky.</p>
<p><img src="https://cdn.hashnode.com/uploads/covers/6ae35677ab46857a53df3d49/a7145dcf-88b1-420c-8bc2-2d1e77cf8f33.png" alt="Leaflet, Graphite theme" /></p>
<h2>What I wanted</h2>
<ul>
<li>Notes are <code>.md</code> files in any folder; the app hides nothing in a database.</li>
<li><code>[[Name]]</code>, <code>[[Name#Section]]</code>, <code>[[Name|label]]</code>, backlinks, <code>#project/site</code> tags, search across all notes.</li>
<li>Live preview in the editor: the line with the caret shows raw Markdown, other lines show formatted text.</li>
<li>Export to HTML and PDF.</li>
<li>A folder with an exe that runs on a clean Windows 10/11 without installation.</li>
</ul>
<h2>How it is built</h2>
<p><strong>Storage.</strong> <code>Vault</code> reads every <code>.md</code> in the chosen folder (skipping hidden <code>.folders</code>), builds a link index and watches the folder for changes. Writes go through a temporary file, so a crash in the middle of a save never leaves half a note. When a note is renamed or moved, <code>Vault.Move</code> rewrites the <code>[[links]]</code> in all other notes, keeping <code>#section</code> and <code>|label</code>. The note object stays the same, so open tabs and history are not broken.</p>
<p><strong>Editor.</strong> The base is AvaloniaEdit, a port of AvalonEdit. The live preview is not a replaced document but styling over the source text: element generators and transformers hide the markup and colour the pieces on every line except the one with the caret. The document is always the original Markdown, so undo, search and selection behave like in a normal editor.</p>
<p>Everything else follows from that:</p>
<ul>
<li>a table is one editor line turned into one element with cells, with the grid drawn by a separate renderer; the line with the caret shows the source, and its <code>|</code> are aligned to the grid lines (a second pass on <code>VisualLinesChanged</code>);</li>
<li>a list marker with its indent becomes one element, the level comes from the indent, numbering stays text; <code>- [ ]</code> and <code>- [x]</code> show as checkboxes, a click changes one character and creates its own undo step;</li>
<li>an image <code>![](assets/x.png)</code> gets a row after the line, decoded in the background straight to the needed width;</li>
<li>an embed <code>![[Name]]</code> is a card with another note's content, nesting up to four levels, with circular embeds detected.</li>
</ul>
<h2>Why Avalonia and not a WebView</h2>
<p>A WebView or Electron would have given me a Markdown renderer in an evening. With it comes a second heavy process, a noticeable start delay and questions about updating the runtime. I wanted one small native process. Avalonia 12 on .NET 8 draws through Skia, runs on Windows 10/11 and keeps everything in a single process. A side benefit: PDF is built without a browser too (below).</p>
<p>The price: the UI had to be written in code. The SDK installed on my machine (8.0.403) is older than Roslyn 4.14, so XAML would not compile (error CS9057), and the whole UI is C#.</p>
<p>Measured on a 5,013-line note (216k characters), 1200×780 window with panels, ReadyToRun build:</p>
<ul>
<li>0.66–0.89 s to the first frame;</li>
<li>about 133 MB working set after loading;</li>
<li>0.4 ms on average to handle a typed character (input + layout);</li>
<li>3 ms on average for Enter.</li>
</ul>
<p>These are my measurements on one machine, a rough guide and not a benchmark.</p>
<p><img src="https://cdn.hashnode.com/uploads/covers/6ae35677ab46857a53df3d49/8ab0c05f-800f-4005-8b68-97c70e5f602e.png" alt="Leaflet, Light theme" /></p>
<h2>Pitfalls</h2>
<p><strong>A window without a white strip.</strong> I wanted my own title bar with tabs and window buttons. With full decorations Avalonia 12 draws its own title over the tabs, and without extending the client area Windows 10 shows a 7-pixel white strip on top. What works:</p>
<pre><code class="language-csharp">WindowDecorations = WindowDecorations.BorderOnly;
ExtendClientAreaToDecorationsHint = true;
</code></pre>
<p>The window buttons are custom; I checked dragging, double-click, edges and corners, maximizing exactly to the work area, minimizing and closing.</p>
<p><strong><code>Editor.Focus()</code> does nothing.</strong> <code>TextEditor</code> does not take focus itself: the method returns <code>false</code> and focus ends up nowhere. Focus goes through <code>Editor.TextArea.Focus()</code>. A related detail: before hiding a panel that holds focus, move focus to the editor first and only then set <code>IsVisible = false</code>.</p>
<p><strong>A collapsed table line.</strong> The <code>|---|</code> separator is hidden so that the line does not end up below the font (<code>CollapseLines</code>). But the collapsed line must be covered by the previous line's element (the header stretches to the end of the separator), otherwise AvaloniaEdit crashes. You only find that out by experiment.</p>
<p><strong>The caret next to an image.</strong> Without a zero-width marker at the end of the line, the caret is drawn full-height on the image row. I added such a marker and handled the arrow keys separately: down goes to the next line, up or a click goes to the end of the text, so the caret never sits in the image row.</p>
<p><strong>Typing speed.</strong> The block map (text, code, table rows) is rebuilt only when <code>`</code>, <code>|</code> or a line break is typed or deleted, and for <code>-</code> and <code>:</code> only if the line contains <code>|</code>. Scanning <code>doc.Text</code> as a string turned out several times cheaper than walking <code>doc.Lines</code> with <code>GetCharAt</code>: about 1.2 ms per Enter on 5,000 lines.</p>
<p><strong>PDF without a browser.</strong> Export uses <code>SKDocument.CreatePdf</code> from SkiaSharp, which Avalonia already ships, so no WebView is needed. The layout is my own: A4, the selected theme's colours, word wrap, page numbers, links as annotations. But SkiaSharp is built without a font subsetter and embeds fonts whole: one page weighed 1.6 MB. I wrote my own <code>FontSubset</code>: glyph ids stay the same, unneeded outlines become empty, a new cmap is built and the GSUB and GPOS tables are dropped. A page went down to about 130 KB. CFF (OTTO) fonts are not subsetted.</p>
<p><strong>Data over looks.</strong> The app sits in a folder with my real notes, so reliability took work:</p>
<ul>
<li>the file is changed outside while the app has unsaved edits: the tab gets a "conflict" state with a bar of choices ("Keep mine", "Take from disk", "Save mine as a copy", "Compare"); before writing, the file date is checked against the one the app saw, so a foreign edit is not overwritten even if the watcher was late;</li>
<li>drafts are written in the background 1.5 s after an edit and removed on save; after a real crash the app offers to restore them;</li>
<li>version snapshots with line diffs (Myers algorithm), limited to 50 per note and 100 MB in total; restoring a version is done as an edit, so Ctrl+Z undoes it too;</li>
<li>a note renamed in Explorer: if one disappears and another with the same text appears in the same batch, it is treated as a rename and the tab follows the file.</li>
</ul>
<p>To test a crash, the self-check starts a second instance of the app, edits without autosave, waits for the draft to appear and kills the process with <code>Process.Kill()</code>. Then the parent checks the file and the recovery.</p>
<p><strong>One instance.</strong> A mutex named <code>Local\Leaflet.Mutex.&lt;data folder hash&gt;</code> plus a named pipe: a second launch passes its paths to the first and waits for "ok", and the first window comes to the front. The data folder hash is in the name so a portable copy, a regular one and the tests do not interfere.</p>
<p><strong>Window position on several monitors.</strong> Sizes are stored in logical units, the position in pixels, plus the monitor scale. On start, the monitor list (<code>EnumDisplayMonitors</code> and <code>GetDpiForMonitor</code>) helps pick the monitor the window overlaps most, move the window inside the work area and make sure the title strip is at least 100 pixels. A window closed while maximized is a separate case: Avalonia then reports the monitor bounds, not the normal ones.</p>
<p><strong>Windows scaling.</strong> The scale on my machine never changed, so the self-check sends the window a real <code>WM_DPICHANGED</code> and verifies that Avalonia recalculated the scale, the logical window size was kept, and panel edges stayed on whole pixels. Moving a window between monitors with different scales on real hardware is not something I tested; that is an honest limitation.</p>
<h2>Build and shipping</h2>
<p>The main zip is self-contained and runs on a clean Windows without .NET. The folder is 115 MB and the zip about 50 MB, against 44 and 18 MB for the variant that needs the .NET 8 Runtime installed. Start and memory are the same, so I chose convenience.</p>
<pre><code class="language-powershell">dotnet publish src/Leaflet -c Release -r win-x64 --self-contained true -p:PublishReadyToRun=true
</code></pre>
<p>I did not do single-file or trimming: Avalonia and AvaloniaEdit use reflection, and the size gain is not worth the risk. <code>*.pdb</code> files are removed from the package (SkiaSharp and HarfBuzz ones weigh about 100 MB). At the end <code>build.ps1</code> unzips the package into a clean temp folder, starts the exe with a separate data folder and waits for the window to appear.</p>
<p>The app is not code-signed, so SmartScreen may show the blue window: "More info" → "Run anyway".</p>
<h2>The result</h2>
<p>Three themes (Graphite, Terminal, Light), English and Russian interface, remappable shortcuts, a command palette and built-in help.</p>
<p><img src="https://cdn.hashnode.com/uploads/covers/6ae35677ab46857a53df3d49/31380dba-990c-4a0b-ba20-072d3311a764.png" alt="Leaflet, Terminal theme" /></p>
<p>The project is covered by its own self-check with 384 checks. It starts a real window and sends real key presses and mouse input (Ctrl+F, typing <code>[[</code>, dragging files, Ctrl+V with an image on the clipboard), so for a few minutes it takes over the keyboard and mouse and runs only on a copy of a test folder.</p>
<p>What it does not have: plugins, sync, mobile apps. It is a "folder of files" notes app, and anything else you can build on top with Git or a cloud folder.</p>
<h2>Links</h2>
<ul>
<li>Project page and source (MIT): <a href="https://github.com/OlegChe77/leaflet">https://github.com/OlegChe77/leaflet</a></li>
<li>Ready zip (about 50 MB, Windows 10/11, nothing to install): <a href="https://github.com/OlegChe77/leaflet/releases/latest/download/Leaflet.zip">https://github.com/OlegChe77/leaflet/releases/latest/download/Leaflet.zip</a></li>
</ul>
<p>It is free. Suggestions and bug reports are welcome in the comments or in GitHub Issues.</p>
]]></content:encoded></item><item><title><![CDATA[Building a local-first Windows app on yt-dlp: embedded Python, a localhost-only Flask server, and DNS-rebinding protection]]></title><description><![CDATA[A free yt-dlp-based Windows downloader under the hood: SHA-256-checked setup, localhost-only Flask, DNS-rebinding protection, updates with rollback.]]></description><link>https://olegche.hashnode.dev/local-first-windows-app-yt-dlp-flask</link><guid isPermaLink="true">https://olegche.hashnode.dev/local-first-windows-app-yt-dlp-flask</guid><category><![CDATA[Python]]></category><category><![CDATA[Flask Framework]]></category><category><![CDATA[Windows]]></category><category><![CDATA[open source]]></category><category><![CDATA[Security]]></category><dc:creator><![CDATA[Oleg Che]]></dc:creator><pubDate>Thu, 29 Oct 2026 14:52:06 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ae35677ab46857a53df3d49/8d3df807-44f0-42e5-ac65-b6fb7d6a207c.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Universal Downloader is a free, open-source Windows app that wraps <a href="https://github.com/yt-dlp/yt-dlp">yt-dlp</a>: paste a link from YouTube, SoundCloud, Vimeo or one of 1000+ other sites, pick a quality, get a file. I originally wrote it for myself because every "online downloader" is full of ads.</p>
<p>This post is about the parts that were more interesting than I expected: how an app that must work for non-developers sets itself up, how it talks to the browser without exposing a hole in your machine, and how it updates itself without breaking.</p>
<p><img src="https://cdn.hashnode.com/uploads/covers/6ae35677ab46857a53df3d49/3b763ec1-e75e-4d5d-99e4-3ec4cb12e01b.png" alt="The main page with a parsed link" /></p>
<h2>The shape of the app</h2>
<ul>
<li>a <strong>Flask</strong> server that listens on <code>127.0.0.1</code> only;</li>
<li>a plain <strong>web page</strong> as the UI (no Electron: the browser you already have is the window);</li>
<li><strong>yt-dlp</strong> for extraction, <strong>ffmpeg</strong> for merging video and audio, <strong>Deno</strong> so yt-dlp can solve YouTube's JavaScript challenges;</li>
<li>a small launcher with a tray icon, a browser extension and a bookmarklet.</li>
</ul>
<p>The nice part of this design: the app is tiny, the UI is just HTML and JS, and there is no packaging step that fights antivirus software. The price is that a local web server is a bigger attack surface than people think.</p>
<h2>Setup without installing anything</h2>
<p>I did not want users to install Python, so the first launch bootstraps a private copy into the app folder: Python 3.12 (embeddable), pip, yt-dlp, ffmpeg and Deno. Nothing goes into the system or the registry, and deleting the folder removes everything.</p>
<p>Downloading executables from the internet at first start is exactly what you should be suspicious about, so every download is checked before it is used:</p>
<ul>
<li>each file has a pinned SHA-256, and if the hash does not match, the file is rejected;</li>
<li><code>python.exe</code> is additionally checked for the Python Software Foundation's digital signature.</li>
</ul>
<p>The PowerShell helper that does the hash check is short:</p>
<pre><code class="language-powershell">function Get-Verified($url, $file, $sha256) {
    # a download whose hash does not match is never used
    Invoke-WebRequest -UseBasicParsing -Uri $url -OutFile $file
    $hash = (Get-FileHash -Algorithm SHA256 $file).Hash
    if ($hash -ne $sha256) { throw "hash mismatch for $(Split-Path $file -Leaf), not using it" }
}
</code></pre>
<p>The signature check on <code>python.exe</code> and <code>python312.dll</code> uses <code>Get-AuthenticodeSignature</code> and requires a valid signature from the Python Software Foundation. The new <code>python</code> folder is built next to the old one and renamed at the end, so an interrupted setup never leaves a half-installed copy.</p>
<h2>A localhost server is not automatically private</h2>
<p>Listening on <code>127.0.0.1</code> stops other machines from connecting. It does <strong>not</strong> stop other <em>web pages</em> in your browser from talking to it. Any site you open can send requests to <code>http://127.0.0.1:PORT</code> using your browser as the middleman, and a malicious site can even point its own domain at <code>127.0.0.1</code> (DNS rebinding) so the request looks same-origin.</p>
<p>My <code>before_request</code> guard handles both, and I would suggest it for any local web UI:</p>
<pre><code class="language-python">LOCAL_HOSTS = {f'127.0.0.1:{PORT}', f'localhost:{PORT}'}
LOCAL_ORIGINS = {f'http://{h}' for h in LOCAL_HOSTS}

@app.before_request
def only_own_page():
    # A foreign Host header means DNS rebinding: refuse.
    if request.host.lower() not in LOCAL_HOSTS:
        return Response('Forbidden', 403, mimetype='text/plain')
    if not request.path.startswith('/api/'):
        return None
    # The API answers only to its own page.
    origin = request.headers.get('Origin')
    if (origin and origin not in LOCAL_ORIGINS) or \
            request.headers.get('Sec-Fetch-Site') in ('cross-site', 'same-site'):
        return jsonify(error='Request from another page.'), 403
    # POST accepts JSON only: a plain HTML form from another site cannot send it.
    if request.method == 'POST' and not request.is_json:
        return jsonify(error='JSON required.'), 415
    return None
</code></pre>
<p>On top of that, responses carry <code>X-Frame-Options: SAMEORIGIN</code> (no clickjacking), <code>Referrer-Policy: no-referrer</code>, and <code>Cross-Origin-Resource-Policy: same-origin</code> for the API. Requests without <code>Origin</code> or <code>Sec-Fetch-*</code> (curl, tests, the "is it already running?" check) are allowed through.</p>
<h2>Keeping up with sites that keep changing</h2>
<p>yt-dlp breaks regularly because sites change, and a downloader that is a month old is a broken downloader. So the app:</p>
<ol>
<li>updates <strong>yt-dlp by itself once a day</strong>;</li>
<li>checks GitHub for <strong>its own new version every three days</strong>, verifies the SHA-256 published with the release asset, and installs it with one click;</li>
<li><strong>rolls back</strong> if the new version does not come up. A separate updater process waits for the app to exit, swaps the files, launches the new version and polls its local <code>/api/status</code> for the expected version number. If nothing answers in time, it restores the old files and writes the outcome to a result file.</li>
</ol>
<p><img src="https://cdn.hashnode.com/uploads/covers/6ae35677ab46857a53df3d49/c56335bb-14e0-4109-b815-0f6fcb5569a7.png" alt="The Help page with update controls" /></p>
<p>Errors from yt-dlp are translated into plain language, with a "Details" button that shows the raw output for bug reports.</p>
<h2>Proxies and the "site does not open" problem</h2>
<p>A VPN browser extension does not help a separate program. So the app can discover a local proxy: it probes the default ports of VPN clients such as v2rayN, Hiddify and Clash, and accepts a proxy only if YouTube actually opens through it. The proxy is used when a site is unreachable directly, and the app remembers which sites needed it. A "secure DNS" switch handles the case where the provider simply does not resolve the domain.</p>
<p><img src="https://cdn.hashnode.com/uploads/covers/6ae35677ab46857a53df3d49/82356f0c-1c7d-45c3-aeca-37fa2420a73d.png" alt="The VPN tab" /></p>
<h2>Rough edges</h2>
<ul>
<li>It ships as a zip with a <code>.bat</code> launcher, not as an installer.</li>
<li>The browser extension is installed manually through "developer mode"; the Help page has step-by-step instructions with pictures.</li>
</ul>
<h2>Try it, read it, break it</h2>
<ul>
<li>Download: <a href="https://github.com/OlegChe77/universal-downloader/releases/latest/download/universal-downloader.zip">universal-downloader.zip</a> (Windows 10/11)</li>
<li>Source: <a href="https://github.com/OlegChe77/universal-downloader">github.com/OlegChe77/universal-downloader</a></li>
</ul>
<p>It is a wrapper meant for personal use, so download only what you have the right to and follow the terms of each site. If you find a hole in the localhost guard above, I would genuinely like to hear about it: open an issue.</p>
]]></content:encoded></item></channel></rss>