135 lines
4.1 KiB
Rust
135 lines
4.1 KiB
Rust
use std::net::SocketAddr;
|
||
use std::path::Path;
|
||
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 3000–3010");
|
||
}
|
||
|
||
#[tokio::main]
|
||
async fn main() {
|
||
// Logging
|
||
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)
|
||
let (listener, port) = find_available_port().await;
|
||
|
||
// 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 static_dir = Path::new("static");
|
||
let has_frontend = static_dir.join("index.html").exists();
|
||
|
||
if has_frontend {
|
||
// Production mode: serve frontend from static/ and use SPA fallback
|
||
info!("Production mode: serving frontend from static/");
|
||
let serve_dir = ServeDir::new("static")
|
||
.fallback(tower_http::services::ServeFile::new("static/index.html"));
|
||
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}");
|
||
|
||
// 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
|
||
);
|
||
}
|
||
});
|
||
}
|
||
|
||
axum::serve(listener, app).await.expect("Server failed");
|
||
}
|