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>
This commit is contained in:
@@ -1,3 +1,6 @@
|
||||
// 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;
|
||||
|
||||
@@ -26,17 +29,29 @@ async fn find_available_port() -> (TcpListener, u16) {
|
||||
panic!("No available ports in range 3000–3010");
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
// Logging
|
||||
fn main() {
|
||||
tracing_subscriber::fmt().init();
|
||||
|
||||
// Resolve data directory (production vs development mode)
|
||||
config::init_data_dir();
|
||||
|
||||
// Find port and bind (listener kept alive to avoid race condition)
|
||||
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());
|
||||
|
||||
@@ -94,7 +109,6 @@ async fn main() {
|
||||
.layer(cors);
|
||||
|
||||
// Check for embedded frontend (production mode)
|
||||
// Resolve relative to the executable's directory, not the working directory
|
||||
let has_frontend = config::is_production();
|
||||
|
||||
if has_frontend {
|
||||
@@ -116,23 +130,110 @@ async fn main() {
|
||||
}
|
||||
|
||||
// Start server
|
||||
info!("🚀 Ironpad running on http://localhost:{port}");
|
||||
|
||||
// Auto-open browser in production mode
|
||||
if has_frontend {
|
||||
let url = format!("http://localhost:{}", port);
|
||||
tokio::spawn(async move {
|
||||
// Small delay to ensure server is ready
|
||||
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
|
||||
if let Err(e) = webbrowser::open(&url) {
|
||||
tracing::warn!(
|
||||
"Failed to open browser: {}. Open http://localhost:{} manually.",
|
||||
e,
|
||||
port
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user