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.
Files changed (212) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +146 -0
  3. package/bin/create-zudo-doc.js +2 -0
  4. package/dist/api.d.ts +20 -0
  5. package/dist/api.js +13 -0
  6. package/dist/claude-md-gen.d.ts +2 -0
  7. package/dist/claude-md-gen.js +113 -0
  8. package/dist/cli.d.ts +39 -0
  9. package/dist/cli.js +157 -0
  10. package/dist/compose.d.ts +95 -0
  11. package/dist/compose.js +206 -0
  12. package/dist/constants.d.ts +20 -0
  13. package/dist/constants.js +224 -0
  14. package/dist/features/body-foot-util.d.ts +10 -0
  15. package/dist/features/body-foot-util.js +12 -0
  16. package/dist/features/claude-resources.d.ts +2 -0
  17. package/dist/features/claude-resources.js +6 -0
  18. package/dist/features/design-token-panel.d.ts +14 -0
  19. package/dist/features/design-token-panel.js +27 -0
  20. package/dist/features/doc-history.d.ts +9 -0
  21. package/dist/features/doc-history.js +11 -0
  22. package/dist/features/doc-tags.d.ts +19 -0
  23. package/dist/features/doc-tags.js +33 -0
  24. package/dist/features/footer-taglist.d.ts +14 -0
  25. package/dist/features/footer-taglist.js +17 -0
  26. package/dist/features/footer.d.ts +8 -0
  27. package/dist/features/footer.js +10 -0
  28. package/dist/features/i18n.d.ts +22 -0
  29. package/dist/features/i18n.js +41 -0
  30. package/dist/features/image-enlarge.d.ts +11 -0
  31. package/dist/features/image-enlarge.js +13 -0
  32. package/dist/features/index.d.ts +15 -0
  33. package/dist/features/index.js +53 -0
  34. package/dist/features/llms-txt.d.ts +11 -0
  35. package/dist/features/llms-txt.js +13 -0
  36. package/dist/features/search.d.ts +9 -0
  37. package/dist/features/search.js +11 -0
  38. package/dist/features/sidebar-resizer.d.ts +14 -0
  39. package/dist/features/sidebar-resizer.js +16 -0
  40. package/dist/features/sidebar-toggle.d.ts +13 -0
  41. package/dist/features/sidebar-toggle.js +15 -0
  42. package/dist/features/tag-governance.d.ts +14 -0
  43. package/dist/features/tag-governance.js +16 -0
  44. package/dist/features/tauri-dev.d.ts +2 -0
  45. package/dist/features/tauri-dev.js +25 -0
  46. package/dist/features/tauri.d.ts +11 -0
  47. package/dist/features/tauri.js +52 -0
  48. package/dist/features/versioning.d.ts +27 -0
  49. package/dist/features/versioning.js +43 -0
  50. package/dist/index.d.ts +1 -0
  51. package/dist/index.js +150 -0
  52. package/dist/preset.d.ts +37 -0
  53. package/dist/preset.js +156 -0
  54. package/dist/prompts.d.ts +32 -0
  55. package/dist/prompts.js +248 -0
  56. package/dist/scaffold.d.ts +4 -0
  57. package/dist/scaffold.js +344 -0
  58. package/dist/settings-gen.d.ts +2 -0
  59. package/dist/settings-gen.js +237 -0
  60. package/dist/utils.d.ts +8 -0
  61. package/dist/utils.js +34 -0
  62. package/dist/zfb-config-gen.d.ts +19 -0
  63. package/dist/zfb-config-gen.js +222 -0
  64. package/package.json +65 -0
  65. package/templates/base/.htmlvalidate.json +5 -0
  66. package/templates/base/.zfb/doc-history-meta.json +1 -0
  67. package/templates/base/pages/404.tsx +55 -0
  68. package/templates/base/pages/_data.ts +179 -0
  69. package/templates/base/pages/_mdx-components.ts +249 -0
  70. package/templates/base/pages/docs/[...slug].tsx +448 -0
  71. package/templates/base/pages/index.tsx +158 -0
  72. package/templates/base/pages/lib/_body-end-islands.tsx +201 -0
  73. package/templates/base/pages/lib/_category-nav.tsx +148 -0
  74. package/templates/base/pages/lib/_category-tree-nav.tsx +104 -0
  75. package/templates/base/pages/lib/_compose-meta-title.ts +29 -0
  76. package/templates/base/pages/lib/_details.tsx +30 -0
  77. package/templates/base/pages/lib/_doc-history-area.tsx +178 -0
  78. package/templates/base/pages/lib/_doc-metainfo-area.tsx +100 -0
  79. package/templates/base/pages/lib/_doc-tags-area.tsx +89 -0
  80. package/templates/base/pages/lib/_extract-headings.ts +81 -0
  81. package/templates/base/pages/lib/_footer-with-defaults.tsx +234 -0
  82. package/templates/base/pages/lib/_frontmatter-preview-data.ts +53 -0
  83. package/templates/base/pages/lib/_head-with-defaults.tsx +113 -0
  84. package/templates/base/pages/lib/_header-with-defaults.tsx +386 -0
  85. package/templates/base/pages/lib/_inline-version-switcher.tsx +84 -0
  86. package/templates/base/pages/lib/_math-block.tsx +63 -0
  87. package/templates/base/pages/lib/_nav-source-docs.ts +68 -0
  88. package/templates/base/pages/lib/_preset-generator.tsx +81 -0
  89. package/templates/base/pages/lib/_search-widget-script.ts +388 -0
  90. package/templates/base/pages/lib/_search-widget.tsx +196 -0
  91. package/templates/base/pages/lib/_sidebar-with-defaults.tsx +176 -0
  92. package/templates/base/pages/lib/_site-tree-nav.tsx +128 -0
  93. package/templates/base/pages/lib/locale-merge.ts +58 -0
  94. package/templates/base/pages/lib/route-enumerators.ts +302 -0
  95. package/templates/base/pages/sitemap.xml.tsx +51 -0
  96. package/templates/base/plugins/connect-adapter.mjs +144 -0
  97. package/templates/base/plugins/copy-public-plugin.mjs +50 -0
  98. package/templates/base/plugins/search-index-plugin.mjs +54 -0
  99. package/templates/base/scripts/run-b4push.sh +102 -0
  100. package/templates/base/src/components/ai-chat-modal.tsx +15 -0
  101. package/templates/base/src/components/client-router-bootstrap.tsx +14 -0
  102. package/templates/base/src/components/content/component-map.ts +25 -0
  103. package/templates/base/src/components/content/content-blockquote.tsx +16 -0
  104. package/templates/base/src/components/content/content-code.tsx +117 -0
  105. package/templates/base/src/components/content/content-link.tsx +83 -0
  106. package/templates/base/src/components/content/content-ol.tsx +19 -0
  107. package/templates/base/src/components/content/content-paragraph.tsx +10 -0
  108. package/templates/base/src/components/content/content-strong.tsx +16 -0
  109. package/templates/base/src/components/content/content-table.tsx +18 -0
  110. package/templates/base/src/components/content/content-ul.tsx +18 -0
  111. package/templates/base/src/components/content/heading-h2.tsx +26 -0
  112. package/templates/base/src/components/content/heading-h3.tsx +26 -0
  113. package/templates/base/src/components/content/heading-h4.tsx +26 -0
  114. package/templates/base/src/components/design-token-panel-bootstrap.tsx +15 -0
  115. package/templates/base/src/components/desktop-sidebar-toggle.tsx +15 -0
  116. package/templates/base/src/components/doc-history.tsx +18 -0
  117. package/templates/base/src/components/html-preview/highlighted-code.tsx +74 -0
  118. package/templates/base/src/components/html-preview/html-preview.tsx +108 -0
  119. package/templates/base/src/components/html-preview/preflight.ts +112 -0
  120. package/templates/base/src/components/html-preview/preview-base.tsx +159 -0
  121. package/templates/base/src/components/image-enlarge.tsx +19 -0
  122. package/templates/base/src/components/mobile-toc.tsx +94 -0
  123. package/templates/base/src/components/preset-generator.tsx +14 -0
  124. package/templates/base/src/components/sidebar-toggle.tsx +98 -0
  125. package/templates/base/src/components/sidebar-tree.tsx +543 -0
  126. package/templates/base/src/components/site-tree-nav.tsx +233 -0
  127. package/templates/base/src/components/theme-toggle.tsx +93 -0
  128. package/templates/base/src/components/toc.tsx +63 -0
  129. package/templates/base/src/components/tree-nav-shared.tsx +71 -0
  130. package/templates/base/src/config/color-scheme-utils.ts +182 -0
  131. package/templates/base/src/config/color-schemes.ts +128 -0
  132. package/templates/base/src/config/frontmatter-preview-defaults.ts +24 -0
  133. package/templates/base/src/config/frontmatter-preview-renderers.tsx +46 -0
  134. package/templates/base/src/config/i18n.ts +225 -0
  135. package/templates/base/src/config/settings-types.ts +162 -0
  136. package/templates/base/src/config/sidebars.ts +66 -0
  137. package/templates/base/src/config/tag-vocabulary-types.ts +39 -0
  138. package/templates/base/src/config/tag-vocabulary.ts +20 -0
  139. package/templates/base/src/hooks/use-active-heading.ts +133 -0
  140. package/templates/base/src/plugins/docs-source-map.ts +103 -0
  141. package/templates/base/src/plugins/hast-utils.ts +10 -0
  142. package/templates/base/src/plugins/rehype-code-title.ts +50 -0
  143. package/templates/base/src/plugins/rehype-heading-links.ts +53 -0
  144. package/templates/base/src/plugins/rehype-image-enlarge.ts +113 -0
  145. package/templates/base/src/plugins/rehype-mermaid.ts +41 -0
  146. package/templates/base/src/plugins/rehype-strip-md-extension.ts +58 -0
  147. package/templates/base/src/plugins/remark-admonitions.ts +99 -0
  148. package/templates/base/src/plugins/remark-resolve-markdown-links.ts +127 -0
  149. package/templates/base/src/plugins/url-utils.ts +4 -0
  150. package/templates/base/src/styles/global.css +1066 -0
  151. package/templates/base/src/types/docs-entry.ts +39 -0
  152. package/templates/base/src/types/heading.ts +5 -0
  153. package/templates/base/src/types/locale.ts +10 -0
  154. package/templates/base/src/utils/base.ts +139 -0
  155. package/templates/base/src/utils/content-files.ts +106 -0
  156. package/templates/base/src/utils/dedent.ts +24 -0
  157. package/templates/base/src/utils/docs.ts +335 -0
  158. package/templates/base/src/utils/git-info.ts +70 -0
  159. package/templates/base/src/utils/github.ts +19 -0
  160. package/templates/base/src/utils/header-right-items.ts +38 -0
  161. package/templates/base/src/utils/nav-scope.ts +63 -0
  162. package/templates/base/src/utils/sidebar.ts +104 -0
  163. package/templates/base/src/utils/slug.ts +10 -0
  164. package/templates/base/src/utils/smart-break.tsx +126 -0
  165. package/templates/base/src/utils/tags.ts +126 -0
  166. package/templates/base/tsconfig.json +36 -0
  167. package/templates/features/bodyFootUtil/files/src/utils/github.ts +19 -0
  168. package/templates/features/claudeResources/files/plugins/claude-resources-plugin.mjs +137 -0
  169. package/templates/features/claudeResources/files/src/integrations/claude-resources/__tests__/escape-for-mdx.test.ts +34 -0
  170. package/templates/features/claudeResources/files/src/integrations/claude-resources/__tests__/generate.test.ts +376 -0
  171. package/templates/features/claudeResources/files/src/integrations/claude-resources/escape-for-mdx.ts +93 -0
  172. package/templates/features/claudeResources/files/src/integrations/claude-resources/generate.ts +586 -0
  173. package/templates/features/designTokenPanel/files/src/components/design-token-panel-bootstrap.tsx +15 -0
  174. package/templates/features/designTokenPanel/files/src/config/design-token-panel-config.ts +99 -0
  175. package/templates/features/designTokenPanel/files/src/config/design-tokens-manifest.ts +177 -0
  176. package/templates/features/designTokenPanel/files/src/lib/design-token-panel-bootstrap.ts +50 -0
  177. package/templates/features/docHistory/files/plugins/doc-history-plugin.mjs +99 -0
  178. package/templates/features/docHistory/files/src/components/doc-history.tsx +598 -0
  179. package/templates/features/docHistory/files/src/types/doc-history.ts +23 -0
  180. package/templates/features/docHistory/files/src/utils/doc-history.ts +180 -0
  181. package/templates/features/docTags/files/pages/[locale]/docs/tags/[tag].tsx +116 -0
  182. package/templates/features/docTags/files/pages/[locale]/docs/tags/index.tsx +99 -0
  183. package/templates/features/docTags/files/pages/docs/tags/[tag].tsx +101 -0
  184. package/templates/features/docTags/files/pages/docs/tags/index.tsx +86 -0
  185. package/templates/features/i18n/files/pages/[locale]/docs/[...slug].tsx +467 -0
  186. package/templates/features/i18n/files/pages/[locale]/index.tsx +213 -0
  187. package/templates/features/imageEnlarge/files/src/components/image-enlarge.tsx +248 -0
  188. package/templates/features/llmsTxt/files/plugins/llms-txt-plugin.mjs +74 -0
  189. package/templates/features/sidebarResizer/files/src/scripts/sidebar-resizer.ts +185 -0
  190. package/templates/features/sidebarToggle/files/src/components/desktop-sidebar-toggle.tsx +126 -0
  191. package/templates/features/tagGovernance/files/scripts/tags-audit.ts +576 -0
  192. package/templates/features/tagGovernance/files/scripts/tags-suggest.ts +428 -0
  193. package/templates/features/tauri/files/src/components/find-bar.tsx +122 -0
  194. package/templates/features/tauri/files/src/components/find-in-page-init.tsx +53 -0
  195. package/templates/features/tauri/files/src/utils/find-in-page.ts +175 -0
  196. package/templates/features/tauri/files/src-tauri/Cargo.toml +14 -0
  197. package/templates/features/tauri/files/src-tauri/build.rs +3 -0
  198. package/templates/features/tauri/files/src-tauri/capabilities/default.json +11 -0
  199. package/templates/features/tauri/files/src-tauri/src/main.rs +250 -0
  200. package/templates/features/tauri/files/src-tauri/tauri.conf.json +25 -0
  201. package/templates/features/tauriDev/files/src-tauri-dev/Cargo.toml +15 -0
  202. package/templates/features/tauriDev/files/src-tauri-dev/build.rs +3 -0
  203. package/templates/features/tauriDev/files/src-tauri-dev/capabilities/default.json +7 -0
  204. package/templates/features/tauriDev/files/src-tauri-dev/frontend/index.html +187 -0
  205. package/templates/features/tauriDev/files/src-tauri-dev/icons/icon.png +0 -0
  206. package/templates/features/tauriDev/files/src-tauri-dev/src/main.rs +995 -0
  207. package/templates/features/tauriDev/files/src-tauri-dev/tauri.conf.json +22 -0
  208. package/templates/features/tauriDev/files/src-tauri-dev/test-launch.sh +65 -0
  209. package/templates/features/versioning/files/pages/[locale]/docs/versions.tsx +100 -0
  210. package/templates/features/versioning/files/pages/docs/versions.tsx +78 -0
  211. package/templates/features/versioning/files/pages/v/[version]/docs/[...slug].tsx +451 -0
  212. 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
+ }