Skip to content

Commit c61c0b1

Browse files
fix(Mountain): Ensure extension manifest fields are always present for renderer
Add `extensions:getManifest` IPC handler to read `extension/package.json` from a `.vsix` archive without extracting to disk, enabling the "Install from VSIX…" preview dialog. Three changes prevent renderer crashes when VS Code accesses extension manifest fields unconditionally: 1. **DTO serialization**: Remove `skip_serializing_if = "String::is_empty"` from `ExtensionDescriptionStateDTO.Name`, `Version`, `Publisher`. VS Code's `extensions.contribution.ts` trusted-publishers migration calls `manifest.publisher.toLowerCase()` at boot—if the key is omitted, the renderer crashes with `TypeError: undefined is not an object`. 2. **IPC handler hardening** (`Extensions.rs`): Handle empty/missing `publisher`/`name` with "unknown" fallback, and inject explicit `publisher`, `name`, `version` fields into the manifest object before sending to the renderer. Also guard against non-object manifest values (null) by substituting an empty skeleton. 3. **Log file eager init** (`Entry.rs`, `DevLog.rs`): Call `InitEager()` at binary startup to create the session log before any panic, preserving post-mortem evidence. Also fix `BinarySignature()` to correctly split PascalCase segments (`ElectronProfile` → `electron.profile`) so logs end up in the correct app-data directory. Also expand the benign ENOENT ignore list with `chatLanguageModels.json`, `configurationDefaultsOverrides`, and window log files (`network.log`, `renderer.log`, `views.log`, `notebook.rendering.log`).
1 parent de7ca50 commit c61c0b1

6 files changed

Lines changed: 191 additions & 21 deletions

File tree

Source/ApplicationState/DTO/ExtensionDescriptionStateDTO.rs

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -55,15 +55,32 @@ pub struct ExtensionDescriptionStateDTO {
5555
pub Identifier:Value,
5656

5757
/// Extension name (from package.json "name")
58-
#[serde(default, skip_serializing_if = "String::is_empty")]
58+
///
59+
/// Always serialized, even when empty, because VS Code's scanner and
60+
/// the trusted-publishers migration (`extensions.contribution.ts`) both
61+
/// evaluate `extension.manifest.name.toLowerCase()` unconditionally.
62+
/// Dropping the field would leave a bare `undefined` and crash the
63+
/// renderer with `TypeError: undefined is not an object`.
64+
#[serde(default)]
5965
pub Name:String,
6066

61-
/// Semantic version string (e.g., "1.0.0")
62-
#[serde(default, skip_serializing_if = "String::is_empty")]
67+
/// Semantic version string (e.g., "1.0.0").
68+
///
69+
/// Always serialized for the same reason as `Name` / `Publisher`: the
70+
/// renderer reads `manifest.version` in several hot paths and crashes
71+
/// if the field is missing outright.
72+
#[serde(default)]
6373
pub Version:String,
6474

65-
/// Publisher name or identifier
66-
#[serde(default, skip_serializing_if = "String::is_empty")]
75+
/// Publisher name or identifier.
76+
///
77+
/// Always serialized, even when empty. VS Code's
78+
/// `extensions.contribution.ts` trusted-publishers migration runs on
79+
/// every User-extension at workbench boot and executes
80+
/// `extension.manifest.publisher.toLowerCase()`. If the key is omitted
81+
/// the renderer crashes with
82+
/// `TypeError: undefined is not an object (evaluating 'manifest.publisher')`.
83+
#[serde(default)]
6784
pub Publisher:String,
6885

6986
/// Engine compatibility requirements: { vscode: string }

Source/Binary/Main/Entry.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,16 @@ macro_rules! TraceStep {
135135
/// 9. Runs the Tauri application
136136
/// 10. Handles graceful shutdown
137137
pub fn Fn() {
138+
// Open `Mountain.dev.log` up front. Forces `InitFileSink` to create
139+
// the session log header on disk before any other code can panic, so
140+
// an early crash still leaves a file with a timestamp + pid + tag
141+
// context for post-mortem. Env vars are read from the shell here (the
142+
// `.env.Land` load below may add MORE keys but never overrides
143+
// LAND_DEV_LOG / LAND_DEV_LOG_FILE because `set_var` only runs when a
144+
// key is currently unset). Harmless to call: the inner `OnceLock`
145+
// gates repeat invocations.
146+
crate::IPC::DevLog::InitEager();
147+
138148
// -------------------------------------------------------------------------
139149
// [Boot] [Env] Load .env.Land into process env so standalone binary
140150
// invocations pick up Product*, Tier*, Network* vars without requiring

Source/ExtensionManagement/VsixInstaller.rs

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,26 @@ pub fn UninstallExtension(InstallDir:&Path) -> Result<(), InstallError> {
159159
// --- Internals ----------------------------------------------------------
160160

161161
fn ReadManifestFacts(VsixPath:&Path) -> Result<ManifestFacts, InstallError> {
162+
let Manifest = ReadFullManifest(VsixPath)?;
163+
164+
let Publisher = ReadStringField(&Manifest, "publisher")?;
165+
let Name = ReadStringField(&Manifest, "name")?;
166+
let Version = ReadStringField(&Manifest, "version")?;
167+
168+
Ok(ManifestFacts { Publisher, Name, Version })
169+
}
170+
171+
/// Read the full `extension/package.json` from a `.vsix` without extracting
172+
/// the archive to disk. Used by the IPC `extensions:getManifest` handler so
173+
/// the "Install from VSIX…" preview dialog and drag-and-drop flow can inspect
174+
/// a manifest before the user confirms installation.
175+
///
176+
/// The returned value is the raw parsed JSON (`serde_json::Value`) - callers
177+
/// can project it into VS Code's `IExtensionManifest` shape. No NLS bundle
178+
/// resolution is performed here (the renderer only needs publisher/name/
179+
/// version/displayName for the preview UI, and NLS keys would require
180+
/// unpacking `package.nls.json` from the archive too).
181+
pub fn ReadFullManifest(VsixPath:&Path) -> Result<Value, InstallError> {
162182
let Archive = File::open(VsixPath).map_err(|Error| InstallError::ArchiveRead(Error.to_string()))?;
163183
let mut Archive = ZipArchive::new(Archive).map_err(|Error| InstallError::ArchiveRead(Error.to_string()))?;
164184

@@ -172,14 +192,7 @@ fn ReadManifestFacts(VsixPath:&Path) -> Result<ManifestFacts, InstallError> {
172192
.read_to_string(&mut Raw)
173193
.map_err(|Error| InstallError::ManifestMissing(Error.to_string()))?;
174194

175-
let Manifest:Value =
176-
serde_json::from_str(&Raw).map_err(|Error| InstallError::ManifestMissing(Error.to_string()))?;
177-
178-
let Publisher = ReadStringField(&Manifest, "publisher")?;
179-
let Name = ReadStringField(&Manifest, "name")?;
180-
let Version = ReadStringField(&Manifest, "version")?;
181-
182-
Ok(ManifestFacts { Publisher, Name, Version })
195+
serde_json::from_str(&Raw).map_err(|Error| InstallError::ManifestMissing(Error.to_string()))
183196
}
184197

185198
fn ReadStringField(Manifest:&Value, Field:&'static str) -> Result<String, InstallError> {

Source/IPC/DevLog.rs

Lines changed: 74 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,20 @@ fn InitFileSink() -> &'static Mutex<Option<BufWriter<File>>> {
209209
})
210210
}
211211

212+
/// Force the file sink to initialize before any `dev_log!` has run.
213+
///
214+
/// `WriteToFile` is otherwise lazy - the log file is only opened the first
215+
/// time a tagged `dev_log!` call fires. When Mountain panics (or the
216+
/// webview traps the user with an early error) before the first enabled
217+
/// tag emits, the session log directory ends up with an empty shell and
218+
/// the post-mortem evidence is lost.
219+
///
220+
/// Call this once at the top of `Binary::Main::Fn()` - as early as the
221+
/// binary can reach - so the header line + `LAND_DEV_LOG_FILE=1` opt-in
222+
/// are honoured even when nothing else ever logs. Harmless to call
223+
/// multiple times; the `OnceLock` inside `InitFileSink` gates it.
224+
pub fn InitEager() { let _ = InitFileSink(); }
225+
212226
/// Append a single formatted line to the session's log file if the file
213227
/// sink is active. Swallows every error - dev_log must never crash.
214228
pub fn WriteToFile(Line:&str) {
@@ -250,12 +264,51 @@ fn BinarySignature() -> String {
250264
let Segments:Vec<&str> = PackageName.split('_').collect();
251265
let Take = Segments.len().min(4);
252266
let Start = Segments.len().saturating_sub(Take);
253-
let Tail = Segments[Start..].join("_");
254-
// Lowercase + `_` → `.` gives the same segment order the Tauri
255-
// identifier uses (identifiers are dot-delimited lowercase). We
256-
// don't split PascalCase into words here - the substring match
257-
// below doesn't need exact equality, just a unique tail.
258-
Tail.to_ascii_lowercase().replace('_', ".")
267+
// Each underscore-delimited segment is PascalCase (e.g. `ElectronProfile`,
268+
// `MountainProfile`, `RestCompiler`). The Tauri identifier Maintain
269+
// generates splits every PascalCase word on its capital boundary, so
270+
// `ElectronProfile` → `electron.profile`, not `electronprofile`. Without
271+
// the per-segment split here the signature becomes `electronprofile`
272+
// and `ends_with` never matches the real Tauri app-data dir
273+
// `…clean.debug.electron.profile.mountain`, so DevLog silently falls
274+
// back to whichever other `*.mountain` directory `read_dir` yielded
275+
// first - the bug that sent the electron-profile binary's logs into
276+
// the compile-profile directory.
277+
let Dotted:String = Segments[Start..]
278+
.iter()
279+
.flat_map(|Segment| SplitPascalCaseIntoWords(Segment))
280+
.collect::<Vec<String>>()
281+
.join(".")
282+
.to_ascii_lowercase();
283+
Dotted
284+
}
285+
286+
/// Split a PascalCase / UPPERCASE string into lowercase component words,
287+
/// matching the tokenisation Maintain's `Build/Process.rs` applies when it
288+
/// stamps the Tauri `identifier`. Example: `ElectronProfile` →
289+
/// `["electron", "profile"]`; `22NodeVersion` → `["22", "node", "version"]`.
290+
/// Empty segments are filtered out.
291+
fn SplitPascalCaseIntoWords(Segment:&str) -> Vec<String> {
292+
let mut Words:Vec<String> = Vec::new();
293+
let mut Current = String::new();
294+
let mut PrevWasUpper = false;
295+
let mut PrevWasDigit = false;
296+
for Ch in Segment.chars() {
297+
let IsUpper = Ch.is_ascii_uppercase();
298+
let IsDigit = Ch.is_ascii_digit();
299+
let NeedBreak = !Current.is_empty()
300+
&& ((IsUpper && !PrevWasUpper) || (IsDigit != PrevWasDigit && !Current.is_empty()));
301+
if NeedBreak {
302+
Words.push(std::mem::take(&mut Current));
303+
}
304+
Current.push(Ch);
305+
PrevWasUpper = IsUpper;
306+
PrevWasDigit = IsDigit;
307+
}
308+
if !Current.is_empty() {
309+
Words.push(Current);
310+
}
311+
Words.into_iter().filter(|Word| !Word.is_empty()).collect()
259312
}
260313

261314
fn DetectAppDataPrefix() -> Option<String> {
@@ -366,11 +419,26 @@ const BENIGN_ENOENT_SUBSTRINGS:&[&str] = &[
366419
// `resolve_userdata`.
367420
"/User/tasks.json",
368421
"/User/mcp.json",
422+
// Chat language-model registry is written on first chat interaction.
423+
// Absent on fresh profiles; VS Code reads-before-first-write every boot.
424+
"chatLanguageModels.json",
425+
// VS Code writes per-profile configuration default overrides lazily; on
426+
// a fresh profile the file does not yet exist and the workbench probes
427+
// on every boot to see if it needs reading.
428+
"configurationDefaultsOverrides",
369429
// Chat images cache directory is lazy-created on first chat attachment.
370430
"vscode-chat-images",
371431
// Per-window output channel log files probed lazily by the workbench
372432
// before first write. Path shape: `$APP/logs/<SESSION>/window<N>/output_<TIMESTAMP>`.
373433
"/output_20",
434+
// Per-window named log files VS Code stats on boot to detect crash-log
435+
// rollovers. The `/<logsPath>/window<N>/<name>.log` layout means the
436+
// window index can change, so match on filename alone - these names
437+
// are stable VS Code conventions and never collide with real files.
438+
"/network.log",
439+
"/renderer.log",
440+
"/views.log",
441+
"/notebook.rendering.log",
374442
// Virtual scheme misses already covered by earlier batches.
375443
"vscode://schemas-associations/",
376444
];

Source/IPC/WindServiceHandlers/Extensions.rs

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,15 @@ pub async fn handle_extensions_get_installed(runtime:Arc<ApplicationRunTime>) ->
3030
let Publisher = Manifest
3131
.get("publisher")
3232
.and_then(Value::as_str)
33+
.filter(|S| !S.is_empty())
34+
.unwrap_or("unknown")
35+
.to_string();
36+
let Name = Manifest
37+
.get("name")
38+
.and_then(Value::as_str)
39+
.filter(|S| !S.is_empty())
3340
.unwrap_or("unknown")
3441
.to_string();
35-
let Name = Manifest.get("name").and_then(Value::as_str).unwrap_or("unknown").to_string();
3642
let Id = format!("{}.{}", Publisher, Name);
3743

3844
// VS Code's `URI.revive()` is a no-op on strings, so the scanner's
@@ -43,9 +49,26 @@ pub async fn handle_extensions_get_installed(runtime:Arc<ApplicationRunTime>) ->
4349
// `location` and the mirror inside `manifest.extensionLocation` so
4450
// callers that read either field get the same shape.
4551
let Location = NormalizeUri(Manifest.get("extensionLocation"));
46-
let mut Manifest = Manifest;
52+
// Guarantee the manifest is an object with non-empty `publisher`,
53+
// `name` and `version` fields before it reaches the renderer. VS
54+
// Code runs a trusted-publishers migration at first-boot
55+
// (`extensions.contribution.ts`) that unconditionally calls
56+
// `extension.manifest.publisher.toLowerCase()`; any missing
57+
// `manifest` object, or a manifest with `publisher === undefined`,
58+
// crashes the webview with
59+
// `TypeError: undefined is not an object (evaluating 'manifest.publisher')`
60+
// before the workbench can render a single pixel. A non-object
61+
// value here (null / Value::Null from upstream scan failures) is
62+
// replaced with a bare skeleton so the renderer always has shape.
63+
let mut Manifest = match Manifest {
64+
Value::Object(_) => Manifest,
65+
_ => json!({}),
66+
};
4767
if let Value::Object(ref mut Map) = Manifest {
4868
Map.insert("extensionLocation".to_string(), Location.clone());
69+
Map.entry("publisher".to_string()).or_insert_with(|| json!(Publisher.clone()));
70+
Map.entry("name".to_string()).or_insert_with(|| json!(Name.clone()));
71+
Map.entry("version".to_string()).or_insert_with(|| json!("0.0.0"));
4972
}
5073

5174
json!({

Source/IPC/WindServiceHandlers/mod.rs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,45 @@ pub async fn mountain_ipc_invoke(app_handle:AppHandle, command:String, args:Vec<
363363
)
364364
.await
365365
},
366+
367+
// `ExtensionManagementChannelClient.getManifest(vsix: URI)` - reads
368+
// the `extension/package.json` from a `.vsix` archive without
369+
// extracting it. Called by the "Install from VSIX…" preview and
370+
// by drag-and-drop onto the Extensions sidebar. The renderer then
371+
// accesses `manifest.publisher` / `.name` / `.displayName` on the
372+
// returned object unconditionally; a missing handler or an Err
373+
// response crashes the webview with
374+
// `TypeError: undefined is not an object (evaluating 'manifest.publisher')`.
375+
"extensions:getManifest" => {
376+
let VsixPath = match args.first() {
377+
Some(serde_json::Value::String(Path)) => Path.clone(),
378+
Some(Obj) => Obj
379+
.get("fsPath")
380+
.and_then(|V| V.as_str())
381+
.map(str::to_owned)
382+
.or_else(|| Obj.get("path").and_then(|V| V.as_str()).map(str::to_owned))
383+
.unwrap_or_default(),
384+
None => String::new(),
385+
};
386+
dev_log!("extensions", "extensions:getManifest vsix={}", VsixPath);
387+
if VsixPath.is_empty() {
388+
Err("extensions:getManifest: missing VSIX path argument".to_string())
389+
} else {
390+
let Path = std::path::PathBuf::from(&VsixPath);
391+
match crate::ExtensionManagement::VsixInstaller::ReadFullManifest(&Path) {
392+
Ok(Manifest) => Ok(Manifest),
393+
Err(Error) => {
394+
dev_log!(
395+
"extensions",
396+
"warn: [WindServiceHandlers] extensions:getManifest failed for '{}': {}",
397+
VsixPath,
398+
Error
399+
);
400+
Err(format!("extensions:getManifest failed: {}", Error))
401+
},
402+
}
403+
}
404+
},
366405
// Reinstall and metadata-update still no-op for now; reinstall needs
367406
// a gallery cache (we only have the on-disk unpack), and metadata
368407
// update only matters for ratings/icons/readme which Land does not

0 commit comments

Comments
 (0)