Skip to content

Commit 25c958e

Browse files
committed
fix(pathmode): harden real-machine background and frontend startup
1 parent 0c7fe64 commit 25c958e

12 files changed

Lines changed: 212 additions & 24 deletions

path_mode/scripts/path_renderer.gd

Lines changed: 62 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ var _initial_ui_settings_retries: int = 0
3636
const DOUBLE_CLICK_THRESHOLD := 0.5
3737
const MAX_INITIAL_UI_SETTINGS_RETRIES := 8
3838
const INITIAL_UI_SETTINGS_RETRY_DELAY := 0.15
39+
const BACKGROUND_MAX_DIMENSION := 2048
40+
const BACKGROUND_MAX_BYTES_HINT := 64 * 1024 * 1024
3941

4042
@onready var ui: PathModeUI = $"../UI"
4143

@@ -845,22 +847,70 @@ func _apply_background_texture(path: String) -> void:
845847
if path == "":
846848
_apply_default_background(world_env, sky_mat)
847849
return
848-
849-
# EXR/HDR files natively import as CompressedTexture2D/Image in Godot 4 via ResourceLoader
850-
if ResourceLoader.exists(path):
851-
var tex = ResourceLoader.load(path)
852-
if tex is Texture2D:
853-
world_env.environment.background_mode = Environment.BG_SKY
854-
sky_mat.panorama = tex
855-
print("[PathRenderer] Applied background texture: ", path)
856-
return
857-
push_warning("[PathRenderer] Background resource is not a Texture2D: %s" % path)
858-
else:
859-
push_warning("[PathRenderer] Background file not found: %s" % path)
850+
851+
var texture = _load_background_texture_safely(path)
852+
if texture:
853+
world_env.environment.background_mode = Environment.BG_SKY
854+
sky_mat.panorama = texture
855+
print("[PathRenderer] Applied background texture: ", path)
856+
return
857+
858+
push_warning("[PathRenderer] Falling back to default background for: %s" % path)
860859

861860
_apply_default_background(world_env, sky_mat)
862861

863862

863+
func _load_background_texture_safely(path: String) -> Texture2D:
864+
if not ResourceLoader.exists(path):
865+
push_warning("[PathRenderer] Background file not found: %s" % path)
866+
return null
867+
868+
var extension := path.get_extension().to_lower()
869+
if extension == "exr" or extension == "hdr":
870+
return _load_hdr_background_safely(path)
871+
872+
var tex = ResourceLoader.load(path)
873+
if tex is Texture2D:
874+
return tex
875+
876+
push_warning("[PathRenderer] Background resource is not a Texture2D: %s" % path)
877+
return null
878+
879+
880+
func _load_hdr_background_safely(path: String) -> Texture2D:
881+
var image := Image.new()
882+
var image_error := image.load(path)
883+
if image_error != OK:
884+
push_warning("[PathRenderer] Failed to load HDR background image %s (error=%s)" % [path, image_error])
885+
return null
886+
887+
var original_size := image.get_size()
888+
if original_size.x <= 0 or original_size.y <= 0:
889+
push_warning("[PathRenderer] Invalid HDR background dimensions for: %s" % path)
890+
return null
891+
892+
if image.get_format() != Image.FORMAT_RGBA8:
893+
image.convert(Image.FORMAT_RGBA8)
894+
895+
var largest_dimension := maxi(original_size.x, original_size.y)
896+
if largest_dimension > BACKGROUND_MAX_DIMENSION:
897+
var scale := float(BACKGROUND_MAX_DIMENSION) / float(largest_dimension)
898+
var resized_width := maxi(1, int(round(original_size.x * scale)))
899+
var resized_height := maxi(1, int(round(original_size.y * scale)))
900+
image.resize(resized_width, resized_height, Image.INTERPOLATE_LANCZOS)
901+
print("[PathRenderer] Downscaled HDR background from %s to %s to avoid large GPU uploads." % [str(original_size), str(image.get_size())])
902+
903+
var estimated_bytes := image.get_width() * image.get_height() * 4
904+
if estimated_bytes > BACKGROUND_MAX_BYTES_HINT:
905+
push_warning("[PathRenderer] HDR background still too large after resize (%s bytes): %s" % [estimated_bytes, path])
906+
return null
907+
908+
var texture := ImageTexture.create_from_image(image)
909+
if texture == null:
910+
push_warning("[PathRenderer] Failed to create ImageTexture from HDR background: %s" % path)
911+
return texture
912+
913+
864914
func _schedule_initial_ui_settings_retry() -> void:
865915
if _initial_ui_settings_retries >= MAX_INITIAL_UI_SETTINGS_RETRIES:
866916
return

path_mode/scripts/settings_panel.gd

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ var _settings: Dictionary = {
3636
"auto_reconstruct": true,
3737
"retain_history": true,
3838
"focus_mode": true,
39-
"background": "belfast_sunset_puresky_4k.exr",
39+
"background": "",
4040
"bg_brightness": 1.0,
4141
"reading_mode": "window",
4242
"reader_render_mode": "render",
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import * as fs from 'fs';
2+
import * as path from 'path';
3+
4+
function getNestedStringValue(record: Record<string, unknown>, dottedKey: string): string | null {
5+
const value = dottedKey.split('.').reduce<unknown>((current, segment) => {
6+
if (!current || typeof current !== 'object' || !(segment in current)) {
7+
return null;
8+
}
9+
return (current as Record<string, unknown>)[segment];
10+
}, record);
11+
12+
return typeof value === 'string' && value.trim().length > 0 ? value : null;
13+
}
14+
15+
describe('frontend locale contract', () => {
16+
const repoRoot = path.resolve(__dirname, '..');
17+
const enLocale = JSON.parse(
18+
fs.readFileSync(path.join(repoRoot, 'src', 'frontend', 'locales', 'en.json'), 'utf8')
19+
) as Record<string, unknown>;
20+
const zhLocale = JSON.parse(
21+
fs.readFileSync(path.join(repoRoot, 'src', 'frontend', 'locales', 'zh.json'), 'utf8')
22+
) as Record<string, unknown>;
23+
const requiredKeys = [
24+
'analysis_title',
25+
'node_details',
26+
'reader_loading',
27+
'reader_outline_title',
28+
'reader_outline_empty',
29+
];
30+
31+
test('required reader and analysis keys resolve in both locales', () => {
32+
const missing = requiredKeys.filter((key) => (
33+
!getNestedStringValue(enLocale, key) || !getNestedStringValue(zhLocale, key)
34+
));
35+
36+
expect(missing).toEqual([]);
37+
});
38+
});

src/frontend/analysis.js

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,15 @@ console.log("Analysis Module: Parsing...");
44
document.addEventListener("DOMContentLoaded", () => {
55
console.log("Analysis Module: DOMContentLoaded");
66

7+
function getGraphDataSafe() {
8+
if (typeof window === 'undefined') return null;
9+
const candidate = window.graphData;
10+
if (!candidate || !Array.isArray(candidate.nodes) || !Array.isArray(candidate.edges)) {
11+
return null;
12+
}
13+
return candidate;
14+
}
15+
716
const UI = {
817
panel: document.getElementById("analysis-panel"),
918
resizer: document.getElementById("analysis-resizer"),
@@ -38,7 +47,8 @@ document.addEventListener("DOMContentLoaded", () => {
3847

3948
// --- 0. Init Cluster Options ---
4049
function initClusters() {
41-
if (!UI.clusterFilter || typeof graphData === 'undefined') return;
50+
const graphData = getGraphDataSafe();
51+
if (!UI.clusterFilter || !graphData) return;
4252

4353
// Find unique clusters
4454
const clusters = new Set();
@@ -64,7 +74,8 @@ document.addEventListener("DOMContentLoaded", () => {
6474
// --- 1. Quick Distribution (Immediate) ---
6575
function initQuickDist() {
6676
if (!UI.quickDist) return;
67-
if (typeof graphData === 'undefined') return;
77+
const graphData = getGraphDataSafe();
78+
if (!graphData) return;
6879

6980
const degrees = graphData.nodes.map(n => n.inDegree + n.outDegree);
7081
const maxDeg = Math.max(...degrees, 1);
@@ -283,7 +294,8 @@ document.addEventListener("DOMContentLoaded", () => {
283294

284295
// Get nodes filtered ONLY by cluster (ignore threshold for histogram context)
285296
// Guard: Check if graphData is available (may not be in MINI mode)
286-
if (typeof graphData === 'undefined' || !graphData || !graphData.nodes) {
297+
const graphData = getGraphDataSafe();
298+
if (!graphData) {
287299
console.warn('[Analysis] graphData not available, skipping histogram render.');
288300
return;
289301
}
@@ -421,6 +433,9 @@ document.addEventListener("DOMContentLoaded", () => {
421433
}
422434

423435
function showNodeDetails(nodeId) {
436+
const graphData = getGraphDataSafe();
437+
if (!graphData) return;
438+
424439
// Open Panel if closed
425440
if (!UI.panel.classList.contains("open")) {
426441
UI.btn.click();
@@ -482,6 +497,11 @@ document.addEventListener("DOMContentLoaded", () => {
482497

483498
// --- 6. Export & Filter Logic ---
484499
function getFilteredData() {
500+
const graphData = getGraphDataSafe();
501+
if (!graphData) {
502+
return { nodes: [], edges: [] };
503+
}
504+
485505
let nodes = graphData.nodes;
486506

487507
// 1. Cluster Filter
@@ -515,7 +535,14 @@ document.addEventListener("DOMContentLoaded", () => {
515535
function updateStats() {
516536
const { nodes } = getFilteredData();
517537
if (UI.count) {
518-
const tot = graphData.nodes.length;
538+
const graphData = getGraphDataSafe();
539+
const tot = graphData ? graphData.nodes.length : 0;
540+
if (tot <= 0) {
541+
UI.count.innerText = "0 / 0 (0.0%)";
542+
renderTable(nodes);
543+
renderHistogram();
544+
return;
545+
}
519546
const pct = ((nodes.length/tot)*100).toFixed(1);
520547
// Use translation for "Selected:" label is handled in HTML, here just numbers
521548
// But wait, the label is separate <span data-i18n="selected">

src/frontend/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
66
<meta
77
http-equiv="Content-Security-Policy"
8-
content="default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' data: https://fonts.gstatic.com; img-src 'self' data: blob:; connect-src 'self' http://127.0.0.1:* http://localhost:* ws://127.0.0.1:* ws://localhost:* tauri://localhost http://tauri.localhost https://tauri.localhost; worker-src 'self' blob:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self';"
8+
content="default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' data: https://fonts.gstatic.com; img-src 'self' data: blob:; connect-src 'self' ipc: http://127.0.0.1:* http://localhost:* http://ipc.localhost https://ipc.localhost ws://127.0.0.1:* ws://localhost:* tauri://localhost http://tauri.localhost https://tauri.localhost; worker-src 'self' blob:; object-src 'none'; base-uri 'self'; form-action 'self';"
99
/>
1010
<title>NoteConnection Knowledge Graph</title>
1111
<link

src/frontend/locales/en.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,10 @@
272272
"open_content": "Specific Content",
273273
"exit_focus": "Exit Focus Mode",
274274
"analysis_title": "Degree Analysis",
275+
"node_details": "Node Details",
276+
"reader_loading": "Loading reader content...",
277+
"reader_outline_title": "Outline",
278+
"reader_outline_empty": "No outline is available for this document.",
275279
"filter_strategy": "Filter Strategy",
276280
"strat_top": "Top X% (by Degree)",
277281
"strat_min": "Min Degree > X",

src/frontend/locales/zh.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,10 @@
251251
"open_content": "查看具体内容",
252252
"exit_focus": "退出专注模式",
253253
"analysis_title": "度数分析",
254+
"node_details": "节点详情",
255+
"reader_loading": "正在加载阅读内容...",
256+
"reader_outline_title": "大纲",
257+
"reader_outline_empty": "当前文档暂无可用大纲。",
254258
"filter_strategy": "筛选策略",
255259
"strat_top": "前 X% (按度数)",
256260
"strat_min": "最小度数 > X",

src/frontend/notemd.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
66
<meta
77
http-equiv="Content-Security-Policy"
8-
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' http://127.0.0.1:* http://localhost:* tauri://localhost http://tauri.localhost; object-src 'none'; base-uri 'self'; frame-ancestors 'self'; form-action 'self';"
8+
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' ipc: http://127.0.0.1:* http://localhost:* http://ipc.localhost https://ipc.localhost tauri://localhost http://tauri.localhost https://tauri.localhost; object-src 'none'; base-uri 'self'; form-action 'self';"
99
/>
1010
<title>NoteMD Workspace</title>
1111
<link rel="stylesheet" href="notemd.css" />
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import * as fs from 'fs';
2+
import * as path from 'path';
3+
4+
describe('pathmode background safety contract', () => {
5+
const repoRoot = path.resolve(__dirname, '..');
6+
const settingsPanelPath = path.join(repoRoot, 'path_mode', 'scripts', 'settings_panel.gd');
7+
const pathRendererPath = path.join(repoRoot, 'path_mode', 'scripts', 'path_renderer.gd');
8+
9+
test('does not default path mode to a heavy HDR background at startup', () => {
10+
const source = fs.readFileSync(settingsPanelPath, 'utf8');
11+
expect(source).toContain('"background": ""');
12+
expect(source).not.toContain('"background": "belfast_sunset_puresky_4k.exr"');
13+
});
14+
15+
test('loads HDR backgrounds through the guarded resize-and-convert path', () => {
16+
const source = fs.readFileSync(pathRendererPath, 'utf8');
17+
expect(source).toContain('const BACKGROUND_MAX_DIMENSION := 2048');
18+
expect(source).toContain('func _load_hdr_background_safely(path: String) -> Texture2D:');
19+
expect(source).toContain('image.convert(Image.FORMAT_RGBA8)');
20+
expect(source).toContain('image.resize(resized_width, resized_height, Image.INTERPOLATE_LANCZOS)');
21+
});
22+
});

src/server.migration.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,16 @@ describe('server migration settings routes', () => {
497497
expect(response.body).toContain('const graphData');
498498
});
499499

500+
test('returns current KB path when cache-busting query string is present', async () => {
501+
const response = await requestJson(port, 'GET', '/api/kb-path?v=12345');
502+
expect(response.status).toBe(200);
503+
expect(response.body).toEqual(
504+
expect.objectContaining({
505+
kbPath: kbRoot,
506+
})
507+
);
508+
});
509+
500510
test('rejects static traversal attempts with raw parent-segment path', async () => {
501511
const response = await requestJson(port, 'GET', '/../../outside/sensitive.md');
502512
expect(response.status).toBe(403);

0 commit comments

Comments
 (0)