Pake

Turn any webpage into a desktop app with Rust

46.8k stars · 92.5% Rust · MIT License · v3.10

github.com/tw93/Pake

ELI5

Explain Like I'm Five

The Problem: Websites vs. Apps

You use websites every day — ChatGPT, YouTube, Twitter. But they live inside your browser, mixed in with dozens of tabs.

What you want

  • A real app icon in your dock/taskbar
  • Its own window, separate from your browser
  • Keyboard shortcuts to open it instantly
  • Less memory usage than a Chrome tab

The old way: Electron

  • Ships an entire copy of Chrome (~200MB+)
  • Hungry for RAM and CPU
  • Needs a full JavaScript project to set up
  • Every app is its own little browser

Pake: One Command, One App

Pake wraps a website in a tiny native window. Think of it like putting a picture frame around a webpage — but the frame is built with Rust, so it's fast and tiny.

pake https://chat.openai.com --name ChatGPT

That's it. You get a real desktop app.

~5 MB
Pake app size
~200 MB
Electron app size
20x
smaller

How Does It Work? (Simply)

Your computer already has a built-in web viewer (called a WebView). Pake reuses it instead of shipping its own browser.

Your URL (e.g. youtube.com)
↓
Pake's Rust wrapper (tiny!)
↓
Your OS's built-in WebView
↓
Native desktop app

The secret sauce

  • macOS uses Safari's WebKit engine
  • Windows uses Edge's WebView2
  • Linux uses WebKitGTK
  • No extra browser shipped = tiny binary
Analogy: Electron is like carrying your own projector to every room. Pake just uses the TV that's already there.

What Can You Package?

Anything with a URL. Pake ships popular pre-built apps and you can make your own.

Pre-built Apps

ChatGPTGemini
DeepSeekGrok
Twitter / XYouTube
YouTube MusicExcalidraw
WeReadXiaoHongShu

Make Your Own

  • Any internal tool (Notion, Linear, etc.)
  • Any SaaS dashboard
  • Your own web app or side project
  • Works on macOS, Windows, and Linux
No coding required — beginners can use GitHub Actions to build apps entirely in the cloud.

ELI5 Summary

Pake turns websites into real desktop apps. It uses Rust and your OS's built-in web viewer to make apps that are 20x smaller and faster than Electron. One command is all it takes.

46.8k
GitHub stars
92.5%
Rust code
41
releases
3
platforms

Technical Deep Dive

Architecture, code patterns, and internals

Repository Structure

Pake is a hybrid Rust + TypeScript project. The Rust core is the app; the TypeScript CLI is the builder tool.

// Rust core (92.5% of codebase)
src-tauri/
  src/
    main.rs         // Entry point
    lib.rs          // Tauri app builder
    util.rs         // Utilities
    app/
      config.rs     // Config structs
      window.rs     // WebView builder
      setup.rs      // Tray & shortcuts
      invoke.rs     // Tauri commands
      menu.rs       // macOS menu bar
    inject/
      style.js      // CSS injection
      event.js      // Keyboard & nav
      component.js  // UI components
      auth.js       // OAuth handling
      custom.js     // User scripts
      theme_refresh.js
// TypeScript CLI (pake-cli)
bin/
  cli.ts          // CLI entry point
  defaults.ts     // Default config
  types.ts        // Type definitions
  dev.ts          // Dev mode
  builders/       // Build orchestration
  helpers/        // Utility helpers
  options/        // CLI arg parsing
  utils/          // General utils

// Config files
src-tauri/
  tauri.conf.json       // Main config
  tauri.macos.conf.json // macOS
  tauri.linux.conf.json // Linux
  tauri.windows.conf.json
  pake.json             // Pake-specific
  Cargo.toml            // Rust deps

Built on Tauri 2.x

Tauri is the foundation — a Rust framework for building desktop apps with web frontends. Pake uses Tauri 2.10.2.

Why Tauri, Not Electron?

AspectElectronTauri
RuntimeBundles ChromiumOS WebView
LanguageJavaScriptRust
Binary size~150-200 MB~5 MB
RAM usageHighLow
SecurityFull Node.jsSandboxed

Platform WebViews

  • macOS: WKWebView (WebKit/Safari)
  • Windows: WebView2 (Chromium/Edge)
  • Linux: WebKitGTK
Key insight: The binary is small because the rendering engine is already on the user's machine. Pake only ships the native shell and configuration.

Application Lifecycle

From main() to a running window — every Pake app follows this flow.

main.rs → app_lib::run()
↓
lib.rs → Load pake.json config
↓
lib.rs → Register 8 Tauri plugins
↓
setup.rs → System tray + shortcuts
↓
window.rs → Build WebView window
↓
inject/ → Inject JS into WebView

The entry point is minimal

// main.rs
#![cfg_attr(
    all(not(debug_assertions),
        target_os = "windows"),
    windows_subsystem = "windows"
)]

fn main() {
    app_lib::run()
}
The windows_subsystem attribute hides the console window on Windows release builds — a standard Tauri pattern.

Configuration System

Everything about a Pake app is driven by two config files: pake.json and tauri.conf.json.

PakeConfig (Rust)

struct PakeConfig {
  windows: Vec<WindowConfig>,
  user_agent: PlatformSpecific<String>,
  system_tray: PlatformSpecific<bool>,
  multi_instance: bool,
  multi_window: bool,
}

struct WindowConfig {
  url: String,
  url_type: String, // "web"|"local"
  hide_title_bar: bool,
  fullscreen: bool,
  resizable: bool,
  dark_mode: bool,
  enable_wasm: bool,
  force_internal_navigation: bool,
  // ... 16 more fields
}

PlatformSpecific<T>

struct PlatformSpecific<T> {
  macos: T,
  linux: T,
  windows: T,
}

impl<T> PlatformSpecific<T> {
  fn get(&self) -> &T {
    // Returns current platform's value
    #[cfg(target_os = "macos")]
    return &self.macos;
    // ...
  }
}
Elegant pattern: The generic PlatformSpecific<T> wrapper lets any config value (user agent, tray settings, etc.) have per-platform overrides resolved at compile time.

Tauri Plugin Architecture

Pake registers 8 plugins to provide native capabilities beyond basic web rendering.

PluginPurposeKey Use
window-statePersist window position/sizeRemember layout across restarts
oauthOAuth authentication flowsGoogle/GitHub login popups
httpNative HTTP requestsFile downloads, API calls
shellOpen URLs in system browserExternal link handling
notificationOS-level notificationsDownload complete alerts
openerOpen files/folders nativelyDownloaded file access
single-instancePrevent duplicate windowsConditional on config
global-shortcutSystem-wide hotkeysActivation shortcut with debounce
Conditional registration: The single-instance plugin is only registered when multi_instance is false. The window-state plugin initializes with fullscreen or visibility flags depending on the app's fullscreen configuration.

WebView Window Construction

The window.rs module orchestrates WebView creation with platform-specific customization.

Build Pipeline

fn build_window(
  app: &AppHandle,
  opts: WindowBuildOptions
) {
  // 1. Resolve URL type
  match url_type {
    "web" => WebviewUrl::App(url),
    "local" => WebviewUrl::App(path),
  }

  // 2. Configure user agent
  builder.user_agent(config.user_agent.get());

  // 3. Inject JS scripts (6 files)
  builder.initialization_script(...);

  // 4. Platform-specific setup
  #[cfg(target_os = "macos")]
  builder.title_bar_style(...);

  #[cfg(target_os = "windows")]
  builder.browser_args(proxy, flags);
}

Platform Divergence

  • macOS: Title bar styling (overlay vs visible), theme enforcement, certificate handling
  • Windows: DPI scaling, proxy via --proxy-server flag, data directories, browser feature flags
  • Linux: DMABUF renderer disabled, fullscreen focus workarounds

Anti-Detection

// Browser args include:
"--disable-blink-features=
 AutomationControlled"

Prevents websites from detecting the app as an automated browser.

JavaScript Injection System

Pake injects 6 JavaScript files into every WebView on page load, providing the "native app" experience.

Injection Order

1. Config script → exposes window.pakeConfig
2. component.js → UI components
3. event.js → Keyboard + navigation
4. style.js → CSS overrides
5. theme_refresh.js → Dark/light mode
6. auth.js → OAuth popup handling
7. custom.js → User-defined scripts

What gets injected

  • Config bridge: Rust config exposed as window.pakeConfig to all JS
  • Keyboard shortcuts: Cmd/Ctrl + bracket for nav, zoom, refresh
  • Link interception: External links open in system browser
  • Context menus: Right-click with download/copy/open options
  • Style overrides: Hide browser chrome, adjust padding for title bar
  • window.open override: Redirects external URLs through shell plugin
The custom.js slot allows users to inject their own JavaScript — enabling ad removal, UI tweaks, or custom behavior.

Event System: event.js

The event injection handles keyboard shortcuts, link navigation, context menus, downloads, and paste interception.

Keyboard Shortcuts

KeyAction
Cmd/Ctrl + [Navigate back
Cmd/Ctrl + ]Navigate forward
Cmd/Ctrl + -Zoom out
Cmd/Ctrl + =Zoom in
Cmd/Ctrl + 0Reset zoom
Cmd/Ctrl + RReload page
Cmd/Ctrl + arrowsScroll top/bottom

Link Routing Logic

detectAnchorElementClick(e) {
  // Classify the link
  if (isInternal(href))
    return; // navigate normally

  if (isDownloadableFile(href))
    invoke('download_file', href);

  if (target === '_blank')
    shell.open(href); // system browser
}

// window.open is overridden too
window.open = (url) => {
  if (isAuthPopup(url))
    return original(url);
  shell.open(url);
}

Style Injection: style.js

On DOMContentLoaded, Pake injects CSS to transform web pages into "app-like" experiences.

What It Hides/Adjusts

  • Browser-specific UI elements (scrollbars, headers)
  • App-specific chrome on Twitter, ChatGPT, YouTube Music, etc.
  • Sidebars and navigation components resized
  • Responsive media queries for different window sizes

Title Bar Padding

When hide_title_bar is enabled on macOS, a second stylesheet adds 20-60px top padding to headers and navs so content doesn't hide behind the native traffic lights.

How It Works

// On DOMContentLoaded:
const style = document
  .createElement('style');
style.textContent = `
  /* 400+ lines of CSS */
  .twitter-header { display: none; }
  .chatgpt-sidebar { width: 240px; }
  /* ... */
`;
document.head.appendChild(style);

// Conditional title-bar CSS
if (pakeConfig?.hide_title_bar
    && navigator.platform === 'MacIntel') {
  // Inject padding overrides
}

Tauri Invoke Commands

Pake exposes 5 Rust commands callable from the WebView JavaScript via Tauri's IPC bridge.

CommandWhat It DoesCalled From
download_file Streams a URL to ~/Downloads with progress toasts Link clicks, context menu
download_file_by_binary Writes binary data (blob/data URIs) to disk Blob URL handling in event.js
send_notification Shows OS notification with title, body, icon Download completion
update_theme_mode Switches dark/light mode (macOS only) Theme refresh script
clear_cache_and_restart Wipes WebView data and relaunches User-triggered (tray menu)
IPC pattern: JavaScript calls invoke('download_file', {url, filename}) which crosses the WebView boundary into Rust via Tauri's secure command system. The Rust side can access the filesystem, network, and OS APIs that JavaScript cannot.

System Tray & Global Shortcuts

The setup.rs module initializes two key native features.

System Tray

  • Menu items: New Window (conditional), Hide, Show, Quit
  • Left-click toggles window visibility
  • Quit saves window state before exit
  • Icon from custom path or built-in fallback
  • Platform-specific: enabled on Linux/Windows, disabled on macOS by default
// Tray click handler
if window.is_visible() {
  window.minimize();
} else {
  window.show();
  window.set_focus();
}

Global Shortcuts

  • Registers system-wide keyboard hotkey
  • Debounced with 300ms throttle via Arc<Mutex<Instant>>
  • Toggles window visibility (same as tray click)
  • Linux: restores fullscreen when configured
Debounce pattern: The shortcut uses an atomic mutex-guarded timestamp to prevent rapid re-triggering — a clean Rust concurrency pattern using Arc<Mutex<Instant>>.

The CLI: pake-cli

The TypeScript CLI is how users actually build Pake apps. It orchestrates the entire build pipeline.

Usage

# Install globally
pnpm install -g pake-cli

# Basic usage
pake https://github.com --name GitHub

# With options
pake https://chat.openai.com \
  --name ChatGPT \
  --icon ./icon.png \
  --width 1200 \
  --height 780 \
  --hide-title-bar \
  --dark-mode \
  --system-tray

Build Pipeline

1. Parse → CLI args + defaults
↓
2. handleInputOptions → Merge config
↓
3. builder.prepare() → Write pake.json + tauri.conf.json
↓
4. builder.build(url) → Run cargo tauri build
↓
5. Output → .dmg / .msi / .deb

CLI Default Configuration

The defaults.ts file defines sensible defaults for every option.

Window & Display

Width1200px
Height780px
Resizabletrue
Fullscreenfalse
Hide title barfalse
Always on topfalse
Zoom100%
Dark modefalse

Features & Security

Multi-instancefalse
Drag & dropfalse
WASMfalse
System trayfalse
Incognitofalse
Camera/Micfalse
Hide on closemacOS: true
Ignore certsfalse

Platform targets: .deb/.appimage (Linux), .dmg (macOS), .msi (Windows)

Binary Size Optimization

Pake achieves its ~5MB binary through aggressive Rust release profile tuning.

Cargo.toml Release Profile

[profile.release]
panic = "abort"      # No unwinding
lto = "thin"         # Link-Time Opt
strip = true         # Remove symbols

Why Each Matters

  • panic = "abort" — removes unwinding code (~10-15% savings)
  • lto = "thin" — cross-crate optimization while keeping reasonable compile times
  • strip = true — removes debug symbols from final binary

Size Comparison

Electron (Slack)

~300 MB

Electron (minimal)

~150 MB

Pake app

~5 MB
The size difference is primarily because Pake doesn't ship a browser engine. The ~5MB is just the Rust native wrapper + config + injected JS.

Dependency Landscape

The Cargo.toml reveals a focused dependency tree centered on Tauri 2.x.

Core Dependencies

CrateVersionRole
tauri2.10.2Application framework
tokio1.49.0Async runtime (full)
serde1.0.228Serialization
serde_json1.0.149JSON parsing

Feature Flags

  • tray-icon — system tray support
  • image-ico + image-png — icon formats
  • macos-proxy — macOS proxy config

Build Features

[features]
# Development builds
cli-build = []

# Production builds (default)
custom-protocol = [
  "tauri/custom-protocol"
]
default = ["custom-protocol"]
Tauri 2.x upgrade: Pake tracks the latest Tauri 2.x series, which introduced a capability-based security model, replacing the older allowlist approach. This gives finer-grained control over what each window can access.

Platform-Specific Code Patterns

Pake uses Rust's conditional compilation extensively to handle OS differences.

macOS Specifics

  • Custom menu bar (File, Edit, Window, Help)
  • Title bar overlay with traffic light positioning
  • Theme mode updates via update_theme_mode
  • Dock click handler to reopen windows
  • Fullscreen exit delays for smooth transitions
  • Safari-based user agent string
#[cfg(target_os = "macos")]
builder.title_bar_style(
  TitleBarStyle::Overlay
);

Windows / Linux

  • Windows: DPI scaling, proxy via CLI args, WebView2 data dirs, windows_subsystem for no console
  • Linux: DMABUF renderer disabled, fullscreen focus fixes, WebKitGTK-specific workarounds
#[cfg(target_os = "linux")]
// Disable DMABUF to prevent rendering
// issues on certain compositors
std::env::set_var(
  "WEBKIT_DISABLE_DMABUF_RENDERER",
  "1"
);
Pattern: #[cfg(target_os = "...")] compiles away code for other platforms at build time, keeping each binary lean.

Download & Binary Handling

Pake bridges the WebView sandbox to the filesystem for file downloads through two complementary paths.

URL Downloads (Rust)

#[tauri::command]
async fn download_file(
  url: String,
  filename: String,
  lang: Option<String>
) -> Result<(), String> {
  // 1. Resolve ~/Downloads path
  // 2. Stream response in chunks
  // 3. Write to disk
  // 4. Show progress toast
  // 5. Send notification on complete
}

Blob/Data URI (JavaScript → Rust)

// event.js handles blob: and data: URIs
if (url.startsWith('blob:')) {
  const reader = new FileReader();
  reader.readAsArrayBuffer(blob);
  reader.onload = () =>
    invoke('download_file_by_binary', {
      binary: Array.from(
        new Uint8Array(result)
      )
    });
}

if (url.startsWith('data:')) {
  // Decode base64 → binary
  // Send to Rust via IPC
}
Why two paths? Regular URLs can be fetched server-side by Rust (faster, no CORS issues). But blob/data URIs only exist in the browser's memory, so JavaScript must decode them first and pass the binary to Rust via IPC.

Security Model

Tauri 2.x's capability-based security model constrains what each window can access.

Tauri Security Layers

Capabilities — declare what each window can do
Permissions — granular access to plugins
IPC Commands — only whitelisted Rust functions
OS Sandbox — WebView runs sandboxed

Pake's Security Choices

  • No CSP configured (null) — relies on site's own CSP
  • Incognito mode option — no persistent cookies/storage
  • Certificate error handling — configurable per platform
  • Navigation guard — on_navigation handler filters allowed domains
  • Anti-automation — disables Blink automation detection flags
vs. Electron: Electron gives Node.js access to the renderer by default (dangerous!). Tauri's WebView has no filesystem/process access unless explicitly granted through invoke commands.

Full Architecture Overview

Compile-Time Pipeline

pake-cli (TypeScript)
Parse args, write configs
↓
cargo tauri build
Compile Rust + bundle assets
↓
Platform binary
.dmg / .msi / .deb (~5MB)

Runtime Architecture

Rust Core
Plugins, tray, shortcuts, commands
↑↓ IPC (invoke/events)
OS WebView
WKWebView / WebView2 / WebKitGTK
↑↓ JS Injection
Injected JS Layer
events, styles, auth, custom
↑↓ HTTP
Target Website
ChatGPT, YouTube, your app, etc.

Key Technical Takeaways

Why Pake is Interesting

  • Rust + Tauri 2.x as a production-grade Electron alternative
  • JS injection as a first-class architectural layer, not a hack
  • PlatformSpecific<T> generic for clean cross-platform configs
  • Conditional compilation keeps each binary lean
  • Plugin architecture for modular native capabilities
  • CLI as config generator — writes JSON, then calls Cargo

Patterns Worth Stealing

  • Reuse existing OS components (WebView) instead of shipping your own
  • Aggressive binary optimization (panic=abort, thin LTO, strip)
  • Bridge heavy operations to Rust (downloads), keep UI in JS
  • Debounced global shortcuts with Arc<Mutex<Instant>>
  • Navigation guards + window.open override for link routing
  • Two-path download system (URL streaming vs blob decode)
Bottom line: Pake demonstrates that a ~5MB Rust binary with injected JavaScript can replace a 200MB Electron app for the common case of "wrap a website." The tradeoff: you lose Node.js access and some Chromium APIs, but gain massive size and performance wins.

Pake

46.8k stars · Rust + Tauri 2.x · MIT License

Try it: pnpm install -g pake-cli && pake https://yourapp.com