Files
IronPad-Docker/backend/src/main.rs
skepsismusic 781ea28097 Release v0.2.0: Task comments, recurring calendar, system tray, app branding
New features:
- Task comments with date-stamped entries and last-comment summary
- Recurring tasks expanded on calendar (daily/weekly/monthly/yearly)
- System tray mode replacing CMD window (Windows/macOS/Linux)
- Ironpad logo as exe icon, tray icon, favicon, and header logo

Technical changes:
- Backend restructured for dual-mode: dev (API-only) / prod (tray + server)
- tray-item crate for cross-platform tray, winresource for icon embedding
- Calendar view refactored with CalendarEntry interface for recurring merging
- Added CHANGELOG.md, build-local.ps1, version bumped to 0.2.0

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-16 13:48:54 +01:00

240 lines
7.6 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Hide console window on Windows in release builds (production mode)
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use std::net::SocketAddr;
use std::sync::Arc;
use axum::{routing::get, Router};
use tokio::net::TcpListener;
use tower_http::cors::CorsLayer;
use tower_http::services::ServeDir;
use tracing::{info, warn};
pub mod config;
mod models;
mod routes;
mod services;
mod watcher;
mod websocket;
/// Find an available port and return the bound listener.
/// Avoids TOCTOU race by keeping the listener alive.
async fn find_available_port() -> (TcpListener, u16) {
for port in 3000..=3010 {
let addr = SocketAddr::from(([127, 0, 0, 1], port));
if let Ok(listener) = TcpListener::bind(addr).await {
return (listener, port);
}
}
panic!("No available ports in range 30003010");
}
fn main() {
tracing_subscriber::fmt().init();
config::init_data_dir();
if config::is_production() {
run_with_tray();
} else {
// Development mode: normal tokio runtime, no tray
let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime");
rt.block_on(run_server(None));
}
}
/// Start the Axum server. In tray mode, sends the bound port through `port_tx`
/// before entering the serve loop.
async fn run_server(port_tx: Option<std::sync::mpsc::Sender<u16>>) {
let (listener, port) = find_available_port().await;
// Notify tray thread of the bound port
if let Some(tx) = port_tx {
let _ = tx.send(port);
}
// WebSocket state (shared across handlers)
let ws_state = Arc::new(websocket::WsState::new());
// Start file watcher
let ws_state_clone = ws_state.clone();
if let Err(e) = watcher::start_watcher(ws_state_clone).await {
warn!("File watcher failed to start: {}", e);
}
// Initialize git repo if needed
if let Err(e) = services::git::init_repo() {
warn!("Git init skipped: {}", e);
}
// Start auto-commit background task (tries to commit every 60s)
services::git::start_auto_commit();
// CORS layer (permissive for local-only app)
let cors = CorsLayer::permissive();
// API router
let api_router = Router::new()
// Notes CRUD
.route(
"/notes",
get(routes::notes::list_notes).post(routes::notes::create_note),
)
.nest("/notes", routes::notes::router())
// Tasks
.nest("/tasks", routes::tasks::router())
// Search
.nest("/search", routes::search::router())
// Git
.nest("/git", routes::git::router())
// Projects
.nest("/projects", routes::projects::router())
// Daily notes
.nest("/daily", routes::daily::router())
// Assets
.nest("/assets", routes::assets::router());
// App router with WebSocket state
let mut app = Router::new()
.route("/health", get(|| async { "ok" }))
.route(
"/ws",
get({
let ws = ws_state.clone();
move |upgrade: axum::extract::WebSocketUpgrade| {
websocket::ws_handler(upgrade, axum::extract::State(ws))
}
}),
)
.nest("/api", api_router)
.layer(cors);
// Check for embedded frontend (production mode)
let has_frontend = config::is_production();
if has_frontend {
// Production mode: serve frontend from static/ next to the exe
let static_path = config::exe_dir().join("static");
let index_path = static_path.join("index.html");
info!(
"Production mode: serving frontend from {}",
static_path.display()
);
let serve_dir =
ServeDir::new(&static_path).fallback(tower_http::services::ServeFile::new(index_path));
app = app.fallback_service(serve_dir);
} else {
// Development mode: API-only
app = app.fallback(|| async {
"Ironpad API server running. Use 'npm run dev' in frontend/ for the GUI."
});
}
// Start server
info!("Ironpad running on http://localhost:{port}");
axum::serve(listener, app).await.expect("Server failed");
}
// ---------------------------------------------------------------------------
// System tray (production mode)
// ---------------------------------------------------------------------------
/// Build a platform-appropriate tray icon.
///
/// On Windows the Ironpad icon is embedded in the .exe via winresource (build.rs).
/// We load it with LoadIconW using the resource ID assigned by winresource.
#[cfg(target_os = "windows")]
fn tray_icon() -> tray_item::IconSource {
let hicon = unsafe {
// winresource embeds the icon at resource ID 1.
// GetModuleHandleW(null) = current exe, MAKEINTRESOURCE(1) = 1 as PCWSTR.
let hinstance =
windows_sys::Win32::System::LibraryLoader::GetModuleHandleW(std::ptr::null());
windows_sys::Win32::UI::WindowsAndMessaging::LoadIconW(hinstance, 1 as *const u16)
};
tray_item::IconSource::RawIcon(hicon)
}
#[cfg(target_os = "macos")]
fn tray_icon() -> tray_item::IconSource {
tray_item::IconSource::Resource("")
}
#[cfg(target_os = "linux")]
fn tray_icon() -> tray_item::IconSource {
tray_item::IconSource::Resource("application-x-executable")
}
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
fn tray_icon() -> tray_item::IconSource {
tray_item::IconSource::Resource("")
}
/// Production mode: run the server on a background thread, tray on main thread.
/// The main thread drives the tray event loop (required on macOS; safe everywhere).
fn run_with_tray() {
use std::sync::mpsc;
enum TrayMessage {
OpenBrowser,
Quit,
}
// Channel to receive the dynamically-bound port from the server thread
let (port_tx, port_rx) = mpsc::channel::<u16>();
// Start the Axum server on a background thread with its own tokio runtime
std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime");
rt.block_on(run_server(Some(port_tx)));
});
// Wait for the server to report its port
let port = port_rx.recv().expect("Server failed to start");
let url = format!("http://localhost:{}", port);
// Auto-open browser after a short delay (non-blocking)
let url_for_open = url.clone();
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(400));
let _ = webbrowser::open(&url_for_open);
});
// Set up system tray icon and menu
let (tx, rx) = mpsc::sync_channel::<TrayMessage>(2);
let mut tray = match tray_item::TrayItem::new("Ironpad", tray_icon()) {
Ok(t) => t,
Err(e) => {
eprintln!("Failed to create system tray: {}. Running headless.", e);
// Keep the process alive so the server thread continues
loop {
std::thread::park();
}
}
};
let tx_open = tx.clone();
let _ = tray.add_menu_item("Open in Browser", move || {
let _ = tx_open.send(TrayMessage::OpenBrowser);
});
let tx_quit = tx;
let _ = tray.add_menu_item("Quit", move || {
let _ = tx_quit.send(TrayMessage::Quit);
});
// Main-thread event loop — processes tray menu actions
for msg in rx {
match msg {
TrayMessage::OpenBrowser => {
let _ = webbrowser::open(&url);
}
TrayMessage::Quit => {
info!("Quit requested from system tray");
std::process::exit(0);
}
}
}
}