Skip to content

Application ​

For method and option tables, see the Application API reference. Native methods are listed under windows, dialogs, menus, trays, and system.

Configuration and startup ​

CryKit::Application owns local content, routes, a view registry, workers, and capability managers. Configure routes and assets before app.run, or use the startup hooks below. Local windows load through the native resource handler at app://crykit. This requires a backend with native resource loading, currently implemented on macOS. Creating windows with unsupported backends fails before their native creation.

app.run(log_level: :info) starts the configured application and runs its UI loop. The log level defaults to :info. app.quit asks the window manager to approve closure of all windows, then closes them and stops the runtime. A window's :close_requested listener can cancel the quit. Calling run while already running raises. Application instances are single-use.

Startup hooks ​

Subclass CryKit::Application to keep application setup together. Construction does not invoke startup hooks. During run, CryKit calls these protected hooks in order, before native startup:

  1. configure : Nil
  2. register_handlers : Nil
  3. register_menus : Nil
  4. register_trays : Nil
  5. register_windows : Nil

Override the hooks your application needs; the defaults do nothing:

crystal
class App < CryKit::Application
  protected def configure : Nil
    self.assets = CryKit::Assets.directory("assets")
  end

  protected def register_handlers : Nil
    handlers.register AppHandler
  end

  protected def register_windows : Nil
    windows.create(id: :main, title: "My app", path: "/")
  end
end

App.new.run

Each hook runs once for that application's single run. If a hook raises, later hooks and native startup are skipped. Shutdown still attempts every cleanup stage, the original failure is preserved, and the previous log threshold is restored. The selected run(log_level: ...) threshold applies during the hooks.

Existing setup through app.routes, app.handlers, capability managers, and app.assets before run remains supported. Hooks can use the same APIs; setup already performed before run is available to them.

Environment ​

CryKit.env returns a CryKit::Environment enum:

ValuePredicateSelection
CryKit::Environment::ProdCryKit.env.prod?Default
CryKit::Environment::DevCryKit.env.dev?CRYKIT_ENV=dev

The CLI's dev command sets CRYKIT_ENV=dev for the application process. It also selects the development build configuration for asset hotreload. To select the environment without the CLI, set the same variable when launching:

sh
CRYKIT_ENV=dev crystal run main.cr

You can also set ENV["CRYKIT_ENV"] = "dev" in Crystal before configuring the application. Use the enum predicates to choose app behavior:

crystal
app.run(log_level: CryKit.env.dev? ? :debug : :info)

The environment selects app behavior. The CLI separately configures the build for asset hotreload, dev hotpatch and dev hotreload.

Activation and windowless lifetime ​

Application activation and last-window closure are independent configuration:

crystal
app = CryKit::Application.new(
  macos: CryKit::Application::MacOSOptions.new(activation_policy: :accessory),
  quit_on_last_window_closed: false)

app.windows.create(id: :panel, html: "<p>Ready when needed</p>", hidden: true)
app.run

app.macos is an immutable Application::MacOSOptions value. Its activation_policy is an Application::ActivationPolicy: :regular (the default) or :accessory. On macOS, Regular provides the usual Dock and application-menu presence. Accessory omits that presence and skips automatic application activation at launch. The policy is applied before native launch and initial window presentation. An explicit window.focus can activate an Accessory application without changing its policy. Window visibility and focus remain separate operations. Accessory configuration requires macOS; unsupported backends reject it.

quit_on_last_window_closed defaults to true and is readable through app.quit_on_last_window_closed?. The default preserves window-oriented startup: an application with no initial windows returns without starting a native UI loop, and closing its last window ends the loop. Set it to false to start and remain running with zero windows. Local content, configured menus, workers, and the scheduler retain their application lifetime, so UI callbacks can create windows later, including after the previous last window closes. Closing a window still releases that window's document and jobs. app.quit and native application quit stop the application even when no windows remain.

The application owns this lifetime choice, including zero-window startup. The window manager consults it after close subscribers return and the window is removed, so a subscriber can create a replacement without ending the loop. Cocoa's automatic last-window termination stays disabled; native application quit enters the same managed approval path as app.quit.

These constructor options cannot be changed while running. Accessory does not imply a resident lifetime; configure both options when needed. A hidden window also remains a live window, independent of last-window closure.

Use app.trays for macOS status items with icons and native menus. Tray-only applications explicitly set quit_on_last_window_closed: false; creating a tray does not change application lifetime or activation configuration. Runtime activation configuration does not create an application bundle or its Info.plist. For a bundled agent application, configure LSUIElement in the project's Info.plist template and use distribution to build the bundle.

Local content ​

path: "/" resolves to app://crykit/. app.content dispatches routes and assets directly from the native webview's resource handler. Mounted events and DOM patches use the native bridge. Initial rendering, mount hooks, publication, event updates, and navigation cleanup run on the owning UI context.

Handlers retain their context and response helpers. Crystal's HTTP request and response types are used as in-memory adapters for local resources; they do not create a listener or provide an HTTP hosting API.

Capability managers ​

Use the application’s managers to access native capabilities. Each guide covers its API, calling context, lifecycle, and platform support.

ManagerGuide
app.windowsWindow manager: creation, lookup, geometry, and window controls
app.dialogsNative dialogs: file pickers, messages, and confirmation
app.systemSystem manager: host OS, screen dimensions, and scale
app.menusMenus: application menus, action events and mutable native items
app.traysSystem trays: macOS status items, popup menus and click actions

Routes ​

Inline routes use app.routes.draw with get "/path" do ... end, as shown in Getting started. Routes match exact paths and GET only. Paths must begin with a single slash and contain no scheme, host, query, or fragment. The /assets namespace is reserved. Duplicate paths are rejected. Add an optional Symbol name with as::

crystal
app.routes.draw do
  get "/", as: :home do
    mount HomeView.new
  end
end

app.routes[:home]?     # => CryKit::Route.new("/", :home)
app.routes[:home].path # => "/"

CryKit::Route is a record with path : String and name : Symbol?, which defaults to nil. app.routes[name]? returns the named route or nil; app.routes[name] raises KeyError when the name is missing. Names must be unique across inline and handler routes in the same application. Configuration batches roll back both paths and names if a declaration fails. Views can access the active current_route.

Handlers and shared state ​

For larger projects, keep startup in main.cr and separate handlers, layouts, views, and components. Handlers declare routes and an optional default layout:

crystal
class AppHandler
  include CryKit::Handler

  layout AppLayout

  routes do
    get "/", :counter, as: :home
  end

  def counter
    mount Counter.new
  end
end

app.handlers.register AppHandler
# Register several handlers together, or use additional register calls:
# app.handlers.register SettingsHandler, AboutHandler

Register classes through app.handlers before run, or through handlers in the application's register_handlers hook. Each request receives a fresh handler instance with its own context and the registering application. The injected application retains its concrete subclass type, so a handler can access app-owned state such as application.state.todos without a cast. Keep state that survives navigation on the app; keep view-local state in mounted views. Separate app instances have separate state. Direct Routes#register without an application remains available for handlers that do not need one. Actions are public methods called without arguments. Their return values are ignored. Routes currently support exact-path GET declarations only.

Without layout, mounts default to no layout. layout AppLayout constructs a fresh layout whenever a mount uses the default. An explicit layout: OtherLayout.new overrides it, and layout: nil disables it. Layouts include CryKit::Layout and must have a zero-argument constructor when used in the declaration.

Registered handlers and app.routes.draw can coexist and use the same navigation and mount ownership pipeline. Each app.handlers.register call is atomic: invalid or conflicting routes roll back that entire batch while preserving previous routes. The reorganized example lives in examples/demo.

Shared background updates ​

app.async { |job| ... } starts application-owned work and returns a CryKit::AppJob. It can start before app.run, continues while the application remains running across navigation and window closure, and is cancelled on application shutdown. Its sleep and every helpers observe cancellation.

Use app.broadcast_to(:channel, value) to publish typed values. Each application has its own channels. Views declare their channel dependencies with subscribe_to. The mount registers each view's dependencies with the hosting application's source and delivers callbacks on that view's UI update queue. Unmount removes the dependencies and revokes queued delivery. A declaration has no active listener while its view is unmounted; application jobs can continue publishing with no listening views.

Shared state can remain in existing application objects while selected updates use channels. The demo keeps ClockState for Pause / Resume and publishes clock timestamps through one application job. See application jobs and channels for value ownership, payload types, and the app.broadcast_to acceptance result.

Assets ​

Declare the application's assets before run, or assign self.assets in its configure hook:

crystal
app.assets = CryKit::Assets.directory("assets")

CryKit::Assets.directory takes a compile-time string path relative to the declaring Crystal file; absolute paths and __DIR__ interpolation also work. The CLI serves this directory from disk during dev and embeds its files in the executable during build. Embedded assets have no runtime dependency on the directory. asset_path("main.css") produces the same URL in the reserved /assets/ namespace in either mode. Asset requests support GET, HEAD, and conditional caching; traversal and symlinks escaping the configured directory are rejected. Disk assets pin the configured directory and open each path component relative to directory descriptors, so concurrent file or symlink replacement cannot redirect a checked path outside it. Internal symlinks are resolved within that pinned directory; changing the root pathname does not replace the configured asset store.

Views and tray definitions can call asset_path(name). Use app.asset_path(name) in application setup or other code outside those definitions.

For a custom build command, crystal build -Dcrykit_embed_assets main.cr enables embedding. Without this flag, directory serves from disk. Applications that need to select a disk directory at runtime can still set app.assets.path = File.expand_path("assets", __DIR__); this explicit disk API is not embedded by build.

Successful native disk asset GETs transfer an open file descriptor to the macOS engine. It reads files off the UI thread and delivers at most 64 KiB per chunk, with one chunk outstanding per request. Cancellation and window closure stop delivery and release the descriptor. Header preparation still runs on the UI context; routes and mounted documents retain their buffered response path. HEAD and conditional responses keep the same metadata and cache policy. Embedded assets retain their immutable compiled bytes through native completion or cancellation, copying at most 64 KiB per UI turn without buffering the whole asset again. They send Cache-Control: no-cache with a strong SHA-256 content ETag and omit Last-Modified.

Both disk and embedded assets advertise Accept-Ranges: bytes. Single GET ranges such as bytes=0-15, bytes=100-, and bytes=-16 return 206 with Content-Range; malformed or unsatisfiable single byte ranges return 416 with Content-Range: bytes */<size>. Unsupported range units and multipart ranges are ignored and return the full representation. HEAD ignores Range. Cache validators are evaluated before ranges; If-Range requires a matching strong ETag or exact modification date. Disk ETags are weak, so use a date for disk If-Range; development assets without validators return the full body when If-Range is present.

Development updates ​

CryKit has three separate development features. Asset hotreload and dev hotpatch update the running interface while preserving its state. Dev hotreload compiles and restarts the application, resetting its in-memory state.

FeatureChanged filesResult
Asset hotreloadLocal CSS and referenced tray icon filesReplace stylesheets and update native tray icons
Dev hotpatchCompatible ECR text and presentation attribute valuesPatch the existing template session
Dev hotreloadCrystal code or incompatible ECR changesBuild a replacement executable and restart on success

Asset hotreload ​

The CLI's dev command enables asset hotreload automatically for disk assets. It refreshes local stylesheets and tray icons loaded through asset_path when their files change. Plain dev enables this feature even without --watch; the source watcher is separate.

Without the CLI, set app.assets.hot_reload = true before app.run, or compile with -Dcrykit_dev. The setting defaults to false in ordinary builds and is independent of log_level. An explicit assignment overrides the disk build default; embedded assets cannot enable hotreload:

crystal
app.assets = CryKit::Assets.directory("assets")
app.assets.hot_reload = CryKit.env.dev?
app.run

While enabled, CryKit uses native FSEvents notifications on macOS to detect asset changes and coalesces saves for about 100 ms. It compares CSS contents and refreshes local stylesheet links in each application document's head, including changes to imported local CSS. The document stays mounted, preserving application state, entered input, and its readiness state; it does not emit another :document_ready event. The previous stylesheet remains active if its replacement fails to load. Successful swaps preserve the stylesheet's current media and disabled settings. Existing inline style attribute values can use dev hotpatch.

The same watcher compares the asset files referenced by active native trays. Changed icon bytes update the existing tray while preserving its menu, label and listeners; unchanged bytes do no work. Missing, empty, invalid or natively rejected replacements keep the previous icon, and a later valid edit retries. See tray icons from application assets for class and inline declarations. Icon.from_file remains a disk snapshot.

Asset hotreload serves disk assets with Cache-Control: no-store and omits ETag and Last-Modified validators. Conditional requests carrying a previous validator receive the current bytes, including imported CSS edited without changing its size or modification time. Outside asset hotreload, disk assets retain metadata-based conditional caching. An If-None-Match: * existence condition remains supported in either mode.

CLI workflow ​

The CLI coordinates all three features. Run dev for disk assets and asset hotreload; add -w or --watch for dev hotpatch and dev hotreload:

sh
crystal run src/cli.cr -- dev -w -f examples/demo/crykit.yml

Source watching runs only with -w or --watch. The development.watch list in crykit.yml selects files, directories (recursive .cr and .ecr files), or glob patterns for that mode. Paths resolve beside the config file. Its default is ["./**/*.cr", "./**/*.ecr"]. Template filenames passed to template resolve beside the declaring Crystal file, so the same app builds from the repository root or its own folder.

Dev hotpatch ​

Edit an existing heading in examples/demo/src/views/templates/counter.ecr and save: the mounted text updates while the counter, callbacks, inputs and view lifetimes remain active. Existing ordinary text and presentation attribute values (class, style, title, placeholder, alt, role and aria-*) can hotpatch when the template structure and dynamic expressions stay unchanged.

Dev hotreload ​

Changes to Crystal files, dynamic expressions, event actions, element structure, attribute names, IDs, navigation targets and control types request compilation. Files without an active mounted template, including response-only document layouts, also rebuild. A settled save containing any incompatible change rebuilds as a whole. The CLI builds a replacement while the current app keeps running, then restarts on success. A successful restart resets in-memory state. Compiler errors retain the previous executable; another watched edit retries the build. Compatible literal hotpatches remain usable while a build is failing. Another settled save cancels an obsolete build, and application exit interrupts compilation.

Watching supports ordinary saves, timestamp-preserving edits, and editors that replace files atomically. Rapid edits settle before CryKit applies a hotpatch or starts a build.

Running without the CLI ​

Plain dev selects development mode and runs once. dev --watch additionally manages source watching, dev hotpatch and the rebuild/restart cycle. It compiles with -Dcrykit_hot_reload and supplies a source baseline and rebuild notification path through CRYKIT_DEV_CONTEXT during compilation. The flag alone does not provide a supervisor or a watch configuration.

Custom runners can supply the same CryKit::Dev::Context JSON with watch, files and rebuild fields, and manage compilation and process replacement themselves. Use dev --watch when that custom integration is unnecessary. Selecting CRYKIT_ENV=dev alone does not enable source watching or asset hotreload. The three features are independent of log_level.

Document ownership and shutdown ​

Ordinary app.quit and native application quit preflight every window's :close_requested events in active window order before closing any window. One veto leaves every window and document alive and keeps the event loop running. A later quit can retry. Window creation is rejected during preflight and committed quit, and becomes available again after a veto. Repeated close/quit requests during an active approval callback are coalesced; they do not open another prompt. Accepted quit emits each window's existing :closing / :closed lifecycle once, without repeating approval hooks.

Each owned webview document has its own mounts, revisions, and event session. Committed navigation, reload, renderer termination, and window closure invalidate the previous document's session and release its mounts. Failed or cancelled provisional navigation preserves the visible document and its session. The native window keeps its identity across document replacement. window.document_ready? becomes false until the replacement document completes its authenticated runtime handshake and the native adapter sends its successful reply; each such connection emits :document_ready. Late subscriptions do not replay earlier readiness. Plain unmounted local pages, raw HTML and external URLs remain unready. See Document readiness for callback and failure semantics, including the separate browser crykit:ready notification.

Document rendering, initial mount hooks, and event callbacks run on the UI context, so keep them short and use async for expensive work. Shutdown attempts every cleanup stage and preserves failures. Native startup, callbacks, and shutdown stay on their original OS thread even when a resource request waits for file I/O; macOS application runs require the process's main thread.

Final application shutdown closes application subscriptions, cancels application jobs, stops the worker executor and expiry scheduler, closes all windows, removes trays and application menus, completes pending disposal, releases the runtime, and closes local content and the registry. This cleanup is unconditional, including after startup/run failure or return from the event loop; close hooks cannot veto resource release. Every stage is attempted. The original startup/run failure takes precedence; otherwise accumulated cleanup failures become Application::ShutdownError. Shutdown does not wait for arbitrary running worker jobs.

Logging ​

app.run(log_level: :info) selects the CryKit log threshold for the run. The default is :info; supported levels are :trace, :debug, :info, :notice, :warn, :error, :fatal, and :none. Pass a Symbol literal, a Symbol variable, or a ::Log::Severity value. app.log_level reports the selected severity.

crystal
log_level = ENV["CRYKIT_TRACE"]? == "1" ? :trace : :info
app.run(log_level: log_level)

:trace and :debug also enable native webview developer tools. Other levels disable them. The selected threshold applies even when the logger was previously configured to another level. CryKit restores the previous logger level after the run, including startup or shutdown failure. It writes synchronously to stderr so native event-loop activity does not starve log delivery.

CryKit classifies its diagnostics by their purpose:

LevelMessages
:traceRegistry bookkeeping, per-click dispatch, job submission and start, and update notifications
:debugAsset changes and demo window events, including readiness, focus, and closing
:infoApplication starting and stopped
:warnRecoverable CSS scan failures that will be retried
:errorFailed operations, callbacks, and cleanup

:notice and :fatal are supported thresholds; CryKit currently emits no framework messages at those levels. :none suppresses CryKit logging. Native engine and browser error diagnostics remain independent of this logger setting. Diagnostics do not log session tokens or view content.

Built with Crystal. Documented with VitePress.