Add gateway root adoption and mobile polish

- Add Tauri commands to inspect and adopt the gateway repo root
- Retry locked vault commands by prompting for unlock
- Improve mobile layout, editor mode toggles, and settings UI
This commit is contained in:
stan44 2026-03-30 00:00:25 -05:00
parent 4fb83a003e
commit 7562cf6fad
29 changed files with 1758 additions and 252 deletions

4
.gitignore vendored
View File

@ -57,4 +57,6 @@ Journal.DevTool/scripts/__pycache__/
.sdt/ .sdt/
devtool.backup.json devtool.backup.json
Journal.App/node_modules.old/ Journal.App/node_modules.old/
scripts/__pycache__/ scripts/__pycache__/
Journal.WebGateway/cookies.txt
output.7z

View File

@ -530,6 +530,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]] [[package]]
name = "chrono" name = "chrono"
version = "0.4.44" version = "0.4.44"
@ -1402,8 +1408,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"wasi 0.11.1+wasi-snapshot-preview1", "wasi 0.11.1+wasi-snapshot-preview1",
"wasm-bindgen",
] ]
[[package]] [[package]]
@ -1413,9 +1421,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"r-efi", "r-efi",
"wasip2", "wasip2",
"wasm-bindgen",
] ]
[[package]] [[package]]
@ -1702,6 +1712,23 @@ dependencies = [
"want", "want",
] ]
[[package]]
name = "hyper-rustls"
version = "0.27.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
dependencies = [
"http",
"hyper",
"hyper-util",
"rustls",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots",
]
[[package]] [[package]]
name = "hyper-util" name = "hyper-util"
version = "0.1.20" version = "0.1.20"
@ -2020,6 +2047,7 @@ dependencies = [
name = "journalapp" name = "journalapp"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"reqwest 0.12.28",
"serde", "serde",
"serde_json", "serde_json",
"tauri", "tauri",
@ -2184,6 +2212,12 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]] [[package]]
name = "mac" name = "mac"
version = "0.1.1" version = "0.1.1"
@ -3083,6 +3117,61 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "quinn"
version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"thiserror 2.0.18",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
dependencies = [
"bytes",
"getrandom 0.3.4",
"lru-slab",
"rand 0.9.2",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.18",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"tracing",
"windows-sys 0.60.2",
]
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.44" version = "1.0.44"
@ -3123,6 +3212,16 @@ dependencies = [
"rand_core 0.6.4", "rand_core 0.6.4",
] ]
[[package]]
name = "rand"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
dependencies = [
"rand_chacha 0.9.0",
"rand_core 0.9.5",
]
[[package]] [[package]]
name = "rand_chacha" name = "rand_chacha"
version = "0.2.2" version = "0.2.2"
@ -3143,6 +3242,16 @@ dependencies = [
"rand_core 0.6.4", "rand_core 0.6.4",
] ]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.5",
]
[[package]] [[package]]
name = "rand_core" name = "rand_core"
version = "0.5.1" version = "0.5.1"
@ -3161,6 +3270,15 @@ dependencies = [
"getrandom 0.2.17", "getrandom 0.2.17",
] ]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
[[package]] [[package]]
name = "rand_hc" name = "rand_hc"
version = "0.2.0" version = "0.2.0"
@ -3254,6 +3372,44 @@ version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "reqwest"
version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"futures-core",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"webpki-roots",
]
[[package]] [[package]]
name = "reqwest" name = "reqwest"
version = "0.13.2" version = "0.13.2"
@ -3312,6 +3468,20 @@ dependencies = [
"windows-sys 0.60.2", "windows-sys 0.60.2",
] ]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]] [[package]]
name = "rustc-hash" name = "rustc-hash"
version = "2.1.1" version = "2.1.1"
@ -3340,12 +3510,53 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "rustls"
version = "0.23.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
dependencies = [
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
dependencies = [
"web-time",
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]] [[package]]
name = "rustversion" name = "rustversion"
version = "1.0.22" version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]] [[package]]
name = "same-file" name = "same-file"
version = "1.0.6" version = "1.0.6"
@ -3535,6 +3746,18 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
"form_urlencoded",
"itoa",
"ryu",
"serde",
]
[[package]] [[package]]
name = "serde_with" name = "serde_with"
version = "3.17.0" version = "3.17.0"
@ -3750,6 +3973,12 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]] [[package]]
name = "swift-rs" name = "swift-rs"
version = "1.0.7" version = "1.0.7"
@ -3903,7 +4132,7 @@ dependencies = [
"percent-encoding", "percent-encoding",
"plist", "plist",
"raw-window-handle", "raw-window-handle",
"reqwest", "reqwest 0.13.2",
"serde", "serde",
"serde_json", "serde_json",
"serde_repr", "serde_repr",
@ -4288,6 +4517,21 @@ dependencies = [
"zerovec", "zerovec",
] ]
[[package]]
name = "tinyvec"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
dependencies = [
"tinyvec_macros",
]
[[package]]
name = "tinyvec_macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]] [[package]]
name = "tokio" name = "tokio"
version = "1.49.0" version = "1.49.0"
@ -4303,6 +4547,16 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls",
"tokio",
]
[[package]] [[package]]
name = "tokio-util" name = "tokio-util"
version = "0.7.18" version = "0.7.18"
@ -4598,6 +4852,12 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]] [[package]]
name = "url" name = "url"
version = "2.5.8" version = "2.5.8"
@ -4850,6 +5110,16 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]] [[package]]
name = "webkit2gtk" name = "webkit2gtk"
version = "2.0.2" version = "2.0.2"
@ -4894,6 +5164,15 @@ dependencies = [
"system-deps", "system-deps",
] ]
[[package]]
name = "webpki-roots"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed"
dependencies = [
"rustls-pki-types",
]
[[package]] [[package]]
name = "webview2-com" name = "webview2-com"
version = "0.38.2" version = "0.38.2"
@ -5153,6 +5432,15 @@ dependencies = [
"windows-targets 0.42.2", "windows-targets 0.42.2",
] ]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets 0.52.6",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.59.0" version = "0.59.0"
@ -5697,6 +5985,12 @@ dependencies = [
"synstructure", "synstructure",
] ]
[[package]]
name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
[[package]] [[package]]
name = "zerotrie" name = "zerotrie"
version = "0.2.3" version = "0.2.3"

View File

@ -25,3 +25,4 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
tokio = { version = "1", features = ["process", "io-util", "sync", "time"] } tokio = { version = "1", features = ["process", "io-util", "sync", "time"] }
tauri-plugin-mic-recorder = "2.0.0" tauri-plugin-mic-recorder = "2.0.0"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }

View File

@ -29,7 +29,6 @@ struct CommandEnvelope {
const DEFAULT_SETTINGS_TAGS: &[&str] = &["Personal", "Work", "Ideas", "Journal"]; const DEFAULT_SETTINGS_TAGS: &[&str] = &["Personal", "Work", "Ideas", "Journal"];
const DEFAULT_FRAGMENT_TYPES: &[&str] = &["Quote", "Snippet", "Reference"]; const DEFAULT_FRAGMENT_TYPES: &[&str] = &["Quote", "Snippet", "Reference"];
const DEFAULT_STARTUP_VIEW: &str = "entries"; const DEFAULT_STARTUP_VIEW: &str = "entries";
#[derive(Deserialize, Serialize)] #[derive(Deserialize, Serialize)]
struct AppSettings { struct AppSettings {
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
@ -213,17 +212,77 @@ fn save_settings(path: &Path, settings: &AppSettings) -> Result<(), String> {
fs::write(path, json).map_err(|e| format!("Failed to save settings: {e}")) fs::write(path, json).map_err(|e| format!("Failed to save settings: {e}"))
} }
fn auto_detect_root() -> Result<PathBuf, String> { fn candidate_roots(root: &Path) -> Vec<PathBuf> {
let mut current = let mut candidates = Vec::new();
env::current_dir().map_err(|err| format!("Unable to read current dir: {err}"))?; let push_unique = |value: PathBuf, items: &mut Vec<PathBuf>| {
loop { if !items.iter().any(|existing| existing == &value) {
if current.join("Journal.Sidecar").exists() { items.push(value);
return Ok(current);
} }
if !current.pop() { };
return Err("Unable to locate repository root containing Journal.Sidecar.".to_string());
push_unique(root.to_path_buf(), &mut candidates);
if let Some(name) = root.file_name().and_then(|v| v.to_str()) {
if name.eq_ignore_ascii_case("output") {
if let Some(parent) = root.parent() {
push_unique(parent.to_path_buf(), &mut candidates);
}
} else if name.eq_ignore_ascii_case("webgateway") {
if let Some(parent) = root.parent() {
push_unique(parent.to_path_buf(), &mut candidates);
}
if let Some(parent) = root.parent().and_then(|v| v.parent()) {
push_unique(parent.to_path_buf(), &mut candidates);
}
} }
} }
candidates
}
fn detect_root_from(start: &Path) -> Option<PathBuf> {
let mut current = start.to_path_buf();
loop {
let has_repo_markers = current.join("Journal.Sidecar").exists()
|| current.join("Journal.Core").exists()
|| current.join("Journal.slnx").exists()
|| current.join("Journal.Sidecar.exe").exists()
|| current.join("Journal.Sidecar").is_file();
if has_repo_markers {
return Some(current);
}
if !current.pop() {
return None;
}
}
}
fn auto_detect_root() -> Result<PathBuf, String> {
if let Some(env_root) = env::var_os("JOURNAL_PROJECT_ROOT") {
let env_path = PathBuf::from(env_root);
if env_path.exists() {
return Ok(env_path);
}
}
let mut starts = Vec::new();
if let Ok(current) = env::current_dir() {
starts.push(current);
}
if let Ok(exe) = env::current_exe() {
if let Some(parent) = exe.parent() {
starts.push(parent.to_path_buf());
}
}
for start in starts {
if let Some(root) = detect_root_from(&start) {
return Ok(root);
}
}
Err("Unable to locate repository root containing Journal.Sidecar.".to_string())
} }
fn effective_root(root_override: &Option<PathBuf>) -> Result<PathBuf, String> { fn effective_root(root_override: &Option<PathBuf>) -> Result<PathBuf, String> {
@ -243,9 +302,9 @@ fn resolve_sidecar_path(root: &Path, resource_dir: Option<&Path>) -> Result<Path
return Ok(root.to_path_buf()); return Ok(root.to_path_buf());
} }
let root_exe_path = root.join(exe_name); let direct = root.join(exe_name);
if root_exe_path.exists() { if direct.exists() {
return Ok(root_exe_path); return Ok(direct);
} }
let tauri_bin_sidecar_path = root let tauri_bin_sidecar_path = root
@ -324,6 +383,37 @@ fn find_sidecar_executable(search_root: &Path, exe_name: &str) -> Option<PathBuf
None None
} }
fn resolve_gateway_appsettings_path(root: &Path) -> Result<PathBuf, String> {
for candidate_root in candidate_roots(root) {
let candidates = [
candidate_root.join("webgateway").join("appsettings.json"),
candidate_root.join("output").join("webgateway").join("appsettings.json"),
candidate_root.join("Journal.WebGateway").join("appsettings.json"),
];
for candidate in candidates {
if candidate.exists() {
return Ok(candidate);
}
}
}
Err(format!(
"Gateway appsettings.json not found near {}.",
root.display()
))
}
fn read_gateway_repo_root(config_path: &Path) -> Option<String> {
let json = fs::read_to_string(config_path).ok()?;
let value = serde_json::from_str::<Value>(&json).ok()?;
value
.get("GatewaySettings")
.and_then(|section| section.get("RepoRoot"))
.and_then(|node| node.as_str())
.map(|value| value.to_string())
}
async fn send_with_managed_sidecar( async fn send_with_managed_sidecar(
state: &SidecarState, state: &SidecarState,
input_line: &str, input_line: &str,
@ -417,6 +507,64 @@ async fn get_sidecar_root(state: tauri::State<'_, SidecarState>) -> Result<Value
})) }))
} }
#[tauri::command]
async fn get_gateway_root_status(
state: tauri::State<'_, SidecarState>,
) -> Result<Value, String> {
let root_override = state.root_override.lock().await.clone();
let root = effective_root(&root_override)?;
let config_path = resolve_gateway_appsettings_path(&root)?;
let configured_root = read_gateway_repo_root(&config_path);
let authoritative_root = root.to_string_lossy().into_owned();
Ok(serde_json::json!({
"authoritativeRoot": authoritative_root,
"gatewayConfigPath": config_path.to_string_lossy(),
"configuredRoot": configured_root,
"needsAdoption": configured_root.as_deref() != Some(root.to_string_lossy().as_ref())
}))
}
#[tauri::command]
async fn adopt_sidecar_root_for_gateway(
state: tauri::State<'_, SidecarState>,
) -> Result<Value, String> {
let root_override = state.root_override.lock().await.clone();
let root = effective_root(&root_override)?;
let config_path = resolve_gateway_appsettings_path(&root)?;
let previous_root = read_gateway_repo_root(&config_path);
let json = fs::read_to_string(&config_path)
.map_err(|err| format!("Failed to read gateway appsettings: {err}"))?;
let mut value = serde_json::from_str::<Value>(&json)
.map_err(|err| format!("Invalid gateway appsettings JSON: {err}"))?;
let root_value = Value::String(root.to_string_lossy().into_owned());
let object = value
.as_object_mut()
.ok_or_else(|| "Gateway appsettings root must be a JSON object.".to_string())?;
let gateway_settings = object
.entry("GatewaySettings")
.or_insert_with(|| Value::Object(serde_json::Map::new()));
let settings_object = gateway_settings
.as_object_mut()
.ok_or_else(|| "GatewaySettings must be a JSON object.".to_string())?;
settings_object.insert("RepoRoot".to_string(), root_value);
let updated = serde_json::to_string_pretty(&value)
.map_err(|err| format!("Failed to serialize gateway appsettings: {err}"))?;
fs::write(&config_path, updated)
.map_err(|err| format!("Failed to write gateway appsettings: {err}"))?;
Ok(serde_json::json!({
"authoritativeRoot": root.to_string_lossy(),
"gatewayConfigPath": config_path.to_string_lossy(),
"previousRoot": previous_root,
"configuredRoot": root.to_string_lossy(),
"needsAdoption": false
}))
}
#[tauri::command] #[tauri::command]
async fn set_sidecar_root( async fn set_sidecar_root(
state: tauri::State<'_, SidecarState>, state: tauri::State<'_, SidecarState>,
@ -442,7 +590,6 @@ async fn set_sidecar_root(
let mut guard = state.process.lock().await; let mut guard = state.process.lock().await;
guard.take(); guard.take();
} }
let is_custom = new_override.is_some(); let is_custom = new_override.is_some();
*state.root_override.lock().await = new_override.clone(); *state.root_override.lock().await = new_override.clone();
@ -670,6 +817,8 @@ pub fn run() {
speech_stop, speech_stop,
speech_cleanup_probe, speech_cleanup_probe,
get_sidecar_root, get_sidecar_root,
get_gateway_root_status,
adopt_sidecar_root_for_gateway,
set_sidecar_root, set_sidecar_root,
get_ui_settings, get_ui_settings,
set_ui_settings, set_ui_settings,
@ -679,7 +828,7 @@ pub fn run() {
fs::create_dir_all(&config_dir).ok(); fs::create_dir_all(&config_dir).ok();
let config_path = config_dir.join("settings.json"); let config_path = config_dir.join("settings.json");
let settings = load_settings(&config_path); let settings = load_settings(&config_path);
let root_override = settings.sidecar_root.map(PathBuf::from); let root_override = settings.sidecar_root.as_ref().map(PathBuf::from);
app.manage(SidecarState { app.manage(SidecarState {
process: Mutex::new(None), process: Mutex::new(None),

View File

@ -7,8 +7,11 @@
rel="stylesheet" rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200"
/> />
<link rel="stylesheet" href="style.css" /> <link rel="stylesheet" href="style.css?v=20260328b" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta
name="viewport"
content="width=device-width, initial-scale=1, viewport-fit=cover"
/>
<title>Journal</title> <title>Journal</title>
%sveltekit.head% %sveltekit.head%
</head> </head>

View File

@ -1,4 +1,8 @@
import { invoke } from "$lib/runtime/invoke"; import { invoke } from "$lib/runtime/invoke";
import {
clearVaultSession,
requestVaultUnlock,
} from "$lib/stores/session";
import type { BackendCommand, BackendResponse } from "./types"; import type { BackendCommand, BackendResponse } from "./types";
function newCorrelationId(): string { function newCorrelationId(): string {
@ -7,8 +11,13 @@ function newCorrelationId(): string {
type SendCommandOptions = { type SendCommandOptions = {
keepalive?: boolean; keepalive?: boolean;
unlockRetryAttempted?: boolean;
}; };
function isDatabaseLockedError(message: string): boolean {
return message.toLowerCase().includes("database is locked");
}
export async function sendCommand<T>( export async function sendCommand<T>(
command: BackendCommand, command: BackendCommand,
options: SendCommandOptions = {}, options: SendCommandOptions = {},
@ -23,7 +32,22 @@ export async function sendCommand<T>(
}); });
if (!response.ok) { if (!response.ok) {
throw new Error(response.error || "Backend command failed"); const errorMessage = response.error || "Backend command failed";
if (
!options.unlockRetryAttempted &&
isDatabaseLockedError(errorMessage)
) {
clearVaultSession();
const unlocked = await requestVaultUnlock();
if (unlocked) {
return sendCommand<T>(command, {
...options,
unlockRetryAttempted: true,
});
}
}
throw new Error(errorMessage);
} }
return response.data; return response.data;

View File

@ -162,4 +162,29 @@
background: var(--surface-3); background: var(--surface-3);
color: var(--text-primary); color: var(--text-primary);
} }
@media (max-width: 820px) {
.modal-backdrop {
align-items: end;
padding: 12px 12px calc(12px + env(safe-area-inset-bottom, 0px));
}
.modal {
width: 100%;
max-width: none;
border-radius: 16px;
padding: 16px;
gap: 12px;
}
.modal-actions {
flex-direction: column-reverse;
align-items: stretch;
}
.modal-actions button {
width: 100%;
min-height: 42px;
}
}
</style> </style>

View File

@ -35,6 +35,8 @@
export let calendarError = ""; export let calendarError = "";
export let previewOnly = true; export let previewOnly = true;
export let onForceSave: () => Promise<void> | void = () => {}; export let onForceSave: () => Promise<void> | void = () => {};
export let onRequestEdit: () => void = () => {};
export let onRequestPreview: () => void = () => {};
type CalendarCard = { type CalendarCard = {
id: string; id: string;
@ -203,6 +205,8 @@
{onForceSave} {onForceSave}
{onOpenDocument} {onOpenDocument}
{previewOnly} {previewOnly}
{onRequestEdit}
{onRequestPreview}
/> />
{/if} {/if}
</main> </main>
@ -377,4 +381,28 @@
border-color: color-mix(in srgb, #f2c266 40%, var(--border-soft) 60%); border-color: color-mix(in srgb, #f2c266 40%, var(--border-soft) 60%);
color: #f4d690; color: #f4d690;
} }
@media (max-width: 820px) {
.editor-panel {
padding: 12px 12px calc(18px + env(safe-area-inset-bottom, 0px));
gap: 12px;
}
.calendar-main {
padding: 2px 0 12px;
}
.calendar-item-head {
flex-direction: column;
align-items: flex-start;
}
.calendar-item-head h3 {
white-space: normal;
}
.calendar-date {
white-space: normal;
}
}
</style> </style>

View File

@ -191,4 +191,44 @@
height: 40px; height: 40px;
} }
} }
@media (max-width: 820px) {
.navbar {
gap: 10px;
padding: calc(10px + env(safe-area-inset-top, 0px)) 6px 10px;
}
.navbar-header {
display: block;
}
.nav-groups {
width: 100%;
}
.nav-group {
flex-direction: column;
gap: 2px;
}
.nav-button,
.settings-chip {
width: 38px;
height: 38px;
border-radius: 9px;
}
.settings-chip {
margin-top: auto;
}
.nav-tooltip {
display: none;
}
.nav-button .material-symbols-outlined,
.settings-chip .material-symbols-outlined {
font-size: 1.08rem;
}
}
</style> </style>

View File

@ -1818,7 +1818,7 @@
} }
} }
@media (max-width: 980px) { @media (max-width: 1100px) {
.side-panel { .side-panel {
padding: 12px 10px; padding: 12px 10px;
} }
@ -1833,7 +1833,31 @@
} }
} }
@media (max-width: 720px) { @media (max-width: 820px) {
.side-panel {
padding: 12px 12px calc(14px + env(safe-area-inset-bottom, 0px));
gap: 10px;
overflow: auto;
border-right: 0;
}
.panel-header {
position: sticky;
top: 0;
z-index: 5;
padding-bottom: 6px;
background: linear-gradient(
180deg,
color-mix(in srgb, var(--surface-2) 96%, transparent 4%) 0%,
color-mix(in srgb, var(--surface-2) 80%, transparent 20%) 100%
);
backdrop-filter: blur(8px);
}
.panel-list {
padding-bottom: 6px;
}
.calendar-control-row { .calendar-control-row {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }

View File

@ -29,6 +29,8 @@
export let openDocumentContent = ""; export let openDocumentContent = "";
export let onDocumentContentChange: (content: string) => void = () => {}; export let onDocumentContentChange: (content: string) => void = () => {};
export let onForceSave: () => Promise<void> | void = () => {}; export let onForceSave: () => Promise<void> | void = () => {};
export let onRequestEdit: () => void = () => {};
export let onRequestPreview: () => void = () => {};
export let onOpenDocument: (doc: { export let onOpenDocument: (doc: {
id: string; id: string;
label: string; label: string;
@ -677,6 +679,33 @@
<header class="editor-header"> <header class="editor-header">
<h1>{editorTitle}</h1> <h1>{editorTitle}</h1>
{#if openDocumentId}
<div class="editor-header-actions">
{#if previewOnly}
<button
type="button"
class="editor-mode-btn"
on:click={onRequestEdit}
aria-label="Edit document"
>
<span class="material-symbols-outlined" aria-hidden="true">edit</span>
<span>Edit</span>
</button>
{:else}
<button
type="button"
class="editor-mode-btn"
on:click={onRequestPreview}
aria-label="Preview document"
>
<span class="material-symbols-outlined" aria-hidden="true"
>visibility</span
>
<span>Preview</span>
</button>
{/if}
</div>
{/if}
</header> </header>
<section class="editor-surface" class:preview-only={previewOnly}> <section class="editor-surface" class:preview-only={previewOnly}>
@ -862,6 +891,34 @@
color: var(--text-primary); color: var(--text-primary);
} }
.editor-header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.editor-mode-btn {
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid var(--border-soft);
border-radius: 8px;
background: color-mix(in srgb, var(--surface-1) 92%, var(--bg-editor) 8%);
color: var(--text-primary);
padding: 7px 10px;
font-size: 0.8rem;
cursor: pointer;
}
.editor-mode-btn:hover {
background: var(--bg-hover);
border-color: var(--border-strong);
}
.editor-mode-btn .material-symbols-outlined {
font-size: 1rem;
}
.editor-surface { .editor-surface {
min-height: 0; min-height: 0;
flex: 1; flex: 1;
@ -1109,6 +1166,14 @@
} }
@media (max-width: 980px) { @media (max-width: 980px) {
.editor-header {
align-items: flex-start;
}
.editor-header-actions {
flex-shrink: 0;
}
.editor-workspace { .editor-workspace {
padding: 4px 8px 10px; padding: 4px 8px 10px;
} }
@ -1131,4 +1196,14 @@
font-size: 0.89rem; font-size: 0.89rem;
} }
} }
@media (max-width: 820px) {
.editor-mode-btn span:last-child {
display: none;
}
.editor-mode-btn {
padding: 7px;
}
}
</style> </style>

View File

@ -88,8 +88,9 @@ async function fetchJson<T>(
path: string, path: string,
init: RequestInit = {}, init: RequestInit = {},
options: FetchJsonOptions = {}, options: FetchJsonOptions = {},
apiBase?: string,
): Promise<T> { ): Promise<T> {
const response = await fetch(`${normalizedApiBase()}${path}`, { const response = await fetch(`${apiBase ?? normalizedApiBase()}${path}`, {
...init, ...init,
keepalive: options.keepalive === true, keepalive: options.keepalive === true,
headers: { headers: {

View File

@ -2,6 +2,7 @@ import { writable, get } from "svelte/store";
const _password = writable<string | null>(null); const _password = writable<string | null>(null);
const _unlocked = writable(false); const _unlocked = writable(false);
let _unlockHandler: (() => Promise<boolean>) | null = null;
export const vaultUnlocked = { subscribe: _unlocked.subscribe }; export const vaultUnlocked = { subscribe: _unlocked.subscribe };
@ -23,6 +24,20 @@ export function clearVaultSession(): void {
_unlocked.set(false); _unlocked.set(false);
} }
export function setUnlockHandler(
fn: (() => Promise<boolean>) | null,
): void {
_unlockHandler = fn;
}
export async function requestVaultUnlock(): Promise<boolean> {
if (!_unlockHandler) {
return false;
}
return _unlockHandler();
}
let _flushCallback: (() => Promise<void>) | null = null; let _flushCallback: (() => Promise<void>) | null = null;
export function setFlushCallback(fn: () => Promise<void>): void { export function setFlushCallback(fn: () => Promise<void>): void {

View File

@ -31,8 +31,10 @@
type StartupView, type StartupView,
} from "$lib/stores/settings"; } from "$lib/stores/settings";
import { import {
clearVaultSession,
isVaultReady, isVaultReady,
setFlushCallback, setFlushCallback,
setUnlockHandler,
setVaultSession, setVaultSession,
} from "$lib/stores/session"; } from "$lib/stores/session";
import { deleteTodoListByStoreId, hydrateTodos } from "$lib/stores/todos"; import { deleteTodoListByStoreId, hydrateTodos } from "$lib/stores/todos";
@ -64,6 +66,7 @@
let selectedSection = "entries"; let selectedSection = "entries";
let panelOpen = true; let panelOpen = true;
let isPhoneLayout = false;
let editMode = false; let editMode = false;
let activeDocumentId = initialEntry?.id ?? "entries/daily-notes"; let activeDocumentId = initialEntry?.id ?? "entries/daily-notes";
let activeDocumentLabel = initialEntry?.label ?? "Daily Notes"; let activeDocumentLabel = initialEntry?.label ?? "Daily Notes";
@ -320,6 +323,31 @@
); );
} }
function updateResponsiveState() {
if (typeof window === "undefined") return;
isPhoneLayout = window.innerWidth <= 820;
}
async function ensureVaultUnlocked(maxAttempts = 3): Promise<boolean> {
let attempts = 0;
while (attempts < maxAttempts) {
try {
const password = await requestVaultPassword();
if (!password) return false;
await unlockVaultWorkspace(password);
setVaultSession(password);
return true;
} catch (error) {
clearVaultSession();
if (!isLockedError(error)) throw error;
attempts += 1;
}
}
return false;
}
async function bootstrapFragmentsWithUnlock(maxAttempts = 3) { async function bootstrapFragmentsWithUnlock(maxAttempts = 3) {
if (fragmentBootstrapInFlight) return; if (fragmentBootstrapInFlight) return;
@ -342,31 +370,20 @@
fragmentBootstrapInFlight = true; fragmentBootstrapInFlight = true;
try { try {
let attempts = 0; const unlocked = await ensureVaultUnlocked(maxAttempts);
while (attempts < maxAttempts) { if (!unlocked) return;
try {
const password = await requestVaultPassword();
if (!password) return;
await unlockVaultWorkspace(password); await hydrateEntries();
setVaultSession(password); templateRefreshToken += 1;
const firstEntry = getDefaultEntry(get(entriesStore));
await hydrateEntries(); if (firstEntry && activeDocumentId === "entries/daily-notes") {
templateRefreshToken += 1; await handleOpenDocument(firstEntry);
const firstEntry = getDefaultEntry(get(entriesStore));
if (firstEntry && activeDocumentId === "entries/daily-notes") {
await handleOpenDocument(firstEntry);
}
await hydrateFragments();
await hydrateLists().catch(() => {});
await hydrateTodos().catch(() => {});
return;
} catch (error) {
if (!isLockedError(error)) return;
attempts += 1;
}
} }
await hydrateFragments();
await hydrateLists().catch(() => {});
await hydrateTodos().catch(() => {});
return;
} finally { } finally {
fragmentBootstrapInFlight = false; fragmentBootstrapInFlight = false;
} }
@ -574,6 +591,9 @@
pruneDocumentCache([resolvedDoc.id]); pruneDocumentCache([resolvedDoc.id]);
activeDocumentId = resolvedDoc.id; activeDocumentId = resolvedDoc.id;
activeDocumentLabel = resolvedDoc.label; activeDocumentLabel = resolvedDoc.label;
if (isPhoneLayout) {
panelOpen = false;
}
} }
async function handleLinkedBack() { async function handleLinkedBack() {
@ -599,6 +619,15 @@
await saveCurrentDocument(); await saveCurrentDocument();
} }
function handleEnterEditMode() {
if (!activeDocumentId) return;
editMode = true;
}
function handleExitEditMode() {
editMode = false;
}
function handleDeleteDocument(id: string) { function handleDeleteDocument(id: string) {
const { [id]: _, ...remaining } = openDocuments; const { [id]: _, ...remaining } = openDocuments;
openDocuments = remaining; openDocuments = remaining;
@ -664,7 +693,17 @@
} }
onMount(() => { onMount(() => {
updateResponsiveState();
const handleResize = () => {
const wasPhoneLayout = isPhoneLayout;
updateResponsiveState();
if (!wasPhoneLayout && isPhoneLayout && activeDocumentId) {
panelOpen = false;
}
};
window.addEventListener("resize", handleResize);
setFlushCallback(saveCurrentDocument); setFlushCallback(saveCurrentDocument);
setUnlockHandler(() => ensureVaultUnlocked());
void (async () => { void (async () => {
await hydrateUiSettings(); await hydrateUiSettings();
const startupSection = resolveStartupSection( const startupSection = resolveStartupSection(
@ -675,7 +714,15 @@
); );
applyStartupSection(sectionFromQuery ?? startupSection); applyStartupSection(sectionFromQuery ?? startupSection);
await bootstrapFragmentsWithUnlock(); await bootstrapFragmentsWithUnlock();
if (isPhoneLayout && activeDocumentId) {
panelOpen = false;
}
})(); })();
return () => {
window.removeEventListener("resize", handleResize);
setUnlockHandler(null);
};
}); });
</script> </script>
@ -709,6 +756,8 @@
calendarError={calendarPanelState.error} calendarError={calendarPanelState.error}
previewOnly={!editMode} previewOnly={!editMode}
onForceSave={handleForceSave} onForceSave={handleForceSave}
onRequestEdit={handleEnterEditMode}
onRequestPreview={handleExitEditMode}
/> />
</div> </div>

View File

@ -38,6 +38,14 @@
let sidecarRootIsCustom = false; let sidecarRootIsCustom = false;
let sidecarRootError = ""; let sidecarRootError = "";
let sidecarBrowseBusy = false; let sidecarBrowseBusy = false;
let gatewayRootBusy = false;
let gatewayRootError = "";
let gatewayRootStatus: {
authoritativeRoot: string;
gatewayConfigPath: string;
configuredRoot?: string | null;
needsAdoption: boolean;
} | null = null;
let returnSection = "entries"; let returnSection = "entries";
onMount(async () => { onMount(async () => {
@ -61,6 +69,8 @@
} catch (e) { } catch (e) {
sidecarRootError = String(e); sidecarRootError = String(e);
} }
await refreshGatewayRootStatus();
}); });
async function saveSidecarRoot() { async function saveSidecarRoot() {
@ -71,6 +81,7 @@
}); });
sidecarRoot = result.root; sidecarRoot = result.root;
sidecarRootIsCustom = result.isCustom; sidecarRootIsCustom = result.isCustom;
await refreshGatewayRootStatus();
} catch (e) { } catch (e) {
sidecarRootError = String(e); sidecarRootError = String(e);
} }
@ -82,6 +93,7 @@
const result: any = await invoke("set_sidecar_root", { path: "" }); const result: any = await invoke("set_sidecar_root", { path: "" });
sidecarRoot = result.root; sidecarRoot = result.root;
sidecarRootIsCustom = result.isCustom; sidecarRootIsCustom = result.isCustom;
await refreshGatewayRootStatus();
} catch (e) { } catch (e) {
sidecarRootError = String(e); sidecarRootError = String(e);
} }
@ -112,6 +124,38 @@
} }
} }
async function refreshGatewayRootStatus() {
gatewayRootError = "";
gatewayRootStatus = null;
if (!isTauriRuntime()) {
return;
}
try {
gatewayRootStatus = await invoke<any>("get_gateway_root_status");
} catch (e) {
gatewayRootError = String(e);
}
}
async function adoptGatewayRoot() {
gatewayRootError = "";
if (!isTauriRuntime()) {
gatewayRootError =
"Gateway root adoption is only available in the desktop app.";
return;
}
gatewayRootBusy = true;
try {
gatewayRootStatus = await invoke<any>("adopt_sidecar_root_for_gateway");
} catch (e) {
gatewayRootError = String(e);
} finally {
gatewayRootBusy = false;
}
}
function showModal(options: { function showModal(options: {
action: "logout-confirm" | "logout-info"; action: "logout-confirm" | "logout-info";
title: string; title: string;
@ -477,6 +521,87 @@
<p class="error-text">{sidecarRootError}</p> <p class="error-text">{sidecarRootError}</p>
{/if} {/if}
</section> </section>
<section class="route-card">
<div class="card-head">
<h2 class="card-title">
<span class="material-symbols-outlined" aria-hidden="true"
>publish</span
>
Gateway Root
</h2>
<p class="section-copy">
Published WebGateway builds should point at one authoritative root.
Use this one-time adopt action to align the packaged gateway with
the desktop app.
</p>
</div>
{#if isTauriRuntime()}
<div class="row-actions">
<button
type="button"
class="ghost-btn"
on:click={refreshGatewayRootStatus}
disabled={gatewayRootBusy}
>
Refresh
</button>
<button
type="button"
class="secondary-btn"
on:click={adoptGatewayRoot}
disabled={gatewayRootBusy}
>
{gatewayRootBusy ? "Adopting..." : "Adopt Current Root"}
</button>
</div>
{#if gatewayRootStatus}
<label>
Desktop authoritative root
<input
type="text"
value={gatewayRootStatus.authoritativeRoot}
readonly
/>
</label>
<label>
Gateway config path
<input
type="text"
value={gatewayRootStatus.gatewayConfigPath}
readonly
/>
</label>
<label>
Gateway configured root
<input
type="text"
value={gatewayRootStatus.configuredRoot ?? "(not set)"}
readonly
/>
</label>
<label>
Status
<input
type="text"
value={gatewayRootStatus.needsAdoption ? "Needs adoption" : "Aligned"}
readonly
/>
</label>
{/if}
{:else}
<p class="section-copy">
Configure `GatewaySettings:RepoRoot` or `JOURNAL_PROJECT_ROOT`
before starting the published gateway.
</p>
{/if}
{#if gatewayRootError}
<p class="error-text">{gatewayRootError}</p>
{/if}
</section>
</div> </div>
</main> </main>
</div> </div>

View File

@ -92,25 +92,70 @@ select {
min-height: 100dvh; min-height: 100dvh;
display: grid; display: grid;
grid-template-columns: 72px 300px minmax(0, 1fr); grid-template-columns: 72px 300px minmax(0, 1fr);
position: relative;
} }
.app-shell.panel-closed { .app-shell.panel-closed {
grid-template-columns: 72px minmax(0, 1fr); grid-template-columns: 72px minmax(0, 1fr);
} }
@media (max-width: 980px) { @media (max-width: 1100px) {
.app-shell { .app-shell {
grid-template-columns: 64px minmax(0, 1fr); grid-template-columns: 64px 280px minmax(0, 1fr);
grid-template-rows: 280px minmax(0, 1fr);
} }
.app-shell:not(.panel-closed) > .side-panel { .app-shell.panel-closed {
grid-column: 2; grid-template-columns: 64px minmax(0, 1fr);
}
}
@media (max-width: 820px) {
body {
overflow: hidden;
}
.app-shell {
min-height: 100dvh;
min-height: 100svh;
grid-template-columns: 56px minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr);
}
.app-shell.panel-closed {
grid-template-columns: 56px minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr);
}
.app-shell > .navbar {
grid-column: 1;
grid-row: 1; grid-row: 1;
} }
.app-shell:not(.panel-closed) > .editor-panel { .app-shell > .side-panel,
.app-shell > .editor-panel,
.app-shell > .route-view {
grid-column: 2; grid-column: 2;
grid-row: 2; grid-row: 1;
min-height: 0;
}
.app-shell > .side-panel {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 56px;
width: min(360px, calc(100vw - 56px));
z-index: 40;
box-shadow: 18px 0 40px rgba(0, 0, 0, 0.45);
}
.app-shell:not(.panel-closed) > .side-panel {
display: flex;
}
.app-shell > .editor-panel,
.app-shell > .route-view {
min-width: 0;
} }
} }

View File

@ -61,7 +61,7 @@ public sealed class DatabaseSessionService(IJournalDatabaseService database) : I
return _connection; return _connection;
_connection = _database.OpenEncryptedConnection(_password); _connection = _database.OpenEncryptedConnection(_password);
_database.EnsureSchema(_connection); _database.EnsureSchemaReady(_connection);
return _connection; return _connection;
} }
} }

View File

@ -11,6 +11,7 @@ public interface IJournalDatabaseService
IReadOnlyDictionary<string, string> GetSchemaStatements(); IReadOnlyDictionary<string, string> GetSchemaStatements();
SqliteConnection OpenEncryptedConnection(string password); SqliteConnection OpenEncryptedConnection(string password);
void EnsureSchema(SqliteConnection connection); void EnsureSchema(SqliteConnection connection);
void EnsureSchemaReady(SqliteConnection connection);
JournalDatabaseStatus GetStatus(string password); JournalDatabaseStatus GetStatus(string password);
JournalDatabaseHydrationResult HydrateWorkspace(string password); JournalDatabaseHydrationResult HydrateWorkspace(string password);
} }

View File

@ -170,7 +170,7 @@ public sealed class JournalDatabaseService(IJournalConfigService config) : IJour
public JournalDatabaseHydrationResult HydrateWorkspace(string password) public JournalDatabaseHydrationResult HydrateWorkspace(string password)
{ {
using var connection = OpenEncryptedConnection(password); using var connection = OpenEncryptedConnection(password);
EnsureSchema(connection); EnsureSchemaReady(connection);
var runtimeReady = HasRequiredTables(connection); var runtimeReady = HasRequiredTables(connection);
var entryDocumentsProcessed = CountEntryDocuments(connection); var entryDocumentsProcessed = CountEntryDocuments(connection);
@ -206,7 +206,8 @@ public sealed class JournalDatabaseService(IJournalConfigService config) : IJour
EnsureSqliteInitialized(); EnsureSqliteInitialized();
var connection = new SqliteConnection($"Data Source={GetDatabasePath()};Mode=ReadWriteCreate;Pooling=False"); var connection = new SqliteConnection(
$"Data Source={GetDatabasePath()};Mode=ReadWriteCreate;Pooling=False;Default Timeout=5");
connection.Open(); connection.Open();
using var keyCmd = connection.CreateCommand(); using var keyCmd = connection.CreateCommand();
@ -217,6 +218,10 @@ public sealed class JournalDatabaseService(IJournalConfigService config) : IJour
verifyCmd.CommandText = "SELECT count(*) FROM sqlite_master;"; verifyCmd.CommandText = "SELECT count(*) FROM sqlite_master;";
_ = verifyCmd.ExecuteScalar(); _ = verifyCmd.ExecuteScalar();
using var busyCmd = connection.CreateCommand();
busyCmd.CommandText = "PRAGMA busy_timeout = 5000;";
busyCmd.ExecuteNonQuery();
return connection; return connection;
} }
@ -230,6 +235,14 @@ public sealed class JournalDatabaseService(IJournalConfigService config) : IJour
} }
} }
public void EnsureSchemaReady(SqliteConnection connection)
{
if (HasRequiredTables(connection))
return;
EnsureSchema(connection);
}
private static bool HasRequiredTables(SqliteConnection connection) private static bool HasRequiredTables(SqliteConnection connection)
{ {
var existing = new HashSet<string>(StringComparer.OrdinalIgnoreCase); var existing = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
@ -250,7 +263,7 @@ public sealed class JournalDatabaseService(IJournalConfigService config) : IJour
try try
{ {
using var connection = OpenEncryptedConnection(password); using var connection = OpenEncryptedConnection(password);
EnsureSchema(connection); EnsureSchemaReady(connection);
var ready = HasRequiredTables(connection); var ready = HasRequiredTables(connection);
return ready return ready
? (true, "SQLCipher runtime is available and schema tables are present.") ? (true, "SQLCipher runtime is available and schema tables are present.")

View File

@ -97,9 +97,15 @@ public class VaultStorageService(IVaultCryptoService crypto, IJournalDatabaseSer
if (string.IsNullOrWhiteSpace(dbFileName)) if (string.IsNullOrWhiteSpace(dbFileName))
continue; continue;
var targetPath = Path.Combine(dataDirectory, dbFileName);
if (File.Exists(targetPath))
{
anyRestored = true;
continue;
}
var encrypted = File.ReadAllBytes(vaultFile); var encrypted = File.ReadAllBytes(vaultFile);
var dbBytes = _crypto.DecryptData(encrypted, password); var dbBytes = _crypto.DecryptData(encrypted, password);
var targetPath = Path.Combine(dataDirectory, dbFileName);
File.WriteAllBytes(targetPath, dbBytes); File.WriteAllBytes(targetPath, dbBytes);
anyRestored = true; anyRestored = true;
} }

View File

@ -0,0 +1,178 @@
using System.Text.Json;
using Journal.Core;
using Journal.Core.Services.Config;
using Journal.Core.Services.Database;
using Journal.WebGateway.Infrastructure;
using Journal.WebGateway.Security;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.FileProviders;
namespace Journal.WebGateway.Extensions;
public static class EndpointExtensions
{
public static void MapGatewayEndpoints(this IEndpointRouteBuilder app, string webDistPath)
{
MapAuthEndpoints(app);
MapApiEndpoints(app);
MapStaticFiles(app, webDistPath);
}
private static void MapAuthEndpoints(IEndpointRouteBuilder app)
{
app.MapGet("/gateway/login", [AllowAnonymous] async (HttpContext context) =>
{
context.Response.ContentType = "text/html; charset=utf-8";
await context.Response.WriteAsync(LoginPage.GetHtml());
});
app.MapPost("/gateway/login", [AllowAnonymous] async (HttpContext context, IConfiguration config, ILogger<Program> logger) =>
{
var form = await context.Request.ReadFormAsync();
var password = form["password"].ToString();
var configuredHash = config.GetValue<string>("Security:AccessPasswordHash");
var ip = context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
var timestamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");
if (string.IsNullOrWhiteSpace(configuredHash))
{
logger.LogError("[{Timestamp}] Gateway login rejected because Security:AccessPasswordHash is not configured.", timestamp);
return Results.Redirect("/gateway/login?error=config");
}
if (GatewayPasswordHasher.VerifyPassword(password, configuredHash))
{
logger.LogInformation("[{Timestamp}] Audit: Successful login from {IP}", timestamp, ip);
var claims = new[] { new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.Name, "WebUser") };
var identity = new System.Security.Claims.ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
var principal = new System.Security.Claims.ClaimsPrincipal(identity);
await context.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal);
return Results.Redirect("/");
}
logger.LogWarning("[{Timestamp}] Audit: FAILED login attempt from {IP}", timestamp, ip);
return Results.Redirect("/gateway/login?error=invalid");
});
app.MapGet("/gateway/logout", async (HttpContext context) =>
{
await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
return Results.Redirect("/gateway/login");
});
}
private static void MapApiEndpoints(IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api");
group.MapGet("/health", [AllowAnonymous] () => Results.Ok(new
{
ok = true,
service = "Journal.WebGateway"
}));
group.MapGet("/web/status", (WebUiState webUiState) => Results.Ok(new
{
distPath = webUiState.DistPath,
exists = webUiState.Exists
}));
group.MapPost("/command", async (CommandEnvelope? command, Entry entry, ILogger<Program> logger) =>
{
if (command is null || string.IsNullOrWhiteSpace(command.Action))
{
return Results.Json(new { ok = false, error = "Missing action" }, statusCode: 400);
}
var options = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
var inputJson = JsonSerializer.Serialize(command, options);
logger.LogInformation("Executing command: {Action}", command.Action);
var responseJson = await entry.HandleCommandAsync(inputJson);
return Results.Content(responseJson, "application/json");
});
group.MapGet("/sidecar/root", (SidecarRootState rootState) =>
{
var (root, isCustom) = rootState.Get();
return Results.Ok(new { root, isCustom });
});
group.MapPost("/sidecar/root", () =>
{
return Results.BadRequest(new
{
ok = false,
error = "WebGateway root is startup-only. Configure GatewaySettings:RepoRoot or JOURNAL_PROJECT_ROOT before launch."
});
});
group.MapGet("/runtime/diagnostics", (
SidecarRootState rootState,
IJournalConfigService configService,
IJournalDatabaseService databaseService,
HttpContext context) =>
{
var (root, isCustom) = rootState.Get();
var config = configService.Current;
var gatewayUrl = $"{context.Request.Scheme}://{context.Request.Host}";
return Results.Ok(new
{
backendMode = "webgateway",
root,
isCustomRoot = isCustom,
vaultDirectory = config.VaultDirectory,
databasePath = databaseService.GetDatabasePath(),
sidecarPath = "Not used by WebGateway",
gatewayPath = Environment.ProcessPath ?? AppContext.BaseDirectory,
gatewayUrl
});
});
}
private static void MapStaticFiles(IEndpointRouteBuilder app, string webDistPath)
{
if (!Directory.Exists(webDistPath) || !File.Exists(Path.Combine(webDistPath, "index.html")))
{
app.MapGet("/", () => Results.Ok(new
{
name = "Journal.WebGateway",
status = "ok",
uiAvailable = false,
message = "No built web UI found. Build Journal.App with ./scripts/publish-app.ps1 -Target web.",
expectedDist = webDistPath
}));
return;
}
var fileProvider = new PhysicalFileProvider(webDistPath);
var indexPath = Path.Combine(webDistPath, "index.html");
// Note: Generic static file middleware should be in Program.cs
// but the specific root/fallback routes can be here.
app.MapGet("/", async (HttpContext context) =>
{
context.Response.ContentType = "text/html; charset=utf-8";
await context.Response.SendFileAsync(indexPath);
});
app.MapFallback(async (HttpContext context) =>
{
if (context.Request.Path.StartsWithSegments("/api"))
{
context.Response.StatusCode = StatusCodes.Status404NotFound;
return;
}
context.Response.ContentType = "text/html; charset=utf-8";
await context.Response.SendFileAsync(indexPath);
});
}
}

View File

@ -0,0 +1,117 @@
namespace Journal.WebGateway.Infrastructure;
public static class LoginPage
{
public static string GetHtml() => @"
<!DOCTYPE html>
<html lang=""en"">
<head>
<meta charset=""UTF-8"">
<meta name=""viewport"" content=""width=device-width, initial-scale=1.0"">
<title>Journal Gateway | Login</title>
<link href=""https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap"" rel=""stylesheet"">
<style>
:root {
--bg: #0a0a0b;
--surface: #141416;
--primary: #6366f1;
--primary-hover: #4f46e5;
--text: #ffffff;
--text-dim: #9ca3af;
--error: #ef4444;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Inter', sans-serif;
background-color: var(--bg);
color: var(--text);
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
overflow: hidden;
}
.login-card {
background: var(--surface);
padding: 2.5rem;
border-radius: 1rem;
width: 100%;
max-width: 400px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
border: 1px solid rgba(255, 255, 255, 0.05);
text-align: center;
animation: fadeIn 0.5s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; font-weight: 600; letter-spacing: -0.025em; }
p { color: var(--text-dim); margin-bottom: 2rem; font-size: 0.875rem; }
.form-group { text-align: left; margin-bottom: 1.5rem; }
label { display: block; font-size: 0.75rem; font-weight: 500; color: var(--text-dim); margin-bottom: 0.5rem; text-transform: uppercase; letter-spacing: 0.05em; }
input {
width: 100%;
padding: 0.75rem 1rem;
background: #000;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 0.5rem;
color: var(--text);
font-size: 1rem;
transition: all 0.2s;
}
input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2);
}
button {
width: 100%;
padding: 0.75rem;
background: var(--primary);
color: white;
border: none;
border-radius: 0.5rem;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
button:hover { background: var(--primary-hover); }
.error-msg {
color: var(--error);
font-size: 0.75rem;
margin-top: 1rem;
padding: 0.5rem;
background: rgba(239, 68, 68, 0.1);
border-radius: 0.25rem;
}
</style>
</head>
<body>
<div class=""login-card"">
<h1>Journal Gateway</h1>
<p>Enter your access password to continue.</p>
<form action=""/gateway/login"" method=""POST"">
<div class=""form-group"">
<label for=""password"">Password</label>
<input type=""password"" id=""password"" name=""password"" required autofocus placeholder="""">
</div>
<button type=""submit"">Sign In</button>
<div id=""error"" class=""error-msg"" style=""display:none""></div>
</form>
</div>
<script>
const urlParams = new URLSearchParams(window.location.search);
const errorEl = document.getElementById('error');
if (urlParams.has('error')) {
const error = urlParams.get('error');
errorEl.textContent = error === 'config'
? 'Gateway password hash is not configured. Set Security:AccessPasswordHash before signing in.'
: 'Invalid password. Please try again.';
errorEl.style.display = 'block';
}
</script>
</body>
</html>";
}

View File

@ -0,0 +1,75 @@
namespace Journal.WebGateway.Infrastructure;
public sealed record RepoRootResolution(string Root, bool IsCustom, string Source);
public static class PathResolver
{
public static RepoRootResolution ResolveRepoRoot(IConfiguration config)
{
var fromConfig = config.GetValue<string>("GatewaySettings:RepoRoot");
if (!string.IsNullOrWhiteSpace(fromConfig) && Directory.Exists(fromConfig))
return new RepoRootResolution(Path.GetFullPath(fromConfig), true, "config");
var fromEnv = Environment.GetEnvironmentVariable("JOURNAL_PROJECT_ROOT");
if (!string.IsNullOrWhiteSpace(fromEnv) && Directory.Exists(fromEnv))
return new RepoRootResolution(Path.GetFullPath(fromEnv), true, "env");
if (IsPublishedLayout())
{
throw new InvalidOperationException(
"Published WebGateway requires an explicit root. Set GatewaySettings:RepoRoot or JOURNAL_PROJECT_ROOT before launch.");
}
foreach (var start in new[] { Directory.GetCurrentDirectory(), AppContext.BaseDirectory })
{
var resolved = FindRepoRoot(start);
if (resolved is not null)
return new RepoRootResolution(resolved, false, "auto");
}
return new RepoRootResolution(Path.GetFullPath(Directory.GetCurrentDirectory()), false, "cwd");
}
private static string? FindRepoRoot(string start)
{
var cursor = Path.GetFullPath(start);
while (!string.IsNullOrWhiteSpace(cursor))
{
if (File.Exists(Path.Combine(cursor, "Journal.slnx")) ||
Directory.Exists(Path.Combine(cursor, "Journal.Sidecar")) ||
File.Exists(Path.Combine(cursor, "Journal.Sidecar.exe")) ||
File.Exists(Path.Combine(cursor, "Journal.Sidecar")) ||
Directory.Exists(Path.Combine(cursor, "Journal.Core")))
return cursor;
var parent = Directory.GetParent(cursor);
if (parent is null || string.Equals(parent.FullName, cursor, StringComparison.OrdinalIgnoreCase))
return null;
cursor = parent.FullName;
}
return null;
}
public static string ResolveWebDist(string repoRootPath, IConfiguration config)
{
var fromConfig = config.GetValue<string>("GatewaySettings:WebDist");
if (!string.IsNullOrWhiteSpace(fromConfig)) return Path.GetFullPath(fromConfig);
var fromEnv = Environment.GetEnvironmentVariable("JOURNAL_WEB_DIST");
if (!string.IsNullOrWhiteSpace(fromEnv)) return Path.GetFullPath(fromEnv);
var packagedWwwRoot = Path.Combine(AppContext.BaseDirectory, "wwwroot");
if (Directory.Exists(packagedWwwRoot)) return packagedWwwRoot;
return Path.Combine(repoRootPath, "Journal.App", "build");
}
private static bool IsPublishedLayout()
{
var baseDir = Path.GetFullPath(AppContext.BaseDirectory);
return Directory.Exists(Path.Combine(baseDir, "wwwroot")) &&
!File.Exists(Path.Combine(baseDir, "Journal.slnx")) &&
!Directory.Exists(Path.Combine(baseDir, "Journal.WebGateway"));
}
}

View File

@ -0,0 +1,55 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Journal.WebGateway.Infrastructure;
public sealed class WebUiState(string distPath)
{
public string DistPath { get; } = distPath;
public bool Exists => Directory.Exists(DistPath) && File.Exists(Path.Combine(DistPath, "index.html"));
}
public sealed class SidecarRootState(string autoRoot)
{
private readonly object _sync = new();
private string _fallbackRoot = autoRoot;
private string _currentRoot = autoRoot;
private bool _isCustom;
public (string Root, bool IsCustom) Get()
{
lock (_sync) return (_currentRoot, _isCustom);
}
public void SetResolved(string path, bool isCustom)
{
lock (_sync)
{
_currentRoot = Path.GetFullPath(path.Trim());
_isCustom = isCustom;
if (!isCustom)
_fallbackRoot = _currentRoot;
}
}
public void ResetToFallback()
{
lock (_sync)
{
_currentRoot = _fallbackRoot;
_isCustom = false;
}
}
}
public sealed class SetSidecarRootRequest { public string? Path { get; set; } }
public sealed class CommandEnvelope
{
public string Action { get; set; } = "";
public string? CorrelationId { get; set; }
public string? Id { get; set; }
public string? Type { get; set; }
public string? Tag { get; set; }
public JsonElement? Payload { get; set; }
}

View File

@ -2,34 +2,83 @@ using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Journal.AI; using Journal.AI;
using Journal.Core; using Journal.Core;
using Journal.WebGateway.Extensions;
using Journal.WebGateway.Infrastructure;
using Journal.WebGateway.Security;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.FileProviders;
var gatewayJsonOptions = new JsonSerializerOptions if (TryHandlePasswordHashCommand(args))
return;
var builder = WebApplication.CreateBuilder(new WebApplicationOptions
{ {
PropertyNamingPolicy = JsonNamingPolicy.CamelCase, Args = args,
PropertyNameCaseInsensitive = true, ContentRootPath = AppContext.BaseDirectory
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull });
};
// Configuration
var securitySettings = builder.Configuration.GetSection("Security");
var repoRootResolution = PathResolver.ResolveRepoRoot(builder.Configuration);
var repoRoot = repoRootResolution.Root;
var webDistPath = PathResolver.ResolveWebDist(repoRoot, builder.Configuration);
var port = builder.Configuration.GetValue<int?>("PORT")
?? builder.Configuration.GetValue<int?>("GatewaySettings:Port")
?? 5002;
var repoRoot = ResolveRepoRoot();
Environment.SetEnvironmentVariable("JOURNAL_PROJECT_ROOT", repoRoot); Environment.SetEnvironmentVariable("JOURNAL_PROJECT_ROOT", repoRoot);
var webDistPath = ResolveWebDist(repoRoot);
var builder = WebApplication.CreateBuilder(args); builder.WebHost.ConfigureKestrel(options => options.ListenAnyIP(port));
// Services
builder.Services.AddFragmentServices(); builder.Services.AddFragmentServices();
builder.Services.AddLlamaSharpServices(); builder.Services.AddLlamaSharpServices();
builder.Services.AddSingleton<Entry>(); builder.Services.AddSingleton<Entry>();
builder.Services.AddSingleton(new SidecarRootState(repoRoot)); builder.Services.AddSingleton(new SidecarRootState(repoRootResolution.Root));
builder.Services.AddSingleton(new WebUiState(webDistPath)); builder.Services.AddSingleton(new WebUiState(webDistPath));
builder.Services.AddAuthentication(options => {
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = CookieAuthenticationDefaults.AuthenticationScheme;
})
.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options =>
{
options.LoginPath = "/gateway/login";
options.LogoutPath = "/gateway/logout";
options.Cookie.Name = "Journal.Gateway.Session";
options.Cookie.HttpOnly = true;
options.Cookie.SameSite = SameSiteMode.Strict;
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
options.ExpireTimeSpan = TimeSpan.FromHours(8);
options.SlidingExpiration = true;
})
.AddScheme<AuthenticationSchemeOptions, ApiKeyAuthenticationHandler>("ApiKey", null);
builder.Services.AddAuthorization(options =>
{
options.FallbackPolicy = new AuthorizationPolicyBuilder()
.AddAuthenticationSchemes(CookieAuthenticationDefaults.AuthenticationScheme, "ApiKey")
.RequireAuthenticatedUser()
.Build();
});
builder.Services.AddCors(options => builder.Services.AddCors(options =>
{ {
options.AddPolicy("GatewayCors", policy => options.AddPolicy("GatewayCors", policy =>
{ {
policy.AllowAnyOrigin() var allowedOrigins = securitySettings.GetSection("AllowedOrigins").Get<string[]>() ?? Array.Empty<string>();
.AllowAnyHeader() if (allowedOrigins.Length > 0)
.AllowAnyMethod(); {
policy.WithOrigins(allowedOrigins).AllowAnyHeader().AllowAnyMethod().AllowCredentials();
}
else
{
policy.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod();
}
}); });
}); });
@ -37,228 +86,163 @@ builder.Services.ConfigureHttpJsonOptions(options =>
{ {
options.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; options.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
options.SerializerOptions.PropertyNameCaseInsensitive = true; options.SerializerOptions.PropertyNameCaseInsensitive = true;
options.SerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
}); });
var app = builder.Build(); var app = builder.Build();
app.Services.GetRequiredService<SidecarRootState>().SetResolved(repoRootResolution.Root, repoRootResolution.IsCustom);
if (string.IsNullOrWhiteSpace(builder.Configuration.GetValue<string>("Security:AccessPasswordHash")))
{
app.Logger.LogWarning("Security:AccessPasswordHash is not configured. Browser login is disabled until a password hash is set.");
}
app.Logger.LogInformation("Journal.WebGateway starting...");
app.Logger.LogInformation("Content Root: {Path}", app.Environment.ContentRootPath);
app.Logger.LogInformation("Web UI Dist: {Path}", webDistPath);
app.UseExceptionHandler(exceptionApp =>
{
exceptionApp.Run(async context =>
{
var logger = context.RequestServices.GetRequiredService<ILogger<Program>>();
var feature = context.Features.Get<IExceptionHandlerPathFeature>();
logger.LogError(feature?.Error, "Unhandled exception occurred.");
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
context.Response.ContentType = "application/json";
await context.Response.WriteAsJsonAsync(new { ok = false, error = "An internal error occurred." });
});
});
app.UseCors("GatewayCors"); app.UseCors("GatewayCors");
app.UseAuthentication();
app.UseAuthorization();
app.MapGet("/api/health", () => Results.Ok(new app.Use(async (context, next) =>
{ {
ok = true, var path = context.Request.Path;
service = "Journal.WebGateway" var isGatewayAuthPath =
})); path.StartsWithSegments("/gateway/login") ||
path.StartsWithSegments("/gateway/logout");
var isAnonymousApiPath =
path.StartsWithSegments("/api/health") ||
path.StartsWithSegments("/api/web/status");
var isProtectedUiPath = !path.StartsWithSegments("/api") && !isGatewayAuthPath;
app.MapGet("/api/web/status", (WebUiState webUiState) => Results.Ok(new if (isProtectedUiPath && context.User.Identity?.IsAuthenticated != true)
{
distPath = webUiState.DistPath,
exists = webUiState.Exists
}));
app.MapPost("/api/command", async (CommandEnvelope? command, Entry entry) =>
{
if (command is null || string.IsNullOrWhiteSpace(command.Action))
{ {
return Results.Content(ErrorResponse("Missing action"), "application/json"); await context.ChallengeAsync(CookieAuthenticationDefaults.AuthenticationScheme);
return;
} }
var inputJson = JsonSerializer.Serialize(command, gatewayJsonOptions); if (isAnonymousApiPath)
var responseJson = await entry.HandleCommandAsync(inputJson);
return Results.Content(responseJson, "application/json");
});
app.MapGet("/api/sidecar/root", (SidecarRootState rootState) =>
{
var (root, isCustom) = rootState.Get();
return Results.Ok(new
{ {
root, await next();
isCustom return;
});
});
app.MapPost("/api/sidecar/root", (SetSidecarRootRequest? request, SidecarRootState rootState) =>
{
var path = request?.Path ?? "";
if (!string.IsNullOrWhiteSpace(path) && !Directory.Exists(path))
{
return Results.BadRequest($"Directory '{path}' does not exist.");
} }
rootState.Set(path); await next();
var (root, isCustom) = rootState.Get();
Environment.SetEnvironmentVariable("JOURNAL_PROJECT_ROOT", root);
return Results.Ok(new
{
root,
isCustom
});
}); });
if (Directory.Exists(webDistPath) && File.Exists(Path.Combine(webDistPath, "index.html"))) // Better handling for 401 challenges on browser requests
app.Use(async (context, next) =>
{ {
var fileProvider = new PhysicalFileProvider(webDistPath); await next();
var indexPath = Path.Combine(webDistPath, "index.html");
app.UseDefaultFiles(new DefaultFilesOptions if (context.Response.StatusCode == 401 &&
!context.Response.HasStarted &&
context.Request.Path != "/gateway/login" &&
context.Request.Headers.Accept.ToString().Contains("text/html"))
{ {
FileProvider = fileProvider, context.Response.Redirect("/gateway/login");
RequestPath = "" }
}); });
app.UseStaticFiles(new StaticFileOptions app.Use(async (context, next) =>
{
FileProvider = fileProvider,
RequestPath = ""
});
app.MapGet("/", async context =>
{
context.Response.ContentType = "text/html; charset=utf-8";
await context.Response.SendFileAsync(indexPath);
});
app.MapFallback(async context =>
{
if (context.Request.Path.StartsWithSegments("/api"))
{
context.Response.StatusCode = StatusCodes.Status404NotFound;
return;
}
context.Response.ContentType = "text/html; charset=utf-8";
await context.Response.SendFileAsync(indexPath);
});
}
else
{ {
app.MapGet("/", () => Results.Ok(new if (securitySettings.GetValue<bool>("EnableAuditLogging"))
{ {
name = "Journal.WebGateway", var logger = context.RequestServices.GetRequiredService<ILogger<Program>>();
status = "ok", var ip = context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
uiAvailable = false, var userAgent = context.Request.Headers["User-Agent"].ToString();
message = "No built web UI found. Build Journal.App with ./scripts/publish-app.ps1 -Target web.", var user = context.User.Identity?.Name ?? "Anonymous";
expectedDist = webDistPath
})); var timestamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");
logger.LogInformation("[{Timestamp}] Audit: [{IP}] {Method} {Path} accessed by {User} ({UA})",
timestamp, ip, context.Request.Method, context.Request.Path, user, userAgent);
}
await next();
});
// Static Files Internal Middleware
if (Directory.Exists(webDistPath))
{
app.UseDefaultFiles(new DefaultFilesOptions { FileProvider = new PhysicalFileProvider(webDistPath), RequestPath = "" });
app.UseStaticFiles(new StaticFileOptions { FileProvider = new PhysicalFileProvider(webDistPath), RequestPath = "" });
} }
// Map specialized endpoints
app.MapGatewayEndpoints(webDistPath);
app.Run(); app.Run();
string ResolveRepoRoot() static bool TryHandlePasswordHashCommand(string[] args)
{ {
var fromEnv = Environment.GetEnvironmentVariable("JOURNAL_PROJECT_ROOT"); if (args.Length == 0 || !string.Equals(args[0], "hash-password", StringComparison.OrdinalIgnoreCase))
if (!string.IsNullOrWhiteSpace(fromEnv) && Directory.Exists(fromEnv)) return false;
{
return Path.GetFullPath(fromEnv);
}
foreach (var start in new[] { Directory.GetCurrentDirectory(), AppContext.BaseDirectory }) string password;
if (args.Length > 1)
{ {
var resolved = FindRepoRoot(start); password = args[1];
if (resolved is not null) }
else
{
password = PromptForPassword("New gateway password: ");
var confirm = PromptForPassword("Confirm gateway password: ");
if (!string.Equals(password, confirm, StringComparison.Ordinal))
{ {
return resolved; Console.Error.WriteLine("Passwords did not match.");
Environment.ExitCode = 1;
return true;
} }
} }
return Path.GetFullPath(Directory.GetCurrentDirectory()); if (string.IsNullOrWhiteSpace(password))
{
Console.Error.WriteLine("Password cannot be empty.");
Environment.ExitCode = 1;
return true;
}
Console.WriteLine(GatewayPasswordHasher.HashPassword(password));
return true;
} }
string? FindRepoRoot(string start) static string PromptForPassword(string prompt)
{ {
var cursor = Path.GetFullPath(start); Console.Write(prompt);
while (!string.IsNullOrWhiteSpace(cursor)) var buffer = new List<char>();
while (true)
{ {
if (File.Exists(Path.Combine(cursor, "Journal.slnx")) || var key = Console.ReadKey(intercept: true);
Directory.Exists(Path.Combine(cursor, "Journal.Sidecar")) || if (key.Key == ConsoleKey.Enter)
Directory.Exists(Path.Combine(cursor, "Journal.Core")))
{ {
return cursor; Console.WriteLine();
return new string([.. buffer]);
} }
var parent = Directory.GetParent(cursor); if (key.Key == ConsoleKey.Backspace)
if (parent is null || string.Equals(parent.FullName, cursor, StringComparison.OrdinalIgnoreCase))
{ {
return null; if (buffer.Count > 0)
buffer.RemoveAt(buffer.Count - 1);
continue;
} }
cursor = parent.FullName; if (!char.IsControl(key.KeyChar))
} buffer.Add(key.KeyChar);
return null;
}
string ResolveWebDist(string repoRootPath)
{
var fromEnv = Environment.GetEnvironmentVariable("JOURNAL_WEB_DIST");
if (!string.IsNullOrWhiteSpace(fromEnv))
{
return Path.GetFullPath(fromEnv);
}
var packagedWwwRoot = Path.Combine(AppContext.BaseDirectory, "wwwroot");
if (Directory.Exists(packagedWwwRoot))
{
return packagedWwwRoot;
}
return Path.Combine(repoRootPath, "Journal.App", "build");
}
string ErrorResponse(string message)
=> JsonSerializer.Serialize(new { ok = false, error = message }, gatewayJsonOptions);
sealed class WebUiState(string distPath)
{
public string DistPath { get; } = distPath;
public bool Exists => Directory.Exists(DistPath) && File.Exists(Path.Combine(DistPath, "index.html"));
}
sealed class SidecarRootState(string autoRoot)
{
private readonly object _sync = new();
private readonly string _autoRoot = autoRoot;
private string _currentRoot = autoRoot;
private bool _isCustom;
public (string Root, bool IsCustom) Get()
{
lock (_sync)
{
return (_currentRoot, _isCustom);
}
}
public void Set(string? path)
{
lock (_sync)
{
if (string.IsNullOrWhiteSpace(path))
{
_currentRoot = _autoRoot;
_isCustom = false;
return;
}
_currentRoot = Path.GetFullPath(path.Trim());
_isCustom = true;
}
} }
} }
sealed class SetSidecarRootRequest
{
public string? Path { get; set; }
}
sealed class CommandEnvelope
{
public string Action { get; set; } = "";
public string? CorrelationId { get; set; }
public string? Id { get; set; }
public string? Type { get; set; }
public string? Tag { get; set; }
public JsonElement? Payload { get; set; }
}

View File

@ -0,0 +1,39 @@
using System.Text.Encodings.Web;
using Microsoft.Extensions.Options;
using Microsoft.AspNetCore.Authentication;
namespace Journal.WebGateway.Security;
public class ApiKeyAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
IConfiguration config)
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (!Request.Headers.TryGetValue("X-API-KEY", out var extractedApiKey))
return Task.FromResult(AuthenticateResult.NoResult()); // Fallback to other schemes (like Cookie)
var expectedApiKey = config.GetValue<string>("Security:ApiKey");
if (string.Equals(expectedApiKey, extractedApiKey, StringComparison.Ordinal))
{
var claims = new[] { new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.Name, "ApiKeyUser") };
var identity = new System.Security.Claims.ClaimsIdentity(claims, Scheme.Name);
var principal = new System.Security.Claims.ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, Scheme.Name);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
return Task.FromResult(AuthenticateResult.Fail("Invalid API Key provided."));
}
// Do NOT issue a 401 challenge from this handler.
// Let the Cookie scheme handle it (which redirects to /gateway/login).
protected override Task HandleChallengeAsync(AuthenticationProperties properties)
{
return Task.CompletedTask;
}
}

View File

@ -0,0 +1,69 @@
using System.Security.Cryptography;
using System.Text;
namespace Journal.WebGateway.Security;
public static class GatewayPasswordHasher
{
private const string AlgorithmName = "PBKDF2-SHA256";
private const int SaltSize = 16;
private const int KeySize = 32;
private const int IterationCount = 600_000;
public static string HashPassword(string password)
{
ArgumentException.ThrowIfNullOrWhiteSpace(password);
var salt = RandomNumberGenerator.GetBytes(SaltSize);
var key = Rfc2898DeriveBytes.Pbkdf2(
Encoding.UTF8.GetBytes(password),
salt,
IterationCount,
HashAlgorithmName.SHA256,
KeySize);
return string.Join(
'$',
AlgorithmName,
IterationCount.ToString(System.Globalization.CultureInfo.InvariantCulture),
Convert.ToBase64String(salt),
Convert.ToBase64String(key));
}
public static bool VerifyPassword(string password, string passwordHash)
{
if (string.IsNullOrWhiteSpace(password) || string.IsNullOrWhiteSpace(passwordHash))
return false;
var parts = passwordHash.Split('$', StringSplitOptions.None);
if (parts.Length != 4)
return false;
if (!string.Equals(parts[0], AlgorithmName, StringComparison.Ordinal))
return false;
if (!int.TryParse(parts[1], out var iterations) || iterations <= 0)
return false;
byte[] salt;
byte[] expectedKey;
try
{
salt = Convert.FromBase64String(parts[2]);
expectedKey = Convert.FromBase64String(parts[3]);
}
catch (FormatException)
{
return false;
}
var actualKey = Rfc2898DeriveBytes.Pbkdf2(
Encoding.UTF8.GetBytes(password),
salt,
iterations,
HashAlgorithmName.SHA256,
expectedKey.Length);
return CryptographicOperations.FixedTimeEquals(actualKey, expectedKey);
}
}

View File

@ -0,0 +1,24 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"GatewaySettings": {
"Port": 5180,
"RepoRoot": null,
"WebDist": null
},
"Description": "API key not fully implemented yet. It is not used for authentication.",
"Security": {
"ApiKey": "CHANGE-ME",
"AccessPasswordHash": "",
"AllowedOrigins": [
"http://localhost:5180"
],
"EnforceHttps": false,
"EnableAuditLogging": true
}
}

View File

@ -25,7 +25,7 @@ backend/ Monorepo root
## Deployment Modes ## Deployment Modes
The backend can run in three modes depending on the surface wired to it: The supported primary workflow is the desktop app with the local sidecar. WebGateway is a separate secondary surface for browser/mobile access.
| Mode | Host | Frontend | | Mode | Host | Frontend |
|------|------|----------| |------|------|----------|
@ -64,7 +64,7 @@ dotnet publish Journal.Sidecar/Journal.Sidecar.csproj -c Release -r win-x64 --se
npm run tauri build -w Journal.App npm run tauri build -w Journal.App
``` ```
Tauri auto-detects `Journal.Sidecar.exe` in the repository. On first launch it walks up from the working directory to find `Journal.Sidecar/` and resolves the built executable. Tauri auto-detects `Journal.Sidecar.exe` in the repository. On first launch it walks up from the working directory to find `Journal.Sidecar/` and resolves the built executable. If you want the desktop app to use a different root, set it from Settings. That choice applies only to the desktop app.
### Option B — WebGateway Server (browser mode) ### Option B — WebGateway Server (browser mode)
@ -75,13 +75,27 @@ npm run build -w Journal.App
dotnet publish Journal.WebGateway/Journal.WebGateway.csproj -c Release -r win-x64 dotnet publish Journal.WebGateway/Journal.WebGateway.csproj -c Release -r win-x64
``` ```
Set the authoritative journal root explicitly before running a published gateway:
```powershell
$env:JOURNAL_PROJECT_ROOT = "F:\path\to\journal-root"
```
Run the gateway: Run the gateway:
```powershell ```powershell
dotnet run --project Journal.WebGateway dotnet run --project Journal.WebGateway
``` ```
Open `http://localhost:5180` in your browser. The gateway automatically serves the SvelteKit build and proxies all `/api/command` calls to `Journal.Core`. Open `http://localhost:5180` in your browser. The gateway serves the SvelteKit build and proxies `/api/command` calls to `Journal.Core`.
Before browser login will work, generate a password hash and place it in `Journal.WebGateway/appsettings.json` under `Security:AccessPasswordHash`:
```powershell
dotnet run --project Journal.WebGateway -- hash-password
```
This prints a PBKDF2-SHA256 hash. Store the hash, not the plaintext password.
Quick health check: Quick health check:
@ -216,6 +230,28 @@ The `Journal.AI` project uses **LLamaSharp** for local LLM inference.
An ASP.NET Core minimal API that wraps `Journal.Core` for browser use. An ASP.NET Core minimal API that wraps `Journal.Core` for browser use.
### Gateway Authentication
- Browser access is protected by a cookie login page at `/gateway/login`.
- The configured secret is `Security:AccessPasswordHash`, not a plaintext password.
- Generate a hash with:
```powershell
dotnet run --project Journal.WebGateway -- hash-password
```
- Paste the resulting value into `Journal.WebGateway/appsettings.json` or set it via environment variable `Security__AccessPasswordHash`.
- The vault password remains separate and is still entered when unlocking the encrypted workspace.
- In published deployments, also set `GatewaySettings:RepoRoot` or `JOURNAL_PROJECT_ROOT`. Published WebGateway builds no longer guess the vault root at runtime.
- If the gateway hits a locked workspace, the web UI prompts for the vault password and retries instead of leaving the raw lock error on screen.
### Authoritative Root
- Desktop and future mobile-native apps are the authoritative clients.
- Published WebGateway should point at that same authoritative root, but it does not share the desktop process or unlock state.
- In the desktop Settings screen, use `Adopt Current Root` under `Gateway Root` to write the current desktop root into the packaged gateway `appsettings.json` as a one-time alignment step.
- For manual deployments, set `GatewaySettings:RepoRoot` in `output/webgateway/appsettings.json` or set `JOURNAL_PROJECT_ROOT` before launch.
### Endpoints ### Endpoints
| Method | Path | Description | | Method | Path | Description |
@ -224,7 +260,8 @@ An ASP.NET Core minimal API that wraps `Journal.Core` for browser use.
| `POST` | `/api/command` | Send a JSON command to `Entry.HandleCommandAsync` | | `POST` | `/api/command` | Send a JSON command to `Entry.HandleCommandAsync` |
| `GET` | `/api/web/status` | Reports web dist path and whether UI is available | | `GET` | `/api/web/status` | Reports web dist path and whether UI is available |
| `GET` | `/api/sidecar/root` | Returns current project root (auto-detected or custom) | | `GET` | `/api/sidecar/root` | Returns current project root (auto-detected or custom) |
| `POST` | `/api/sidecar/root` | Override project root at runtime | | `POST` | `/api/sidecar/root` | Disabled in WebGateway; root is startup-only |
| `GET` | `/api/runtime/diagnostics` | Returns resolved root, vault path, DB path, and gateway path |
| `GET` | `/*` | Serves built SvelteKit UI from `wwwroot` (SPA fallback) | | `GET` | `/*` | Serves built SvelteKit UI from `wwwroot` (SPA fallback) |
### Web UI Resolution ### Web UI Resolution
@ -243,6 +280,13 @@ If no dist is found, `/` returns a JSON status message instead of the UI.
dotnet run --project Journal.WebGateway dotnet run --project Journal.WebGateway
``` ```
For published output, configure the root before launch:
```powershell
$env:JOURNAL_PROJECT_ROOT = "F:\path\to\journal-root"
.\Journal.WebGateway.exe
```
--- ---
## Journal.App (Tauri + SvelteKit) ## Journal.App (Tauri + SvelteKit)
@ -271,6 +315,7 @@ Tauri commands exposed to the frontend:
| `sidecar_command` | Forward a `CommandEnvelope` to `Journal.Sidecar` and return parsed JSON | | `sidecar_command` | Forward a `CommandEnvelope` to `Journal.Sidecar` and return parsed JSON |
| `get_sidecar_root` | Get the current resolved sidecar root path | | `get_sidecar_root` | Get the current resolved sidecar root path |
| `set_sidecar_root` | Override sidecar root path (saves to `settings.json`, restarts sidecar) | | `set_sidecar_root` | Override sidecar root path (saves to `settings.json`, restarts sidecar) |
| `get_runtime_diagnostics` | Inspect resolved root, vault/database paths, sidecar path, and gateway path/url |
| `get_ui_settings` | Load tag/fragment-type settings from `settings.json` | | `get_ui_settings` | Load tag/fragment-type settings from `settings.json` |
| `set_ui_settings` | Persist tag/fragment-type settings | | `set_ui_settings` | Persist tag/fragment-type settings |
| `shutdown` | Stop the sidecar and exit the app | | `shutdown` | Stop the sidecar and exit the app |