lune v0.1.0

Build native desktop apps with Crystal and a web frontend.

Lune

Specs License: MIT Crystal

Build native desktop apps with Crystal and a web frontend.

Lune wraps a native WebView and lets you call Crystal code from JavaScript over a typed bridge — no servers, no IPC boilerplate. Think Wails or Tauri, but for Crystal.

Prerequisites

  • Crystal >= 1.20.0
  • Node.js (for the frontend build)
  • The Lune CLI — see below

Getting the CLI

The CLI is not distributed as a pre-built binary. Clone this repo and either install it globally or run it directly:

git clone https://github.com/aristorap/lune
cd lune
make setup        # shards install

make deploy       # build release binary → /usr/local/bin/lune

# or run without installing (runs relative to your path):
crystal run bin/lune.cr -- <command>

Quick start

With the CLI on your PATH:

lune init my_app
cd my_app
lune dev

lune init scaffolds a Crystal entry point and a Vite frontend. lune dev compiles your Crystal app and starts the Vite dev server together, with hot-reload on source changes. See examples/main.cr for what the generated entry point looks like.

Adding Lune to an existing project

Add it to your shard.yml:

dependencies:
  lune:
    github: aristorap/lune
    version: ~> 0.1
shards install

You still need the CLI for lune dev and lune build — see Getting the CLI.

Crystal API

Lune.run

require "lune"

Lune.run(
  title:      "My App",
  assets:     "frontend/dist",   # embedded at compile time
  width:      1200,
  height:     800,
  min_width:  800,
  min_height: 600,
  debug:      false,
  on_load:    -> { puts "page loaded" },
  on_navigate: ->(url : String) { puts "navigated to #{url}" },
  on_close:   -> { puts "window closed" },
) do |app|
  # register bindings here
end

Navigation priority (first match wins):

  1. html: — inline HTML string
  2. url: — explicit URL
  3. LUNE_DEV_URL env var — set automatically by lune dev
  4. assets: — directory embedded at compile time, served over a local HTTP server

Binding Crystal to JavaScript

bind_typed — single typed argument, return is auto-converted:

app.bind_typed("greet", String) { |msg| "Hello, #{msg}!" }

bind — raw Array(JSON::Any) args, for multiple positional arguments:

app.bind("add") do |args|
  a = args[0].as_i
  b = args[1].as_i
  JSON::Any.new((a + b).to_i64)
end

bind_typed with a struct — named arguments via a JSON-serializable struct:

struct AddArgs
  include JSON::Serializable
  getter a : Int32
  getter b : Int32
end

app.bind_typed("add", AddArgs) { |args| args.a + args.b }

bind_async — same raw signature, runs the block off the main thread:

app.bind_async("slow_echo") do |args|
  sleep 1.second
  JSON::Any.new("(delayed) #{args[0].as_s}")
end

Namespaces

Group related bindings under a dot-separated prefix:

app.namespace("counter") do |counter|
  counter.bind_typed("inc", Int32) { |n| n + 1 }
  counter.bind_typed("dec", Int32) { |n| n - 1 }
end

Namespaces compose: math.trig.sin registers as math.trig.sin in JS.

Plugin modules

Extract binding sets into reusable Installable modules:

class GreetModule
  include Lune::Installable

  def install(app : Lune::App)
    app.bind_typed("greet", String) { |msg| "Hello, #{msg}!" }
  end
end

Lune.run(title: "My App", assets: "frontend/dist") do |app|
  app.install(GreetModule.new)
end

JavaScript API

Lune generates frontend/lunejs/app/App.js from your registered bindings. Import api for a fully dynamic proxy, or import named stubs directly:

import api from "../lunejs/app/App.js";

// dynamic proxy — any registered binding works
const msg = await api.greet("world");
const next = await api.counter.inc(0);

// named stub — same call, IDE-autocompletable
import { greet } from "../lunejs/app/App.js";
const msg = await greet("world");

All bindings return Promise. Exceptions thrown in Crystal reject the promise.

CLI

lune init [APP_NAME]    Scaffold a new Lune app (--template vanilla|vue)
lune dev                Start Vite + Crystal with hot-reload
lune check              Type-check without building
lune build              Build frontend + compile Crystal binary
lune build --release    Build with Crystal --release optimizations
lune run                Launch the previously built artifact

Shared flags (apply to all commands):

--frontend-dir   Frontend directory (default: frontend)
--app-entry      Crystal entry file (default: src/main.cr)
--debug          Enable debug logging

lune build output

lune build
# macOS  → build/bin/my_app.app
# Linux  → build/bin/my_app

The frontend is compiled with npm run build and embedded in the binary via Crystal macros — the artifact is a single self-contained file.

Development

make setup             # shards install + npm install
make test              # crystal spec
make deploy            # build release binary + copy to /usr/local/bin

Contributing

  1. Fork it (https://github.com/aristorap/lune/fork)
  2. Create your feature branch (git checkout -b my-new-feature)
  3. Add specs for your changes (crystal spec)
  4. Commit and push (git commit -am 'Add feature' && git push origin my-new-feature)
  5. Open a Pull Request

Contributors

License

MIT

Repository

lune

Owner
Statistic
  • 1
  • 0
  • 0
  • 0
  • 2
  • about 3 hours ago
  • May 9, 2026
License

MIT License

Links
Synced at

Sat, 09 May 2026 20:18:52 GMT

Languages