v1.0.0 — release avec auto-update Gitea

Ajout du systeme de mise a jour automatique :
- UpdateManager (main) : verifie les tags Gitea, telecharge et applique les MAJ
- UpdateBanner (renderer) : banniere UI avec progression et retry
- IPC channels : check-for-updates, apply-update, update-available, update-progress, update-error
- Desactivation asar pour permettre le remplacement des sources
- version.json comme source de verite pour la version locale

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
sorlinv
2026-02-25 15:58:02 +01:00
parent b556cce88c
commit f31f5aa605
16 changed files with 766 additions and 54 deletions

View File

@@ -1,12 +1,14 @@
const { spawn } = require("child_process");
const PathResolver = require("./PathResolver.js");
const STR_CAMERA_MARKER = "CAMERAS_JSON:";
const STR_SCENE_MARKER = "SCENE_JSON:";
const STR_PYTHON_EXPR = [
"import bpy, json, sys;",
"s=bpy.context.scene;",
"cams=[o.name for o in bpy.data.objects if o.type=='CAMERA'];",
"sys.stdout.write('CAMERAS_JSON:' + json.dumps(cams) + '\\n');",
"info={'list_cameras':cams,'obj_scene':{'nb_resolution_x':s.render.resolution_x,'nb_resolution_y':s.render.resolution_y,'nb_frame_start':s.frame_start,'nb_frame_end':s.frame_end,'nb_frame_step':s.frame_step}};",
"sys.stdout.write('SCENE_JSON:' + json.dumps(info) + '\\n');",
"sys.stdout.flush()",
].join("");
@@ -35,14 +37,14 @@ const CameraParser = {
return;
}
let list_cameras = CameraParser._parse_camera_output(str_stdout);
if (list_cameras === null) {
let obj_result = CameraParser._parse_scene_output(str_stdout);
if (obj_result === null) {
console.error("[CameraParser] Stdout Blender:\n" + str_stdout.substring(str_stdout.length - 2000));
reject(new Error("Impossible de parser les cameras. Verifiez que le fichier .blend contient des cameras."));
return;
}
resolve(list_cameras);
resolve(obj_result);
});
obj_process.on("error", (obj_err) => {
@@ -51,12 +53,12 @@ const CameraParser = {
});
},
_parse_camera_output: (str_stdout) => {
_parse_scene_output: (str_stdout) => {
let list_lines = str_stdout.split("\n");
for (let str_line of list_lines) {
let nb_index = str_line.indexOf(STR_CAMERA_MARKER);
let nb_index = str_line.indexOf(STR_SCENE_MARKER);
if (nb_index !== -1) {
let str_json = str_line.substring(nb_index + STR_CAMERA_MARKER.length).trim();
let str_json = str_line.substring(nb_index + STR_SCENE_MARKER.length).trim();
try {
return JSON.parse(str_json);
} catch (obj_err) {

View File

@@ -66,6 +66,8 @@ class QueueManager {
let str_mode = obj_config.str_render_mode;
let str_base_output = obj_config.str_output_path;
let str_output_mode = obj_config.str_output_mode || "subfolder";
let str_frame_prefix = obj_config.str_frame_prefix !== undefined ? obj_config.str_frame_prefix : "f_";
let nb_frame_padding = obj_config.nb_frame_padding || 5;
let list_cameras = obj_config.list_cameras;
let list_enabled = [];
@@ -77,8 +79,9 @@ class QueueManager {
if (str_mode === "camera_by_camera") {
for (let obj_cam of list_enabled) {
for (let nb_frame = obj_cam.nb_frame_start; nb_frame <= obj_cam.nb_frame_end; nb_frame++) {
list_queue.push(this._create_queue_item(str_blend_path, str_base_output, str_output_mode, obj_cam, nb_frame));
let nb_step = obj_cam.nb_frame_step || 1;
for (let nb_frame = obj_cam.nb_frame_start; nb_frame <= obj_cam.nb_frame_end; nb_frame += nb_step) {
list_queue.push(this._create_queue_item(str_blend_path, str_base_output, str_output_mode, str_frame_prefix, nb_frame_padding, obj_cam, nb_frame));
}
}
} else {
@@ -95,8 +98,9 @@ class QueueManager {
for (let nb_frame = nb_min_frame; nb_frame <= nb_max_frame; nb_frame++) {
for (let obj_cam of list_enabled) {
if (nb_frame >= obj_cam.nb_frame_start && nb_frame <= obj_cam.nb_frame_end) {
list_queue.push(this._create_queue_item(str_blend_path, str_base_output, str_output_mode, obj_cam, nb_frame));
let nb_cam_step = obj_cam.nb_frame_step || 1;
if (nb_frame >= obj_cam.nb_frame_start && nb_frame <= obj_cam.nb_frame_end && (nb_frame - obj_cam.nb_frame_start) % nb_cam_step === 0) {
list_queue.push(this._create_queue_item(str_blend_path, str_base_output, str_output_mode, str_frame_prefix, nb_frame_padding, obj_cam, nb_frame));
}
}
}
@@ -105,8 +109,9 @@ class QueueManager {
return list_queue;
}
_create_queue_item(str_blend_path, str_base_output, str_output_mode, obj_cam, nb_frame) {
let str_padded_frame = String(nb_frame).padStart(5, "0");
_create_queue_item(str_blend_path, str_base_output, str_output_mode, str_frame_prefix, nb_frame_padding, obj_cam, nb_frame) {
let str_padded_frame = String(nb_frame).padStart(nb_frame_padding, "0");
let str_hash_pattern = "#".repeat(nb_frame_padding);
let str_ext = obj_cam.str_format.toLowerCase();
if (str_ext === "open_exr") {
str_ext = "exr";
@@ -120,14 +125,16 @@ class QueueManager {
let str_expected_file = "";
if (str_output_mode === "prefix") {
// Flat: /sortie/Camera.001_frame_#####
str_output_path = path.join(str_base_output, obj_cam.str_name + "_frame_#####");
str_expected_file = path.join(str_base_output, obj_cam.str_name + "_frame_" + str_padded_frame + "." + str_ext);
} else {
// Subfolder: /sortie/Camera.001/frame_#####
str_output_path = path.join(str_base_output, obj_cam.str_name + "_" + str_frame_prefix + str_hash_pattern);
str_expected_file = path.join(str_base_output, obj_cam.str_name + "_" + str_frame_prefix + str_padded_frame + "." + str_ext);
} else if (str_output_mode === "both") {
let str_output_dir = path.join(str_base_output, obj_cam.str_name);
str_output_path = path.join(str_output_dir, "frame_#####");
str_expected_file = path.join(str_output_dir, "frame_" + str_padded_frame + "." + str_ext);
str_output_path = path.join(str_output_dir, obj_cam.str_name + "_" + str_frame_prefix + str_hash_pattern);
str_expected_file = path.join(str_output_dir, obj_cam.str_name + "_" + str_frame_prefix + str_padded_frame + "." + str_ext);
} else {
let str_output_dir = path.join(str_base_output, obj_cam.str_name);
str_output_path = path.join(str_output_dir, str_frame_prefix + str_hash_pattern);
str_expected_file = path.join(str_output_dir, str_frame_prefix + str_padded_frame + "." + str_ext);
}
return {

410
src/main/UpdateManager.js Normal file
View File

@@ -0,0 +1,410 @@
const https = require("https");
const http = require("http");
const fs = require("fs");
const path = require("path");
const { app } = require("electron");
const { execFile } = require("child_process");
const GITEA_HOST = "git.sorlinv.fr";
const REPO_PATH = "/api/v1/repos/sorlinv/multi_render_blender/tags";
const ARCHIVE_URL_BASE = "https://git.sorlinv.fr/sorlinv/multi_render_blender/archive/";
const NB_TIMEOUT = 10000;
const UpdateManager = {
obj_window: null,
str_local_version: null,
init: (obj_window) => {
UpdateManager.obj_window = obj_window;
UpdateManager.str_local_version = UpdateManager._read_local_version();
},
check_for_updates: () => {
if (!UpdateManager.str_local_version) {
return Promise.resolve(null);
}
return UpdateManager._fetch_tags()
.then((list_tags) => {
if (!list_tags || list_tags.length === 0) {
return null;
}
let str_latest = null;
let str_latest_tag = null;
for (let obj_tag of list_tags) {
let str_name = obj_tag.name || "";
let str_ver = str_name.replace(/^v/, "");
if (!UpdateManager._is_valid_semver(str_ver)) {
continue;
}
if (!str_latest || UpdateManager._compare_versions(str_ver, str_latest) > 0) {
str_latest = str_ver;
str_latest_tag = str_name;
}
}
if (!str_latest) {
return null;
}
if (UpdateManager._compare_versions(str_latest, UpdateManager.str_local_version) > 0) {
UpdateManager._send_event("update-available", {
str_version: str_latest,
str_tag_name: str_latest_tag,
});
return { str_version: str_latest, str_tag_name: str_latest_tag };
}
return null;
})
.catch(() => {
return null;
});
},
download_and_apply: (str_tag_name) => {
let str_temp_dir = path.join(app.getPath("temp"), "mrb_update_" + Date.now());
let str_zip_path = path.join(str_temp_dir, "update.zip");
UpdateManager._send_event("update-progress", {
str_step: "downloading",
nb_percent: 0,
});
return UpdateManager._ensure_dir(str_temp_dir)
.then(() => {
return UpdateManager._download_zip(str_tag_name, str_zip_path);
})
.then(() => {
UpdateManager._send_event("update-progress", {
str_step: "extracting",
nb_percent: 0,
});
return UpdateManager._extract_zip(str_zip_path, str_temp_dir);
})
.then(() => {
UpdateManager._send_event("update-progress", {
str_step: "installing",
nb_percent: 0,
});
return UpdateManager._find_extracted_root(str_temp_dir);
})
.then((str_extracted_root) => {
return UpdateManager._replace_app_files(str_extracted_root);
})
.then(() => {
UpdateManager._send_event("update-progress", {
str_step: "restarting",
nb_percent: 100,
});
UpdateManager._cleanup(str_temp_dir);
app.relaunch();
app.quit();
})
.catch((obj_err) => {
UpdateManager._cleanup(str_temp_dir);
UpdateManager._send_event("update-error", {
str_message: obj_err.message || "Erreur inconnue",
str_tag_name: str_tag_name,
});
throw obj_err;
});
},
// ── Private helpers ──────────────────────────────────────
_read_local_version: () => {
let str_version_path = path.join(__dirname, "..", "..", "version.json");
try {
let str_content = fs.readFileSync(str_version_path, "utf8");
let obj_data = JSON.parse(str_content);
return obj_data.str_version || null;
} catch (obj_err) {
console.error("UpdateManager: impossible de lire version.json :", obj_err.message);
return null;
}
},
_is_valid_semver: (str_ver) => {
return /^\d+\.\d+\.\d+$/.test(str_ver);
},
_compare_versions: (str_a, str_b) => {
let list_a = str_a.split(".").map(Number);
let list_b = str_b.split(".").map(Number);
for (let i = 0; i < 3; i++) {
if (list_a[i] > list_b[i]) {
return 1;
}
if (list_a[i] < list_b[i]) {
return -1;
}
}
return 0;
},
_fetch_tags: () => {
return new Promise((resolve, reject) => {
let obj_options = {
hostname: GITEA_HOST,
path: REPO_PATH,
method: "GET",
timeout: NB_TIMEOUT,
headers: {
"Accept": "application/json",
"User-Agent": "MultiRenderBlender",
},
};
let obj_req = https.request(obj_options, (obj_res) => {
let str_data = "";
obj_res.on("data", (chunk) => {
str_data += chunk;
});
obj_res.on("end", () => {
if (obj_res.statusCode !== 200) {
reject(new Error("Gitea API status " + obj_res.statusCode));
return;
}
try {
let list_tags = JSON.parse(str_data);
resolve(list_tags);
} catch (obj_err) {
reject(new Error("Reponse JSON invalide"));
}
});
});
obj_req.on("timeout", () => {
obj_req.destroy();
reject(new Error("Timeout"));
});
obj_req.on("error", (obj_err) => {
reject(obj_err);
});
obj_req.end();
});
},
_download_zip: (str_tag_name, str_zip_path) => {
let str_url = ARCHIVE_URL_BASE + str_tag_name + ".zip";
let fn_download = (str_download_url, nb_redirects) => {
if (nb_redirects > 5) {
return Promise.reject(new Error("Trop de redirections"));
}
return new Promise((resolve, reject) => {
let obj_parsed = new URL(str_download_url);
let obj_module = obj_parsed.protocol === "https:" ? https : http;
let obj_req = obj_module.get(str_download_url, {
timeout: NB_TIMEOUT,
headers: { "User-Agent": "MultiRenderBlender" },
}, (obj_res) => {
if (obj_res.statusCode === 301 || obj_res.statusCode === 302) {
let str_redirect = obj_res.headers.location;
if (!str_redirect) {
reject(new Error("Redirection sans header Location"));
return;
}
fn_download(str_redirect, nb_redirects + 1)
.then(resolve)
.catch(reject);
return;
}
if (obj_res.statusCode !== 200) {
reject(new Error("Telechargement echoue : HTTP " + obj_res.statusCode));
return;
}
let nb_total = parseInt(obj_res.headers["content-length"], 10) || 0;
let nb_downloaded = 0;
let obj_file = fs.createWriteStream(str_zip_path);
obj_res.on("data", (chunk) => {
nb_downloaded += chunk.length;
if (nb_total > 0) {
let nb_percent = Math.round((nb_downloaded / nb_total) * 100);
UpdateManager._send_event("update-progress", {
str_step: "downloading",
nb_percent: nb_percent,
});
}
});
obj_res.pipe(obj_file);
obj_file.on("finish", () => {
obj_file.close(() => {
resolve();
});
});
obj_file.on("error", (obj_err) => {
fs.unlink(str_zip_path, () => {});
reject(obj_err);
});
});
obj_req.on("timeout", () => {
obj_req.destroy();
reject(new Error("Timeout telechargement"));
});
obj_req.on("error", (obj_err) => {
reject(obj_err);
});
});
};
return fn_download(str_url, 0);
},
_extract_zip: (str_zip_path, str_dest_dir) => {
return new Promise((resolve, reject) => {
if (process.platform === "win32") {
let str_cmd = "Expand-Archive";
let list_args = [
"-Path", str_zip_path,
"-DestinationPath", str_dest_dir,
"-Force",
];
execFile("powershell.exe", ["-Command", str_cmd + " " + list_args.join(" ")], (obj_err) => {
if (obj_err) {
reject(new Error("Extraction echouee : " + obj_err.message));
return;
}
resolve();
});
} else {
execFile("unzip", ["-o", str_zip_path, "-d", str_dest_dir], (obj_err) => {
if (obj_err) {
reject(new Error("Extraction echouee : " + obj_err.message));
return;
}
resolve();
});
}
});
},
_find_extracted_root: (str_temp_dir) => {
return new Promise((resolve, reject) => {
let list_entries = fs.readdirSync(str_temp_dir);
let str_root = null;
for (let str_entry of list_entries) {
if (str_entry === "update.zip") {
continue;
}
let str_full = path.join(str_temp_dir, str_entry);
if (fs.statSync(str_full).isDirectory()) {
str_root = str_full;
break;
}
}
if (!str_root) {
reject(new Error("Dossier extrait introuvable"));
return;
}
resolve(str_root);
});
},
_replace_app_files: (str_source_dir) => {
let str_app_dir = path.join(__dirname, "..", "..");
let LIST_TARGETS = ["main.js", "preload.js", "src", "version.json", "package.json"];
return new Promise((resolve, reject) => {
try {
for (let str_target of LIST_TARGETS) {
let str_src = path.join(str_source_dir, str_target);
let str_dest = path.join(str_app_dir, str_target);
if (!fs.existsSync(str_src)) {
continue;
}
if (fs.existsSync(str_dest)) {
let obj_stat = fs.statSync(str_dest);
if (obj_stat.isDirectory()) {
UpdateManager._rm_recursive(str_dest);
} else {
fs.unlinkSync(str_dest);
}
}
UpdateManager._copy_recursive(str_src, str_dest);
}
resolve();
} catch (obj_err) {
reject(new Error("Remplacement fichiers echoue : " + obj_err.message));
}
});
},
_copy_recursive: (str_src, str_dest) => {
let obj_stat = fs.statSync(str_src);
if (obj_stat.isDirectory()) {
if (!fs.existsSync(str_dest)) {
fs.mkdirSync(str_dest, { recursive: true });
}
let list_entries = fs.readdirSync(str_src);
for (let str_entry of list_entries) {
UpdateManager._copy_recursive(
path.join(str_src, str_entry),
path.join(str_dest, str_entry)
);
}
} else {
fs.copyFileSync(str_src, str_dest);
}
},
_rm_recursive: (str_path) => {
fs.rmSync(str_path, { recursive: true, force: true });
},
_ensure_dir: (str_dir) => {
return new Promise((resolve, reject) => {
try {
fs.mkdirSync(str_dir, { recursive: true });
resolve();
} catch (obj_err) {
reject(obj_err);
}
});
},
_cleanup: (str_dir) => {
try {
if (fs.existsSync(str_dir)) {
UpdateManager._rm_recursive(str_dir);
}
} catch (obj_err) {
console.error("UpdateManager: nettoyage echoue :", obj_err.message);
}
},
_send_event: (str_channel, obj_data) => {
if (UpdateManager.obj_window && !UpdateManager.obj_window.isDestroyed()) {
UpdateManager.obj_window.webContents.send(str_channel, obj_data);
}
},
};
module.exports = UpdateManager;