Skip to content

feat(frontend): 設定プロファイルの同期 - #17803

Open
syuilo wants to merge 22 commits into
developfrom
prefer-sync
Open

feat(frontend): 設定プロファイルの同期#17803
syuilo wants to merge 22 commits into
developfrom
prefer-sync

Conversation

@syuilo

@syuilo syuilo commented Jul 25, 2026

Copy link
Copy Markdown
Member

What

Resolve #17788

Why

Additional info (optional)

Checklist

  • Read the contribution guide
  • Test working in a local environment
  • (If needed) Add story of storybook
  • (If needed) Update CHANGELOG.md
  • (If possible) Add tests

@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

設定値に項目単位の変更日時を追加しました。プロファイル統合、クラウドバックアップ、デバイス間同期、自動同期、設定画面、メニュー表示、ローカライズ、設計仕様書を更新しました。

Changes

設定クラウド同期

Layer / File(s) Summary
設定メタデータと保存契約
packages/frontend/src/preferences/manager.ts, packages/frontend/src/preferences.ts, packages/frontend/src/store.ts, packages/frontend/src/types/menu.ts
設定値に modifiedAt を追加しました。クラウド取得・保存のデータ形式を値とメタデータの組み合わせに更新しました。自動同期フラグと実行日時をストアに追加しました。
プロファイル統合と変更反映
packages/frontend/src/preferences/manager.ts
mergeProfiles を追加しました。項目単位の modifiedAt が新しい設定を採用します。変更時だけプロファイルを保存し、クラウドへ変更日時を送信します。
クラウド同期と自動実行
packages/frontend/src/preferences.ts, packages/frontend/src/preferences/utility.ts
cloudSync と更新済みの cloudBackup を追加しました。自動同期の確認処理、タブ間の storage 同期、クラウド設定の統合処理を更新しました。
設定画面と表示文言
packages/frontend/src/pages/settings/other.vue, packages/frontend/src/components/MkMenu.vue, packages/i18n/src/autogen/locale.ts, locales/ja-JP.yml, packages/frontend/docs/preferences.md
設定画面に手動バックアップ・同期を追加しました。無効なメニュー項目を表示できるようにしました。関連する表示文言と設計仕様書を追加しました。

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant 設定ストア
  participant cloudSync
  participant PreferencesManager
  participant StorageProvider
  設定ストア->>cloudSync: 自動同期が有効な状態で起動
  cloudSync->>StorageProvider: クラウド設定を取得
  StorageProvider-->>cloudSync: 値とmodifiedAtを返却
  cloudSync->>PreferencesManager: mergeProfiles()を実行
  PreferencesManager-->>cloudSync: 統合済みプロファイルを返却
  cloudSync->>PreferencesManager: プロファイルを再読み込み
Loading

Suggested reviewers: kakkokari-gtyih

Poem

設定の時刻をそろえます
雲と端末をつなぎます
古い値には負けません
同期の灯りがともります
バックアップ、完了です ☁️

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning What欄にはIssue番号のみがあり、Why欄と実装内容、動機、テスト情報が未記載です。 What欄に変更内容と影響を記載し、Why欄に目的と背景を追加してください。実施したテスト結果もChecklistで明示してください。
✅ Passed checks (4 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed タイトルは設定プロファイルの同期という主な変更内容を簡潔かつ明確に示しています。
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch prefer-sync

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added the packages/frontend Client side specific issue/PR label Jul 25, 2026
@codecov

codecov Bot commented Jul 25, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 0% with 78 lines in your changes missing coverage. Please review.
✅ Project coverage is 13.93%. Comparing base (b95e484) to head (597e634).
⚠️ Report is 5 commits behind head on develop.

Files with missing lines Patch % Lines
packages/frontend/src/preferences/manager.ts 0.00% 39 Missing and 4 partials ⚠️
packages/frontend/src/preferences/utility.ts 0.00% 27 Missing and 8 partials ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##           develop   #17803      +/-   ##
===========================================
- Coverage    14.00%   13.93%   -0.07%     
===========================================
  Files          248      248              
  Lines        12042    12098      +56     
  Branches      4042     4057      +15     
===========================================
  Hits          1686     1686              
- Misses        8117     8162      +45     
- Partials      2239     2250      +11     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@syuilo
syuilo marked this pull request as ready for review August 3, 2026 13:23
@dosubot dosubot Bot added the size:L This PR changes 100-499 lines, ignoring generated files. label Aug 3, 2026
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

🖥 Frontend Diagnostics Report

(No significant changes)

View details

Requests by resource type
Type Requests Encoded bytes
Base Head Δ Base Head Δ
Document 2 2 0 33 KB 33 KB 0 B
Script 148 148 0 2.1 MB 2.1 MB $\color{orange}{\text{+4.2 KB}}$
Stylesheet 58 58 0 288 KB 288 KB $\color{orange}{\text{+58 B}}$
Fetch 20 20 0 46 KB 46 KB 0 B
Image 10 10 0 254 KB 254 KB 0 B
Font 2 2 0 118 KB 118 KB 0 B
Other 9 9 0 421 KB 421 KB $\color{green}{\text{-9 B}}$
V8 heap snapshot statistics
Metric @ Base @ Head Δ MAD
$\color{gray}{\rule{8pt}{8pt}}$ Total 11 MB
± 8.3 KB
11 MB
± 21 KB
$\text{-45 KB}$
$\text{-0.4\%}$
22 KB
$\color{orange}{\rule{8pt}{8pt}}$ Code 2.8 MB 2.8 MB $\text{-4.5 KB}$ 7.2 KB
$\color{red}{\rule{8pt}{8pt}}$ Strings 1.2 MB 1.2 MB $\text{-72 B}$ 308 B
$\color{cyan}{\rule{8pt}{8pt}}$ JS arrays 120 KB 120 KB $\text{-140 B}$ 0 B
$\color{green}{\rule{8pt}{8pt}}$ Typed arrays 0 B 0 B 0 B 0 B
$\color{yellow}{\rule{8pt}{8pt}}$ System objects 0 B 0 B 0 B 0 B
$\color{violet}{\rule{8pt}{8pt}}$ Other JS objs 2.1 MB 2.1 MB $\text{-312 B}$ 417 B
$\color{pink}{\rule{8pt}{8pt}}$ Other non-JS objs 5.1 MB 5 MB $\text{-51 KB}$ 11 KB

Download representative heap snapshot: base / head

📦 Bundle Stats

Chunk size diff (6 updated, 0 added, 0 removed)
Chunk Base Head Δ Δ (%)
(total) 5.8 MB 5.9 MB $\color{orange}{\text{+3.5 KB}}$ $\text{+0.1\%}$
i18n ja-JP/7MGtnJXyC-BlQB1ob-.js → ja-JP/bE0wpUrmy-DYLRATY0.js
216 KB 217 KB $\text{+778 B}$ $\color{orange}{\text{+0.4\%}}$
src/pages/settings/other.vue ja-JP/7MGtnJXyC-s-eUUgZq2.js → ja-JP/bE0wpUrmy-G7VWclUC2.js
13 KB 14 KB $\text{+497 B}$ $\color{orange}{\text{+3.8\%}}$
search-index:settings ja-JP/7MGtnJXyC-omcyWnQb.js → ja-JP/bE0wpUrmy-C2FbMEyF.js
32 KB 32 KB $\text{+141 B}$ $\color{orange}{\text{+0.4\%}}$
(other generated chunks) 2 MB 2 MB $\color{orange}{\text{+2 KB}}$ $\color{orange}{\text{+0.1\%}}$
(other) 55 KB 55 KB $\text{+11 B}$ $\text{+0\%}$
Startup chunk size (1 updated, 0 added, 0 removed)
Chunk Base Head Δ Δ (%)
(total) 1 MB 1 MB $\color{orange}{\text{+2.8 KB}}$ $\color{orange}{\text{+0.3\%}}$
i18n ja-JP/7MGtnJXyC-BlQB1ob-.js → ja-JP/bE0wpUrmy-DYLRATY0.js
216 KB 217 KB $\text{+778 B}$ $\color{orange}{\text{+0.4\%}}$
(other generated chunks) 754 KB 756 KB $\color{orange}{\text{+2 KB}}$ $\color{orange}{\text{+0.3\%}}$
(other) 64 KB 64 KB 0 B 0%

Startup chunks are the Vite entry for src/_boot_.ts and its static imports.

Bundles Modules Entries Imports Size
Static Dynamic Rendered Gzip Brotli
Base 468 2,861 21 10,662 332 10 MB 2.8 MB 2.4 MB
Head 468 2,860 21 10,660 332 10 MB 2.8 MB 2.4 MB
Δ 0 $\color{green}{\text{-1}}$ 0 $\color{green}{\text{-2}}$ 0 $\color{orange}{\text{+4.5 KB}}$ $\text{+379 B}$ $\text{+246 B}$
Δ (%) 0% $\text{-0\%}$ 0% $\text{-0\%}$ 0% $\text{+0\%}$ $\text{+0\%}$ $\text{+0\%}$

Open treemap HTML

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
packages/frontend/src/pages/settings/other.vue (1)

240-249: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

forceCloudBackup / forceCloudSync にエラーハンドリングがありません。

forceCloudSync(および同じパターンを持つ forceCloudBackup)は cloudBackup() / cloudSync()try/catch なしで呼び出しています。これらの関数がエラーを投げた場合(例えば、manager.tsmergeProfiles に関するコメントで挙げたスキーマ不一致によるクラッシュや、通信エラー)、os.success() が呼ばれないだけで、ユーザーには失敗したことが一切通知されません。

PreferencesManager.enableSync() では同様のクラウド操作の失敗時に os.alert でエラーを通知するパターンが既にあります。同様のエラーハンドリングをこの2つの関数にも追加することをおすすめします。

🛡️ 修正案
 async function forceCloudSync() {
-	await cloudSync();
-	os.success();
+	try {
+		await cloudSync();
+		os.success();
+	} catch (err) {
+		os.alert({
+			type: 'error',
+			title: i18n.ts.somethingHappened,
+		});
+		console.error(err);
+	}
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/frontend/src/pages/settings/other.vue` around lines 240 - 249,
Update forceCloudBackup and forceCloudSync to wrap their cloudBackup and
cloudSync calls in try/catch handling, respectively. Preserve os.success() only
for successful operations, and notify the user of failures through the existing
os.alert pattern used by PreferencesManager.enableSync().
packages/frontend/src/preferences/manager.ts (1)

550-572: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

enableSynccommit() の戻り値が実際には使われていません。

Line 550 で commitedRecord を受け取っていますが、以降の Line 555 の cloudSet 呼び出しと Line 571 の record[2].sync = true は、いずれも Line 539 で取得した古い record をそのまま使い続けています。

この実装には2つの問題があります。

1つ目は、対象キーがアカウント依存・サーバー依存の設定で、まだアカウント・サーバー固有のスコープを持っていない場合です。この場合 commit()(Line 296-317)は新しいスコープのレコードを作成して返しますが、record は古い(より汎用的な)スコープのレコードのままです。そのため cloudSet は誤ったスコープと古い modifiedAt を送信し、Line 571 の record[2].sync = true も実際に値を保持している新しいレコードではなく、古いレコードに設定されてしまいます。結果として、UI 上は同期が有効に見えても、実際にはその後の commit() が正しいレコードの sync フラグを見つけられず、同期が機能しなくなります。

2つ目は、newValue が現在値と同じ場合です。この場合 commit()deepEqual により null を返す(Line 285-288)ため modifiedAt は更新されず、古い値または undefined のまま cloudSet に送られます。今後の mergeProfiles による比較で、このレコードは常に他の値に負けてしまう可能性があります。

commitedRecord を実際に使用するよう修正することをご検討ください。

🐛 修正案
-		const commitedRecord = this.commit(key, newValue);
+		const commitedRecord = this.commit(key, newValue) ?? this.getMatchedRecordOf(key);

 		const done = os.waiting();

 		try {
-			await this.io.cloudSet({ key, scope: record[0], value: newValue, meta: { modifiedAt: record[2].modifiedAt } });
+			await this.io.cloudSet({ key, scope: commitedRecord[0], value: newValue, meta: { modifiedAt: commitedRecord[2].modifiedAt ?? Date.now() } });
 		} catch (err) {
 			...
 		}

 		done({ success: true });

-		record[2].sync = true;
+		commitedRecord[2].sync = true;
 		this.save();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/frontend/src/preferences/manager.ts` around lines 550 - 572, Update
enableSync to use the record returned by commit() for cloudSet’s scope and
modifiedAt metadata and for setting sync=true, rather than the stale record
captured before commit. Handle commit() returning null for an unchanged value
without sending undefined or stale metadata, while preserving the existing
success and error flows.
packages/frontend/src/preferences.ts (1)

55-83: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

cloudBackupcloudRead/cloudSet 経由で同期値を消さないよう分岐してください。

cloudBackup() はバックアップキーを取得・マージして i/registry/set で保存しますが、ここから cloudSet() を呼ぶと同じキーの client.preferences.sync 配列全体が新規の更新対象で上書きされます。同期フラグ付き設定値や既存の sync スコープ値を消さないよう、cloudSet() の対象を同步設定の更新のみに絞るか、同期値を合成して書き戻す実装にしてください。

cloudSet() 自体も i/registry/get → 配列更新 → i/registry/set の非アトミックな read-modify-write なので、別のタブ・デバイスで同じキーの別スコープが更新されると後発の書き込みで先発の更新分を失う可能性があります。docs/preferences.md の「設定値が意図せず失われることが絶対にあってはならない」の設計要件に合わせて、サーバー側の更新条件や compare-and-swap を活用してください。

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/frontend/src/preferences.ts` around lines 55 - 83, Update
cloudBackup and cloudSet so backup writes preserve existing
client.preferences.sync entries and do not overwrite synchronization values
outside the intended scope. Make cloudSet’s registry update atomic by using the
server-side conditional update or compare-and-swap mechanism, retrying on
conflicts as needed so concurrent tabs or devices cannot lose each other’s scope
updates.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/frontend/docs/preferences.md`:
- Around line 7-16: preferences.md に mergeProfiles
の衝突解決規則を追加し、設定消失を防ぐ判定契約を明文化してください。modifiedAt
が同値または欠落した場合、端末時計のずれ、削除・初期値への復元、再アップロード時の古い値による上書きをどう扱うかを明示し、常に新しい変更を保持できるルールにしてください。
- Around line 40-42: Update the same-profile sharing section in preferences
documentation to state that sharing an entire profile across devices is not
recommended, distinguish it from item-level synchronization via
syncBetweenDevices, and document that autoBackup requires a named profile
through youNeedToNameYourProfileToEnableAutoBackup. Replace the recommended
procedure with using separate profiles and enabling syncBetweenDevices for
item-level synchronization, while documenting the relevant sync targets and
conflict behavior.

In `@packages/frontend/src/preferences/manager.ts`:
- Around line 398-420: Update the comparison in fetchCloudValues to compare
cloudValue.value with record[1], not the metadata wrapper cloudValue. Preserve
the existing rewriteRawState, modified tracking, and save behavior so they run
only when the actual preference value changes.
- Around line 184-221: Update mergeProfiles to defensively handle missing
preference records for any key in PREF_DEF: treat undefined a.preferences[key]
or b.preferences[key] as an empty record list before copying or iterating.
Preserve the existing per-scope, latest-modifiedAt merge behavior and avoid
mutating either input profile.

In `@packages/frontend/src/preferences/utility.ts`:
- Around line 219-246: Update cloudBackup to handle concurrent executions
without losing either device or tab’s merged changes. Protect the i/registry/get
→ mergeProfiles → i/registry/set sequence with the same concurrency or
conflict-resolution approach used by cloudSet, such as server-side optimistic
locking, atomic per-key updates, or retrying after conflict with a fresh read,
while preserving the existing backup timestamp update after a successful write.

---

Outside diff comments:
In `@packages/frontend/src/pages/settings/other.vue`:
- Around line 240-249: Update forceCloudBackup and forceCloudSync to wrap their
cloudBackup and cloudSync calls in try/catch handling, respectively. Preserve
os.success() only for successful operations, and notify the user of failures
through the existing os.alert pattern used by PreferencesManager.enableSync().

In `@packages/frontend/src/preferences.ts`:
- Around line 55-83: Update cloudBackup and cloudSet so backup writes preserve
existing client.preferences.sync entries and do not overwrite synchronization
values outside the intended scope. Make cloudSet’s registry update atomic by
using the server-side conditional update or compare-and-swap mechanism, retrying
on conflicts as needed so concurrent tabs or devices cannot lose each other’s
scope updates.

In `@packages/frontend/src/preferences/manager.ts`:
- Around line 550-572: Update enableSync to use the record returned by commit()
for cloudSet’s scope and modifiedAt metadata and for setting sync=true, rather
than the stale record captured before commit. Handle commit() returning null for
an unchanged value without sending undefined or stale metadata, while preserving
the existing success and error flows.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: d0860406-651f-4605-821a-0a1936c20c4d

📥 Commits

Reviewing files that changed from the base of the PR and between b95e484 and 597e634.

⛔ Files ignored due to path filters (1)
  • CHANGELOG.md is excluded by !CHANGELOG.md
📒 Files selected for processing (10)
  • locales/ja-JP.yml
  • packages/frontend/docs/preferences.md
  • packages/frontend/src/components/MkMenu.vue
  • packages/frontend/src/pages/settings/other.vue
  • packages/frontend/src/preferences.ts
  • packages/frontend/src/preferences/manager.ts
  • packages/frontend/src/preferences/utility.ts
  • packages/frontend/src/store.ts
  • packages/frontend/src/types/menu.ts
  • packages/i18n/src/autogen/locale.ts

Comment on lines +7 to +16
実装上のミスで、**設定値が意図せず失われる(古い値で上書きされる)ことが絶対にあってはならない。**

設定値が失われる、考えられるシナリオの例:

- 複数のタブでMisskeyを開いていて、タブAで設定を編集した後、タブBを開いたところ、タブBの古い状態の内容で設定が保存され、タブAで行った編集が巻き戻ってしまった。
- プロファイルの同期機能をオンにしたところ、サーバーに保存されていた古い設定でローカルが上書きされてしまった。

上記のシナリオが絶対に発生しない設計・実装にしなければならない。

上記以外にも考えられるシナリオがあれば、必ず適切な対処を行う設計・実装にしなければならない。

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 8 \
  'modifiedAt|merge|autoBackup|autoSync|cloudBackup|cloudSync|conflict|retry|Date\.now' \
  packages/frontend/src/preferences/manager.ts \
  packages/frontend/src/preferences.ts \
  packages/frontend/src/preferences/utility.ts \
  packages/frontend/src/store.ts

Repository: misskey-dev/misskey

Length of output: 39387


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== docs =="
sed -n '1,80p' packages/frontend/docs/preferences.md

echo "== manager merged lines =="
sed -n '177,216p' packages/frontend/src/preferences/manager.ts

echo "== utility merge backup/sync sections =="
sed -n '197,245p' packages/frontend/src/preferences/utility.ts

echo "== tests mentioning preferences merge/timestamp =="
rg -n -C 6 'mergeProfiles|modifiedAt|preferences.*backup|preferences.*sync'| packages/frontend/test 2>/dev/null || true

Repository: misskey-dev/misskey

Length of output: 3813


設定値が失われないことを保証する衝突解決規則を仕様に追加してください。

packages/frontend/src/preferences/manager.tsmergeProfilesmodifiedAt で最新を選択していますが、この文書では判定契約自体が規定されていません。同じ値の modifiedAt、欠落値、端末時計のずれ、削除や初期値への復元、再アップロード時の古い設定で上書きされない再試行などについて、明示的なルールとして追加してください。

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/frontend/docs/preferences.md` around lines 7 - 16, preferences.md に
mergeProfiles の衝突解決規則を追加し、設定消失を防ぐ判定契約を明文化してください。modifiedAt
が同値または欠落した場合、端末時計のずれ、削除・初期値への復元、再アップロード時の古い値による上書きをどう扱うかを明示し、常に新しい変更を保持できるルールにしてください。

Comment on lines +40 to +42
### 同じプロファイルを複数のデバイスで使いたい(同期したい)

autoBackupとautoSyncをオンにする。

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 8 \
  'shareSameProfileBetweenDevicesIsNotRecommended|useSyncBetweenDevicesOptionIfYouWantToSyncSetting|autoSyncAreYouSure_description|youNeedToNameYourProfileToEnableAutoBackup|autoBackup|autoSync|syncBetweenDevices' \
  packages/frontend/src/preferences/manager.ts \
  packages/frontend/src/preferences.ts \
  packages/frontend/src/preferences/utility.ts \
  packages/frontend/src/pages/settings/other.vue \
  locales/ja-JP.yml

Repository: misskey-dev/misskey

Length of output: 12033


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== docs/preferences.md around referenced lines =="
sed -n '20,60p' packages/frontend/docs/preferences.md

echo
echo "== syncBetweenDevices references =="
rg -n -C 8 'syncBetweenDevices|overrideByAccount|modifyPreference|getPreference|setPreference|pref\.enablePreferencesAutoCloudSync|enablePreferencesAutoCloudSync|enablePreferencesAutoCloudBackup|cloudSync|cloudBackup|_preferencesProfile|shareSameProfileBetweenDevicesIsNotRecommended|useSyncBetweenDevicesOptionIfYouWantToSyncSetting|autoSyncAreYouSure_description' packages/frontend/src packages/frontend/docs || true

echo
echo "== store schema preferences keys =="
rg -n "enablePreferencesAutoCloudSync|enablePreferencesAutoCloudBackup|preventAutoBackup|latestPreferencesBackupAt" packages/frontend/src locales/ja-JP.yml

Repository: misskey-dev/misskey

Length of output: 43442


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== packages/frontend/src/preferences/utility.ts cloudBackup/cloudSync/mergeProfiles =="
sed -n '196,270p' packages/frontend/src/preferences/utility.ts

echo
echo "== packages/frontend/src/preferences/manager.ts sync-related registry methods =="
rg -n -C 12 'isSyncEnabled|set.*Sync|Sync|autoBackup|autoSync|cloudSync|cloudBackup|Registry|registry/get|registry/set|registry/delete|prevent' packages/frontend/src/preferences/manager.ts packages/frontend/src/preferences/utility.ts

echo
echo "== packages/frontend/src/preferences.ts cloud backup/sync periodics =="
sed -n '100,140p' packages/frontend/src/preferences.ts

echo
echo "== packages/frontend/src/pages/settings/index.vue auto backup button prompt =="
sed -n '18,23p;18,22p' packages/frontend/src/pages/settings/index.vue

Repository: misskey-dev/misskey

Length of output: 28247


同一プロファイル共有の手順と前提条件を追加してください。

autoBackupautoSync でプロファイル全体を複数デバイス間で共有できますが、この手順の冒頭だけで非推奨の前提条件や、同期対象・競合時の動作が揃っておりません。指定したいのはプロファイル全体の共有であれば、同一プロファイル共有が非推奨であること、syncBetweenDevices を有効にする項目単位同期とは明確に区別すること、そして autoBackup にプロファイル名が必要なので youNeedToNameYourProfileToEnableAutoBackup もこの手順の前提として記述してください。推奨は項目単位の同期であれば、手順を別プロファイルと syncBetweenDevices の利用に変更してください。

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/frontend/docs/preferences.md` around lines 40 - 42, Update the
same-profile sharing section in preferences documentation to state that sharing
an entire profile across devices is not recommended, distinguish it from
item-level synchronization via syncBetweenDevices, and document that autoBackup
requires a named profile through youNeedToNameYourProfileToEnableAutoBackup.
Replace the recommended procedure with using separate profiles and enabling
syncBetweenDevices for item-level synchronization, while documenting the
relevant sync targets and conflict behavior.

Comment on lines +184 to +221
// 各recordについて、modifiedAtが大きい方を採用する
// 引数の参照をmutateしないように注意すること
export function mergeProfiles(a: PreferencesProfile, b: PreferencesProfile): PreferencesProfile {
const merged = {
...a,
modifiedAt: Math.max(a.modifiedAt, b.modifiedAt),
preferences: {},
} as PreferencesProfile;

for (const _key in PREF_DEF) {
const key = _key as keyof PREF;
const aRecords = a.preferences[key];
const bRecords = b.preferences[key];

const mergedRecords = [...aRecords];

for (const bRecord of bRecords) {
const existingIndex = mergedRecords.findIndex(([scope]) => isSameScope(scope, bRecord[0]));
if (existingIndex === -1) {
mergedRecords.push(bRecord);
} else {
const aRecord = mergedRecords[existingIndex];
if ((bRecord[2].modifiedAt ?? 0) > (aRecord[2].modifiedAt ?? 0)) {
mergedRecords[existingIndex] = bRecord;
}
}
}

(merged.preferences[key] as PrefRecord<typeof key>[]) = mergedRecords;
}

return merged;
}

// eslint-disable-next-line @typescript-eslint/no-empty-object-type
type PreferencesManagerEvents = {
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🔴 Critical | 🏗️ Heavy lift

mergeProfiles はマージ前にプロファイルの正規化を必要とします。

mergeProfiles は、a.preferences[key]b.preferences[key]PREF_DEF の全キーに対応するレコードを持つことを前提にしています。しかし呼び出し元の cloudSync(preferences/utility.ts Line 200-203)や cloudBackup(同 Line 229-232)では、misskeyApi('i/registry/get', ...) as PreferencesProfile | null で取得した値を normalizePreferences() に通さずそのまま mergeProfiles の引数に渡しています。

もしサーバー上のプロファイルが別バージョンのアプリで保存されたもので、現在の PREF_DEF にあるキーを持っていない場合、Line 196 の bRecords = b.preferences[key]undefined になります。この状態で Line 198-200 の for (const bRecord of bRecords) を実行すると、undefined はイテレート不可のため例外が発生します。

この例外は forceCloudSyncforceCloudBackup(pages/settings/other.vue)などの呼び出し元で捕捉されておらず、ユーザーには何も通知されないまま処理が失敗する可能性があります。docs/preferences.md の指針では「設定値が意図せず失われることが絶対にあってはならない」と明記されているため、想定外のスキーマ差異によるクラッシュは重要度が高い懸念だと考えます。

aRecords/bRecords を undefined に対して防御的にしておくことをご検討ください。

🛡️ 修正案
 	for (const _key in PREF_DEF) {
 		const key = _key as keyof PREF;
-		const aRecords = a.preferences[key];
-		const bRecords = b.preferences[key];
+		const aRecords = a.preferences[key] ?? [];
+		const bRecords = b.preferences[key] ?? [];
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 各recordについて、modifiedAtが大きい方を採用する
// 引数の参照をmutateしないように注意すること
export function mergeProfiles(a: PreferencesProfile, b: PreferencesProfile): PreferencesProfile {
const merged = {
...a,
modifiedAt: Math.max(a.modifiedAt, b.modifiedAt),
preferences: {},
} as PreferencesProfile;
for (const _key in PREF_DEF) {
const key = _key as keyof PREF;
const aRecords = a.preferences[key];
const bRecords = b.preferences[key];
const mergedRecords = [...aRecords];
for (const bRecord of bRecords) {
const existingIndex = mergedRecords.findIndex(([scope]) => isSameScope(scope, bRecord[0]));
if (existingIndex === -1) {
mergedRecords.push(bRecord);
} else {
const aRecord = mergedRecords[existingIndex];
if ((bRecord[2].modifiedAt ?? 0) > (aRecord[2].modifiedAt ?? 0)) {
mergedRecords[existingIndex] = bRecord;
}
}
}
(merged.preferences[key] as PrefRecord<typeof key>[]) = mergedRecords;
}
return merged;
}
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
type PreferencesManagerEvents = {
};
// 各recordについて、modifiedAtが大きい方を採用する
// 引数の参照をmutateしないように注意すること
export function mergeProfiles(a: PreferencesProfile, b: PreferencesProfile): PreferencesProfile {
const merged = {
...a,
modifiedAt: Math.max(a.modifiedAt, b.modifiedAt),
preferences: {},
} as PreferencesProfile;
for (const _key in PREF_DEF) {
const key = _key as keyof PREF;
const aRecords = a.preferences[key] ?? [];
const bRecords = b.preferences[key] ?? [];
const mergedRecords = [...aRecords];
for (const bRecord of bRecords) {
const existingIndex = mergedRecords.findIndex(([scope]) => isSameScope(scope, bRecord[0]));
if (existingIndex === -1) {
mergedRecords.push(bRecord);
} else {
const aRecord = mergedRecords[existingIndex];
if ((bRecord[2].modifiedAt ?? 0) > (aRecord[2].modifiedAt ?? 0)) {
mergedRecords[existingIndex] = bRecord;
}
}
}
(merged.preferences[key] as PrefRecord<typeof key>[]) = mergedRecords;
}
return merged;
}
// eslint-disable-next-line `@typescript-eslint/no-empty-object-type`
type PreferencesManagerEvents = {
};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/frontend/src/preferences/manager.ts` around lines 184 - 221, Update
mergeProfiles to defensively handle missing preference records for any key in
PREF_DEF: treat undefined a.preferences[key] or b.preferences[key] as an empty
record list before copying or iterating. Preserve the existing per-scope,
latest-modifiedAt merge behavior and avoid mutating either input profile.

Comment on lines +398 to +420
let modified = false;

for (const _key in PREF_DEF) {
const key = _key as keyof PREF;
const record = this.getMatchedRecordOf(key);
if (record[2].sync && Object.hasOwn(cloudValues, key) && cloudValues[key] !== undefined) {
const cloudValue = cloudValues[key];
if (!deepEqual(cloudValue, record[1])) {
this.rewriteRawState(key, cloudValue);
record[1] = cloudValue;
this.rewriteRawState(key, cloudValue.value);
record[1] = cloudValue.value;
record[2].modifiedAt = cloudValue.meta.modifiedAt;
modified = true;
if (_DEV_) console.log('cloud fetched', key, cloudValue);
}
}
}

this.save();
if (modified) this.save();

if (_DEV_) console.log('cloud fetch completed');
}

public save() {
private save() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

fetchCloudValues の比較対象の型が一致していません。

Line 405 の deepEqual(cloudValue, record[1]) は、cloudValue({ value: ValueOf<K>; meta: {...} } という形の値)と record[1](値そのもの)を比較しています。cloudGetBulk(preferences.ts Line 90-93)の戻り値の形からも分かる通り、両者は形が異なるため、この比較はほぼ常に不一致と判定されます。

その結果、クラウド同期(sync)が有効な設定項目が1つでもあると、ページを読み込むたびに実際の値が変わっていなくても modifiedtrue になり、rewriteRawStatethis.save() が毎回実行されます。これは、このセグメントの変更意図として記載されている「変更があった場合のみプロファイルを保存し」という方針に反しています。

さらに this.save() は他のタブへ storage イベントを発生させるため、開いている他のタブで不要な reloadProfile() が繰り返し呼び出される可能性もあります。比較対象を cloudValue.value に修正することをおすすめします。

🐛 修正案
-				if (!deepEqual(cloudValue, record[1])) {
+				if (!deepEqual(cloudValue.value, record[1])) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let modified = false;
for (const _key in PREF_DEF) {
const key = _key as keyof PREF;
const record = this.getMatchedRecordOf(key);
if (record[2].sync && Object.hasOwn(cloudValues, key) && cloudValues[key] !== undefined) {
const cloudValue = cloudValues[key];
if (!deepEqual(cloudValue, record[1])) {
this.rewriteRawState(key, cloudValue);
record[1] = cloudValue;
this.rewriteRawState(key, cloudValue.value);
record[1] = cloudValue.value;
record[2].modifiedAt = cloudValue.meta.modifiedAt;
modified = true;
if (_DEV_) console.log('cloud fetched', key, cloudValue);
}
}
}
this.save();
if (modified) this.save();
if (_DEV_) console.log('cloud fetch completed');
}
public save() {
private save() {
let modified = false;
for (const _key in PREF_DEF) {
const key = _key as keyof PREF;
const record = this.getMatchedRecordOf(key);
if (record[2].sync && Object.hasOwn(cloudValues, key) && cloudValues[key] !== undefined) {
const cloudValue = cloudValues[key];
if (!deepEqual(cloudValue.value, record[1])) {
this.rewriteRawState(key, cloudValue.value);
record[1] = cloudValue.value;
record[2].modifiedAt = cloudValue.meta.modifiedAt;
modified = true;
if (_DEV_) console.log('cloud fetched', key, cloudValue);
}
}
}
if (modified) this.save();
if (_DEV_) console.log('cloud fetch completed');
}
private save() {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/frontend/src/preferences/manager.ts` around lines 398 - 420, Update
the comparison in fetchCloudValues to compare cloudValue.value with record[1],
not the metadata wrapper cloudValue. Preserve the existing rewriteRawState,
modified tracking, and save behavior so they run only when the actual preference
value changes.

Comment on lines 219 to 246
export async function cloudBackup() {
if ($i == null) return;
if (!canAutoBackup()) {
throw new Error('cannot auto backup for this profile');
}

let currentProfile = prefer.profile;

if (_DEV_) console.log('cloud backup', currentProfile);

const backupedProfile = await misskeyApi('i/registry/get', {
scope: ['client', 'preferences', 'backups'],
key: prefer.profile.name,
}) as PreferencesProfile | null;

// 古い設定で新しいバックアップを上書きしないようにマージ
if (backupedProfile != null) {
currentProfile = mergeProfiles(currentProfile, backupedProfile);
}

await misskeyApi('i/registry/set', {
scope: ['client', 'preferences', 'backups'],
key: prefer.profile.name,
value: prefer.profile,
value: currentProfile,
});

store.set('latestPreferencesBackupAt', Date.now());
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate files =="
git ls-files | grep -E 'packages/frontend/src/(preferences(-utility)?|.*preferences.*\.ts)$' | sed -n '1,80p'

echo "== utility outline =="
ast-grep outline packages/frontend/src/preferences/utility.ts --view compact 2>/dev/null | sed -n '1,120p' || true

echo "== utility relevant lines =="
sed -n '180,270p' packages/frontend/src/preferences/utility.ts | cat -n

echo "== search cloudSet/cloudBackup references =="
rg -n "function cloudSet|async function cloudSet|cloudSet|mergeProfiles|cloudBackup|i/registry/(get|set)|latestPreferencesBackupAt" packages/frontend/src -S

echo "== preferences outline/relevant =="
ast-grep outline packages/frontend/src/preferences.ts --view compact 2>/dev/null | sed -n '1,160p' || true
rg -n "cloudSet|mergeProfiles|cloudBackup|backups|registry/get|registry/set|latestPreferencesBackupAt" packages/frontend/src/preferences.ts -S -C 3

Repository: misskey-dev/misskey

Length of output: 9603


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== preferences.ts full relevant section =="
sed -n '1,145p' packages/frontend/src/preferences.ts | cat -n

echo "== manager mergeProfiles and save/update relevant sections =="
sed -n '150,210p' packages/frontend/src/preferences/manager.ts | cat -n
sed -n '280,350p' packages/frontend/src/preferences/manager.ts | cat -n

echo "== diff summary/stat =="
git diff --stat
echo "== changed files list =="
git diff --name-only

Repository: misskey-dev/misskey

Length of output: 9778


cloudBackup を並列書き込み対応に変更してください。

cloudBackupi/registry/getmergeProfilesi/registry/set の順序で行われるため、cloudSet と同様に同時実行時に一方のマージ結果が他方を完全に上書きする可能性があります。2つのデバイスまたはタブがほぼ同時に実行すれば、最新の変更が失われるため、サーバー側での楽観的排他制御やキーごとのアトミック更新、あるいは cloudBackup 側の衝突検知・再取得などが必要になります。

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/frontend/src/preferences/utility.ts` around lines 219 - 246, Update
cloudBackup to handle concurrent executions without losing either device or
tab’s merged changes. Protect the i/registry/get → mergeProfiles →
i/registry/set sequence with the same concurrency or conflict-resolution
approach used by cloudSet, such as server-side optimistic locking, atomic
per-key updates, or retrying after conflict with a fresh read, while preserving
the existing backup timestamp update after a successful write.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

packages/frontend Client side specific issue/PR size:L This PR changes 100-499 lines, ignoring generated files.

Projects

Development

Successfully merging this pull request may close these issues.

設定のプロファイルの同期

1 participant