Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
375 changes: 373 additions & 2 deletions src-tauri/Cargo.lock

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,16 @@ serde_json = "1"
tokio = { version = "1", features = ["full"] }
notify = "6"
notify-debouncer-mini = "0.4"
# Unduh + verifikasi binary Caddy (reverse proxy Sites). rustls dipilih supaya
# tidak menarik OpenSSL sebagai dependensi build di Windows.
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
sha2 = "0.10"

[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.59", features = ["Win32_System_Diagnostics_Debug"] }
zip = { version = "2", default-features = false, features = ["deflate"] }

[target.'cfg(not(windows))'.dependencies]
flate2 = "1"
tar = "0.4"

89 changes: 86 additions & 3 deletions src-tauri/src/commands/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,18 @@ pub struct Profile {
pub service_ids: Vec<String>,
}

/// Tujuan routing sebuah site di balik reverse proxy.
///
/// Bentuknya sengaja tagged (`{ "kind": "port", "value": 5173 }`) supaya bisa
/// tumbuh tanpa migrasi ulang — mis. varian `docroot` untuk mode Valet penuh,
/// yang di-descope dari v1.5 (lihat keputusan A1 di grooming Phase 12).
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum SiteTarget {
/// Proxy ke port lokal: `domain` → `127.0.0.1:value`.
Port { value: u16 },
}

/// Domain lokal yang dipetakan ke sebuah IP lewat file hosts sistem.
/// `id` di-generate frontend (`crypto.randomUUID`), pola sama `Profile`.
/// Hosts = proyeksi dari `sites` yang `enabled`; config.json = source of truth.
Expand All @@ -26,6 +38,10 @@ pub struct Site {
pub domain: String,
pub ip: String,
pub enabled: bool,
/// `None` = site hosts-only: nama resolve ke `ip`, tapi tak ada yang
/// mem-proxy-kan. Perilaku site v1.4 ke bawah, dan default hasil migrasi.
#[serde(default)]
pub target: Option<SiteTarget>,
}

#[derive(Serialize, Deserialize, Clone, Debug)]
Expand Down Expand Up @@ -70,8 +86,11 @@ fn default_true() -> bool {
/// profil "Default" berisi selection existing, set `active_profile_id`.
/// - v2 → v3: perkenalkan `sites` (fitur hosts M11). Config lama tanpa field
/// ini di-isi `[]` oleh serde default — tak butuh rebuild, cukup bump versi.
/// - v3 → v4: perkenalkan `Site.target` (reverse proxy M12). Site lama tak
/// punya makna proxy, jadi sengaja **tidak** diberi target default — serde
/// mengisinya `None` dan site tetap hosts-only. Cukup bump versi.
///
/// Idempotent: config yang sudah v3 & punya profil valid dibiarkan apa adanya.
/// Idempotent: config yang sudah v4 & punya profil valid dibiarkan apa adanya.
fn migrate(mut cfg: ConfigState) -> ConfigState {
if cfg.version == 0 {
cfg.version = 1;
Expand Down Expand Up @@ -115,7 +134,7 @@ fn project_active_selection(cfg: &mut ConfigState) {
const DEFAULT_PROFILE_ID: &str = "default";

/// Skema config terkini. Naikkan saat ada perubahan struktur yang butuh migrasi.
const CURRENT_VERSION: u32 = 3;
const CURRENT_VERSION: u32 = 4;

impl Default for ConfigState {
fn default() -> Self {
Expand Down Expand Up @@ -311,6 +330,7 @@ mod tests {
domain: "myapp.test".to_string(),
ip: "127.0.0.1".to_string(),
enabled: true,
target: Some(SiteTarget::Port { value: 5173 }),
}],
last_php_version: Some("8.3".to_string()),
last_node_version: Some("20.0.0".to_string()),
Expand Down Expand Up @@ -345,6 +365,10 @@ mod tests {
assert_eq!(restored.sites[0].domain, "myapp.test");
assert_eq!(restored.sites[0].ip, "127.0.0.1");
assert!(restored.sites[0].enabled);
assert_eq!(
restored.sites[0].target,
Some(SiteTarget::Port { value: 5173 })
);
assert_eq!(restored.last_php_version, original.last_php_version);
assert_eq!(restored.last_node_version, original.last_node_version);
assert_eq!(restored.watched_path, original.watched_path);
Expand Down Expand Up @@ -453,6 +477,65 @@ mod tests {
assert_eq!(cfg.profiles[0].service_ids, vec!["mysql"]);
}

#[test]
fn test_migrate_v3_to_v4_keeps_sites_hosts_only() {
// Config v3 (sites sudah ada, belum kenal target) → naik ke v4 tanpa
// memberi target apa pun. Site lama tak punya makna proxy.
let json = r#"{
"version": 3,
"selectedServiceIds": ["mysql"],
"profiles": [{ "id": "default", "name": "Default", "serviceIds": ["mysql"] }],
"activeProfileId": "default",
"sites": [
{ "id": "s-1", "domain": "myapp.test", "ip": "127.0.0.1", "enabled": true },
{ "id": "s-2", "domain": "admin.test", "ip": "127.0.0.1", "enabled": false }
]
}"#;

let cfg: ConfigState = serde_json::from_str(json).expect("parse gagal");
let cfg = migrate(cfg);

assert_eq!(cfg.version, 4);
assert_eq!(cfg.sites.len(), 2);
assert!(cfg.sites.iter().all(|s| s.target.is_none()));
// Sisa isi site tak boleh berubah.
assert_eq!(cfg.sites[0].domain, "myapp.test");
assert!(cfg.sites[0].enabled);
assert!(!cfg.sites[1].enabled);
}

#[test]
fn test_migrate_v4_preserves_existing_target() {
// Config yang sudah v4 tak boleh kehilangan target yang sudah di-set.
let json = r#"{
"version": 4,
"profiles": [{ "id": "default", "name": "Default", "serviceIds": [] }],
"activeProfileId": "default",
"sites": [
{ "id": "s-1", "domain": "myapp.test", "ip": "127.0.0.1", "enabled": true,
"target": { "kind": "port", "value": 5173 } }
]
}"#;

let cfg: ConfigState = serde_json::from_str(json).expect("parse gagal");
let cfg = migrate(cfg);

assert_eq!(cfg.version, 4);
assert_eq!(cfg.sites[0].target, Some(SiteTarget::Port { value: 5173 }));
}

#[test]
fn test_site_target_json_shape_is_tagged() {
// Bentuk on-disk dikunci: { "kind": "port", "value": N }. Varian lain
// (mis. docroot) menyusul tanpa migrasi ulang.
let target = SiteTarget::Port { value: 8000 };
let json = serde_json::to_string(&target).expect("serialize gagal");
assert_eq!(json, r#"{"kind":"port","value":8000}"#);

let back: SiteTarget = serde_json::from_str(&json).expect("deserialize gagal");
assert_eq!(back, target);
}

#[test]
fn test_migrate_v2_idempotent() {
// Config v2 dgn profil custom aktif tidak boleh diubah/ditimpa.
Expand Down Expand Up @@ -516,7 +599,7 @@ mod tests {
let cfg: ConfigState = serde_json::from_str(json).expect("parse gagal");
assert!(cfg.sites.is_empty());
let cfg = migrate(cfg);
assert_eq!(cfg.version, 3);
assert_eq!(cfg.version, CURRENT_VERSION);
assert!(cfg.sites.is_empty());
// Profil existing dipertahankan (migrasi v2→v3 tak menyentuh profiles).
assert_eq!(cfg.profiles.len(), 1);
Expand Down
2 changes: 2 additions & 0 deletions src-tauri/src/commands/hosts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -644,6 +644,8 @@ mod tests {
domain: domain.to_string(),
ip: ip.to_string(),
enabled,
// Hosts tak peduli target proxy — itu urusan lapisan proxy.
target: None,
}
}

Expand Down
19 changes: 18 additions & 1 deletion src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
mod commands;
mod proxy;
mod tray;
mod watcher;

Expand Down Expand Up @@ -62,6 +63,7 @@ pub fn run() {
.manage(watcher::default_watcher_state())
.manage(Mutex::new(HashMap::<String, bool>::new()))
.manage(Mutex::new(ConfigState::default()))
.manage(proxy::lifecycle::ProxyProcess::default())
.invoke_handler(tauri::generate_handler![
commands::prereq::check_prerequisites,
commands::prereq::start_docker,
Expand All @@ -88,6 +90,14 @@ pub fn run() {
commands::hosts::sites_status,
commands::hosts::sites_apply,
commands::hosts::sites_restore,
proxy::binary::proxy_binary_status,
proxy::binary::proxy_binary_install,
proxy::lifecycle::proxy_status,
proxy::lifecycle::proxy_check_ports,
proxy::lifecycle::proxy_start,
proxy::lifecycle::proxy_stop,
proxy::lifecycle::proxy_reload,
proxy::lifecycle::proxy_install_cert,
])
.on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
Expand Down Expand Up @@ -316,9 +326,16 @@ pub fn run() {

// Set flag shutdown saat app exit / Windows session-end → kedua loop polling
// break di awal iterasi berikutnya, hindari spawn docker.exe saat teardown.
app.run(move |_handle, event| {
app.run(move |handle, event| {
if let tauri::RunEvent::ExitRequested { .. } | tauri::RunEvent::Exit = event {
run_shutting_down.store(true, Ordering::Relaxed);

// Caddy adalah child-process yang memegang :80/:443. Tanpa stop
// eksplisit, port bisa nyangkut setelah Servel ditutup.
let handle = handle.clone();
tauri::async_runtime::block_on(async move {
let _ = proxy::lifecycle::stop_process(&handle).await;
});
}
});
}
Loading