CH-J ToolsSerververwaltung über SSH
Menü
Veröffentlichte Quellen

CH-J Tools

Durchsuchen Sie Verzeichnisse und Dateien einer bestimmten Anwendungsausgabe.

Quellen als ZIP herunterladen
CH-J / Server Manager 872 Dateien
core-20261030T220507Z-61e4558d889f Hinzugefügt: · Erstellt:
migration.rs Hinzugefügt: Datum nicht erfasst 22,3 KB · 500 Zeilen
Code Datei herunterladen
1//! Upgrade from Electron 0.1.0 (and later version-to-version upgrades).
2//!
3//! Policy (owner requirements):
4//! * The 0.1.0 data directory is used in place as long as the data format stays backwards
5//! compatible. 0.1.1 reads and writes exactly the 0.1.0 formats (data format 1).
6//! * Two Cores must never write the same directory at the same time: start is refused while
7//! Electron 0.1.0 runs; while 0.1.1 runs it holds its own runtime lock and, on Windows,
8//! Chromium's `lockfile`, so a 0.1.0 started later cannot take the profile.
9//! * Before the first start of every new version a complete, SHA-256 verified backup of the
10//! directory (everything except regenerable Chromium caches and download caches) is written
11//! to `backups/pre-<version>-<timestamp>/` and atomically published. Originals are untouched.
12//! * A future, backwards-incompatible data format must be migrated into a separate store
13//! (`stores/format-<n>/`), never in place; see [`DATA_FORMAT`] and [`separate_store_for`].
14//! * Rollback: [`restore_backup`] moves the current state aside (never deletes it) and puts
15//! the verified backup back in place; reinstalling 0.1.0 then works on the restored data.
17use crate::core::error::{CoreError, CoreResult};
18use crate::core::storage::{atomic_write_json, ensure_private_dir};
19use serde_json::{json, Value};
20use sha2::{Digest, Sha256};
21use std::fs::{self, File};
22use std::io::Read;
23use std::path::{Path, PathBuf};
25pub const MARKER_FILE: &str = ".chj-core-runtime.json";
26pub const RUNTIME_LOCK_FILE: &str = ".chj-core-runtime.lock";
27pub const BACKUP_DIR: &str = "backups";
28pub const STORES_DIR: &str = "stores";
29/// On-disk data format written by this Core. 1 = format of Electron 0.1.0.
30pub const DATA_FORMAT: u32 = 1;
31const KEEP_BACKUPS: usize = 3;
32const MAX_BACKUP_BYTES: u64 = 2 * 1024 * 1024 * 1024;
34/// Top-level entries that are regenerable caches, locks or our own management folders.
35const EXCLUDED_TOP_LEVEL: &[&str] = &[
36 BACKUP_DIR, STORES_DIR, "webview", RUNTIME_LOCK_FILE, "SingletonLock", "SingletonSocket", "SingletonCookie", "lockfile",
37 "Cache", "Code Cache", "GPUCache", "DawnCache", "DawnGraphiteCache", "DawnWebGPUCache", "GrShaderCache", "ShaderCache",
38 "GraphiteDawnCache", "Crashpad", "blob_storage",
39];
40/// Download caches that the Core deletes at every start anyway.
41const EXCLUDED_NESTED: &[&str] = &["plugins/.downloads", "plugins/.staging", "updates/downloads"];
43#[derive(Debug, Clone, serde::Serialize)]
44#[serde(rename_all = "camelCase")]
45pub struct MigrationReport {
46 pub previous_runtime: Option<String>,
47 pub previous_version: Option<String>,
48 pub data_format: u32,
49 pub backup: Option<String>,
50 pub files: usize,
51 pub bytes: u64,
52 pub skipped_links: usize,
53 pub vault: String,
54 pub config: String,
55 pub performed: bool,
58pub fn sha256_file(path: &Path) -> std::io::Result<String> {
59 let mut file = File::open(path)?;
60 let mut hasher = Sha256::new();
61 let mut buffer = vec![0u8; 64 * 1024];
62 loop {
63 let n = file.read(&mut buffer)?;
64 if n == 0 {
65 break;
66 }
67 hasher.update(&buffer[..n]);
68 }
69 Ok(hex::encode(hasher.finalize()))
72// ---------------------------------------------------------------------------------------------
73// Concurrency guard
74// ---------------------------------------------------------------------------------------------
76#[cfg(unix)]
77fn hostname() -> String {
78 let mut buffer = [0u8; 256];
79 if unsafe { libc::gethostname(buffer.as_mut_ptr().cast(), buffer.len()) } != 0 {
80 return String::new();
81 }
82 let end = buffer.iter().position(|b| *b == 0).unwrap_or(buffer.len());
83 String::from_utf8_lossy(&buffer[..end]).to_string()
86/// Detects a running Electron 0.1.0 Core (Chromium profile lock in the same directory).
87pub fn legacy_electron_running(data: &Path) -> bool {
88 #[cfg(unix)]
89 {
90 let Ok(target) = fs::read_link(data.join("SingletonLock")) else { return false };
91 let target = target.to_string_lossy().to_string();
92 let Some((host, pid)) = target.rsplit_once('-') else { return false };
93 let Ok(pid) = pid.parse::<i32>() else { return false };
94 let local = hostname();
95 if pid <= 0 || (!local.is_empty() && host != local) || pid as u32 == std::process::id() {
96 return false;
97 }
98 // kill(pid, 0) succeeds (or fails with EPERM) only for a live process.
99 unsafe { libc::kill(pid, 0) == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM) }
100 }
101 #[cfg(windows)]
102 {
103 use std::os::windows::fs::OpenOptionsExt;
104 let lock = data.join("lockfile");
105 if !lock.exists() {
106 return false;
107 }
108 // Chromium keeps `lockfile` open without write sharing while the browser process runs.
109 fs::OpenOptions::new().write(true).share_mode(0x1 | 0x2).open(&lock).is_err()
110 }
113/// Locks held for the whole process lifetime.
114pub struct RuntimeLock {
115 _runtime: File,
116 #[cfg(windows)]
117 _chromium: Option<File>,
120pub fn acquire_runtime_lock(data: &Path) -> CoreResult<RuntimeLock> {
121 use fs4::fs_std::FileExt;
122 ensure_private_dir(data)?;
123 let runtime = fs::OpenOptions::new().create(true).truncate(false).read(true).write(true).open(data.join(RUNTIME_LOCK_FILE))?;
124 match runtime.try_lock_exclusive() {
125 Ok(true) => {}
126 _ => return Err(CoreError::new("CORE_ALREADY_RUNNING", "Another CH-J Server Manager Core is already using this data directory.")),
127 }
128 #[cfg(windows)]
129 let chromium = {
130 use std::os::windows::fs::OpenOptionsExt;
131 // Same sharing as Chromium's ProcessSingleton (read only): a later Electron 0.1.0 start
132 // cannot create its profile lock and its single-instance request fails.
133 fs::OpenOptions::new().create(true).truncate(false).write(true).share_mode(0x1).open(data.join("lockfile")).ok()
134 };
135 Ok(RuntimeLock {
136 _runtime: runtime,
137 #[cfg(windows)]
138 _chromium: chromium,
139 })
142// ---------------------------------------------------------------------------------------------
143// Backup
144// ---------------------------------------------------------------------------------------------
146fn is_excluded(relative: &Path) -> bool {
147 let text = relative.to_string_lossy().replace('\\', "/");
148 let first = text.split('/').next().unwrap_or("");
149 if relative.components().count() == 1 && EXCLUDED_TOP_LEVEL.contains(&first) {
150 return true;
151 }
152 if EXCLUDED_NESTED.iter().any(|prefix| text == *prefix || text.starts_with(&format!("{prefix}/"))) {
153 return true;
154 }
155 let name = relative.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
156 name.contains(".tmp-") || name.ends_with(".replace-backup") || name.starts_with(".staging-")
159fn collect(data: &Path, relative: &Path, files: &mut Vec<PathBuf>, links: &mut usize) -> CoreResult<()> {
160 if !relative.as_os_str().is_empty() && is_excluded(relative) {
161 return Ok(());
162 }
163 let full = data.join(relative);
164 let meta = match fs::symlink_metadata(&full) {
165 Ok(meta) => meta,
166 Err(_) => return Ok(()),
167 };
168 if meta.file_type().is_symlink() {
169 *links += 1; // never follow links out of the data directory
170 } else if meta.is_file() {
171 files.push(relative.to_path_buf());
172 } else if meta.is_dir() {
173 let mut names: Vec<_> = fs::read_dir(&full)?.flatten().map(|e| e.file_name()).collect();
174 names.sort();
175 for name in names {
176 collect(data, &relative.join(name), files, links)?;
177 }
178 }
179 Ok(())
182pub fn check_integrity(data: &Path) -> (String, String) {
183 let config = match fs::read_to_string(data.join("config/config.json")) {
184 Err(_) => "missing".to_string(),
185 Ok(text) => match serde_json::from_str::<Value>(&text) {
186 Ok(value) if value.get("schemaVersion").and_then(Value::as_u64) == Some(1) => "ok".to_string(),
187 Ok(_) => "unsupported-schema".to_string(),
188 Err(_) => "invalid-json".to_string(),
189 },
190 };
191 let meta = data.join("vault/core-v1.meta.json");
192 let blob = data.join("vault/core-v1.data.json");
193 let read = |path: &Path| fs::read_to_string(path).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok());
194 let vault = match (meta.exists(), blob.exists()) {
195 (false, false) => "not-created".to_string(),
196 (true, true) => {
197 let meta_ok = read(&meta)
198 .map(|m| {
199 m.get("formatVersion").and_then(Value::as_u64) == Some(1)
200 && m.get("cipher").and_then(Value::as_str) == Some("aes-256-gcm")
201 && m.pointer("/kdf/name").and_then(Value::as_str) == Some("scrypt")
202 && m.get("salt").and_then(Value::as_str).map(|s| !s.is_empty()).unwrap_or(false)
203 })
204 .unwrap_or(false);
205 let blob_ok = read(&blob)
206 .map(|b| {
207 b.get("formatVersion").and_then(Value::as_u64) == Some(1)
208 && ["iv", "authTag", "ciphertext"].iter().all(|k| b.get(*k).and_then(Value::as_str).map(|s| !s.is_empty()).unwrap_or(false))
209 })
210 .unwrap_or(false);
211 if meta_ok && blob_ok { "ok".to_string() } else { "damaged".to_string() }
212 }
213 _ => "incomplete".to_string(),
214 };
215 (config, vault)
218fn prune_backups(root: &Path) {
219 let Ok(entries) = fs::read_dir(root) else { return };
220 let mut dirs: Vec<PathBuf> = entries
221 .flatten()
222 .map(|e| e.path())
223 .filter(|p| p.is_dir() && p.file_name().map(|n| n.to_string_lossy().starts_with("pre-")).unwrap_or(false))
224 .collect();
225 dirs.sort_by_key(|p| fs::metadata(p).and_then(|m| m.modified()).ok());
226 while dirs.len() > KEEP_BACKUPS {
227 let oldest = dirs.remove(0);
228 let _ = fs::remove_dir_all(oldest);
229 }
232/// Complete verified backup. Returns (backup dir, files, bytes, skipped symlinks).
233pub fn create_backup(data: &Path, label: &str, metadata: Value) -> CoreResult<(PathBuf, usize, u64, usize)> {
234 let mut files = Vec::new();
235 let mut links = 0usize;
236 collect(data, Path::new(""), &mut files, &mut links)?;
237 let total: u64 = files.iter().filter_map(|f| fs::metadata(data.join(f)).ok()).map(|m| m.len()).sum();
238 if total > MAX_BACKUP_BYTES {
239 return Err(CoreError::new("MIGRATION_BACKUP_TOO_LARGE", "The data directory is too large for an automatic backup."));
240 }
241 let stamp = chrono::Utc::now().format("%Y%m%dT%H%M%S%.3fZ").to_string();
242 let backup_root = data.join(BACKUP_DIR);
243 ensure_private_dir(&backup_root)?;
244 let target = backup_root.join(format!("{label}-{stamp}"));
245 let staging = backup_root.join(format!(".staging-{label}-{stamp}"));
246 let result = (|| -> CoreResult<Vec<Value>> {
247 let mut manifest = Vec::with_capacity(files.len());
248 for relative in &files {
249 let source = data.join(relative);
250 let destination = staging.join(relative);
251 if let Some(parent) = destination.parent() {
252 ensure_private_dir(parent)?;
253 }
254 fs::copy(&source, &destination)?;
255 let (original, copy) = (sha256_file(&source)?, sha256_file(&destination)?);
256 if original != copy {
257 return Err(CoreError::new("MIGRATION_BACKUP_VERIFY_FAILED", "A backup copy does not match its original."));
258 }
259 manifest.push(json!({ "path": relative.to_string_lossy().replace('\\', "/"), "size": fs::metadata(&destination)?.len(), "sha256": copy }));
260 }
261 Ok(manifest)
262 })();
263 let manifest = match result {
264 Ok(manifest) => manifest,
265 Err(error) => {
266 let _ = fs::remove_dir_all(&staging);
267 return Err(error);
268 }
269 };
270 let mut document = json!({ "schemaVersion": 1, "createdAt": chrono::Utc::now().to_rfc3339(), "dataFormat": DATA_FORMAT, "files": manifest, "skippedSymlinks": links });
271 if let (Some(doc), Value::Object(extra)) = (document.as_object_mut(), metadata) {
272 doc.extend(extra);
273 }
274 ensure_private_dir(&staging)?;
275 atomic_write_json(&staging.join("backup-manifest.json"), &document)?;
276 fs::rename(&staging, &target)?;
277 Ok((target, files.len(), total, links))
280/// Verifies every file of a backup against its manifest.
281pub fn verify_backup(backup: &Path) -> CoreResult<Vec<(String, String)>> {
282 let manifest: Value = serde_json::from_str(&fs::read_to_string(backup.join("backup-manifest.json"))?)?;
283 let mut entries = Vec::new();
284 for entry in manifest["files"].as_array().cloned().unwrap_or_default() {
285 let path = entry["path"].as_str().unwrap_or("").to_string();
286 let expected = entry["sha256"].as_str().unwrap_or("").to_string();
287 let relative = Path::new(&path);
288 if path.is_empty() || relative.is_absolute() || relative.components().any(|c| matches!(c, std::path::Component::ParentDir)) {
289 return Err(CoreError::new("MIGRATION_BACKUP_INVALID", "The backup manifest contains an unsafe path."));
290 }
291 if sha256_file(&backup.join(relative)).map_err(|_| CoreError::new("MIGRATION_BACKUP_INVALID", format!("Backup file is missing: {path}")))? != expected {
292 return Err(CoreError::new("MIGRATION_BACKUP_INVALID", format!("Backup file is damaged: {path}")));
293 }
294 entries.push((path, expected));
295 }
296 Ok(entries)
299/// Rollback: verify the backup, move the current top-level entries it covers to
300/// `backups/pre-restore-<timestamp>/` (kept, not deleted) and copy the backup into place.
301pub fn restore_backup(data: &Path, backup: &Path) -> CoreResult<PathBuf> {
302 let entries = verify_backup(backup)?;
303 let mut tops: Vec<String> = entries.iter().map(|(p, _)| p.split('/').next().unwrap_or("").to_string()).collect();
304 tops.sort();
305 tops.dedup();
306 let stamp = chrono::Utc::now().format("%Y%m%dT%H%M%S%.3fZ").to_string();
307 let aside = data.join(BACKUP_DIR).join(format!("pre-restore-{stamp}"));
308 ensure_private_dir(&aside)?;
309 for top in &tops {
310 if top.is_empty() || EXCLUDED_TOP_LEVEL.contains(&top.as_str()) {
311 continue;
312 }
313 let current = data.join(top);
314 if fs::symlink_metadata(&current).is_ok() {
315 fs::rename(&current, aside.join(top))?;
316 }
317 }
318 for (path, expected) in &entries {
319 let destination = data.join(path);
320 if let Some(parent) = destination.parent() {
321 ensure_private_dir(parent)?;
322 }
323 fs::copy(backup.join(path), &destination)?;
324 if &sha256_file(&destination)? != expected {
325 return Err(CoreError::new("MIGRATION_RESTORE_FAILED", format!("Restored file does not match the backup: {path}")));
326 }
327 }
328 Ok(aside)
331/// Location for a future, backwards-incompatible data format (never the 0.1.0 files).
332pub fn separate_store_for(data: &Path, format: u32) -> PathBuf {
333 data.join(STORES_DIR).join(format!("format-{format}"))
336/// Once-per-version upgrade step. Never modifies existing data files.
337pub fn prepare_data_directory(data: &Path, version: &str, build_id: &str) -> CoreResult<MigrationReport> {
338 ensure_private_dir(data)?;
339 let marker_path = data.join(MARKER_FILE);
340 let marker: Option<Value> = fs::read_to_string(&marker_path).ok().and_then(|t| serde_json::from_str(&t).ok());
341 let previous_version = marker.as_ref().and_then(|m| m.get("lastVersion")).and_then(Value::as_str).map(str::to_string);
342 let previous_format = marker.as_ref().and_then(|m| m.get("dataFormat")).and_then(Value::as_u64).unwrap_or(1) as u32;
343 if previous_format > DATA_FORMAT {
344 return Err(CoreError::new(
345 "DATA_FORMAT_NEWER",
346 "The data directory was written by a newer CH-J Server Manager. Install that version or restore a backup.",
347 ));
348 }
349 let (config, vault) = check_integrity(data);
350 let mut probe = Vec::new();
351 let mut probe_links = 0;
352 collect(data, Path::new(""), &mut probe, &mut probe_links)?;
353 let has_existing_data = probe.iter().any(|p| p != Path::new(MARKER_FILE));
354 let previous_runtime = marker
355 .as_ref()
356 .and_then(|m| m.get("runtime"))
357 .and_then(Value::as_str)
358 .map(str::to_string)
359 .or_else(|| has_existing_data.then(|| "electron".to_string()));
360 let mut report = MigrationReport {
361 previous_runtime: previous_runtime.clone(),
362 previous_version: previous_version.clone(),
363 data_format: DATA_FORMAT,
364 backup: None,
365 files: 0,
366 bytes: 0,
367 skipped_links: 0,
368 vault,
369 config,
370 performed: false,
371 };
372 if previous_version.as_deref() == Some(version) {
373 return Ok(report);
374 }
375 if has_existing_data {
376 let (backup, files, bytes, links) = create_backup(
377 data,
378 &format!("pre-{version}"),
379 json!({ "fromVersion": previous_version, "fromRuntime": previous_runtime, "toVersion": version, "toBuild": build_id, "vault": report.vault, "config": report.config }),
380 )?;
381 verify_backup(&backup)?;
382 prune_backups(&data.join(BACKUP_DIR));
383 report.backup = Some(backup.to_string_lossy().to_string());
384 report.files = files;
385 report.bytes = bytes;
386 report.skipped_links = links;
387 }
388 let mut history = marker.as_ref().and_then(|m| m.get("history")).and_then(Value::as_array).cloned().unwrap_or_default();
389 history.push(json!({ "version": version, "buildId": build_id, "at": chrono::Utc::now().to_rfc3339(), "backup": report.backup, "vault": report.vault, "config": report.config, "fromRuntime": previous_runtime }));
390 let keep = history.len().saturating_sub(20);
391 history.drain(..keep);
392 atomic_write_json(
393 &marker_path,
394 &json!({ "schemaVersion": 1, "runtime": "tauri", "lastVersion": version, "lastBuildId": build_id, "dataFormat": DATA_FORMAT, "history": history }),
395 )?;
396 report.performed = true;
397 Ok(report)
400#[cfg(test)]
401mod tests {
402 use super::*;
404 fn electron_like_directory(data: &Path) {
405 for (path, content) in [
406 ("config/config.json", r#"{"schemaVersion":1}"#),
407 ("vault/core-v1.meta.json", r#"{"formatVersion":1,"cipher":"aes-256-gcm","kdf":{"name":"scrypt"},"salt":"AAAA"}"#),
408 ("vault/core-v1.data.json", r#"{"formatVersion":1,"iv":"a","authTag":"b","ciphertext":"c"}"#),
409 ("logs/core.log", "{}\n"),
410 ("plugins/chj.x/1.0.0/manifest.json", "{}"),
411 ("plugins/.downloads/x.zip", "x"),
412 ("updates/downloads/a.dmg", "x"),
413 ("GPUCache/data", "cache"),
414 ("Local Storage/leveldb/000003.log", "ls"),
415 ("transfers.json", "{}"),
416 ] {
417 let full = data.join(path);
418 fs::create_dir_all(full.parent().unwrap()).unwrap();
419 fs::write(full, content).unwrap();
420 }
421 }
423 #[test]
424 fn complete_backup_once_per_version_keeps_originals() {
425 let dir = tempfile::tempdir().unwrap();
426 let data = dir.path();
427 electron_like_directory(data);
428 let first = prepare_data_directory(data, "0.1.1", "b1").unwrap();
429 assert!(first.performed);
430 assert_eq!(first.previous_runtime.as_deref(), Some("electron"));
431 assert_eq!(first.vault, "ok");
432 let backup = PathBuf::from(first.backup.clone().unwrap());
433 for kept in ["vault/core-v1.data.json", "logs/core.log", "plugins/chj.x/1.0.0/manifest.json", "Local Storage/leveldb/000003.log", "transfers.json"] {
434 assert!(backup.join(kept).exists(), "{kept} missing in backup");
435 }
436 for skipped in ["plugins/.downloads", "updates/downloads", "GPUCache"] {
437 assert!(!backup.join(skipped).exists(), "{skipped} must not be backed up");
438 }
439 assert_eq!(verify_backup(&backup).unwrap().len(), first.files);
440 assert!(data.join("config/config.json").exists());
441 let second = prepare_data_directory(data, "0.1.1", "b1").unwrap();
442 assert!(!second.performed && second.backup.is_none());
443 }
445 #[test]
446 fn restore_moves_current_state_aside() {
447 let dir = tempfile::tempdir().unwrap();
448 let data = dir.path();
449 electron_like_directory(data);
450 let report = prepare_data_directory(data, "0.1.1", "b1").unwrap();
451 fs::write(data.join("config/config.json"), r#"{"schemaVersion":1,"changed":true}"#).unwrap();
452 fs::write(data.join("config/extra.json"), "{}").unwrap();
453 let aside = restore_backup(data, Path::new(report.backup.as_ref().unwrap())).unwrap();
454 assert_eq!(fs::read_to_string(data.join("config/config.json")).unwrap(), r#"{"schemaVersion":1}"#);
455 assert!(!data.join("config/extra.json").exists());
456 assert!(aside.join("config/extra.json").exists());
457 }
459 #[test]
460 fn damaged_backup_is_rejected() {
461 let dir = tempfile::tempdir().unwrap();
462 let data = dir.path();
463 electron_like_directory(data);
464 let backup = PathBuf::from(prepare_data_directory(data, "0.1.1", "b").unwrap().backup.unwrap());
465 fs::write(backup.join("transfers.json"), "tampered").unwrap();
466 assert_eq!(restore_backup(data, &backup).unwrap_err().code, "MIGRATION_BACKUP_INVALID");
467 assert_eq!(fs::read_to_string(data.join("transfers.json")).unwrap(), "{}");
468 }
470 #[test]
471 fn newer_data_format_is_refused() {
472 let dir = tempfile::tempdir().unwrap();
473 fs::write(dir.path().join(MARKER_FILE), r#"{"lastVersion":"9.0.0","dataFormat":99}"#).unwrap();
474 assert_eq!(prepare_data_directory(dir.path(), "0.1.1", "b").unwrap_err().code, "DATA_FORMAT_NEWER");
475 assert_eq!(separate_store_for(dir.path(), 2), dir.path().join("stores/format-2"));
476 }
478 #[test]
479 fn fresh_install_has_no_backup() {
480 let dir = tempfile::tempdir().unwrap();
481 let report = prepare_data_directory(dir.path(), "0.1.1", "b1").unwrap();
482 assert!(report.performed && report.backup.is_none());
483 assert_eq!(report.vault, "not-created");
484 }
486 #[test]
487 fn runtime_lock_is_exclusive() {
488 let dir = tempfile::tempdir().unwrap();
489 let _lock = acquire_runtime_lock(dir.path()).unwrap();
490 assert_eq!(acquire_runtime_lock(dir.path()).err().unwrap().code, "CORE_ALREADY_RUNNING");
491 }
493 #[cfg(unix)]
494 #[test]
495 fn stale_singleton_lock_is_not_running() {
496 let dir = tempfile::tempdir().unwrap();
497 std::os::unix::fs::symlink(format!("{}-999999", hostname()), dir.path().join("SingletonLock")).unwrap();
498 assert!(!legacy_electron_running(dir.path()));
499 }
SHA-256c731189a1fef9d85234a9e582d48483f99fd1d80485cc2dc28aa236d347bf402

CH-J Proprietary Software License 1.14 · Die Quellen werden unter der CH-J Proprietary Software License 1.14 bereitgestellt. Ihre Verfügbarkeit ändert die Lizenzbedingungen nicht und gewährt keine zusätzlichen Rechte.

SHA-256 des Archivs77380d4805485ffa4027fbe7858b5836f197a4aa9d52358a43e2d12d018744df
Dateien