diff --git a/DESIGN.md b/DESIGN.md index 207930644..af15f823c 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -247,7 +247,7 @@ NoteDeck のスカラー設定 (選択・トグル・ユーザー preferences) |---|---|---| | テーマ選択状態 | `theme.manual`, `theme.selectedDarkThemeId` | テーマ **定義** は `themes/*.ndtheme.json5` に別置き | | モード | `modes.realtime`, `modes.offline` | | -| デック | `deck.activeProfileId`, `deck.wallpaper` | プロファイル **定義** は `profiles/*.ndprofile.json5` に別置き | +| デック | `deck.wallpaper` | プロファイル **定義** は `profiles/*.ndprofile.json5` に別置き。アクティブプロファイルはウィンドウごとに切り替わるためファイルに持たない | | ミュート | `mute.emojis` 等 | ワード / インスタンスミュートはサーバー側設定 | | 投稿フォームの挙動 | `postForm.preview`, `postForm.rememberVisibility` 等 | ボタン構成そのものは `postform.json5` | | キャッシュ | `cache.evictionPreset`, `chat.cacheEnabled` 等 | | diff --git a/package.json b/package.json index 2492c78b4..8bbf23f84 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "notedeck", "description": "Misskey Pro — integrated deck environment (IDE) for Misskey power users", "private": true, - "version": "1.46.2", + "version": "1.47.0", "type": "module", "packageManager": "pnpm@11.18.0", "engines": { @@ -53,6 +53,7 @@ "@codemirror/language-data": "^6.5.2", "@codemirror/lint": "^6.9.7", "@codemirror/lsp-client": "^6.2.5", + "@codemirror/merge": "^6.12.2", "@codemirror/state": "^6.6.0", "@codemirror/view": "^6.43.1", "@lezer/highlight": "^1.2.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ef85de256..154f66106 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: '@codemirror/lsp-client': specifier: ^6.2.5 version: 6.2.5 + '@codemirror/merge': + specifier: ^6.12.2 + version: 6.12.2 '@codemirror/state': specifier: ^6.6.0 version: 6.6.0 @@ -533,6 +536,9 @@ packages: '@codemirror/lsp-client@6.2.5': resolution: {integrity: sha512-1EqhGRmCZOV7Me+rRuwwkTuvkNoD4Nz6UcE1yx5gdwTVTLD4D9xIy48MJc0LeBQGFLn/HNRW/pHmet4EAEkJFQ==} + '@codemirror/merge@6.12.2': + resolution: {integrity: sha512-V8JvyAPjHbPupqP7BeMcsdsYCbyPij74jxIbaIJDORI+VZzW44zFmon8bF+oxGWvOKhcRmkiUMXd8MxHr3YA2w==} + '@codemirror/state@6.6.0': resolution: {integrity: sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==} @@ -5005,6 +5011,14 @@ snapshots: marked: 15.0.12 vscode-languageserver-protocol: 3.18.0 + '@codemirror/merge@6.12.2': + dependencies: + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.43.1 + '@lezer/highlight': 1.2.3 + style-mod: 4.1.3 + '@codemirror/state@6.6.0': dependencies: '@marijn/find-cluster-break': 1.0.2 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 1f8909ba6..a12f8661d 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2088,21 +2088,15 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.16.1" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ "allocator-api2", "equivalent", "foldhash 0.2.0", ] -[[package]] -name = "hashbrown" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" - [[package]] name = "hashlink" version = "0.10.0" @@ -2891,11 +2885,11 @@ dependencies = [ [[package]] name = "lru" -version = "0.16.4" +version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +checksum = "5d2f2f9b4ba7e6b24d95e7e899329d35be83bcded72c8540cdd5368932d1d90a" dependencies = [ - "hashbrown 0.16.1", + "hashbrown 0.17.1", ] [[package]] @@ -3203,7 +3197,7 @@ dependencies = [ [[package]] name = "notedeck" -version = "1.46.2" +version = "1.47.0" dependencies = [ "async-trait", "axum", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ad2b29399..967b8a6c3 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "notedeck" -version = "1.46.2" +version = "1.47.0" description = "Misskey Pro — integrated deck environment (IDE) for Misskey power users" edition = "2021" license = "AGPL-3.0-only" @@ -37,7 +37,7 @@ tower-http = { version = "0.6", features = ["cors"] } reqwest = { version = "0.12", default-features = false, features = ["rustls-tls-native-roots", "stream", "json"] } url = "2" sha2 = "0.10" -lru = "0.16" +lru = "0.18" regex = "1" async-trait = "0.1" mime_guess = "2" diff --git a/src-tauri/openapi.json b/src-tauri/openapi.json index 040f21b42..79287a7c9 100644 --- a/src-tauri/openapi.json +++ b/src-tauri/openapi.json @@ -6,7 +6,7 @@ "license": { "name": "MIT" }, - "version": "1.46.2" + "version": "1.47.0" }, "paths": { "/api": { diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index e26b2879d..d1f2d91ce 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -221,10 +221,20 @@ pub async fn export_settings_json(app: tauri::AppHandle) -> Result { Ok(true) } +/// import_settings_json の結果。`imported: false` はダイアログのキャンセル。 +/// `warnings` はスキップ / 別名退避したエントリの説明 (#913 付随修正 — フロントは +/// 復元完了メッセージに件数 + 内容を表示する)。 +#[derive(serde::Serialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct ImportSettingsResult { + pub imported: bool, + pub warnings: Vec, +} + /// Import settings from a JSON bundle via open dialog. #[tauri::command] #[specta::specta] -pub async fn import_settings_json(app: tauri::AppHandle) -> Result { +pub async fn import_settings_json(app: tauri::AppHandle) -> Result { use std::collections::BTreeMap; use tauri_plugin_dialog::DialogExt; @@ -237,7 +247,11 @@ pub async fn import_settings_json(app: tauri::AppHandle) -> Result { .blocking_pick_file(); let Some(src) = src else { - return Ok(false); // user cancelled + // user cancelled + return Ok(ImportSettingsResult { + imported: false, + warnings: vec![], + }); }; let src_path = src @@ -249,7 +263,10 @@ pub async fn import_settings_json(app: tauri::AppHandle) -> Result { let bundle: BTreeMap = serde_json::from_str(&raw) .map_err(|e| NoteDeckError::InvalidInput(format!("Invalid JSON: {e}")))?; - store::import_bundle(&base_dir, &bundle)?; + let warnings = store::import_bundle(&base_dir, &bundle)?; - Ok(true) + Ok(ImportSettingsResult { + imported: true, + warnings, + }) } diff --git a/src-tauri/src/settings_store.rs b/src-tauri/src/settings_store.rs index a9876c58b..4171a0b98 100644 --- a/src-tauri/src/settings_store.rs +++ b/src-tauri/src/settings_store.rs @@ -40,6 +40,9 @@ pub const ALLOWED_ROOT_FILES: &[&str] = &[ // 公開しない制約はここではなく capability registry 側で担保している // (settingsFs の固定名ラッパーのみが本コマンドに到達する) "permissions.json5", + // custom.css の編集履歴サイドカー (#913 付随修正)。allowlist から漏れて + // いたため、フロントの履歴 read/write が一度も成功していなかった + "custom.css.history.json5", ]; /// Validate a subdirectory name against the whitelist. @@ -300,36 +303,295 @@ pub fn export_bundle(base_dir: &Path) -> Result> { Ok(bundle) } -/// バックアップバンドルを検証しながら書き戻す。 -/// path traversal と allowlist 外のエントリは warn してスキップする。 -pub fn import_bundle(base_dir: &Path, bundle: &BTreeMap) -> Result<()> { - for (key, content) in bundle { - // Path traversal prevention - if key.contains("..") || key.starts_with('/') || key.starts_with('\\') { - tracing::warn!("Skipping suspicious entry: {key}"); - continue; +/// import キー内ファイル名の強化検証 (#913)。「新しく置くファイル」にのみ適用し、 +/// `validate_filename` 本体は強化しない (規約外名の既存ファイルへの読取・削除・ +/// リネーム元指定を拒否すると移行が成立しないため)。 +/// 文字種 (日本語等) は寛容のまま受ける — 旧バックアップの復元を拒否しない。 +fn validate_import_filename(name: &str) -> Result<()> { + validate_filename(name)?; + if name.chars().any(|c| c.is_control()) { + return Err(NoteDeckError::InvalidInput(format!( + "Filename contains control characters: {name:?}" + ))); + } + if name.starts_with('.') { + return Err(NoteDeckError::InvalidInput(format!( + "Filename must not start with a dot: {name}" + ))); + } + if name.ends_with('.') || name.ends_with(' ') { + return Err(NoteDeckError::InvalidInput(format!( + "Filename must not end with a dot or space: {name}" + ))); + } + // Windows 予約デバイス名 (stem = 最初のドットより前で判定) + let stem = name.split('.').next().unwrap_or(name); + if is_windows_reserved_stem(stem) { + return Err(NoteDeckError::InvalidInput(format!( + "Filename uses a Windows reserved device name: {name}" + ))); + } + Ok(()) +} + +fn is_windows_reserved_stem(stem: &str) -> bool { + let upper = stem.to_ascii_uppercase(); + matches!(upper.as_str(), "CON" | "PRN" | "AUX" | "NUL") + || (upper.len() == 4 + && (upper.starts_with("COM") || upper.starts_with("LPT")) + && matches!(upper.as_bytes()[3], b'1'..=b'9')) +} + +/// 種別の規定複合拡張子。suffix は「複合拡張子の前」に挿入するので、長い順に +/// 剥がして basename を得る (`.json5` は複合拡張子の suffix なので必ず最後)。 +const COMPOUND_EXTS: &[&str] = &[ + ".meta.json5", + ".history.json5", + ".ndprofile.json5", + ".ndtheme.json5", + ".is", + ".md", + ".json5", +]; + +/// ファイル名を (basename, 複合拡張子) に分割する。既知の拡張子がなければ +/// 全体を basename とする (suffix は末尾付与になる)。 +fn split_compound_ext(name: &str) -> (&str, &str) { + for ext in COMPOUND_EXTS { + if let Some(base) = name.strip_suffix(ext) { + if !base.is_empty() { + return (base, ext); + } } + } + (name, "") +} - // Validate: must be in allowed subdirs or allowed root files - let allowed = ALLOWED_SUBDIRS +/// グループ全構成の宛先を `create_new` で排他予約する。1 つでも既存衝突したら +/// 予約済み分を削除して `Ok(false)` を返す (FS 自身の同名解決 — 非 ASCII +/// casefold・NFC/NFD — を衝突検出の正とするため、事前照合では拾えない衝突も +/// ここで検出される)。 +fn reserve_all(dir: &Path, names: &[String]) -> Result { + let mut reserved: Vec = Vec::new(); + for name in names { + let path = dir.join(name); + match fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) + { + Ok(_) => reserved.push(path), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { + for p in &reserved { + let _ = fs::remove_file(p); + } + return Ok(false); + } + Err(e) => { + for p in &reserved { + let _ = fs::remove_file(p); + } + return Err(NoteDeckError::InvalidInput(format!( + "Failed to write {}: {e}", + path.display() + ))); + } + } + } + Ok(true) +} + +/// `reserve_all` で確保した宛先をまとめて解放する。書込途中で失敗したグループを +/// そのまま残すと、空の予約ファイルがゴミとして残り次回 import の衝突判定を +/// 汚す (グループは全構成そろって初めて意味を持つので部分適用も残さない)。 +fn release_reserved(dir: &Path, names: &[String]) { + for name in names { + let _ = fs::remove_file(dir.join(name)); + } +} + +/// 同一 basename のグループ (ソース + メタ + 履歴。単一ファイル種別は 1 ファイル +/// = 1 グループ) を書き込む。衝突判定・skip/suffix はグループ単位 — エントリ +/// 単位でばらすとペア種別のソースとメタが別名に分裂し、既存の孤児ソースと +/// 誤ペアリングしたり履歴だけが別アイテムのリングに結合する。 +fn import_group( + base_dir: &Path, + subdir: &str, + members: &[(String, String)], + warnings: &mut Vec, +) -> Result<()> { + let dir = base_dir.join(subdir); + fs::create_dir_all(&dir).map_err(|e| NoteDeckError::InvalidInput(e.to_string()))?; + // sessions/ は AI 会話内容のため 0o600 を維持 (write_file と同じ流儀) + let mode = if subdir == "sessions" { + Some(0o600) + } else { + None + }; + + // 書込直前の実列挙 + ASCII casefold の事前スクリーニング (case-sensitive FS + // でも「大文字小文字のみ異なる既存名は占有」の規則を守る) + let existing = list_files(base_dir, subdir)?; + let collides = |name: &str| -> bool { + let folded = name.to_ascii_lowercase(); + existing.iter().any(|e| e.to_ascii_lowercase() == folded) + }; + + if !members.iter().any(|(n, _)| collides(n)) { + let names: Vec = members.iter().map(|(n, _)| n.clone()).collect(); + if reserve_all(&dir, &names)? { + for (name, content) in members { + if let Err(e) = atomic_write(&dir.join(name), content, mode) { + release_reserved(&dir, &names); + return Err(e); + } + } + return Ok(()); + } + // 排他予約が失敗 = 事前照合で拾えない FS の同名解決 (非 ASCII casefold・ + // NFC/NFD)。予約済み分は削除済みなので、グループごと衝突分岐へ回す + } + + // --- 衝突分岐 (グループ単位) --- + // 衝突した構成が全て内容バイト一致ならグループ全体 skip (再 import の no-op + // 収束)。いずれか不一致ならグループ全体を同一 suffix の basename へ退避する + // (ファイル内 ID は変えず、次回起動の重複 ID 警告で手動解決に乗せる)。 + let mut any_collision = false; + let mut all_identical = true; + for (name, content) in members { + // fs::read は FS の同名解決を通るので、排他失敗の実体も拾える + match fs::read(dir.join(name)) { + Ok(bytes) => { + any_collision = true; + if bytes != content.as_bytes() { + all_identical = false; + } + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => { + return Err(NoteDeckError::InvalidInput(format!( + "Failed to read {subdir}/{name}: {e}" + ))); + } + } + // casefold 一致の別表記 (case-sensitive FS では上の read に映らない) + let folded = name.to_ascii_lowercase(); + for e in existing.iter().filter(|e| e.to_ascii_lowercase() == folded) { + any_collision = true; + match fs::read(dir.join(e)) { + Ok(bytes) => { + if bytes != content.as_bytes() { + all_identical = false; + } + } + Err(_) => all_identical = false, + } + } + } + + let member_keys = || -> String { + members .iter() - .any(|d| key.starts_with(&format!("{d}/"))) - || ALLOWED_ROOT_FILES.contains(&key.as_str()); - if !allowed { - tracing::warn!("Skipping unknown entry: {key}"); + .map(|(n, _)| format!("{subdir}/{n}")) + .collect::>() + .join(", ") + }; + + if any_collision && all_identical { + tracing::warn!("Import: skipping identical group: {}", member_keys()); + warnings.push(format!("スキップ (既存と内容同一): {}", member_keys())); + return Ok(()); + } + + // suffix 退避: 全構成が同時に空く番号を create_new プローブで探索する + for n in 2..10_000u32 { + let candidates: Vec = members + .iter() + .map(|(name, _)| { + let (base, ext) = split_compound_ext(name); + format!("{base}-{n}{ext}") + }) + .collect(); + if candidates.iter().any(|c| collides(c)) { continue; } + if !reserve_all(&dir, &candidates)? { + continue; + } + for ((_, content), cand) in members.iter().zip(&candidates) { + if let Err(e) = atomic_write(&dir.join(cand), content, mode) { + release_reserved(&dir, &candidates); + return Err(e); + } + } + let renamed = candidates.join(", "); + tracing::warn!( + "Import: group collides, restored with suffix: {} -> {renamed}", + member_keys() + ); + warnings.push(format!( + "別名で復元 (既存と衝突): {} → {renamed}", + member_keys() + )); + return Ok(()); + } + Err(NoteDeckError::InvalidInput(format!( + "No free suffix found for import group: {}", + member_keys() + ))) +} - let dest_path = base_dir.join(key); +/// バックアップバンドルを検証しながら書き戻す。 +/// +/// - キー構造は「許可サブディレクトリ + ファイル名」の 2 要素、または許可 +/// ルートファイル名そのものの 1 要素のみ。3 要素以上のネストは拒否 +/// - サブディレクトリ側のファイル名には強化検証 (`validate_import_filename`) を +/// 適用。拒否したエントリはスキップし警告として収集する +/// - 許可ルートファイルは固定名の単一ファイルなので復元 = atomic 置換 +/// (suffix 退避先の名前は allowlist 外で二度と読まれないため衝突分岐は不適用) +/// - サブディレクトリのアイテムは basename グループ単位で casefold 衝突検査 + +/// 排他書込 (詳細は `import_group`) +/// +/// 戻り値はスキップ / 別名退避したエントリの警告リスト。 +pub fn import_bundle(base_dir: &Path, bundle: &BTreeMap) -> Result> { + let mut warnings: Vec = Vec::new(); + // (subdir, basename) → [(filename, content)]。BTreeMap なので処理順は決定的 + let mut groups: BTreeMap<(String, String), Vec<(String, String)>> = BTreeMap::new(); + + if !bundle.is_empty() { + fs::create_dir_all(base_dir).map_err(|e| NoteDeckError::InvalidInput(e.to_string()))?; + } - if let Some(parent) = dest_path.parent() { - fs::create_dir_all(parent).map_err(|e| NoteDeckError::InvalidInput(e.to_string()))?; + for (key, content) in bundle { + let parts: Vec<&str> = key.split('/').collect(); + match parts.as_slice() { + [name] if ALLOWED_ROOT_FILES.contains(name) => { + atomic_write(&base_dir.join(name), content, None)?; + } + [subdir, name] if ALLOWED_SUBDIRS.contains(subdir) => { + if let Err(e) = validate_import_filename(name) { + tracing::warn!("Import: skipping invalid filename: {key}: {e}"); + warnings.push(format!("スキップ (不正なファイル名): {key}")); + continue; + } + let (base, _) = split_compound_ext(name); + groups + .entry((subdir.to_string(), base.to_string())) + .or_default() + .push((name.to_string(), content.clone())); + } + _ => { + tracing::warn!("Import: skipping unknown entry: {key}"); + warnings.push(format!("スキップ (不正なキー): {key}")); + } } + } - fs::write(&dest_path, content) - .map_err(|e| NoteDeckError::InvalidInput(format!("Failed to write {key}: {e}")))?; + for ((subdir, _), members) in &groups { + import_group(base_dir, subdir, members, &mut warnings)?; } - Ok(()) + + Ok(warnings) } #[cfg(test)] @@ -572,10 +834,261 @@ mod tests { bundle.insert("secret.txt".to_string(), "secret".to_string()); bundle.insert("config/bad.json".to_string(), "bad".to_string()); - import_bundle(&base, &bundle).unwrap(); + let warnings = import_bundle(&base, &bundle).unwrap(); assert!(base.join("custom.css").exists()); assert!(!base.join("secret.txt").exists()); assert!(!base.join("config/bad.json").exists()); + // 拒否 2 件が警告として収集される + assert_eq!(warnings.len(), 2); + } + + #[test] + fn custom_css_history_is_allowed_root_file() { + // #913 付随修正: allowlist から漏れていて履歴の read/write が常に + // reject されていた (フロントは settingsFs の history 系でこの名前を使う) + assert!(ALLOWED_ROOT_FILES.contains(&"custom.css.history.json5")); + let dir = tempfile::tempdir().unwrap(); + write_root_file(dir.path(), "custom.css.history.json5", "{ entries: [] }").unwrap(); + assert_eq!( + read_root_file(dir.path(), "custom.css.history.json5").unwrap(), + "{ entries: [] }" + ); + } + + #[test] + fn import_bundle_rejects_nested_keys() { + // キー構造は 2 要素 (subdir/name) か許可ルートファイルの 1 要素のみ。 + // 3 要素以上のネストは拒否 + 警告 + let dir = tempfile::tempdir().unwrap(); + let base = dir.path(); + + let mut bundle = BTreeMap::new(); + bundle.insert("themes/deep/evil.json5".to_string(), "x".to_string()); + + let warnings = import_bundle(base, &bundle).unwrap(); + assert!(!base.join("themes/deep/evil.json5").exists()); + assert_eq!(warnings.len(), 1); + assert!(warnings[0].contains("themes/deep/evil.json5")); + } + + #[test] + fn import_bundle_rejects_reserved_and_malformed_filenames() { + let dir = tempfile::tempdir().unwrap(); + let base = dir.path(); + + let mut bundle = BTreeMap::new(); + // Windows 予約デバイス名 (stem 判定) + bundle.insert("themes/aux.ndtheme.json5".to_string(), "x".to_string()); + bundle.insert("skills/COM1.md".to_string(), "x".to_string()); + // 制御文字 / 先頭ドット / 末尾ドット・空白 + bundle.insert("skills/bad\u{1}name.md".to_string(), "x".to_string()); + bundle.insert("skills/.hidden.md".to_string(), "x".to_string()); + bundle.insert("skills/trail .md ".to_string(), "x".to_string()); + + let warnings = import_bundle(base, &bundle).unwrap(); + assert_eq!(warnings.len(), 5); + assert_eq!(list_files(base, "themes").unwrap(), Vec::::new()); + assert_eq!(list_files(base, "skills").unwrap(), Vec::::new()); + } + + #[test] + fn import_bundle_accepts_lenient_charset() { + // 文字種 (日本語等) は寛容のまま — 旧バックアップの復元を拒否しない + let dir = tempfile::tempdir().unwrap(); + let base = dir.path(); + + let mut bundle = BTreeMap::new(); + bundle.insert( + "themes/天気テーマ.ndtheme.json5".to_string(), + "{ id: 't1' }".to_string(), + ); + + let warnings = import_bundle(base, &bundle).unwrap(); + assert!(warnings.is_empty()); + assert_eq!( + read_file(base, "themes", "天気テーマ.ndtheme.json5").unwrap(), + "{ id: 't1' }" + ); + } + + #[test] + fn import_bundle_skips_identical_group() { + // 衝突した構成が全て内容バイト一致 → グループ全体 skip (再 import の + // no-op 収束) + let dir = tempfile::tempdir().unwrap(); + let base = dir.path(); + write_file(base, "skills", "weather.md", "# weather").unwrap(); + + let mut bundle = BTreeMap::new(); + bundle.insert("skills/weather.md".to_string(), "# weather".to_string()); + + let warnings = import_bundle(base, &bundle).unwrap(); + assert_eq!(warnings.len(), 1); + assert!(warnings[0].contains("skills/weather.md")); + assert_eq!(list_files(base, "skills").unwrap(), vec!["weather.md"]); + } + + #[test] + fn import_bundle_diverts_conflicting_group_with_suffix() { + // 内容不一致の衝突はグループ全体を同一 suffix の basename へ退避する。 + // ペア種別 (ソース + メタ) はメタが非衝突でも分裂させない + let dir = tempfile::tempdir().unwrap(); + let base = dir.path(); + write_file(base, "widgets", "clock.is", "local code").unwrap(); + + let mut bundle = BTreeMap::new(); + bundle.insert("widgets/clock.is".to_string(), "backup code".to_string()); + bundle.insert( + "widgets/clock.meta.json5".to_string(), + "{ id: 'w1' }".to_string(), + ); + + let warnings = import_bundle(base, &bundle).unwrap(); + assert_eq!(warnings.len(), 1); + + // 既存はそのまま、バックアップ側は -2 で並置 (suffix は複合拡張子の前) + assert_eq!( + read_file(base, "widgets", "clock.is").unwrap(), + "local code" + ); + assert_eq!( + read_file(base, "widgets", "clock-2.is").unwrap(), + "backup code" + ); + assert_eq!( + read_file(base, "widgets", "clock-2.meta.json5").unwrap(), + "{ id: 'w1' }" + ); + assert!(!base.join("widgets/clock.meta.json5").exists()); + } + + #[test] + fn import_bundle_casefold_collision_diverts_on_case_sensitive_fs() { + // 大文字小文字のみ異なる既存名は占有 (ASCII casefold の事前照合)。 + // case-sensitive FS でも Windows/macOS と挙動を揃える + let dir = tempfile::tempdir().unwrap(); + let base = dir.path(); + write_file(base, "skills", "Weather.md", "local").unwrap(); + + let mut bundle = BTreeMap::new(); + bundle.insert("skills/weather.md".to_string(), "backup".to_string()); + + let warnings = import_bundle(base, &bundle).unwrap(); + assert_eq!(warnings.len(), 1); + assert_eq!(read_file(base, "skills", "Weather.md").unwrap(), "local"); + assert_eq!(read_file(base, "skills", "weather-2.md").unwrap(), "backup"); + } + + #[test] + fn import_bundle_suffix_probes_next_free_number() { + // -2 が占有済みなら全構成が同時に空く次の番号へ + let dir = tempfile::tempdir().unwrap(); + let base = dir.path(); + write_file(base, "skills", "weather.md", "local").unwrap(); + write_file(base, "skills", "weather-2.md", "taken").unwrap(); + + let mut bundle = BTreeMap::new(); + bundle.insert("skills/weather.md".to_string(), "backup".to_string()); + + let warnings = import_bundle(base, &bundle).unwrap(); + assert_eq!(warnings.len(), 1); + assert_eq!(read_file(base, "skills", "weather-3.md").unwrap(), "backup"); + assert_eq!(read_file(base, "skills", "weather-2.md").unwrap(), "taken"); + } + + #[test] + fn import_bundle_overwrites_root_files_atomically() { + // 許可ルートファイルは固定名の単一ファイル。復元 = 置換 (skip/suffix の + // 衝突分岐は適用しない — suffix 先は allowlist 外で二度と読まれない) + let dir = tempfile::tempdir().unwrap(); + let base = dir.path(); + write_root_file(base, "custom.css", "old {}").unwrap(); + + let mut bundle = BTreeMap::new(); + bundle.insert("custom.css".to_string(), "new {}".to_string()); + + let warnings = import_bundle(base, &bundle).unwrap(); + assert!(warnings.is_empty()); + assert_eq!(read_root_file(base, "custom.css").unwrap(), "new {}"); + } + + #[cfg(unix)] + #[test] + fn import_bundle_applies_sessions_mode() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let base = dir.path(); + + let mut bundle = BTreeMap::new(); + bundle.insert("sessions/20260722.json5".to_string(), "{}".to_string()); + + import_bundle(base, &bundle).unwrap(); + let mode = fs::metadata(base.join("sessions/20260722.json5")) + .unwrap() + .permissions() + .mode(); + assert_eq!(mode & 0o777, 0o600); + } + + #[test] + fn import_bundle_leaves_no_temp_or_reservation_files() { + // 排他予約 (create_new) + atomic_write 後に空ファイルや .tmp が残らない + let dir = tempfile::tempdir().unwrap(); + let base = dir.path(); + write_file(base, "skills", "weather.md", "local").unwrap(); + + let mut bundle = BTreeMap::new(); + bundle.insert("skills/weather.md".to_string(), "backup".to_string()); + bundle.insert("skills/fresh.md".to_string(), "fresh".to_string()); + + import_bundle(base, &bundle).unwrap(); + assert_eq!( + list_files(base, "skills").unwrap(), + vec!["fresh.md", "weather-2.md", "weather.md"] + ); + assert_eq!(read_file(base, "skills", "fresh.md").unwrap(), "fresh"); + } + + #[test] + fn split_compound_ext_longest_first() { + assert_eq!( + split_compound_ext("clock.meta.json5"), + ("clock", ".meta.json5") + ); + assert_eq!( + split_compound_ext("clock.history.json5"), + ("clock", ".history.json5") + ); + assert_eq!( + split_compound_ext("p.ndprofile.json5"), + ("p", ".ndprofile.json5") + ); + assert_eq!( + split_compound_ext("t.ndtheme.json5"), + ("t", ".ndtheme.json5") + ); + assert_eq!(split_compound_ext("w.is"), ("w", ".is")); + assert_eq!(split_compound_ext("s.md"), ("s", ".md")); + assert_eq!(split_compound_ext("q.json5"), ("q", ".json5")); + assert_eq!(split_compound_ext("noext"), ("noext", "")); + } + + #[test] + fn validate_import_filename_rules() { + assert!(validate_import_filename("weather.md").is_ok()); + assert!(validate_import_filename("日本語.json5").is_ok()); + // 予約デバイス名は stem 判定・case-insensitive + assert!(validate_import_filename("CON").is_err()); + assert!(validate_import_filename("nul.json5").is_err()); + assert!(validate_import_filename("Lpt9.is").is_err()); + // COM0 / COM10 / 部分一致は予約名ではない + assert!(validate_import_filename("com0.md").is_ok()); + assert!(validate_import_filename("com10.md").is_ok()); + assert!(validate_import_filename("console.md").is_ok()); + assert!(validate_import_filename("\u{7f}x.md").is_err()); + assert!(validate_import_filename(".dotfile").is_err()); + assert!(validate_import_filename("dot.").is_err()); + assert!(validate_import_filename("space ").is_err()); } } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 0c262aa94..c20d94a03 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-cli/schema.json", "productName": "NoteDeck", - "version": "1.46.2", + "version": "1.47.0", "identifier": "com.notedeck.desktop", "build": { "frontendDist": "../dist", diff --git a/src/bindings.ts b/src/bindings.ts index d69755242..fe068e45e 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -2329,7 +2329,7 @@ async exportSettingsJson() : Promise> { +async importSettingsJson() : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("import_settings_json") }; } catch (e) { @@ -3223,6 +3223,12 @@ export type HttpFetchResponse = { status: number; headers: Partial<{ [key in str * 画像ディスクキャッシュの使用量 (#815)。設定のキャッシュ画面で表示する */ export type ImageCacheStats = { bytes: number; files: number } +/** + * import_settings_json の結果。`imported: false` はダイアログのキャンセル。 + * `warnings` はスキップ / 別名退避したエントリの説明 (#913 付随修正 — フロントは + * 復元完了メッセージに件数 + 内容を表示する)。 + */ +export type ImportSettingsResult = { imported: boolean; warnings: string[] } export type JsonValue = null | boolean | number | string | JsonValue[] | Partial<{ [key in string]: JsonValue }> /** * Misskey の `mutedWords` / `hardMutedWords` の 1 要素。 diff --git a/src/capabilities/builtins/plugins.ts b/src/capabilities/builtins/plugins.ts index 15bd9e763..150748659 100644 --- a/src/capabilities/builtins/plugins.ts +++ b/src/capabilities/builtins/plugins.ts @@ -464,7 +464,7 @@ export const pluginsHistoryCapability: Command = { if (!plugin) { throw new Error(`plugins.history: plugin "${installId}" not found`) } - const basename = plugin.name || plugin.installId + const basename = plugin.fileBase ?? (plugin.name || plugin.installId) return await listSnapshots('plugin', basename) }, } @@ -483,7 +483,7 @@ export const pluginsRevertCapability: Command = { const index = typeof params?.index === 'number' ? params.index : -1 const cur = usePluginsStore().getPlugin(installId) if (!cur || index < 0) return null - const basename = cur.name || cur.installId + const basename = cur.fileBase ?? (cur.name || cur.installId) const entry = await getSnapshotAt('plugin', basename, index) if (!entry) return null const snap = entry.snapshot @@ -533,7 +533,7 @@ export const pluginsRevertCapability: Command = { if (!plugin) { throw new Error(`plugins.revert: plugin "${installId}" not found`) } - const basename = plugin.name || plugin.installId + const basename = plugin.fileBase ?? (plugin.name || plugin.installId) const entry = await getSnapshotAt('plugin', basename, index) if (!entry) { throw new Error(`plugins.revert: no snapshot at index ${index}`) diff --git a/src/capabilities/builtins/skills.ts b/src/capabilities/builtins/skills.ts index a8c20a9eb..f04a2f532 100644 --- a/src/capabilities/builtins/skills.ts +++ b/src/capabilities/builtins/skills.ts @@ -528,7 +528,8 @@ export const skillsHistoryCapability: Command = { const store = useSkillsStore() const skill = store.skills.find((s) => s.id === id) if (!skill) throw new Error(`skills.history: skill "${id}" not found`) - const basename = skill.name || skill.id + // 履歴キーは対応表の fileBase (#913)。未割当なら旧キーに落ちる + const basename = skill.fileBase ?? (skill.name || skill.id) return await listSnapshots('skill', basename) }, } @@ -546,7 +547,7 @@ export const skillsRevertCapability: Command = { const index = typeof params?.index === 'number' ? params.index : -1 const cur = useSkillsStore().skills.find((s) => s.id === id) if (!cur || index < 0) return null - const basename = cur.name || cur.id + const basename = cur.fileBase ?? (cur.name || cur.id) const entry = await getSnapshotAt('skill', basename, index) if (!entry) return null return { @@ -592,7 +593,7 @@ export const skillsRevertCapability: Command = { const store = useSkillsStore() const skill = store.skills.find((s) => s.id === id) if (!skill) throw new Error(`skills.revert: skill "${id}" not found`) - const basename = skill.name || skill.id + const basename = skill.fileBase ?? (skill.name || skill.id) const entry = await getSnapshotAt('skill', basename, index) if (!entry) { throw new Error(`skills.revert: no snapshot at index ${index}`) diff --git a/src/capabilities/builtins/theme.ts b/src/capabilities/builtins/theme.ts index db3fc988d..6043701e0 100644 --- a/src/capabilities/builtins/theme.ts +++ b/src/capabilities/builtins/theme.ts @@ -363,6 +363,12 @@ function isStringRecord(v: unknown): v is Record { return true } +/** 履歴サイドカーのキーは対応表の fileBase (#913)。未割当なら id に落ちる。 */ +function themeHistoryBase(id: string): string { + const theme = useThemeStore().installedThemes.find((t) => t.id === id) + return theme?.fileBase ?? id +} + export const themeHistoryCapability: Command = { id: 'theme.history', label: 'テーマの編集履歴', @@ -387,7 +393,7 @@ export const themeHistoryCapability: Command = { execute: async (params) => { const id = typeof params?.id === 'string' ? params.id : '' if (!id) throw new Error('theme.history: id is required') - return await listSnapshots('theme', id) + return await listSnapshots('theme', themeHistoryBase(id)) }, } @@ -403,7 +409,11 @@ export const themeRevertCapability: Command = { const id = typeof params?.id === 'string' ? params.id : '' const index = typeof params?.index === 'number' ? params.index : -1 if (!id || index < 0) return null - const entry = await getSnapshotAt('theme', id, index) + const entry = await getSnapshotAt( + 'theme', + themeHistoryBase(id), + index, + ) if (!entry) return null const snap = entry.snapshot return { @@ -441,7 +451,11 @@ export const themeRevertCapability: Command = { const index = typeof params?.index === 'number' ? params.index : -1 if (!id) throw new Error('theme.revert: id is required') if (index < 0) throw new Error('theme.revert: index must be >= 0') - const entry = await getSnapshotAt('theme', id, index) + const entry = await getSnapshotAt( + 'theme', + themeHistoryBase(id), + index, + ) if (!entry) { throw new Error(`theme.revert: no snapshot at index ${index}`) } diff --git a/src/capabilities/builtins/widgets.ts b/src/capabilities/builtins/widgets.ts index d998b7e2e..60ffab8c5 100644 --- a/src/capabilities/builtins/widgets.ts +++ b/src/capabilities/builtins/widgets.ts @@ -381,7 +381,7 @@ export const widgetsHistoryCapability: Command = { if (!widget) { throw new Error(`widgets.history: widget "${installId}" not found`) } - const basename = widget.name || widget.installId + const basename = widget.fileBase ?? (widget.name || widget.installId) return await listSnapshots('widget', basename) }, } @@ -400,7 +400,7 @@ export const widgetsRevertCapability: Command = { const index = typeof params?.index === 'number' ? params.index : -1 const cur = useWidgetsStore().getWidget(installId) if (!cur || index < 0) return null - const basename = cur.name || cur.installId + const basename = cur.fileBase ?? (cur.name || cur.installId) const entry = await getSnapshotAt('widget', basename, index) if (!entry) return null return { @@ -445,7 +445,7 @@ export const widgetsRevertCapability: Command = { if (!widget) { throw new Error(`widgets.revert: widget "${installId}" not found`) } - const basename = widget.name || widget.installId + const basename = widget.fileBase ?? (widget.name || widget.installId) const entry = await getSnapshotAt('widget', basename, index) if (!entry) { throw new Error(`widgets.revert: no snapshot at index ${index}`) diff --git a/src/components/common/AppConfirm.vue b/src/components/common/AppConfirm.vue index 33ec708cd..dc55d3a57 100644 --- a/src/components/common/AppConfirm.vue +++ b/src/components/common/AppConfirm.vue @@ -1,6 +1,7 @@ + + + + diff --git a/src/components/deck/DeckPluginManagerColumn.vue b/src/components/deck/DeckPluginManagerColumn.vue index 591e88b26..cf02b5b74 100644 --- a/src/components/deck/DeckPluginManagerColumn.vue +++ b/src/components/deck/DeckPluginManagerColumn.vue @@ -251,6 +251,15 @@ async function handleStoreInstall(entry: StorePluginEntry) { } } +async function handleStoreUpdate(entry: StorePluginEntry) { + installError.value = null + try { + await misStore.updatePlugin(entry) + } catch (e) { + installError.value = e instanceof Error ? e.message : '更新失敗' + } +} + /** ストアエントリがこのカラムのスコープに参加済みか (「インストール済み」表示基準)。 */ function isEntryInScope(entry: StorePluginEntry): boolean { const plugin = pluginsStore.plugins.find((p) => p.storeId === entry.id) @@ -292,13 +301,20 @@ function openNewPlugin() { } /** - * カード trash = このカラムのスコープから外す (widgets の detach と同型)。 - * 本体はライブラリに残り、ピッカーから再追加/完全削除できる。 + * カードの「外す」= このカラムのスコープから外す (widgets の detach と同型)。 + * 本体はライブラリに残り、ピッカーから再追加/完全削除できる。可逆なので + * 確認は挟まず undo トーストで戻せるようにする (#1048)。 */ function detachFromScope(plugin: PluginMeta) { const scope = columnScope.value if (!scope) return pluginsStore.unlinkScope(plugin.installId, scope) + useToast().show('プラグインを外しました', 'info', { + action: { + label: '元に戻す', + onClick: () => pluginsStore.linkScope(plugin.installId, scope), + }, + }) } const detachTitle = computed(() => @@ -439,12 +455,12 @@ async function deleteFromLibrary(plugin: PluginMeta) { :category="storeByName.get(plugin.name)?.category" :category-label="storeByName.get(plugin.name)?.category ? PLUGIN_CATEGORY_LABELS[storeByName.get(plugin.name)!.category] : undefined" :active="plugin.active" - :uninstall-title="detachTitle" + :detach-title="detachTitle" :icon-url="plugin.iconUrl ?? storeByName.get(plugin.name)?.iconUrl" :denied-badge="getPluginDenial(plugin.installId)" @click="openPluginDetail(plugin.installId)" @toggle="toggleActive(plugin)" - @uninstall="detachFromScope(plugin)" + @detach="detachFromScope(plugin)" @settings="openPluginDetail(plugin.installId)" @denied-click="openPermissionSettings()" /> @@ -530,12 +546,15 @@ async function deleteFromLibrary(plugin: PluginMeta) { :category-label="entry.category ? PLUGIN_CATEGORY_LABELS[entry.category] : undefined" :installing="misStore.installing === entry.id" :already-installed="isEntryInScope(entry)" + :has-update="misStore.hasPluginUpdate(entry)" + :updated-at="entry.updatedAt" :capabilities="entry.capabilities" :capability-ok="capabilityChecks[entry.id]?.ok" :capability-badge="capabilityChecks[entry.id]?.badge" :capability-reason="capabilityChecks[entry.id]?.reason" :icon-url="entry.iconUrl" @install="handleStoreInstall(entry)" + @update="handleStoreUpdate(entry)" @open-detail="handleOpenStoreDetail(entry)" /> diff --git a/src/components/deck/DeckQueryManagerColumn.vue b/src/components/deck/DeckQueryManagerColumn.vue index a673528d5..04cfb309f 100644 --- a/src/components/deck/DeckQueryManagerColumn.vue +++ b/src/components/deck/DeckQueryManagerColumn.vue @@ -178,6 +178,15 @@ async function handleInstall(entry: StoreQueryEntry): Promise { } } +async function handleUpdate(entry: StoreQueryEntry): Promise { + installError.value = null + try { + await misStore.updateQuery(entry) + } catch (e) { + installError.value = e instanceof Error ? e.message : '更新失敗' + } +} + function handleOpenStoreDetail(entry: StoreQueryEntry): void { openSafeUrl(getQueryDetailUrl(entry.id)) } @@ -314,7 +323,10 @@ function handleOpenStoreDetail(entry: StoreQueryEntry): void { :category-label="queryCategoryLabel(entry.category)" :installing="misStore.installingQuery === entry.id" :already-installed="misStore.isQueryInstalled(entry)" + :has-update="misStore.hasQueryUpdate(entry)" + :updated-at="entry.updatedAt" @install="handleInstall(entry)" + @update="handleUpdate(entry)" @open-detail="handleOpenStoreDetail(entry)" /> diff --git a/src/components/deck/DeckSkillColumn.vue b/src/components/deck/DeckSkillColumn.vue index 3905a7700..c7aea4431 100644 --- a/src/components/deck/DeckSkillColumn.vue +++ b/src/components/deck/DeckSkillColumn.vue @@ -17,6 +17,7 @@ import { } from '@/stores/skills' import { useToast } from '@/stores/toast' import { useWindowsStore } from '@/stores/windows' +import { formatDate } from '@/utils/format' import { isProxiable, proxyCssUrl } from '@/utils/mediaProxy' import { openSafeUrl } from '@/utils/url' import { isWindowExposed } from '@/windows/exposure' @@ -205,6 +206,20 @@ async function handleStoreInstall(entry: StoreSkillEntry) { } } +async function handleStoreUpdate(entry: StoreSkillEntry) { + installError.value = null + try { + await misStore.updateSkill(entry) + } catch (e) { + installError.value = e instanceof Error ? e.message : '更新失敗' + } +} + +/** 更新の主表示は updatedAt、version は補助 (#1040) */ +function storeUpdateTitle(entry: StoreSkillEntry): string { + return `ストア更新日: ${formatDate(entry.updatedAt)} / v${entry.version}` +} + function handleOpenStoreDetail(entry: StoreSkillEntry) { openSafeUrl(getSkillDetailUrl(entry.id)) } @@ -321,8 +336,8 @@ function handleOpenStoreDetail(entry: StoreSkillEntry) { +