create-zudo-doc 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +146 -0
- package/bin/create-zudo-doc.js +2 -0
- package/dist/api.d.ts +20 -0
- package/dist/api.js +13 -0
- package/dist/claude-md-gen.d.ts +2 -0
- package/dist/claude-md-gen.js +113 -0
- package/dist/cli.d.ts +39 -0
- package/dist/cli.js +157 -0
- package/dist/compose.d.ts +95 -0
- package/dist/compose.js +206 -0
- package/dist/constants.d.ts +20 -0
- package/dist/constants.js +224 -0
- package/dist/features/body-foot-util.d.ts +10 -0
- package/dist/features/body-foot-util.js +12 -0
- package/dist/features/claude-resources.d.ts +2 -0
- package/dist/features/claude-resources.js +6 -0
- package/dist/features/design-token-panel.d.ts +14 -0
- package/dist/features/design-token-panel.js +27 -0
- package/dist/features/doc-history.d.ts +9 -0
- package/dist/features/doc-history.js +11 -0
- package/dist/features/doc-tags.d.ts +19 -0
- package/dist/features/doc-tags.js +33 -0
- package/dist/features/footer-taglist.d.ts +14 -0
- package/dist/features/footer-taglist.js +17 -0
- package/dist/features/footer.d.ts +8 -0
- package/dist/features/footer.js +10 -0
- package/dist/features/i18n.d.ts +22 -0
- package/dist/features/i18n.js +41 -0
- package/dist/features/image-enlarge.d.ts +11 -0
- package/dist/features/image-enlarge.js +13 -0
- package/dist/features/index.d.ts +15 -0
- package/dist/features/index.js +53 -0
- package/dist/features/llms-txt.d.ts +11 -0
- package/dist/features/llms-txt.js +13 -0
- package/dist/features/search.d.ts +9 -0
- package/dist/features/search.js +11 -0
- package/dist/features/sidebar-resizer.d.ts +14 -0
- package/dist/features/sidebar-resizer.js +16 -0
- package/dist/features/sidebar-toggle.d.ts +13 -0
- package/dist/features/sidebar-toggle.js +15 -0
- package/dist/features/tag-governance.d.ts +14 -0
- package/dist/features/tag-governance.js +16 -0
- package/dist/features/tauri-dev.d.ts +2 -0
- package/dist/features/tauri-dev.js +25 -0
- package/dist/features/tauri.d.ts +11 -0
- package/dist/features/tauri.js +52 -0
- package/dist/features/versioning.d.ts +27 -0
- package/dist/features/versioning.js +43 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +150 -0
- package/dist/preset.d.ts +37 -0
- package/dist/preset.js +156 -0
- package/dist/prompts.d.ts +32 -0
- package/dist/prompts.js +248 -0
- package/dist/scaffold.d.ts +4 -0
- package/dist/scaffold.js +344 -0
- package/dist/settings-gen.d.ts +2 -0
- package/dist/settings-gen.js +237 -0
- package/dist/utils.d.ts +8 -0
- package/dist/utils.js +34 -0
- package/dist/zfb-config-gen.d.ts +19 -0
- package/dist/zfb-config-gen.js +222 -0
- package/package.json +65 -0
- package/templates/base/.htmlvalidate.json +5 -0
- package/templates/base/.zfb/doc-history-meta.json +1 -0
- package/templates/base/pages/404.tsx +55 -0
- package/templates/base/pages/_data.ts +179 -0
- package/templates/base/pages/_mdx-components.ts +249 -0
- package/templates/base/pages/docs/[...slug].tsx +448 -0
- package/templates/base/pages/index.tsx +158 -0
- package/templates/base/pages/lib/_body-end-islands.tsx +201 -0
- package/templates/base/pages/lib/_category-nav.tsx +148 -0
- package/templates/base/pages/lib/_category-tree-nav.tsx +104 -0
- package/templates/base/pages/lib/_compose-meta-title.ts +29 -0
- package/templates/base/pages/lib/_details.tsx +30 -0
- package/templates/base/pages/lib/_doc-history-area.tsx +178 -0
- package/templates/base/pages/lib/_doc-metainfo-area.tsx +100 -0
- package/templates/base/pages/lib/_doc-tags-area.tsx +89 -0
- package/templates/base/pages/lib/_extract-headings.ts +81 -0
- package/templates/base/pages/lib/_footer-with-defaults.tsx +234 -0
- package/templates/base/pages/lib/_frontmatter-preview-data.ts +53 -0
- package/templates/base/pages/lib/_head-with-defaults.tsx +113 -0
- package/templates/base/pages/lib/_header-with-defaults.tsx +386 -0
- package/templates/base/pages/lib/_inline-version-switcher.tsx +84 -0
- package/templates/base/pages/lib/_math-block.tsx +63 -0
- package/templates/base/pages/lib/_nav-source-docs.ts +68 -0
- package/templates/base/pages/lib/_preset-generator.tsx +81 -0
- package/templates/base/pages/lib/_search-widget-script.ts +388 -0
- package/templates/base/pages/lib/_search-widget.tsx +196 -0
- package/templates/base/pages/lib/_sidebar-with-defaults.tsx +176 -0
- package/templates/base/pages/lib/_site-tree-nav.tsx +128 -0
- package/templates/base/pages/lib/locale-merge.ts +58 -0
- package/templates/base/pages/lib/route-enumerators.ts +302 -0
- package/templates/base/pages/sitemap.xml.tsx +51 -0
- package/templates/base/plugins/connect-adapter.mjs +144 -0
- package/templates/base/plugins/copy-public-plugin.mjs +50 -0
- package/templates/base/plugins/search-index-plugin.mjs +54 -0
- package/templates/base/scripts/run-b4push.sh +102 -0
- package/templates/base/src/components/ai-chat-modal.tsx +15 -0
- package/templates/base/src/components/client-router-bootstrap.tsx +14 -0
- package/templates/base/src/components/content/component-map.ts +25 -0
- package/templates/base/src/components/content/content-blockquote.tsx +16 -0
- package/templates/base/src/components/content/content-code.tsx +117 -0
- package/templates/base/src/components/content/content-link.tsx +83 -0
- package/templates/base/src/components/content/content-ol.tsx +19 -0
- package/templates/base/src/components/content/content-paragraph.tsx +10 -0
- package/templates/base/src/components/content/content-strong.tsx +16 -0
- package/templates/base/src/components/content/content-table.tsx +18 -0
- package/templates/base/src/components/content/content-ul.tsx +18 -0
- package/templates/base/src/components/content/heading-h2.tsx +26 -0
- package/templates/base/src/components/content/heading-h3.tsx +26 -0
- package/templates/base/src/components/content/heading-h4.tsx +26 -0
- package/templates/base/src/components/design-token-panel-bootstrap.tsx +15 -0
- package/templates/base/src/components/desktop-sidebar-toggle.tsx +15 -0
- package/templates/base/src/components/doc-history.tsx +18 -0
- package/templates/base/src/components/html-preview/highlighted-code.tsx +74 -0
- package/templates/base/src/components/html-preview/html-preview.tsx +108 -0
- package/templates/base/src/components/html-preview/preflight.ts +112 -0
- package/templates/base/src/components/html-preview/preview-base.tsx +159 -0
- package/templates/base/src/components/image-enlarge.tsx +19 -0
- package/templates/base/src/components/mobile-toc.tsx +94 -0
- package/templates/base/src/components/preset-generator.tsx +14 -0
- package/templates/base/src/components/sidebar-toggle.tsx +98 -0
- package/templates/base/src/components/sidebar-tree.tsx +543 -0
- package/templates/base/src/components/site-tree-nav.tsx +233 -0
- package/templates/base/src/components/theme-toggle.tsx +93 -0
- package/templates/base/src/components/toc.tsx +63 -0
- package/templates/base/src/components/tree-nav-shared.tsx +71 -0
- package/templates/base/src/config/color-scheme-utils.ts +182 -0
- package/templates/base/src/config/color-schemes.ts +128 -0
- package/templates/base/src/config/frontmatter-preview-defaults.ts +24 -0
- package/templates/base/src/config/frontmatter-preview-renderers.tsx +46 -0
- package/templates/base/src/config/i18n.ts +225 -0
- package/templates/base/src/config/settings-types.ts +162 -0
- package/templates/base/src/config/sidebars.ts +66 -0
- package/templates/base/src/config/tag-vocabulary-types.ts +39 -0
- package/templates/base/src/config/tag-vocabulary.ts +20 -0
- package/templates/base/src/hooks/use-active-heading.ts +133 -0
- package/templates/base/src/plugins/docs-source-map.ts +103 -0
- package/templates/base/src/plugins/hast-utils.ts +10 -0
- package/templates/base/src/plugins/rehype-code-title.ts +50 -0
- package/templates/base/src/plugins/rehype-heading-links.ts +53 -0
- package/templates/base/src/plugins/rehype-image-enlarge.ts +113 -0
- package/templates/base/src/plugins/rehype-mermaid.ts +41 -0
- package/templates/base/src/plugins/rehype-strip-md-extension.ts +58 -0
- package/templates/base/src/plugins/remark-admonitions.ts +99 -0
- package/templates/base/src/plugins/remark-resolve-markdown-links.ts +127 -0
- package/templates/base/src/plugins/url-utils.ts +4 -0
- package/templates/base/src/styles/global.css +1066 -0
- package/templates/base/src/types/docs-entry.ts +39 -0
- package/templates/base/src/types/heading.ts +5 -0
- package/templates/base/src/types/locale.ts +10 -0
- package/templates/base/src/utils/base.ts +139 -0
- package/templates/base/src/utils/content-files.ts +106 -0
- package/templates/base/src/utils/dedent.ts +24 -0
- package/templates/base/src/utils/docs.ts +335 -0
- package/templates/base/src/utils/git-info.ts +70 -0
- package/templates/base/src/utils/github.ts +19 -0
- package/templates/base/src/utils/header-right-items.ts +38 -0
- package/templates/base/src/utils/nav-scope.ts +63 -0
- package/templates/base/src/utils/sidebar.ts +104 -0
- package/templates/base/src/utils/slug.ts +10 -0
- package/templates/base/src/utils/smart-break.tsx +126 -0
- package/templates/base/src/utils/tags.ts +126 -0
- package/templates/base/tsconfig.json +36 -0
- package/templates/features/bodyFootUtil/files/src/utils/github.ts +19 -0
- package/templates/features/claudeResources/files/plugins/claude-resources-plugin.mjs +137 -0
- package/templates/features/claudeResources/files/src/integrations/claude-resources/__tests__/escape-for-mdx.test.ts +34 -0
- package/templates/features/claudeResources/files/src/integrations/claude-resources/__tests__/generate.test.ts +376 -0
- package/templates/features/claudeResources/files/src/integrations/claude-resources/escape-for-mdx.ts +93 -0
- package/templates/features/claudeResources/files/src/integrations/claude-resources/generate.ts +586 -0
- package/templates/features/designTokenPanel/files/src/components/design-token-panel-bootstrap.tsx +15 -0
- package/templates/features/designTokenPanel/files/src/config/design-token-panel-config.ts +99 -0
- package/templates/features/designTokenPanel/files/src/config/design-tokens-manifest.ts +177 -0
- package/templates/features/designTokenPanel/files/src/lib/design-token-panel-bootstrap.ts +50 -0
- package/templates/features/docHistory/files/plugins/doc-history-plugin.mjs +99 -0
- package/templates/features/docHistory/files/src/components/doc-history.tsx +598 -0
- package/templates/features/docHistory/files/src/types/doc-history.ts +23 -0
- package/templates/features/docHistory/files/src/utils/doc-history.ts +180 -0
- package/templates/features/docTags/files/pages/[locale]/docs/tags/[tag].tsx +116 -0
- package/templates/features/docTags/files/pages/[locale]/docs/tags/index.tsx +99 -0
- package/templates/features/docTags/files/pages/docs/tags/[tag].tsx +101 -0
- package/templates/features/docTags/files/pages/docs/tags/index.tsx +86 -0
- package/templates/features/i18n/files/pages/[locale]/docs/[...slug].tsx +467 -0
- package/templates/features/i18n/files/pages/[locale]/index.tsx +213 -0
- package/templates/features/imageEnlarge/files/src/components/image-enlarge.tsx +248 -0
- package/templates/features/llmsTxt/files/plugins/llms-txt-plugin.mjs +74 -0
- package/templates/features/sidebarResizer/files/src/scripts/sidebar-resizer.ts +185 -0
- package/templates/features/sidebarToggle/files/src/components/desktop-sidebar-toggle.tsx +126 -0
- package/templates/features/tagGovernance/files/scripts/tags-audit.ts +576 -0
- package/templates/features/tagGovernance/files/scripts/tags-suggest.ts +428 -0
- package/templates/features/tauri/files/src/components/find-bar.tsx +122 -0
- package/templates/features/tauri/files/src/components/find-in-page-init.tsx +53 -0
- package/templates/features/tauri/files/src/utils/find-in-page.ts +175 -0
- package/templates/features/tauri/files/src-tauri/Cargo.toml +14 -0
- package/templates/features/tauri/files/src-tauri/build.rs +3 -0
- package/templates/features/tauri/files/src-tauri/capabilities/default.json +11 -0
- package/templates/features/tauri/files/src-tauri/src/main.rs +250 -0
- package/templates/features/tauri/files/src-tauri/tauri.conf.json +25 -0
- package/templates/features/tauriDev/files/src-tauri-dev/Cargo.toml +15 -0
- package/templates/features/tauriDev/files/src-tauri-dev/build.rs +3 -0
- package/templates/features/tauriDev/files/src-tauri-dev/capabilities/default.json +7 -0
- package/templates/features/tauriDev/files/src-tauri-dev/frontend/index.html +187 -0
- package/templates/features/tauriDev/files/src-tauri-dev/icons/icon.png +0 -0
- package/templates/features/tauriDev/files/src-tauri-dev/src/main.rs +995 -0
- package/templates/features/tauriDev/files/src-tauri-dev/tauri.conf.json +22 -0
- package/templates/features/tauriDev/files/src-tauri-dev/test-launch.sh +65 -0
- package/templates/features/versioning/files/pages/[locale]/docs/versions.tsx +100 -0
- package/templates/features/versioning/files/pages/docs/versions.tsx +78 -0
- package/templates/features/versioning/files/pages/v/[version]/docs/[...slug].tsx +451 -0
- package/templates/features/versioning/files/pages/v/[version]/ja/docs/[...slug].tsx +490 -0
|
@@ -0,0 +1,995 @@
|
|
|
1
|
+
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
|
2
|
+
|
|
3
|
+
use std::fs;
|
|
4
|
+
use std::path::PathBuf;
|
|
5
|
+
use std::process::{Child, Command, Stdio};
|
|
6
|
+
use std::sync::{Arc, Mutex};
|
|
7
|
+
use std::sync::atomic::{AtomicU64, Ordering};
|
|
8
|
+
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
|
9
|
+
use std::thread;
|
|
10
|
+
|
|
11
|
+
use serde::Deserialize;
|
|
12
|
+
use tauri::menu::{MenuBuilder, MenuItemBuilder, SubmenuBuilder};
|
|
13
|
+
use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder};
|
|
14
|
+
|
|
15
|
+
const IS_DEV: bool = cfg!(debug_assertions);
|
|
16
|
+
|
|
17
|
+
// ── Dev mode URL (matches tauri.conf.json devUrl) ──
|
|
18
|
+
const DEV_URL: &str = "http://localhost:4321/";
|
|
19
|
+
|
|
20
|
+
// ── Types ─────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
struct Sidecar {
|
|
23
|
+
child: Child,
|
|
24
|
+
pid: u32,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
struct AppState {
|
|
28
|
+
sidecar: Arc<Mutex<Option<Sidecar>>>,
|
|
29
|
+
zoom: Mutex<f64>,
|
|
30
|
+
// Resolved paths set once in setup(), then readable by menu handlers.
|
|
31
|
+
app_log_path: Mutex<String>,
|
|
32
|
+
sidecar_log_path: Mutex<String>,
|
|
33
|
+
app_data_dir: Mutex<PathBuf>,
|
|
34
|
+
// Bumped at the start of every launch attempt (initial setup + each
|
|
35
|
+
// restart). A launch thread that finishes after a newer launch began
|
|
36
|
+
// sees a mismatch and skips its navigate/emit so the two cannot race.
|
|
37
|
+
launch_gen: AtomicU64,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/// Shape of config.json in the platform app-data dir.
|
|
41
|
+
// macOS path: ~/Library/Application Support/com.takazudo.zudo-doc-dev/config.json
|
|
42
|
+
#[derive(Debug, Deserialize)]
|
|
43
|
+
struct Config {
|
|
44
|
+
// Read off the raw JSON value in load_config's version gate; kept on the
|
|
45
|
+
// struct to document the schema (and exercised by the unit tests).
|
|
46
|
+
#[allow(dead_code)]
|
|
47
|
+
version: u32,
|
|
48
|
+
#[serde(rename = "projectDir")]
|
|
49
|
+
project_dir: String,
|
|
50
|
+
#[serde(rename = "devCommand")]
|
|
51
|
+
dev_command: String,
|
|
52
|
+
#[serde(rename = "devServerUrl")]
|
|
53
|
+
dev_server_url: String,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── Logging ───────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
fn log_to(path: &str, msg: &str) {
|
|
59
|
+
use std::io::Write;
|
|
60
|
+
if let Ok(mut f) = fs::OpenOptions::new()
|
|
61
|
+
.create(true)
|
|
62
|
+
.append(true)
|
|
63
|
+
.open(path)
|
|
64
|
+
{
|
|
65
|
+
let secs = SystemTime::now()
|
|
66
|
+
.duration_since(UNIX_EPOCH)
|
|
67
|
+
.unwrap_or_default()
|
|
68
|
+
.as_secs();
|
|
69
|
+
let _ = writeln!(f, "[{secs}] {msg}");
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── pnpm detection ────────────────────────────────
|
|
74
|
+
|
|
75
|
+
fn find_pnpm() -> Option<PathBuf> {
|
|
76
|
+
let home = std::env::var("HOME").ok()?;
|
|
77
|
+
let candidates = [
|
|
78
|
+
"/opt/homebrew/bin/pnpm".to_string(), // Apple Silicon Homebrew
|
|
79
|
+
"/usr/local/bin/pnpm".to_string(), // Intel Homebrew
|
|
80
|
+
format!("{home}/.volta/bin/pnpm"), // Volta shim (stable absolute path)
|
|
81
|
+
];
|
|
82
|
+
for p in &candidates {
|
|
83
|
+
let path = PathBuf::from(p);
|
|
84
|
+
if path.exists() {
|
|
85
|
+
return Some(path);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if let Ok(output) = Command::new("/usr/bin/which").arg("pnpm").output() {
|
|
89
|
+
let path_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
|
90
|
+
if !path_str.is_empty() {
|
|
91
|
+
let path = PathBuf::from(&path_str);
|
|
92
|
+
if path.exists() {
|
|
93
|
+
return Some(path);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
None
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── Config loading ────────────────────────────────
|
|
101
|
+
|
|
102
|
+
enum ConfigResult {
|
|
103
|
+
Ok(Config),
|
|
104
|
+
Missing,
|
|
105
|
+
Invalid(String),
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
fn load_config(app_data_dir: &PathBuf, log_path: &str) -> ConfigResult {
|
|
109
|
+
let config_path = app_data_dir.join("config.json");
|
|
110
|
+
log_to(
|
|
111
|
+
log_path,
|
|
112
|
+
&format!("load_config: reading {}", config_path.display()),
|
|
113
|
+
);
|
|
114
|
+
let raw = match fs::read_to_string(&config_path) {
|
|
115
|
+
Ok(r) => r,
|
|
116
|
+
Err(e) => {
|
|
117
|
+
log_to(log_path, &format!("load_config: missing ({e})"));
|
|
118
|
+
return ConfigResult::Missing;
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
let val: serde_json::Value = match serde_json::from_str(&raw) {
|
|
122
|
+
Ok(v) => v,
|
|
123
|
+
Err(e) => {
|
|
124
|
+
log_to(log_path, &format!("load_config: malformed JSON ({e})"));
|
|
125
|
+
return ConfigResult::Invalid(format!("malformed JSON: {e}"));
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
// version check before typed deserialization
|
|
129
|
+
match val.get("version").and_then(|v| v.as_u64()) {
|
|
130
|
+
Some(1) => {}
|
|
131
|
+
other => {
|
|
132
|
+
let msg = format!("unsupported version: {other:?}");
|
|
133
|
+
log_to(log_path, &format!("load_config: {msg}"));
|
|
134
|
+
return ConfigResult::Invalid(msg);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
match serde_json::from_value::<Config>(val) {
|
|
138
|
+
Ok(c) => {
|
|
139
|
+
// devCommand's first token must be `pnpm`: find_pnpm() resolves
|
|
140
|
+
// only pnpm and spawn_sidecar substitutes it for token 0, so a
|
|
141
|
+
// non-pnpm command would silently run the wrong binary.
|
|
142
|
+
match c.dev_command.split_whitespace().next() {
|
|
143
|
+
Some("pnpm") => {
|
|
144
|
+
log_to(
|
|
145
|
+
log_path,
|
|
146
|
+
&format!(
|
|
147
|
+
"load_config: ok projectDir={} devServerUrl={}",
|
|
148
|
+
c.project_dir, c.dev_server_url
|
|
149
|
+
),
|
|
150
|
+
);
|
|
151
|
+
ConfigResult::Ok(c)
|
|
152
|
+
}
|
|
153
|
+
other => {
|
|
154
|
+
let msg =
|
|
155
|
+
format!("devCommand must start with 'pnpm' (got {other:?})");
|
|
156
|
+
log_to(log_path, &format!("load_config: {msg}"));
|
|
157
|
+
ConfigResult::Invalid(msg)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
Err(e) => {
|
|
162
|
+
let msg = format!("invalid fields: {e}");
|
|
163
|
+
log_to(log_path, &format!("load_config: {msg}"));
|
|
164
|
+
ConfigResult::Invalid(msg)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ── Port helpers ──────────────────────────────────
|
|
170
|
+
|
|
171
|
+
fn parse_port(url: &str) -> Option<u16> {
|
|
172
|
+
// Parses the port from a URL like "http://localhost:4321"
|
|
173
|
+
url.split(':').nth(2).and_then(|s| {
|
|
174
|
+
// strip any trailing path
|
|
175
|
+
s.split('/').next()?.parse().ok()
|
|
176
|
+
})
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
fn kill_port(port: u16, log_path: &str) {
|
|
180
|
+
if let Ok(output) = Command::new("/usr/bin/lsof")
|
|
181
|
+
.args(["-ti", &format!(":{port}")])
|
|
182
|
+
.output()
|
|
183
|
+
{
|
|
184
|
+
let pids = String::from_utf8_lossy(&output.stdout);
|
|
185
|
+
for line in pids.trim().lines() {
|
|
186
|
+
if let Ok(pid) = line.trim().parse::<i32>() {
|
|
187
|
+
log_to(
|
|
188
|
+
log_path,
|
|
189
|
+
&format!("kill_port: killing stale pid {pid} on port {port}"),
|
|
190
|
+
);
|
|
191
|
+
#[cfg(unix)]
|
|
192
|
+
unsafe {
|
|
193
|
+
libc::kill(pid, libc::SIGTERM);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if !pids.trim().is_empty() {
|
|
198
|
+
thread::sleep(Duration::from_millis(500));
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ── Sidecar management ──────────────────────────
|
|
204
|
+
|
|
205
|
+
/// Spawn the dev-server sidecar. Returns `Err` (instead of panicking) on the
|
|
206
|
+
/// realistic unhappy paths — a bad `projectDir`, an unwritable log location —
|
|
207
|
+
/// so the caller can surface a `launch-error` rather than leave the loading
|
|
208
|
+
/// spinner stuck forever in a panicked background thread.
|
|
209
|
+
fn spawn_sidecar(
|
|
210
|
+
config: &Config,
|
|
211
|
+
pnpm_path: &std::path::Path,
|
|
212
|
+
sidecar_log_path: &str,
|
|
213
|
+
app_log_path: &str,
|
|
214
|
+
) -> Result<Sidecar, String> {
|
|
215
|
+
log_to(
|
|
216
|
+
app_log_path,
|
|
217
|
+
&format!(
|
|
218
|
+
"spawn_sidecar: pnpm={} projectDir={} devCommand={}",
|
|
219
|
+
pnpm_path.display(),
|
|
220
|
+
config.project_dir,
|
|
221
|
+
config.dev_command
|
|
222
|
+
),
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
let log_file = fs::OpenOptions::new()
|
|
226
|
+
.create(true)
|
|
227
|
+
.write(true)
|
|
228
|
+
.truncate(true)
|
|
229
|
+
.open(sidecar_log_path)
|
|
230
|
+
.map_err(|e| {
|
|
231
|
+
log_to(app_log_path, &format!("Failed to open sidecar log: {e}"));
|
|
232
|
+
format!("failed to open sidecar log at {sidecar_log_path}: {e}")
|
|
233
|
+
})?;
|
|
234
|
+
let log_file_clone = log_file.try_clone().map_err(|e| {
|
|
235
|
+
log_to(app_log_path, &format!("Failed to clone sidecar log handle: {e}"));
|
|
236
|
+
format!("failed to clone sidecar log handle: {e}")
|
|
237
|
+
})?;
|
|
238
|
+
|
|
239
|
+
// devCommand is e.g. "pnpm dev" — first token is pnpm (verified by
|
|
240
|
+
// load_config); pnpm_path replaces it, rest become args.
|
|
241
|
+
let tokens: Vec<&str> = config.dev_command.split_whitespace().collect();
|
|
242
|
+
let args: &[&str] = if tokens.len() > 1 { &tokens[1..] } else { &[] };
|
|
243
|
+
|
|
244
|
+
let mut cmd = Command::new(pnpm_path);
|
|
245
|
+
cmd.args(args)
|
|
246
|
+
.current_dir(&config.project_dir)
|
|
247
|
+
.stdout(Stdio::from(log_file))
|
|
248
|
+
.stderr(Stdio::from(log_file_clone));
|
|
249
|
+
|
|
250
|
+
#[cfg(unix)]
|
|
251
|
+
{
|
|
252
|
+
use std::os::unix::process::CommandExt;
|
|
253
|
+
cmd.process_group(0);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
let child = cmd.spawn().map_err(|e| {
|
|
257
|
+
log_to(
|
|
258
|
+
app_log_path,
|
|
259
|
+
&format!("Failed to spawn sidecar (pnpm={}): {e}", pnpm_path.display()),
|
|
260
|
+
);
|
|
261
|
+
format!(
|
|
262
|
+
"failed to spawn pnpm sidecar in {}: {e}",
|
|
263
|
+
config.project_dir
|
|
264
|
+
)
|
|
265
|
+
})?;
|
|
266
|
+
let pid = child.id();
|
|
267
|
+
log_to(app_log_path, &format!("spawn_sidecar: pid={pid}"));
|
|
268
|
+
|
|
269
|
+
Ok(Sidecar { child, pid })
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
fn kill_sidecar(sidecar: &mut Sidecar, log_path: &str) {
|
|
273
|
+
log_to(log_path, &format!("kill_sidecar: pid={}", sidecar.pid));
|
|
274
|
+
#[cfg(unix)]
|
|
275
|
+
{
|
|
276
|
+
if let Ok(pid) = i32::try_from(sidecar.pid) {
|
|
277
|
+
unsafe { libc::kill(-pid, libc::SIGTERM) };
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
thread::sleep(Duration::from_millis(500));
|
|
281
|
+
match sidecar.child.try_wait() {
|
|
282
|
+
Ok(Some(_)) => {
|
|
283
|
+
log_to(log_path, "kill_sidecar: process already exited");
|
|
284
|
+
}
|
|
285
|
+
_ => {
|
|
286
|
+
log_to(log_path, "kill_sidecar: escalating to SIGKILL");
|
|
287
|
+
let _ = sidecar.child.kill();
|
|
288
|
+
let _ = sidecar.child.wait();
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ── Readiness polling ────────────────────────────
|
|
294
|
+
|
|
295
|
+
/// Outcome of waiting for the dev server to become ready.
|
|
296
|
+
///
|
|
297
|
+
/// `SidecarExited` short-circuits the wait: once the sidecar process has
|
|
298
|
+
/// died there is no point burning the rest of the timeout on curl polls.
|
|
299
|
+
#[derive(Debug)]
|
|
300
|
+
enum ReadyResult {
|
|
301
|
+
Ready,
|
|
302
|
+
Timeout,
|
|
303
|
+
SidecarExited { code: Option<i32> },
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/// Poll dev server root via curl. Returns HTTP status code as string.
|
|
307
|
+
fn curl_ready(url: &str) -> String {
|
|
308
|
+
Command::new("/usr/bin/curl")
|
|
309
|
+
.args(["-s", "-o", "/dev/null", "-w", "%{http_code}", url])
|
|
310
|
+
.output()
|
|
311
|
+
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
|
312
|
+
.unwrap_or_else(|_| "err".to_string())
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/// Wait until the dev server responds with 200, up to timeout.
|
|
316
|
+
///
|
|
317
|
+
/// The `sidecar` handle is inspected each tick via `try_wait`; if it has
|
|
318
|
+
/// exited we return `SidecarExited` immediately instead of waiting out the
|
|
319
|
+
/// full timeout.
|
|
320
|
+
fn wait_for_ready(
|
|
321
|
+
timeout: Duration,
|
|
322
|
+
sidecar: &Arc<Mutex<Option<Sidecar>>>,
|
|
323
|
+
dev_server_url: &str,
|
|
324
|
+
log_path: &str,
|
|
325
|
+
) -> ReadyResult {
|
|
326
|
+
log_to(log_path, "wait_for_ready: start");
|
|
327
|
+
let probe_url = format!("{}/", dev_server_url.trim_end_matches('/'));
|
|
328
|
+
let start = Instant::now();
|
|
329
|
+
while start.elapsed() < timeout {
|
|
330
|
+
// Check sidecar liveness before curl so we detect exits even if the
|
|
331
|
+
// port still happens to be held (e.g. by a leftover process).
|
|
332
|
+
{
|
|
333
|
+
let mut guard = sidecar.lock().unwrap();
|
|
334
|
+
if let Some(ref mut s) = *guard {
|
|
335
|
+
match s.child.try_wait() {
|
|
336
|
+
Ok(Some(status)) => {
|
|
337
|
+
let code = status.code();
|
|
338
|
+
log_to(
|
|
339
|
+
log_path,
|
|
340
|
+
&format!(
|
|
341
|
+
"wait_for_ready: sidecar exited early (code={:?})",
|
|
342
|
+
code
|
|
343
|
+
),
|
|
344
|
+
);
|
|
345
|
+
return ReadyResult::SidecarExited { code };
|
|
346
|
+
}
|
|
347
|
+
Ok(None) => {} // still running
|
|
348
|
+
Err(e) => {
|
|
349
|
+
log_to(log_path, &format!("wait_for_ready: try_wait error: {e}"));
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
let code = curl_ready(&probe_url);
|
|
356
|
+
log_to(
|
|
357
|
+
log_path,
|
|
358
|
+
&format!("curl: {code} ({}s)", start.elapsed().as_secs()),
|
|
359
|
+
);
|
|
360
|
+
if code == "200" {
|
|
361
|
+
log_to(log_path, "wait_for_ready: ready");
|
|
362
|
+
return ReadyResult::Ready;
|
|
363
|
+
}
|
|
364
|
+
thread::sleep(Duration::from_secs(1));
|
|
365
|
+
}
|
|
366
|
+
log_to(log_path, "wait_for_ready: TIMEOUT — dev server may not be up");
|
|
367
|
+
ReadyResult::Timeout
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ── Error emission ────────────────────────────────
|
|
371
|
+
|
|
372
|
+
fn emit_launch_error_str(app_handle: &AppHandle, reason: &str, sidecar_log_path: &str) {
|
|
373
|
+
let payload = serde_json::json!({
|
|
374
|
+
"reason": reason,
|
|
375
|
+
"logPath": sidecar_log_path,
|
|
376
|
+
});
|
|
377
|
+
let state = app_handle.state::<AppState>();
|
|
378
|
+
let app_log = state.app_log_path.lock().unwrap();
|
|
379
|
+
log_to(
|
|
380
|
+
&app_log,
|
|
381
|
+
&format!(
|
|
382
|
+
"emit_launch_error: reason={reason} logPath={sidecar_log_path}"
|
|
383
|
+
),
|
|
384
|
+
);
|
|
385
|
+
if let Some(w) = app_handle.get_webview_window("main") {
|
|
386
|
+
if let Err(e) = w.emit("launch-error", payload) {
|
|
387
|
+
log_to(&app_log, &format!("emit_launch_error: emit failed: {e}"));
|
|
388
|
+
}
|
|
389
|
+
} else {
|
|
390
|
+
log_to(&app_log, "emit_launch_error: no main window to emit to");
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
fn emit_launch_error(app_handle: &AppHandle, result: &ReadyResult) {
|
|
395
|
+
let reason = match result {
|
|
396
|
+
ReadyResult::Ready => return,
|
|
397
|
+
ReadyResult::Timeout => "timeout",
|
|
398
|
+
ReadyResult::SidecarExited { code } => {
|
|
399
|
+
let state = app_handle.state::<AppState>();
|
|
400
|
+
let app_log = state.app_log_path.lock().unwrap();
|
|
401
|
+
log_to(
|
|
402
|
+
&app_log,
|
|
403
|
+
&format!("emit_launch_error: sidecar exit code = {code:?}"),
|
|
404
|
+
);
|
|
405
|
+
"sidecar_exited"
|
|
406
|
+
}
|
|
407
|
+
};
|
|
408
|
+
let state = app_handle.state::<AppState>();
|
|
409
|
+
let sidecar_log = state.sidecar_log_path.lock().unwrap().clone();
|
|
410
|
+
drop(state);
|
|
411
|
+
emit_launch_error_str(app_handle, reason, &sidecar_log);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// ── Restart dev server ────────────────────────────
|
|
415
|
+
|
|
416
|
+
fn do_restart(app_handle: &AppHandle) {
|
|
417
|
+
if IS_DEV {
|
|
418
|
+
// In dev mode, just navigate back to the dev URL.
|
|
419
|
+
if let Some(w) = app_handle.get_webview_window("main") {
|
|
420
|
+
let url: tauri::Url = DEV_URL.parse().expect("BUG: DEV_URL is invalid");
|
|
421
|
+
let _ = w.navigate(url);
|
|
422
|
+
}
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
let state = app_handle.state::<AppState>();
|
|
427
|
+
let app_log = state.app_log_path.lock().unwrap().clone();
|
|
428
|
+
let sidecar_log = state.sidecar_log_path.lock().unwrap().clone();
|
|
429
|
+
let app_data_dir = state.app_data_dir.lock().unwrap().clone();
|
|
430
|
+
// Claim a new launch generation: any in-flight launch (the initial setup
|
|
431
|
+
// thread or an earlier restart) is now stale and will skip its navigate.
|
|
432
|
+
let my_gen = state.launch_gen.fetch_add(1, Ordering::SeqCst) + 1;
|
|
433
|
+
drop(state);
|
|
434
|
+
|
|
435
|
+
log_to(&app_log, "do_restart: re-reading config.json");
|
|
436
|
+
|
|
437
|
+
// 1. Re-read config.json
|
|
438
|
+
let config = match load_config(&app_data_dir, &app_log) {
|
|
439
|
+
ConfigResult::Ok(c) => c,
|
|
440
|
+
ConfigResult::Missing => {
|
|
441
|
+
emit_launch_error_str(app_handle, "config_missing", &sidecar_log);
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
ConfigResult::Invalid(_) => {
|
|
445
|
+
emit_launch_error_str(app_handle, "config_invalid", &sidecar_log);
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
let port = match parse_port(&config.dev_server_url) {
|
|
451
|
+
Some(p) => p,
|
|
452
|
+
None => {
|
|
453
|
+
log_to(&app_log, "do_restart: could not parse port from devServerUrl");
|
|
454
|
+
emit_launch_error_str(app_handle, "config_invalid", &sidecar_log);
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
let pnpm_path = match find_pnpm() {
|
|
460
|
+
Some(p) => {
|
|
461
|
+
log_to(&app_log, &format!("do_restart: pnpm found at {}", p.display()));
|
|
462
|
+
p
|
|
463
|
+
}
|
|
464
|
+
None => {
|
|
465
|
+
log_to(&app_log, "do_restart: pnpm not found");
|
|
466
|
+
emit_launch_error_str(app_handle, "pnpm_not_found", &sidecar_log);
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
let dev_server_url = config.dev_server_url.clone();
|
|
472
|
+
let state = app_handle.state::<AppState>();
|
|
473
|
+
let sidecar_arc = state.sidecar.clone();
|
|
474
|
+
|
|
475
|
+
// 2. Kill current sidecar
|
|
476
|
+
{
|
|
477
|
+
let mut guard = sidecar_arc.lock().unwrap();
|
|
478
|
+
if let Some(mut old) = guard.take() {
|
|
479
|
+
kill_sidecar(&mut old, &app_log);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// 3. Kill stale port holders
|
|
484
|
+
kill_port(port, &app_log);
|
|
485
|
+
|
|
486
|
+
// 4. Spawn new sidecar
|
|
487
|
+
{
|
|
488
|
+
let mut guard = sidecar_arc.lock().unwrap();
|
|
489
|
+
match spawn_sidecar(&config, &pnpm_path, &sidecar_log, &app_log) {
|
|
490
|
+
Ok(s) => *guard = Some(s),
|
|
491
|
+
Err(e) => {
|
|
492
|
+
drop(guard);
|
|
493
|
+
log_to(&app_log, &format!("do_restart: spawn failed: {e}"));
|
|
494
|
+
emit_launch_error_str(app_handle, "spawn_failed", &sidecar_log);
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// 5. Wait for ready (use full 120s — restart may point at a cold project)
|
|
501
|
+
let result = wait_for_ready(
|
|
502
|
+
Duration::from_secs(120),
|
|
503
|
+
&sidecar_arc,
|
|
504
|
+
&dev_server_url,
|
|
505
|
+
&app_log,
|
|
506
|
+
);
|
|
507
|
+
|
|
508
|
+
// 6. Skip navigate/emit if a newer launch superseded this one.
|
|
509
|
+
if app_handle.state::<AppState>().launch_gen.load(Ordering::SeqCst) != my_gen {
|
|
510
|
+
log_to(
|
|
511
|
+
&app_log,
|
|
512
|
+
"do_restart: superseded by a newer launch — skipping navigate/emit",
|
|
513
|
+
);
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// 7. Navigate or emit error
|
|
518
|
+
match result {
|
|
519
|
+
ReadyResult::Ready => {
|
|
520
|
+
if let Some(w) = app_handle.get_webview_window("main") {
|
|
521
|
+
if let Ok(url) = dev_server_url.parse::<tauri::Url>() {
|
|
522
|
+
let _ = w.navigate(url);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
ReadyResult::Timeout | ReadyResult::SidecarExited { .. } => {
|
|
527
|
+
emit_launch_error(app_handle, &result);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
fn apply_zoom(app_handle: &AppHandle, level: f64) {
|
|
533
|
+
let state = app_handle.state::<AppState>();
|
|
534
|
+
*state.zoom.lock().unwrap() = level;
|
|
535
|
+
if let Some(w) = app_handle.get_webview_window("main") {
|
|
536
|
+
let _ = w.eval(&format!("document.body.style.zoom = '{level}'"));
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
#[tauri::command]
|
|
541
|
+
fn retry_launch(app_handle: AppHandle) {
|
|
542
|
+
let state = app_handle.state::<AppState>();
|
|
543
|
+
let app_log = state.app_log_path.lock().unwrap().clone();
|
|
544
|
+
drop(state);
|
|
545
|
+
log_to(&app_log, "retry_launch: invoked from frontend");
|
|
546
|
+
thread::spawn(move || do_restart(&app_handle));
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// ── Main ──────────────────────────────────────────
|
|
550
|
+
|
|
551
|
+
fn main() {
|
|
552
|
+
// AppState is initialized with empty log path strings; they are filled in
|
|
553
|
+
// during setup() once app_data_dir is available from AppHandle.
|
|
554
|
+
let app_state = AppState {
|
|
555
|
+
sidecar: Arc::new(Mutex::new(None)),
|
|
556
|
+
zoom: Mutex::new(1.0),
|
|
557
|
+
app_log_path: Mutex::new(String::new()),
|
|
558
|
+
sidecar_log_path: Mutex::new(String::new()),
|
|
559
|
+
app_data_dir: Mutex::new(PathBuf::new()),
|
|
560
|
+
launch_gen: AtomicU64::new(0),
|
|
561
|
+
};
|
|
562
|
+
let sidecar_for_exit = app_state.sidecar.clone();
|
|
563
|
+
|
|
564
|
+
tauri::Builder::default()
|
|
565
|
+
.manage(app_state)
|
|
566
|
+
.invoke_handler(tauri::generate_handler![retry_launch])
|
|
567
|
+
.setup(move |app| {
|
|
568
|
+
// ── Resolve app-data paths ──
|
|
569
|
+
// These are derived from the bundle identifier
|
|
570
|
+
// (com.takazudo.zudo-doc-dev) via Tauri's path resolver.
|
|
571
|
+
let app_data_dir = app.path().app_data_dir()
|
|
572
|
+
.expect("app_data_dir unavailable");
|
|
573
|
+
fs::create_dir_all(&app_data_dir)?;
|
|
574
|
+
|
|
575
|
+
let app_log_path = app_data_dir.join("zudo-doc-dev.log")
|
|
576
|
+
.to_string_lossy()
|
|
577
|
+
.into_owned();
|
|
578
|
+
let sidecar_log_path = app_data_dir.join("zudo-doc-dev-sidecar.log")
|
|
579
|
+
.to_string_lossy()
|
|
580
|
+
.into_owned();
|
|
581
|
+
|
|
582
|
+
{
|
|
583
|
+
let state = app.state::<AppState>();
|
|
584
|
+
*state.app_log_path.lock().unwrap() = app_log_path.clone();
|
|
585
|
+
*state.sidecar_log_path.lock().unwrap() = sidecar_log_path.clone();
|
|
586
|
+
*state.app_data_dir.lock().unwrap() = app_data_dir.clone();
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
log_to(&app_log_path, "setup: starting zudo-doc dev");
|
|
590
|
+
|
|
591
|
+
// ── Menu ──
|
|
592
|
+
let app_menu = SubmenuBuilder::new(app, "zudo-doc dev")
|
|
593
|
+
.about(None)
|
|
594
|
+
.separator()
|
|
595
|
+
.quit()
|
|
596
|
+
.build()?;
|
|
597
|
+
|
|
598
|
+
let edit_menu = SubmenuBuilder::new(app, "Edit")
|
|
599
|
+
.undo()
|
|
600
|
+
.redo()
|
|
601
|
+
.separator()
|
|
602
|
+
.cut()
|
|
603
|
+
.copy()
|
|
604
|
+
.paste()
|
|
605
|
+
.select_all()
|
|
606
|
+
.build()?;
|
|
607
|
+
|
|
608
|
+
let view_menu = SubmenuBuilder::new(app, "View")
|
|
609
|
+
.item(
|
|
610
|
+
&MenuItemBuilder::with_id("restart", "Restart dev server")
|
|
611
|
+
.accelerator("CmdOrCtrl+R")
|
|
612
|
+
.build(app)?,
|
|
613
|
+
)
|
|
614
|
+
.item(
|
|
615
|
+
&MenuItemBuilder::with_id("devtools", "Toggle Developer Tools")
|
|
616
|
+
.accelerator("CmdOrCtrl+Alt+I")
|
|
617
|
+
.build(app)?,
|
|
618
|
+
)
|
|
619
|
+
.separator()
|
|
620
|
+
.item(
|
|
621
|
+
&MenuItemBuilder::with_id("actual_size", "Actual Size")
|
|
622
|
+
.accelerator("CmdOrCtrl+0")
|
|
623
|
+
.build(app)?,
|
|
624
|
+
)
|
|
625
|
+
.item(
|
|
626
|
+
&MenuItemBuilder::with_id("zoom_in", "Zoom In")
|
|
627
|
+
.accelerator("CmdOrCtrl+=")
|
|
628
|
+
.build(app)?,
|
|
629
|
+
)
|
|
630
|
+
.item(
|
|
631
|
+
&MenuItemBuilder::with_id("zoom_out", "Zoom Out")
|
|
632
|
+
.accelerator("CmdOrCtrl+-")
|
|
633
|
+
.build(app)?,
|
|
634
|
+
)
|
|
635
|
+
.build()?;
|
|
636
|
+
|
|
637
|
+
let menu = MenuBuilder::new(app)
|
|
638
|
+
.item(&app_menu)
|
|
639
|
+
.item(&edit_menu)
|
|
640
|
+
.item(&view_menu)
|
|
641
|
+
.build()?;
|
|
642
|
+
|
|
643
|
+
app.set_menu(menu)?;
|
|
644
|
+
|
|
645
|
+
app.on_menu_event(|app_handle, event| match event.id().as_ref() {
|
|
646
|
+
"restart" => {
|
|
647
|
+
let handle = app_handle.clone();
|
|
648
|
+
thread::spawn(move || do_restart(&handle));
|
|
649
|
+
}
|
|
650
|
+
"devtools" => {
|
|
651
|
+
if let Some(w) = app_handle.get_webview_window("main") {
|
|
652
|
+
if w.is_devtools_open() {
|
|
653
|
+
w.close_devtools();
|
|
654
|
+
} else {
|
|
655
|
+
w.open_devtools();
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
"actual_size" => apply_zoom(app_handle, 1.0),
|
|
660
|
+
"zoom_in" => {
|
|
661
|
+
let state = app_handle.state::<AppState>();
|
|
662
|
+
let z = (*state.zoom.lock().unwrap() + 0.1).min(3.0);
|
|
663
|
+
apply_zoom(app_handle, z);
|
|
664
|
+
}
|
|
665
|
+
"zoom_out" => {
|
|
666
|
+
let state = app_handle.state::<AppState>();
|
|
667
|
+
let z = (*state.zoom.lock().unwrap() - 0.1).max(0.1);
|
|
668
|
+
apply_zoom(app_handle, z);
|
|
669
|
+
}
|
|
670
|
+
_ => {}
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
// ── Window ──
|
|
674
|
+
// Open immediately with the bundled loading page. In production
|
|
675
|
+
// a background thread handles config loading, sidecar spawn, and
|
|
676
|
+
// ready polling, then navigates to devServerUrl.
|
|
677
|
+
if IS_DEV {
|
|
678
|
+
let url: tauri::Url = DEV_URL.parse().expect("BUG: DEV_URL is invalid");
|
|
679
|
+
WebviewWindowBuilder::new(app, "main", WebviewUrl::External(url))
|
|
680
|
+
.title("zudo-doc dev")
|
|
681
|
+
.inner_size(1200.0, 800.0)
|
|
682
|
+
.build()?;
|
|
683
|
+
} else {
|
|
684
|
+
// Show the bundled loading spinner immediately — never block setup().
|
|
685
|
+
WebviewWindowBuilder::new(app, "main", WebviewUrl::default())
|
|
686
|
+
.title("zudo-doc dev")
|
|
687
|
+
.inner_size(1200.0, 800.0)
|
|
688
|
+
.build()?;
|
|
689
|
+
|
|
690
|
+
let handle = app.handle().clone();
|
|
691
|
+
let app_log = app_log_path.clone();
|
|
692
|
+
let sidecar_log = sidecar_log_path.clone();
|
|
693
|
+
let data_dir = app_data_dir.clone();
|
|
694
|
+
|
|
695
|
+
thread::spawn(move || {
|
|
696
|
+
// Claim this launch's generation; a restart pressed during
|
|
697
|
+
// the wait below bumps it and makes this thread skip its
|
|
698
|
+
// navigate/emit so the two launches do not race.
|
|
699
|
+
let my_gen = {
|
|
700
|
+
let state = handle.state::<AppState>();
|
|
701
|
+
state.launch_gen.fetch_add(1, Ordering::SeqCst) + 1
|
|
702
|
+
};
|
|
703
|
+
|
|
704
|
+
// Load config.json — location is <app_data_dir>/config.json
|
|
705
|
+
let config = match load_config(&data_dir, &app_log) {
|
|
706
|
+
ConfigResult::Ok(c) => c,
|
|
707
|
+
ConfigResult::Missing => {
|
|
708
|
+
log_to(&app_log, "setup thread: config missing");
|
|
709
|
+
emit_launch_error_str(&handle, "config_missing", &sidecar_log);
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
ConfigResult::Invalid(reason) => {
|
|
713
|
+
log_to(&app_log, &format!("setup thread: config invalid: {reason}"));
|
|
714
|
+
emit_launch_error_str(&handle, "config_invalid", &sidecar_log);
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
};
|
|
718
|
+
|
|
719
|
+
let port = match parse_port(&config.dev_server_url) {
|
|
720
|
+
Some(p) => p,
|
|
721
|
+
None => {
|
|
722
|
+
log_to(
|
|
723
|
+
&app_log,
|
|
724
|
+
"setup thread: could not parse port from devServerUrl",
|
|
725
|
+
);
|
|
726
|
+
emit_launch_error_str(&handle, "config_invalid", &sidecar_log);
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
};
|
|
730
|
+
|
|
731
|
+
let pnpm_path = match find_pnpm() {
|
|
732
|
+
Some(p) => {
|
|
733
|
+
log_to(
|
|
734
|
+
&app_log,
|
|
735
|
+
&format!("setup thread: pnpm found at {}", p.display()),
|
|
736
|
+
);
|
|
737
|
+
p
|
|
738
|
+
}
|
|
739
|
+
None => {
|
|
740
|
+
log_to(&app_log, "setup thread: pnpm not found");
|
|
741
|
+
emit_launch_error_str(&handle, "pnpm_not_found", &sidecar_log);
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
};
|
|
745
|
+
|
|
746
|
+
// Kill any stale process holding the port before spawning.
|
|
747
|
+
kill_port(port, &app_log);
|
|
748
|
+
|
|
749
|
+
let state = handle.state::<AppState>();
|
|
750
|
+
let sidecar_arc = state.sidecar.clone();
|
|
751
|
+
drop(state);
|
|
752
|
+
|
|
753
|
+
{
|
|
754
|
+
let mut guard = sidecar_arc.lock().unwrap();
|
|
755
|
+
match spawn_sidecar(&config, &pnpm_path, &sidecar_log, &app_log) {
|
|
756
|
+
Ok(s) => *guard = Some(s),
|
|
757
|
+
Err(e) => {
|
|
758
|
+
drop(guard);
|
|
759
|
+
log_to(&app_log, &format!("setup thread: spawn failed: {e}"));
|
|
760
|
+
emit_launch_error_str(&handle, "spawn_failed", &sidecar_log);
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
let dev_server_url = config.dev_server_url.clone();
|
|
767
|
+
let result = wait_for_ready(
|
|
768
|
+
Duration::from_secs(120),
|
|
769
|
+
&sidecar_arc,
|
|
770
|
+
&dev_server_url,
|
|
771
|
+
&app_log,
|
|
772
|
+
);
|
|
773
|
+
|
|
774
|
+
if handle.state::<AppState>().launch_gen.load(Ordering::SeqCst)
|
|
775
|
+
!= my_gen
|
|
776
|
+
{
|
|
777
|
+
log_to(
|
|
778
|
+
&app_log,
|
|
779
|
+
"setup thread: superseded by a restart — skipping navigate/emit",
|
|
780
|
+
);
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
match result {
|
|
785
|
+
ReadyResult::Ready => {
|
|
786
|
+
if let Some(w) = handle.get_webview_window("main") {
|
|
787
|
+
if let Ok(url) = dev_server_url.parse::<tauri::Url>() {
|
|
788
|
+
let _ = w.navigate(url);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
ReadyResult::Timeout | ReadyResult::SidecarExited { .. } => {
|
|
793
|
+
emit_launch_error(&handle, &result);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
Ok(())
|
|
800
|
+
})
|
|
801
|
+
.build(tauri::generate_context!())
|
|
802
|
+
.expect("error while building tauri application")
|
|
803
|
+
.run(move |app_handle, event| match &event {
|
|
804
|
+
tauri::RunEvent::WindowEvent {
|
|
805
|
+
event: tauri::WindowEvent::Destroyed,
|
|
806
|
+
..
|
|
807
|
+
} => {
|
|
808
|
+
if !IS_DEV {
|
|
809
|
+
// Retrieve log path from AppState (set during setup()).
|
|
810
|
+
let log_path = {
|
|
811
|
+
let state = app_handle.state::<AppState>();
|
|
812
|
+
let p = state.app_log_path.lock().unwrap().clone();
|
|
813
|
+
p
|
|
814
|
+
};
|
|
815
|
+
if let Ok(mut g) = sidecar_for_exit.lock() {
|
|
816
|
+
if let Some(mut s) = g.take() {
|
|
817
|
+
kill_sidecar(&mut s, &log_path);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
app_handle.exit(0);
|
|
822
|
+
}
|
|
823
|
+
_ => {}
|
|
824
|
+
});
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// ── Tests ─────────────────────────────────────────
|
|
828
|
+
|
|
829
|
+
#[cfg(test)]
|
|
830
|
+
mod tests {
|
|
831
|
+
use super::*;
|
|
832
|
+
use std::process::Stdio;
|
|
833
|
+
|
|
834
|
+
fn read_tauri_conf() -> serde_json::Value {
|
|
835
|
+
let conf_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tauri.conf.json");
|
|
836
|
+
let raw = std::fs::read_to_string(&conf_path).expect("Failed to read tauri.conf.json");
|
|
837
|
+
serde_json::from_str(&raw).expect("Failed to parse tauri.conf.json")
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
#[test]
|
|
841
|
+
fn find_pnpm_returns_valid_path_or_none() {
|
|
842
|
+
let result = find_pnpm();
|
|
843
|
+
if let Some(ref path) = result {
|
|
844
|
+
assert!(path.is_absolute(), "pnpm path should be absolute");
|
|
845
|
+
assert!(path.exists(), "returned pnpm path should exist");
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
#[test]
|
|
850
|
+
fn parse_port_standard_url() {
|
|
851
|
+
assert_eq!(parse_port("http://localhost:4321"), Some(4321));
|
|
852
|
+
assert_eq!(parse_port("http://localhost:4321/"), Some(4321));
|
|
853
|
+
assert_eq!(parse_port("http://localhost:4321/docs/"), Some(4321));
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
#[test]
|
|
857
|
+
fn parse_port_returns_none_for_missing() {
|
|
858
|
+
assert_eq!(parse_port("not-a-url"), None);
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
#[test]
|
|
862
|
+
fn tauri_conf_identifier_matches() {
|
|
863
|
+
let conf = read_tauri_conf();
|
|
864
|
+
let id = conf["identifier"].as_str().expect("identifier must be string");
|
|
865
|
+
assert_eq!(id, "com.takazudo.zudo-doc-dev");
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
#[test]
|
|
869
|
+
fn tauri_conf_product_name() {
|
|
870
|
+
let conf = read_tauri_conf();
|
|
871
|
+
let name = conf["productName"].as_str().expect("productName must be string");
|
|
872
|
+
assert_eq!(name, "zudo-doc dev");
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
#[test]
|
|
876
|
+
fn tauri_conf_dev_url_is_port_4321() {
|
|
877
|
+
let conf = read_tauri_conf();
|
|
878
|
+
let dev_url = conf["build"]["devUrl"].as_str().expect("devUrl must be string");
|
|
879
|
+
assert!(
|
|
880
|
+
dev_url.contains("4321"),
|
|
881
|
+
"devUrl '{dev_url}' should reference port 4321"
|
|
882
|
+
);
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
#[test]
|
|
886
|
+
fn tauri_conf_enables_global_tauri() {
|
|
887
|
+
let conf = read_tauri_conf();
|
|
888
|
+
let flag = conf["app"]["withGlobalTauri"].as_bool();
|
|
889
|
+
assert_eq!(
|
|
890
|
+
flag,
|
|
891
|
+
Some(true),
|
|
892
|
+
"app.withGlobalTauri must be true for the bundled loading page"
|
|
893
|
+
);
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
#[test]
|
|
897
|
+
fn tauri_conf_before_dev_command() {
|
|
898
|
+
let conf = read_tauri_conf();
|
|
899
|
+
let cmd = conf["build"]["beforeDevCommand"]
|
|
900
|
+
.as_str()
|
|
901
|
+
.expect("beforeDevCommand must be a string");
|
|
902
|
+
assert!(!cmd.is_empty(), "beforeDevCommand must not be empty");
|
|
903
|
+
assert!(
|
|
904
|
+
cmd.contains("pnpm dev"),
|
|
905
|
+
"beforeDevCommand '{cmd}' should run pnpm dev"
|
|
906
|
+
);
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
#[test]
|
|
910
|
+
fn loading_page_wires_launch_error_and_retry_launch() {
|
|
911
|
+
let html_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
|
912
|
+
.join("frontend")
|
|
913
|
+
.join("index.html");
|
|
914
|
+
let html = std::fs::read_to_string(&html_path)
|
|
915
|
+
.expect("Failed to read frontend/index.html");
|
|
916
|
+
assert!(
|
|
917
|
+
html.contains("\"launch-error\""),
|
|
918
|
+
"frontend/index.html should listen for the launch-error event"
|
|
919
|
+
);
|
|
920
|
+
assert!(
|
|
921
|
+
html.contains("\"retry_launch\""),
|
|
922
|
+
"frontend/index.html should invoke the retry_launch command"
|
|
923
|
+
);
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
#[test]
|
|
927
|
+
fn wait_for_ready_detects_sidecar_exit() {
|
|
928
|
+
let child = Command::new("/bin/sh")
|
|
929
|
+
.args(["-c", "exit 7"])
|
|
930
|
+
.stdout(Stdio::null())
|
|
931
|
+
.stderr(Stdio::null())
|
|
932
|
+
.spawn()
|
|
933
|
+
.expect("failed to spawn test child");
|
|
934
|
+
let pid = child.id();
|
|
935
|
+
let sidecar = Arc::new(Mutex::new(Some(Sidecar { child, pid })));
|
|
936
|
+
|
|
937
|
+
let start = Instant::now();
|
|
938
|
+
let result = wait_for_ready(
|
|
939
|
+
Duration::from_secs(30),
|
|
940
|
+
&sidecar,
|
|
941
|
+
"http://localhost:19999",
|
|
942
|
+
"/dev/null",
|
|
943
|
+
);
|
|
944
|
+
let elapsed = start.elapsed();
|
|
945
|
+
|
|
946
|
+
match result {
|
|
947
|
+
ReadyResult::SidecarExited { code } => {
|
|
948
|
+
assert_eq!(code, Some(7), "expected child exit code 7, got {code:?}");
|
|
949
|
+
assert!(
|
|
950
|
+
elapsed < Duration::from_secs(10),
|
|
951
|
+
"SidecarExited should be detected quickly, took {elapsed:?}"
|
|
952
|
+
);
|
|
953
|
+
}
|
|
954
|
+
other => panic!("expected SidecarExited, got {other:?}"),
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
#[test]
|
|
959
|
+
fn wait_for_ready_times_out_without_sidecar_or_server() {
|
|
960
|
+
let sidecar: Arc<Mutex<Option<Sidecar>>> = Arc::new(Mutex::new(None));
|
|
961
|
+
let start = Instant::now();
|
|
962
|
+
let result = wait_for_ready(
|
|
963
|
+
Duration::from_millis(100),
|
|
964
|
+
&sidecar,
|
|
965
|
+
"http://localhost:19998",
|
|
966
|
+
"/dev/null",
|
|
967
|
+
);
|
|
968
|
+
let elapsed = start.elapsed();
|
|
969
|
+
assert!(
|
|
970
|
+
elapsed < Duration::from_secs(5),
|
|
971
|
+
"wait_for_ready should exit quickly on short timeout, took {elapsed:?}"
|
|
972
|
+
);
|
|
973
|
+
match result {
|
|
974
|
+
ReadyResult::Timeout | ReadyResult::Ready => {}
|
|
975
|
+
ReadyResult::SidecarExited { .. } => {
|
|
976
|
+
panic!("did not pass a sidecar — SidecarExited is impossible")
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
#[test]
|
|
982
|
+
fn config_deserializes_valid_json() {
|
|
983
|
+
let raw = r#"{
|
|
984
|
+
"version": 1,
|
|
985
|
+
"projectDir": "/Users/foo/repos/my-doc",
|
|
986
|
+
"devCommand": "pnpm dev",
|
|
987
|
+
"devServerUrl": "http://localhost:4321"
|
|
988
|
+
}"#;
|
|
989
|
+
let c: Config = serde_json::from_str(raw).expect("should parse");
|
|
990
|
+
assert_eq!(c.version, 1);
|
|
991
|
+
assert_eq!(c.project_dir, "/Users/foo/repos/my-doc");
|
|
992
|
+
assert_eq!(c.dev_command, "pnpm dev");
|
|
993
|
+
assert_eq!(c.dev_server_url, "http://localhost:4321");
|
|
994
|
+
}
|
|
995
|
+
}
|