Cross-platform desktop system tray application for controlling monitor brightness, contrast, dark mode, volume, keep-awake (sleep prevention), and window tiling (macOS + Windows + Linux/X11). Built with Tauri v2 (Rust backend) + React 18 (TypeScript frontend) + Vite 6.
Display, dark mode, and volume operations are delegated to the display-dj CLI, which runs as a bundled HTTP server sidecar. The Tauri backend makes HTTP requests to it.
For full architecture details, request lifecycle, layer-by-layer breakdown, data flow diagrams, and "where to edit" reference, see DEV.md.
npm install # Install frontend dependencies
npm run dev # Start Vite dev server (frontend only)
npm run build # Build frontend (tsc + vite build)
npx tauri dev # Run full app in development mode
npx tauri build # Production build (binary + .dmg/.exe/.deb/.AppImage)
cargo check # Check Rust compilation (from src-tauri/)Use the /install-app slash command to download and install the latest release for the current platform. It handles all platform-specific steps automatically.
After downloading and copying the .app to /Applications, these steps are mandatory:
# Strip Apple quarantine (required for unsigned builds)
xattr -cr "/Applications/Display DJ.app"
# Reset Accessibility permission (required after each new build for tiling to work)
tccutil reset Accessibility com.synle.display-dj
# Open Accessibility settings so user can re-grant permission
open "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"
# Launch the app
open "/Applications/Display DJ.app"Run the *_x64-setup.exe installer — it handles everything.
The single source of truth for the app version is src-tauri/tauri.conf.json → "version". This controls:
- UI header:
build.rsreadstauri.conf.jsonand sets the compile-time env varAPP_VERSION. For dev/local builds, the version includes[beta - <short_sha>](e.g.5.6.0 [beta - abc1234]). Release builds (CI withTAURI_RELEASE=true) show the clean version only.build.rsalso setsBUILD_DATE(ISO 8601, e.g.2026-04-19) usingSystemTime+ civil date math. The Tauri commandget_app_version()(config.rs) returns the version string.get_about_info()returns structured info (version, engine, arch, os, buildDate, homepage). The frontendHeader.tsxdisplays the version as "Display DJ v{version}". - Installer/bundle metadata: Tauri uses this version for
.dmg,.exe,.deb,.AppImagebundles (shown in macOS "Get Info", Windows "Properties", etc.).
Other version fields:
package.json→"version": Set to0.0.0. Not used by the app (not published to npm).Cargo.toml→version: Set to0.0.0. Not used (the crate is not published).- Release versioning is driven by git tags (
v*triggersrelease-official.yml).
npm test # Run all frontend tests (Vitest)
npm run test:watch # Run frontend tests in watch mode
cd src-tauri && cargo test # Run all Rust backend tests- Setup:
src/test/setup.ts— Configures jsdom, jest-dom matchers, and Tauri API mocks - Unit tests:
src/components/*.test.tsx— Tests for each component (Header, Slider, DarkModeToggle, VolumeControl, AllMonitorsControl, MonitorControl, KeepAwakeToggle) - Smoke test:
src/App.test.tsx— Verifies App renders without errors, fetches initial data, handles backend failures gracefully - Tauri
invoke()andlisten()are mocked globally in the test setup
- Unit tests: Inline
#[cfg(test)]modules inconfig.rs,display.rs,keep_awake.rs,tray_icon.rs, andtiling.rsconfig.rs: Serialization/deserialization, defaults, camelCase conventions, file roundtrips, CommandValue enum variants, MonitorMetadata serde, effective min brightness, backward-compatible deserialization of old configs, preferences with monitorConfigs roundtrip, expose_columns/expose_rows defaults and roundtrip, legacy expose_max_windows migration, layout preset serde, night mode schedule commands roundtrip and backward compat, config_dir existence and naming, WallpaperPreferences defaults/serde/backward-compatdisplay.rs:DjDisplaytoMonitorconversion (including uid computation),merge_with_configs(rename, sort),reconcile_migrated_configs,ensure_metadata_for_monitors, Monitor serde,resolve_monitor(by id, uid, substring — used for per-monitor wallpaper)keep_awake.rs: KeepAwake guard creation, Mutex<Option> pattern (enable/disable/re-enable)tray_icon.rs: Percentage-to-pixel conversion, icon generation for all state combinations (dark/light, keep-awake, muted), filled rect and thick line drawingtray.rs: Command URL building for all command types (brightness, contrast, volume — both all-monitors and per-monitor), min brightness clamping, contrast capping, invalid value handlingtiling/mod.rs(shared types + layout math + orchestration): TilingLayout parsing, layout calculation for all 17 layouts (halves, thirds, two-thirds, quarters, maximize), gap/padding math, custom ratio support, TilingState creation,layout_across_displaysmulti-display overflow with oversized window handling and DPI-scaled min cell size enforcement (effective_capacity), layout preset resolution (by index and name), window-to-rule matching (substring, case-insensitive, first-match-wins, unknown layout skip),plan_expose(all-windows placement with min cell sizes),plan_expose_app(target app + others on remaining displays, no overflow to consumed displays, with min cell sizes),plan_layout_preset(preset rule matching + placement),effective_capacity(min cell → max windows per display), smart restore helpers (is_rect_oversizedthreshold check,calculate_smart_restore_rectdisplay-centered sizing,calculate_smart_restore_rect_at_cursorcursor-centered sizing), grid-aligned oversized window placement (find_free_cell,find_free_block,mark_block)tiling/macos.rs(macOS only): macOS-specific AXUIElement window manipulation, NSScreen display detection, Tile Snap via NSEvent global monitor,is_window_move(move vs resize detection),build_snap_zones/detect_snap_zone_macos(rectangle hit-test zone detection),get_display_full_frames(NSScreen.frame for pseudo-fullscreen detection),is_pseudo_fullscreen/send_escape_key(exit browser/video fullscreen),get_all_gui_app_pids(NSWorkspace enumeration),move_all_windows_to_current_space(CGS private API for Spaces collapsing)tiling/windows.rs(Windows only): Win32 window manipulation via GetForegroundWindow/SetWindowPos, EnumDisplayMonitors for display detection, EnumWindows for Expose,should_skip_system_windowfiltering (Program Manager, TextInputHost, etc.), IsZoomed restore, DWM border over-expand with post-move DPI correction (reads back visible frame after first SetWindowPos and applies a corrective second SetWindowPos if DWM borders changed due to cross-DPI move),dbg_loghelper for debug file logging, expose debounce via AtomicBooltiling/linux.rs(Linux only): X11 availability check, strut-to-work-area math (top/bottom/left/right panels, dual-monitor panel isolation, combined struts), process name resolutionwallpaper.rs: Image validation (extension, existence, size), MD5 path hashing for destination filenames, content hash comparison, fit mode parsing from command strings, slideshow arg parsing, wallpapers_dir path correctness, WallpaperPreferences serde roundtrip, remote pack URL/folder validation
- Smoke test:
src-tauri/tests/smoke.rs— Integration test verifying the crate compiles, links, and public API (AppState, run) is accessible
GitHub Actions (build.yml) runs npm test and cargo test on all platforms (macOS ARM/Intel, Windows, Linux) for every push and PR. On PRs, a comment is posted with download links for each platform's build artifacts.
After making changes to frontend code (src/), config files, or docs, always run npx prettier --write on the changed files before considering the task done. The prettier hook in .claude/settings.json handles this automatically for Edit/Write tool calls, but if you create or modify files via other means, run prettier manually.
- Tests: Always add tests to cover new code. Frontend components get
*.test.tsxfiles; Rust modules get#[cfg(test)]unit tests. If a function is hard to test directly (e.g., depends on platform APIs, hardware, or external services), mock the API boundary and test the logic. Runnpm testandcd src-tauri && cargo testto verify all tests pass before finishing. - Formatting: Always run
npx prettier --writeon all changed frontend files (src/,*.ts,*.tsx,*.json,*.md,*.yml). - Documentation: Always update
CLAUDE.md,README.md(if it exists), andCONTRIBUTING.mdto reflect any features added or removed — including new commands, preferences, HTTP routes, UI components, and architecture changes. - Method comments: Always document every new function, method, and test. Rust uses
///doc comments; TypeScript/React uses/** */JSDoc comments. Every public function, Tauri command, React component, non-trivial helper, and test case must have a comment describing what it does or what it verifies. - CLI sidecar version bumps: When updating
displayDjCliVersioninpackage.json, always check the display-dj-cli changelog and commits for upstream changes (new endpoints, changed response formats, removed features). Update our code to use any new APIs and remove usage of deprecated ones. Document the changes in CLAUDE.md and CONTRIBUTING.md.
On macOS, two patterns in Tauri command handlers break the system tray icon — both left-click and right-click stop working entirely:
-
Sync Tauri commands that access
AppState: Declaring a#[tauri::command]aspub fn(sync) instead ofpub async fncauses Tauri to run it on a blocking thread that starves the macOS main-thread run-loop, preventingon_tray_icon_eventfrom firing. All Tauri commands that accessState<'_, AppState>must beasync. -
write_debug_log()in frequently-called sync commands:write_debug_log()locksstate.preferencesto checkdebug_logging. Using it inget_preferences(sync, called on every frontend render) creates enough mutex contention to starve the run-loop. Uselog::info!instead in sync commands.write_debug_log()is safe in async/infrequent commands likesave_preferences.
These are documented inline in config.rs with WARNING comments.
Tile Snap uses NSEvent.addGlobalMonitorForEvents(matching:handler:) to observe mouse events (down, up, dragged) globally. This replaced the previous CGEventTap approach which silently failed in production .app bundles (macOS Sequoia rejects CGEventTapEnable for ad-hoc signed bundles — drag events were never delivered).
Why NSEvent instead of CGEventTap:
NSEventis a higher-level Cocoa API that macOS trusts from.appbundles without special code signing- Listen-only (which is all Tile Snap needs — it observes, never blocks/modifies events)
- No
codesignworkaround needed — works with the default ad-hoc linker signature fromtauri build - Handler runs on the main thread automatically (no separate
CFRunLoopRunthread needed)
Rules for the NSEvent handler (runs on main thread):
- Keep it fast — the handler runs on the main thread. AX API calls (
get_focused_window,get_window_rect) are deferred to the first confirmed drag (after 10px threshold). - Use
try_lock(), neverlock()— same as before, to avoid blocking the main thread. catch_unwindwraps the handler — Rust panics cannot unwind through the Objective-C block boundary (would abort). The handler catches panics silently.objc_msgSendfor[event type]—typeis a Rust keyword;msg_send![event, r#type]causes ObjC exceptions. Use rawobjc_msgSendwithSel::register("type")instead.- Cocoa coordinate conversion —
[NSEvent mouseLocation]returns Cocoa coords (Y up from bottom-left). Convert to CG coords (Y down from top-left) usingprimary_h - cocoa_y. blockcrate (v0.1) — used to create Objective-C blocks from Rust closures for the NSEvent handler. Must stay alive (heap-allocated via.copy()) for the lifetime of the monitor.
- All Rust structs sent to frontend use
#[serde(rename_all = "camelCase")] - Tauri commands are snake_case in Rust, called with snake_case strings from frontend
invoke() - Frontend parameter objects use camelCase (Serde handles the conversion)
- The
CommandValueenum uses#[serde(untagged)]to support both"string"and["array"]in keybindings - Preferences use
#[serde(default)]so old config files missing new fields gracefully fall back to defaults - Brightness values are clamped to
effective_min_brightness()which enforces an absolute floor of 5 - Contrast is DDC-only (
Option<u32>/number | null): built-in displays returnnull. The contrast slider is hidden by default and toggled via theshowContrastpreference in Settings - Keep Awake uses the
keepawakecrate (v0.6) to prevent system idle sleep and display sleep. The guard is stored asMutex<Option<KeepAwake>>inAppState— creating the guard enables keep-awake, dropping it (setting toNone) releases the assertion. Works on macOS (IOKit), Windows (SetThreadExecutionState), and Linux (D-Bus). Theset_keep_awakecommand isasyncto avoid the tray icon pitfall. - Dynamic Tray Icon (
tray_icon.rs): The system tray icon is drawn programmatically at 128x128 using percentage-based layout constants (no PNG assets). It reflects three app states: (1) Dark/light mode — border color swaps (white on dark menu bar, black on light) with inverse default fill; (2) Keep-awake — fill changes to blue (deep blue on dark, sky blue on light); (3) Muted (volume=0) — red X drawn over the icon. States are cached inAppState(is_dark_mode,is_muted) and the icon is regenerated viaupdate_tray_icon()whenever any state changes. Initial state is fetched from the sidecar on startup viafetch_initial_tray_state(). - Settings Panel: Auto-saves preferences on every change (300ms debounce). No Save/Cancel buttons — changes take effect immediately. The
SettingsPanelcomponent usesuseCallback+setTimeoutto debouncesave_preferencescalls and triggersonPreferencesSavedafter each save to refresh the parent UI. - About Panel (
AboutPanel.tsx): Triggered by tray menu "About Display DJ" → emitsshow-aboutevent → frontend shows the panel. Displays: version (fromget_about_infocommand), latest version (fetched from GitHub APIreleases/latest), engine ("Tauri + Rust Sidecar"), platform + arch, build date (compile-timeBUILD_DATEenv var set inbuild.rsusing civil date math fromSystemTime), homepage link. Shows "Up to date" (green) or "Update available" (orange) badge with download link. macOS-only section shows thexattr -crquarantine fix and Accessibility settings terminal command with selectable code blocks. - Window Tiling (macOS + Windows + Linux/X11,
tiling/module directory): Moves/resizes the focused window into tiled layouts. 19 layouts (halves, thirds, two-thirds including vertical, quarters, maximize) plus restore. Smart Restore: When restoring a window whose saved original rect is oversized (≥ 85% of the display in both width and height — e.g., from maximize or fullscreen), a smart size is used instead of the raw original: 60% of the smallest display across all monitors, but no smaller than the app's own minimum size (queried via AXMinimumSize on macOS). The result is centered on the window's display. This prevents windows from restoring to near-fullscreen sizes that are hard to manage. The shared helpersis_rect_oversized()andcalculate_smart_restore_rect()intiling/mod.rsare used by all three platforms. On macOS, Tile Snap also smart-shrinks oversized windows when a drag starts: if the window being dragged covers ≥ 85% of the display, it is resized to the smart size centered on the cursor (viacalculate_smart_restore_rect_at_cursor()), so the user can see snap zones while dragging. Two Exposé modes: Exposé (command/tile/expose, Shift+Ctrl+E / Ctrl+Up) spreads all on-screen windows into a deterministic alphabetical grid; App Exposé (command/tile/exposeApp, Shift+Ctrl+A / Ctrl+Down) grids only the frontmost app's windows. Both normalize windows first (unminimize + exit native fullscreen + exit browser/video pseudo-fullscreen via Escape key + collapse all virtual desktops/Spaces to current Space) and use fill-first multi-display overflow (fill display 1 up toexposeColumns * exposeRows, overflow to display 2, etc.). Windows with minimum size constraints (e.g., Steam, Chrome) that exceed the grid cell dimensions on the current display overflow to subsequent displays where fewer windows mean larger cells; the last display uses grid-aligned placement where oversized windows consume ceil'd grid cells (e.g., a window needing 600×500 on a grid with 333×360 cells gets 2×2 cells = 666×720, snapped to grid boundaries with no gaps). Resizable windows are placed first, then oversized windows fill remaining grid slots. The sharedlayout_across_displaysfunction intiling/mod.rshandles this overflow logic for all platforms. Each invocation re-lays out all windows (no toggle/restore behavior). Layout is deterministic: sorted alphabetically by app name, then by window_id. The Exposé Grid Size in Settings uses separate Columns and Rows sliders (1-5 each, default 3x3 = 9 per screen), allowing non-square grids like 2x3 or 3x4. Triggered viacommand/tile/{layoutName}commands bound to keyboard shortcuts, or the Tiling submenu in the tray menu. The tiling code is organized as a module directory with a clean separation between pure logic and OS calls.tiling/mod.rscontains all shared types, layout math, TilingLayout enum, coordinate calculations, AND shared orchestration functions (plan_expose,plan_expose_app,plan_layout_preset) that compute window placements without touching any OS APIs. Platform files (tiling/macos.rs,tiling/windows.rs,tiling/linux.rs) are thin wrappers that call the shared plan functions, then apply the resulting placements via platform-specific OS APIs. This means layout bugs are fixed once inmod.rsinstead of in 3 files. ThePlacementstruct (returned by plan functions) containswindow_id,owner_pid, andtargetrect. macOS implementation: Uses the Accessibility API (AXUIElement) to move/resize windows. Requires Accessibility permission. State tracked per-window by CGWindowID inAppState.tiling_state. Uses_AXUIElementGetWindow(private API, stable since macOS 10.6) to bridge AXUIElement to CGWindowID. NSScreen visible frames are used for display bounds (accounts for menu bar/dock) — must usescreens[0](primary) notmainScreen(focused) for coordinate conversion. Tile Snap usesNSEvent.addGlobalMonitorForEvents(viaobjcv0.2 +blockv0.1 crates) to detect window drags near screen edges. On mouse_down, nothing happens until a 10px cursor movement threshold is crossed AND the window position changes (confirming a title-bar drag, not a resize or content drag). Drop zone indicators (colored edge/corner overlays) appear when a move is confirmed. Zone detection uses simple rectangle hit-testing (build_snap_zonesbuilds the same rects drawn as indicators,detect_snap_zone_macoschecks point-in-rect). On mouse_up, the window is moved directly to the pre-calculated target rect (the same rect shown as the overlay preview) — no re-detection or recalculation. Theobjccrate (v0.2) is used for NSScreen/NSEvent interop; all other macOS FFI (AX API) is rawextern "C". Windows implementation: Uses Win32 API (GetForegroundWindow,SetWindowPos,EnumDisplayMonitors,EnumWindows) via thewindowscrate (v0.58). No special permissions needed. All 19 layouts + restore + Exposé + App Exposé work. Tile Snap (mouse edge snapping) is not yet implemented on Windows — it remains macOS-only. Linux/X11 implementation: Usesx11rbcrate (pure Rust X11 client) with EWMH window manager hints. Gets the focused window via_NET_ACTIVE_WINDOW, moves/resizes via_NET_MOVERESIZE_WINDOWclient messages, enumerates windows via_NET_CLIENT_LIST, and gets display geometry via XRandr. Panel/dock reservations are handled via_NET_WM_STRUT_PARTIAL/_NET_WM_STRUTwith_NET_WORKAREAfallback. Window frame decorations are compensated via_NET_FRAME_EXTENTS. No special permissions needed on X11. Tiling is runtime-gated on Linux:get_tiling_supportedchecks$DISPLAYenv var and returns false on Wayland-only sessions. Tile Snap is not yet implemented on Linux. Tested on Linux Mint with XFCE (xfwm4). Preferences:tiling.enabled,tiling.halfRatio(default 50),tiling.thirdRatio(default 33),tiling.gap(default 0),tiling.sideEdgeTrigger(default 18, px for left/right/bottom snap zones),tiling.topEdgeTrigger(default 18, px for top/maximize snap zone),tiling.cornerTrigger(default 50, px for corner quarter snap zones),tiling.exposeEnabled(default true, master toggle for Exposé features),tiling.exposeColumns(default 3, grid columns per display),tiling.exposeRows(default 3, grid rows per display),tiling.exposeMinWidth(default 400, minimum grid cell width in logical pixels — scaled by DPI on Windows),tiling.exposeMinHeight(default 300, minimum grid cell height in logical pixels — scaled by DPI on Windows). Exposé has its own top-level tray submenu (separate from Tiling) with Enable/Disable toggle, Exposé/App Exposé actions, and grid size presets (2x2, 2x3, 3x3, 3x4, 4x4, 5x5). The Exposé submenu is only visible when tiling is supported on the platform (same gate as Tiling). The Settings panel has a separate "Enable Exposé" checkbox; the grid size sliders (Columns/Rows) are only visible when Exposé is enabled. Tiling is platform-gated: on macOS, Windows, and Linux (X11) the tray submenu, Settings toggle, and Tauri commands (get_tiling_supported,get_accessibility_trusted) are active. On Wayland-only Linux sessions,get_tiling_supportedreturns false at runtime. The Settings panel shows an accessibility permission warning on macOS when tiling is enabled but permission is not granted (Windows and Linux do not require special permissions).
Commands are strings dispatched by execute_command() in tray.rs. They can be bound to keyboard shortcuts in keyBindings, used in profiles, or triggered from the tray menu. The build_command_url() helper maps commands to sidecar HTTP URLs and is covered by unit tests.
| Command | Sidecar Endpoint | Description |
|---|---|---|
command/changeBrightness/{value} |
/set_all/{value} |
Set brightness on all monitors |
command/changeBrightness/{monitor_id}/{value} |
/set_one/{id}/{value} |
Set brightness on a single monitor |
command/changeContrast/{value} |
/set_contrast_all/{value} |
Set contrast on all monitors |
command/changeContrast/{monitor_id}/{value} |
/set_contrast_one/{id}/{value} |
Set contrast on a single monitor |
command/changeVolume/{value} |
/set_volume/{value} |
Set volume (0-100) |
command/changeDarkMode/{dark|light|toggle} |
/dark, /light, /theme |
Toggle or set dark/light mode |
command/changeProfile/{index} |
(executes profile commands) | Run all commands in a saved profile |
command/tile/{layoutName} |
(calls tiling module) | Tile the focused window |
command/layout/{name_or_index} |
(calls tiling module) | Apply a layout preset by name or 0-based index |
command/wallpaper/change/{path} |
(calls wallpaper module) | Set wallpaper on all monitors (default fit) |
command/wallpaper/change/{fit}/{path} |
(calls wallpaper module) | Set wallpaper with explicit fit mode |
command/wallpaper/change_single/{monitor}/{path} |
(calls wallpaper module) | Set wallpaper on a single monitor |
command/wallpaper/change_single/{monitor}/{fit}/{path} |
(calls wallpaper module) | Per-monitor wallpaper with explicit fit |
command/wallpaper/slideshow/{folder_path} |
(calls wallpaper module) | Start slideshow (default interval/order) |
command/wallpaper/slideshow/{interval}/{order}/{folder} |
(calls wallpaper module) | Start slideshow with explicit interval/order |
command/wallpaper/slideshow_stop |
(calls wallpaper module) | Stop the active slideshow |
command/wallpaper/slideshow_remote/{url_to_zip} |
(calls wallpaper module) | Download zip, extract images, start slideshow |
Monitor IDs are the sidecar API IDs (e.g. "1", "2", "builtin"). Brightness is clamped to [effective_min_brightness, 100]; contrast is clamped to [0, 100]. Wallpaper monitor matching ({monitor}) uses resolve_monitor(): tries exact id, exact uid, then case-insensitive substring on name/original_name.
-
Night Mode Schedule (
NightModeScheduleinconfig.rs): Optionally acceptsnightCommandsanddayCommandsarrays of command strings. When non-empty, these replace the default brightness + dark/light mode behavior and are executed viatray::execute_command(). When empty (default), falls back to the legacynightBrightness/dayBrightness+ dark/light mode behavior. This allows users to run arbitrary commands on schedule (e.g., volume changes, profile activation, per-monitor brightness). Backward-compatible — old configs missing the command arrays get empty defaults. -
Window Layout Presets: Named presets that specify which apps go to which tiling layouts. Stored in
preferences.layoutPresetsas an array ofLayoutPresetobjects, each with anameandrulesarray. EachLayoutRulehasappMatch(case-insensitive substring),layout(camelCase TilingLayout name), and optionaldisplayIndex(0-based). Triggered viacommand/layout/{name_or_index}— works from keyboard shortcuts, profiles, and tray menu. The tray menu shows a "Layout Presets" submenu when presets are configured. Rules match one window per rule (first match wins); create duplicate rules to tile multiple windows of the same app. Users configure presets by editingpreferences.json(Open App Preferences in tray menu) or browsing the config directory (Open App Folder in tray menu). -
Wallpaper (
wallpaper.rs): Changes the desktop wallpaper viacommand/wallpaper/change/{path}(all monitors, default fit),command/wallpaper/change/{fit}/{path}(all monitors, explicit fit),command/wallpaper/change_single/{monitor}/{path}(single monitor), orcommand/wallpaper/change_single/{monitor}/{fit}/{path}(single monitor, explicit fit). Images are validated (must exist, have a valid image extension, and be > 1 KB), then copied to{config_dir}/display-dj/wallpapers/with a stable filenamewallpaper-{md5(source_path)}.{ext}. On subsequent calls with the same source path, content MD5 hashes are compared to avoid unnecessary overwrites. The wallpaper is set using the cached copy (not the original) via the display-dj-cli sidecar endpoints/set_wallpaper/{fit}/{path}(all monitors) and/set_wallpaper_one/{index}/{fit}/{path}(per-monitor). Fit modes:fill(default),fit,stretch,center,tile— all supported on macOS, Windows, and Linux (handled by the sidecar; per-monitor is macOS + Windows only, Linux falls back to global). The{monitor}parameter is resolved viaresolve_monitor()indisplay.rswhich tries exactid, exactuid, then case-insensitive substring on name/original_name. Preferences:wallpaper.fit(default"fill"),wallpaper.currentWallpaperPath(tracks active wallpaper for all monitors),wallpaper.perMonitorWallpapers(array ofMonitorWallpaperwithmonitorUidandwallpaperPath),wallpaper.slideshowEnabled(default false),wallpaper.slideshowFolder(path to images),wallpaper.slideshowIntervalMinutes(default 30, min 5),wallpaper.slideshowOrder("forward","backward", or"random"). Slideshow is managed by the display-dj-cli sidecar (timer, state, cycling). The GUI sends start/stop commands via/wallpaper_slideshow_start/{interval}/{order}/{fit}/{folder}and/wallpaper_slideshow_stop. On app startup, ifslideshowEnabledis true, the slideshow is resumed viaresume_slideshow_if_enabled(). Manual wallpaper changes (command/wallpaper/change) auto-stop the slideshow (sidecar handles this). The Settings panel has a Wallpaper Fit dropdown, Enable Slideshow checkbox, folder path input, interval (hours + minutes dropdowns, min 5 min), and order dropdown (Forward/Backward/Random). Works as a command, so it can be used in keyboard shortcuts, profiles, and night/day schedules. Remote wallpaper packs:command/wallpaper/slideshow_remote/{url}downloads a.zipfile from a URL, extracts valid images towallpapers/remote-{md5(url)}/, and starts a slideshow on the extracted folder. Only.zipformat supported; max 500 MB download; idempotent (skips download if folder exists and has images). Uses thezipcrate for extraction. SeeWALLPAPER_CLI_SPEC.mdfor the full CLI API spec.
- display-dj-cli — The Rust CLI/HTTP server that handles all display operations (brightness, contrast, dark mode, wallpaper). Bundled as a Tauri sidecar (currently v2.2.0). Source at
/Users/syle/Downloads/display-dj-cli. When bumping the sidecar version, always review upstream changes in that repo.
The display-dj CLI sidecar handles all platform-specific display and volume dependencies internally. No external tools need to be installed for display or volume control. The keepawake crate handles sleep prevention natively on all platforms (macOS IOKit, Windows SetThreadExecutionState, Linux D-Bus). The tauri-plugin-dialog crate provides native OS confirmation dialogs (used by Reset to Default). The md5 crate (v0.7) provides MD5 hashing for wallpaper filename generation and content comparison. The zip crate (v2) extracts remote wallpaper packs downloaded as .zip files. The windows crate (v0.58) is a Windows-only dependency used for Win32 window tiling APIs (GetForegroundWindow, SetWindowPos, EnumDisplayMonitors, EnumWindows). The x11rb crate (v0.13) is a Linux-only dependency (pure Rust X11 client) used for X11/EWMH window tiling (_NET_ACTIVE_WINDOW, _NET_MOVERESIZE_WINDOW, _NET_CLIENT_LIST, XRandr).
The sidecar has three layers of shutdown protection:
- Parent-death detection (primary): The sidecar monitors stdin in a background thread. Tauri's shell plugin pipes stdin automatically. When Tauri exits (normal exit, crash, force-quit), the OS closes the pipe → stdin returns EOF → the sidecar shuts itself down via
process::exit(0). This is the fastest and most reliable mechanism. - Explicit kill on exit: On
RunEvent::Exit,lib.rscallschild.kill()on the storedCommandChildas a belt-and-suspenders backup. - Stale process cleanup on startup:
kill_stale_sidecars()kills any leftoverdisplay-dj-serverprocesses from a previous run usingpkillon macOS/Linux andtaskkillon Windows. This catches edge cases where both (1) and (2) failed (e.g.,SIGKILLto both processes simultaneously).
Pre-built sidecar binaries for all 6 platforms are committed in src-tauri/binaries/. The build script (src-tauri/build.rs) skips the download if the binary already exists and is non-empty (to avoid triggering infinite rebuild loops in tauri dev), otherwise tries to download the latest from GitHub releases, then falls back to the committed binary if the download fails (offline, timeout, etc.).
The sidecar version is defined in package.json under displayDjCliVersion. The DISPLAY_DJ_CLI_VERSION env var can override it (used by CI workflow_dispatch).
To update the committed binaries after a version bump, run from the project root:
VERSION=$(node -p "require('./package.json').displayDjCliVersion")
cd src-tauri/binaries
curl -fSL "https://github.com/synle/display-dj-cli/releases/download/${VERSION}/display-dj-macos-arm64" -o display-dj-server-aarch64-apple-darwin
curl -fSL "https://github.com/synle/display-dj-cli/releases/download/${VERSION}/display-dj-macos-x64" -o display-dj-server-x86_64-apple-darwin
curl -fSL "https://github.com/synle/display-dj-cli/releases/download/${VERSION}/display-dj-windows-x64.exe" -o display-dj-server-x86_64-pc-windows-msvc.exe
curl -fSL "https://github.com/synle/display-dj-cli/releases/download/${VERSION}/display-dj-windows-arm64.exe" -o display-dj-server-aarch64-pc-windows-msvc.exe
curl -fSL "https://github.com/synle/display-dj-cli/releases/download/${VERSION}/display-dj-linux-x64" -o display-dj-server-x86_64-unknown-linux-gnu
curl -fSL "https://github.com/synle/display-dj-cli/releases/download/${VERSION}/display-dj-linux-arm64" -o display-dj-server-aarch64-unknown-linux-gnu
chmod 755 display-dj-server-*build.yml: Runs tests and builds on all platforms for every push and PR. On PRs, posts a comment with artifact download links.release-official.yml: Triggered byv*tags or manualworkflow_dispatch. Uses shared release actions fromsynle/workflows/actions/release/with the unified flow:begin-release(prepare) -> Tauri matrix build ->end-release(finalize).begin-releaseresolves the tag, cleans up any existing release, and creates a draft placeholder. The Tauri build uploads assets to the draft.end-releasegenerates changelog (top 10 commits since last tag, diff link, platform support from.github/release-body-static.md) and sets final release flags. Custom notes viarelease_notesinput. SetsTAURI_RELEASE=trueso builds show clean version. Use/release-officialfor interactive triggering.release-beta.yml: Manualworkflow_dispatchonly. Same unified flow (begin-release-> build ->end-release) withmode: beta. Takes optionalsha(defaults to HEAD) andnotesinputs. Creates a draft prerelease taggedrelease-beta-<date>-<sha>. Does not setTAURI_RELEASEso builds show the[beta - <sha>]suffix. Use/release-betafor interactive triggering.
When fetching raw file content from GitHub repos, always use the ?raw=1 blob URL format:
https://github.com/{owner}/{repo}/blob/head/{path}?raw=1
Do NOT use:
https://api.github.com/repos/{owner}/{repo}/contents/{path}(GitHub Contents API)https://raw.githubusercontent.com/{owner}/{repo}/{branch}/{path}
sudo apt install ddcutil brightnessctl i2c-tools
sudo modprobe i2c-dev
sudo usermod -aG i2c $USER- Always use squash and merge when merging PRs. Never use merge commits or rebase merges. This keeps the git history clean with one commit per PR.
- Always rebase before pushing (
git pull --rebasebeforegit push). - You may
git merge origin/mainorgit merge origin/masterlocally to sync branches, but PR merges must always be squash merges.