From 8a3e33218e93b91fb953017b9a3d28c72dae8d21 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Sat, 25 Jul 2026 13:18:37 +0900 Subject: [PATCH 01/26] wip --- locales/ja-JP.yml | 4 +- packages/frontend/docs/preferences.md | 7 ++++ .../frontend/src/pages/settings/other.vue | 19 ++++++++- packages/frontend/src/preferences.ts | 39 ++----------------- packages/frontend/src/preferences/manager.ts | 37 ++++++------------ packages/frontend/src/preferences/utility.ts | 34 +++++++++++++++- packages/frontend/src/store.ts | 4 ++ packages/i18n/src/autogen/locale.ts | 10 ++++- 8 files changed, 87 insertions(+), 67 deletions(-) create mode 100644 packages/frontend/docs/preferences.md diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 2f6c9120441..2fdab6862ce 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1597,7 +1597,9 @@ _preferencesBackup: youNeedToNameYourProfileToEnableAutoBackup: "自動バックアップを有効にするにはプロファイル名の設定が必要です。" autoPreferencesBackupIsNotEnabledForThisDevice: "このデバイスで設定の自動バックアップは有効になっていません。" backupFound: "設定のバックアップが見つかりました" - forceBackup: "設定の強制バックアップ" + forceBackup: "今すぐバックアップ" + autoSync: "自動同期" + forceSync: "今すぐ同期" _accountSettings: requireSigninToViewContents: "コンテンツの表示にログインを必須にする" diff --git a/packages/frontend/docs/preferences.md b/packages/frontend/docs/preferences.md new file mode 100644 index 00000000000..f36fd1ceebd --- /dev/null +++ b/packages/frontend/docs/preferences.md @@ -0,0 +1,7 @@ +# Preferences system + +## ユースケース + +### (新しいデバイスなどで)既存のプロファイルを継承した新しいプロファイルを作りたい + +継承したいプロファイルをバックアップから復元した後、プロファイルの名前を変える。 diff --git a/packages/frontend/src/pages/settings/other.vue b/packages/frontend/src/pages/settings/other.vue index 6ab645ed1ce..f91bc48742e 100644 --- a/packages/frontend/src/pages/settings/other.vue +++ b/packages/frontend/src/pages/settings/other.vue @@ -144,7 +144,17 @@ SPDX-License-Identifier: AGPL-3.0-only {{ i18n.ts._settings.settingsPersistence_title }} - {{ i18n.ts._preferencesBackup.forceBackup }} + + + + + +
+ {{ i18n.ts._preferencesBackup.forceBackup }} + {{ i18n.ts._preferencesBackup.forceSync }} +
+
+
@@ -170,7 +180,7 @@ import MkRolePreview from '@/components/MkRolePreview.vue'; import { signout } from '@/signout.js'; import { hideAllTips as _hideAllTips, resetAllTips as _resetAllTips } from '@/tips.js'; import { suggestReload } from '@/utility/reload-suggest.js'; -import { cloudBackup } from '@/preferences/utility.js'; +import { cloudBackup, cloudSync } from '@/preferences/utility.js'; const $i = ensureSignin(); @@ -232,6 +242,11 @@ async function forceCloudBackup() { os.success(); } +async function forceCloudSync() { + await cloudSync(); + os.success(); +} + const headerActions = computed(() => []); const headerTabs = computed(() => []); diff --git a/packages/frontend/src/preferences.ts b/packages/frontend/src/preferences.ts index 91866f1fbdb..932e9fcdbbd 100644 --- a/packages/frontend/src/preferences.ts +++ b/packages/frontend/src/preferences.ts @@ -3,7 +3,6 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { BroadcastChannel } from 'broadcast-channel'; import { createVisibilityAwareInterval } from '@@/js/interval.js'; import type { StorageProvider } from '@/preferences/manager.js'; import { cloudBackup } from '@/preferences/utility.js'; @@ -12,7 +11,6 @@ import { isSameScope, PreferencesManager } from '@/preferences/manager.js'; import { store } from '@/store.js'; import { $i } from '@/i.js'; import { misskeyApi } from '@/utility/misskey-api.js'; -import { TAB_ID } from '@/tab-id.js'; // クラウド同期用グループ名 const syncGroup = 'default'; @@ -102,41 +100,10 @@ const io: StorageProvider = { export const prefer = new PreferencesManager(io, $i); //#region タブ間同期 -let latestPreferencesUpdate: { - tabId: string; - timestamp: number; -} | null = null; - -const preferencesChannel = new BroadcastChannel<{ - type: 'preferencesUpdate'; - tabId: string; - timestamp: number; -}>('preferences'); - -prefer.on('committed', () => { - latestPreferencesUpdate = { - tabId: TAB_ID, - timestamp: Date.now(), - }; - preferencesChannel.postMessage({ - type: 'preferencesUpdate', - tabId: TAB_ID, - timestamp: latestPreferencesUpdate.timestamp, - }); -}); - -preferencesChannel.addEventListener('message', (msg) => { - if (msg.type === 'preferencesUpdate') { - if (msg.tabId === TAB_ID) return; - if (latestPreferencesUpdate != null) { - if (msg.timestamp <= latestPreferencesUpdate.timestamp) return; - } +window.addEventListener('storage', (ev) => { + if (ev.key === 'preferences') { prefer.reloadProfile(); - if (_DEV_) console.log('prefer:received update from other tab'); - latestPreferencesUpdate = { - tabId: msg.tabId, - timestamp: msg.timestamp, - }; + if (_DEV_) console.log('prefer: received update from other tab'); } }); //#endregion diff --git a/packages/frontend/src/preferences/manager.ts b/packages/frontend/src/preferences/manager.ts index 05df4c70d97..363343d7670 100644 --- a/packages/frontend/src/preferences/manager.ts +++ b/packages/frontend/src/preferences/manager.ts @@ -102,14 +102,6 @@ type PreferencesDefinitionRecord export type PreferencesDefinition = Record>; -type PreferencesManagerEvents = { - 'committed': (ctx: { - key: K; - value: ValueOf; - oldValue: ValueOf; - }) => void; -}; - export function definePreferences>(x: { [K in keyof T]: PreferencesDefinitionRecord }): { @@ -188,6 +180,10 @@ function normalizePreferences(preferences: PossiblyNonNormalizedPreferencesProfi return data as PreferencesProfile['preferences']; } +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +type PreferencesManagerEvents = { +}; + // TODO: PreferencesManagerForGuest のような非ログイン専用のクラスを分離すればthis.currentAccountのnullチェックやaccountがnullであるスコープのレコード挿入などが不要になり綺麗になるかもしれない // と思ったけど操作アカウントが存在しない場合も考慮する現在の設計の方が汎用的かつ堅牢かもしれない // NOTE: accountDependentな設定は初期状態であってもアカウントごとのスコープでレコードを作成しておかないと、サーバー同期する際に正しく動作しなくなる @@ -262,21 +258,12 @@ export class PreferencesManager extends EventEmitter { const record = this.getMatchedRecordOf(key); - const _save = () => { - this.save(); - this.emit('committed', { - key, - value: v, - oldValue: this.s[key], - }); - }; - if (parseScope(record[0]).account == null && isAccountDependentKey(key) && currentAccount != null) { this.profile.preferences[key].push([makeScope({ server: host, account: currentAccount.id, }), v, {}]); - _save(); + this.save(); return; } @@ -284,12 +271,12 @@ export class PreferencesManager extends EventEmitter { this.profile.preferences[key].push([makeScope({ server: host, }), v, {}]); - _save(); + this.save(); return; } record[1] = v; - _save(); + this.save(); if (record[2].sync) { // awaitの必要なし @@ -554,20 +541,18 @@ export class PreferencesManager extends EventEmitter { } public reloadProfile() { - const newProfile = this.io.load(); - if (newProfile == null) return; + const freshProfile = this.io.load(); + if (freshProfile == null) return; this.profile = { - ...newProfile, - preferences: normalizePreferences(newProfile.preferences, this.currentAccount), + ...freshProfile, + preferences: normalizePreferences(freshProfile.preferences, this.currentAccount), }; const states = this.genStates(); for (const _key in states) { const key = _key as keyof PREF; this.rewriteRawState(key, states[key]); } - - this.fetchCloudValues(); } public getPerPrefMenu(key: K): MenuItem[] { diff --git a/packages/frontend/src/preferences/utility.ts b/packages/frontend/src/preferences/utility.ts index 7507a071f43..e712d465341 100644 --- a/packages/frontend/src/preferences/utility.ts +++ b/packages/frontend/src/preferences/utility.ts @@ -42,6 +42,16 @@ export function getPreferencesProfileMenu(): MenuItem[] { } }); + const autoSyncEnabled = ref(store.s.enablePreferencesAutoCloudSync); + + watch(autoSyncEnabled, () => { + if (autoSyncEnabled.value) { + store.set('enablePreferencesAutoCloudSync', true); + } else { + store.set('enablePreferencesAutoCloudSync', false); + } + }); + const menu: MenuItem[] = [{ type: 'label', text: prefer.profile.name || `(${i18n.ts.noName})`, @@ -56,6 +66,11 @@ export function getPreferencesProfileMenu(): MenuItem[] { icon: 'ti ti-cloud-up', text: i18n.ts._preferencesBackup.autoBackup, ref: autoBackupEnabled, + }, { + type: 'switch', + icon: 'ti ti-cloud-down', + text: i18n.ts._preferencesBackup.autoSync, + ref: autoSyncEnabled, }, { text: i18n.ts.export, icon: 'ti ti-download', @@ -139,6 +154,24 @@ function importProfile() { input.click(); } +export async function cloudSync() { + if ($i == null) return; + + const cloudProfile = await misskeyApi('i/registry/get', { + scope: ['client', 'preferences', 'backups'], + key: prefer.profile.name, + }) as PreferencesProfile | null; + + if (cloudProfile == null || cloudProfile.modifiedAt < prefer.profile.modifiedAt) { + await cloudBackup(); + return; + } + + miLocalStorage.setItem('preferences', JSON.stringify(cloudProfile)); + + prefer.reloadProfile(); +} + export async function cloudBackup() { if ($i == null) return; if (!canAutoBackup()) { @@ -186,7 +219,6 @@ export async function restoreFromCloudBackup() { const select = await os.select({ title: i18n.ts._preferencesBackup.selectBackupToRestore, - text: 'ℹ️ ' + i18n.ts._preferencesProfile.shareSameProfileBetweenDevicesIsNotRecommended + ' ' + i18n.ts._preferencesProfile.useSyncBetweenDevicesOptionIfYouWantToSyncSetting, items: backups.map(backup => ({ label: backup.name, value: backup.name, diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts index 7017725e30d..84ed36abcdb 100644 --- a/packages/frontend/src/store.ts +++ b/packages/frontend/src/store.ts @@ -110,6 +110,10 @@ export const store = markRaw(new Pizzax('base', { where: 'device', default: false, }, + enablePreferencesAutoCloudSync: { + where: 'device', + default: false, + }, showPreferencesAutoCloudBackupSuggestion: { where: 'device', default: true, diff --git a/packages/i18n/src/autogen/locale.ts b/packages/i18n/src/autogen/locale.ts index 5103f3845f8..33fdfdd1ff6 100644 --- a/packages/i18n/src/autogen/locale.ts +++ b/packages/i18n/src/autogen/locale.ts @@ -6336,9 +6336,17 @@ export interface Locale extends ILocale { */ "backupFound": string; /** - * 設定の強制バックアップ + * 今すぐバックアップ */ "forceBackup": string; + /** + * 自動同期 + */ + "autoSync": string; + /** + * 今すぐ同期 + */ + "forceSync": string; }; "_accountSettings": { /** From 0d677f288a686c8112910cdd1b0acc501311bcdb Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Sat, 25 Jul 2026 14:27:06 +0900 Subject: [PATCH 02/26] Update preferences.ts --- packages/frontend/src/preferences.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/frontend/src/preferences.ts b/packages/frontend/src/preferences.ts index 932e9fcdbbd..4f3e36e9ac4 100644 --- a/packages/frontend/src/preferences.ts +++ b/packages/frontend/src/preferences.ts @@ -5,7 +5,7 @@ import { createVisibilityAwareInterval } from '@@/js/interval.js'; import type { StorageProvider } from '@/preferences/manager.js'; -import { cloudBackup } from '@/preferences/utility.js'; +import { cloudBackup, cloudSync } from '@/preferences/utility.js'; import { miLocalStorage } from '@/local-storage.js'; import { isSameScope, PreferencesManager } from '@/preferences/manager.js'; import { store } from '@/store.js'; @@ -123,6 +123,11 @@ createVisibilityAwareInterval(() => { }, 1000 * 60 * 3); //#endregion +if (store.s.enablePreferencesAutoCloudSync) { + // TODO: 前回同期してから10分以上経過している場合のみ + cloudSync(); +} + if (_DEV_) { (window as any).prefer = prefer; (window as any).cloudBackup = cloudBackup; From b33ec08cbfa247cb8f136437ad0764a9339ea323 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Sat, 25 Jul 2026 14:56:37 +0900 Subject: [PATCH 03/26] wip --- packages/frontend/src/preferences.ts | 10 ++++++---- packages/frontend/src/preferences/manager.ts | 6 +++++- packages/frontend/src/preferences/utility.ts | 10 ++++++++-- 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/packages/frontend/src/preferences.ts b/packages/frontend/src/preferences.ts index 4f3e36e9ac4..b32369aa11a 100644 --- a/packages/frontend/src/preferences.ts +++ b/packages/frontend/src/preferences.ts @@ -123,10 +123,12 @@ createVisibilityAwareInterval(() => { }, 1000 * 60 * 3); //#endregion -if (store.s.enablePreferencesAutoCloudSync) { - // TODO: 前回同期してから10分以上経過している場合のみ - cloudSync(); -} +store.loaded.then(() => { + if (store.s.enablePreferencesAutoCloudSync) { + // TODO: 前回同期してから10分以上経過している場合のみ + cloudSync(); + } +}); if (_DEV_) { (window as any).prefer = prefer; diff --git a/packages/frontend/src/preferences/manager.ts b/packages/frontend/src/preferences/manager.ts index 363343d7670..ffdc09f5c48 100644 --- a/packages/frontend/src/preferences/manager.ts +++ b/packages/frontend/src/preferences/manager.ts @@ -351,6 +351,8 @@ export class PreferencesManager extends EventEmitter { const cloudValues = await this.io.cloudGetBulk({ needs }); + let modified = false; + for (const _key in PREF_DEF) { const key = _key as keyof PREF; const record = this.getMatchedRecordOf(key); @@ -359,12 +361,14 @@ export class PreferencesManager extends EventEmitter { if (!deepEqual(cloudValue, record[1])) { this.rewriteRawState(key, cloudValue); record[1] = cloudValue; + modified = true; if (_DEV_) console.log('cloud fetched', key, cloudValue); } } } - this.save(); + if (modified) this.save(); + if (_DEV_) console.log('cloud fetch completed'); } diff --git a/packages/frontend/src/preferences/utility.ts b/packages/frontend/src/preferences/utility.ts index e712d465341..c62e0cf13d4 100644 --- a/packages/frontend/src/preferences/utility.ts +++ b/packages/frontend/src/preferences/utility.ts @@ -162,11 +162,13 @@ export async function cloudSync() { key: prefer.profile.name, }) as PreferencesProfile | null; - if (cloudProfile == null || cloudProfile.modifiedAt < prefer.profile.modifiedAt) { - await cloudBackup(); + if (cloudProfile == null || cloudProfile.modifiedAt <= prefer.profile.modifiedAt) { + if (_DEV_) console.log('no new cloud profile found, skipping sync'); return; } + if (_DEV_) console.log('new cloud profile found, restoring from cloud', cloudProfile); + miLocalStorage.setItem('preferences', JSON.stringify(cloudProfile)); prefer.reloadProfile(); @@ -178,6 +180,10 @@ export async function cloudBackup() { throw new Error('cannot auto backup for this profile'); } + // TODO: 同期有効時、既に新しいバージョンがバックアップされている場合は上書きしないようにする + + if (_DEV_) console.log('cloud backup', prefer.profile); + await misskeyApi('i/registry/set', { scope: ['client', 'preferences', 'backups'], key: prefer.profile.name, From c93b093bff59ee288e576e5d6a40d2682c0c6ecb Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Sat, 25 Jul 2026 14:57:59 +0900 Subject: [PATCH 04/26] Update manager.ts --- packages/frontend/src/preferences/manager.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/frontend/src/preferences/manager.ts b/packages/frontend/src/preferences/manager.ts index ffdc09f5c48..746e7861890 100644 --- a/packages/frontend/src/preferences/manager.ts +++ b/packages/frontend/src/preferences/manager.ts @@ -372,7 +372,7 @@ export class PreferencesManager extends EventEmitter { if (_DEV_) console.log('cloud fetch completed'); } - public save() { + private save() { this.profile.modifiedAt = Date.now(); this.profile.version = version; this.io.save({ profile: this.profile }); From 3ead8d840e3d51590e58ce5f8a2d6ac5ca4e294d Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Sat, 25 Jul 2026 15:43:46 +0900 Subject: [PATCH 05/26] Update manager.ts --- packages/frontend/src/preferences/manager.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/frontend/src/preferences/manager.ts b/packages/frontend/src/preferences/manager.ts index 746e7861890..438c7f6ff47 100644 --- a/packages/frontend/src/preferences/manager.ts +++ b/packages/frontend/src/preferences/manager.ts @@ -36,6 +36,7 @@ type Scope = Partial<{ type ValueMeta = Partial<{ sync: boolean; + // TODO: modifiedAtをここでも(Record個別に)持っておいた方が値の新旧比較に使えて便利そう }>; type PrefRecord = [scope: Scope, value: ValueOf, meta: ValueMeta]; From 4b82eab30456d6569859eb30af8458671199c9ac Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:37:05 +0900 Subject: [PATCH 06/26] wip --- locales/ja-JP.yml | 1 + packages/frontend/src/preferences.ts | 15 +++--- packages/frontend/src/preferences/manager.ts | 49 +++++++++++++------- packages/i18n/src/autogen/locale.ts | 4 ++ 4 files changed, 45 insertions(+), 24 deletions(-) diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 2fdab6862ce..de5bc2cc3d8 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1312,6 +1312,7 @@ noteOfThisUser: "このユーザーのノート一覧" clipNoteLimitExceeded: "これ以上このクリップにノートを追加できません。" performance: "パフォーマンス" modified: "変更あり" +modifiedAt: "変更日時" discard: "破棄" thereAreNChanges: "{n}件の変更があります" signinWithPasskey: "パスキーでログイン" diff --git a/packages/frontend/src/preferences.ts b/packages/frontend/src/preferences.ts index b32369aa11a..6a03a917837 100644 --- a/packages/frontend/src/preferences.ts +++ b/packages/frontend/src/preferences.ts @@ -36,11 +36,12 @@ const io: StorageProvider = { const cloudData = await misskeyApi('i/registry/get', { scope: ['client', 'preferences', 'sync'], key: syncGroup + ':' + ctx.key, - }) as [any, any][]; + }) as [any, any, any][]; const target = cloudData.find(([scope]) => isSameScope(scope, ctx.scope)); if (target == null) return null; return { value: target[1], + meta: target[2], }; } catch (err: any) { if (err.code === 'NO_SUCH_KEY') { // TODO: いちいちエラーキャッチするのは面倒なのでキーが無くてもエラーにならない maybe-get のようなエンドポイントをバックエンドに実装する @@ -52,12 +53,12 @@ const io: StorageProvider = { }, cloudSet: async (ctx) => { - let cloudData: [any, any][] = []; + let cloudData: [any, any, any][] = []; try { cloudData = await misskeyApi('i/registry/get', { scope: ['client', 'preferences', 'sync'], key: syncGroup + ':' + ctx.key, - }) as [any, any][]; + }) as [any, any, any][]; } catch (err: any) { if (err.code === 'NO_SUCH_KEY') { // TODO: いちいちエラーキャッチするのは面倒なのでキーが無くてもエラーにならない maybe-get のようなエンドポイントをバックエンドに実装する cloudData = []; @@ -69,9 +70,9 @@ const io: StorageProvider = { const i = cloudData.findIndex(([scope]) => isSameScope(scope, ctx.scope)); if (i === -1) { - cloudData.push([ctx.scope, ctx.value]); + cloudData.push([ctx.scope, ctx.value, ctx.meta]); } else { - cloudData[i] = [ctx.scope, ctx.value]; + cloudData[i] = [ctx.scope, ctx.value, ctx.meta]; } await misskeyApi('i/registry/set', { @@ -86,10 +87,10 @@ const io: StorageProvider = { const fetchings = ctx.needs.map(need => io.cloudGet(need).then(res => [need.key, res] as const)); const cloudDatas = await Promise.all(fetchings); - const res = {} as Partial>; + const res = {} as Partial>; for (const cloudData of cloudDatas) { if (cloudData[1] != null) { - res[cloudData[0]] = cloudData[1].value; + res[cloudData[0]] = cloudData[1]; } } diff --git a/packages/frontend/src/preferences/manager.ts b/packages/frontend/src/preferences/manager.ts index 438c7f6ff47..12b84706bbe 100644 --- a/packages/frontend/src/preferences/manager.ts +++ b/packages/frontend/src/preferences/manager.ts @@ -36,7 +36,7 @@ type Scope = Partial<{ type ValueMeta = Partial<{ sync: boolean; - // TODO: modifiedAtをここでも(Record個別に)持っておいた方が値の新旧比較に使えて便利そう + modifiedAt?: number; }>; type PrefRecord = [scope: Scope, value: ValueOf, meta: ValueMeta]; @@ -89,9 +89,9 @@ export type PossiblyNonNormalizedPreferencesProfile = Omit PossiblyNonNormalizedPreferencesProfile | null; save: (ctx: { profile: PreferencesProfile; }) => void; - cloudGetBulk: (ctx: { needs: { key: K; scope: Scope; }[] }) => Promise>>>; - cloudGet: (ctx: { key: K; scope: Scope; }) => Promise<{ value: ValueOf; } | null>; - cloudSet: (ctx: { key: K; scope: Scope; value: ValueOf; }) => Promise; + cloudGetBulk: (ctx: { needs: { key: K; scope: Scope; }[] }) => Promise; meta: { modifiedAt: ValueMeta['modifiedAt'] }; }>>>; + cloudGet: (ctx: { key: K; scope: Scope; }) => Promise<{ value: ValueOf; meta: { modifiedAt: ValueMeta['modifiedAt'] }; } | null>; + cloudSet: (ctx: { key: K; scope: Scope; value: ValueOf; meta: { modifiedAt: ValueMeta['modifiedAt'] }; }) => Promise; }; type PreferencesDefinitionRecord infer R ? R : Default> = { @@ -244,13 +244,13 @@ export class PreferencesManager extends EventEmitter { } // TODO: desync対策 cloudの値のfetchが正常に完了していない状態でcommitすると多分値が上書きされる - public commit(key: K, value: ValueOf) { + public commit(key: K, value: ValueOf): PrefRecord | null { const currentAccount = this.currentAccount; // TSを黙らせるため const v = JSON.parse(JSON.stringify(value)); // deep copy 兼 vueのプロキシ解除 if (deepEqual(this.s[key], v)) { if (_DEV_) console.log('(skip) prefer:commit', key, v); - return; + return null; } if (_DEV_) console.log('prefer:commit', key, v); @@ -260,30 +260,39 @@ export class PreferencesManager extends EventEmitter { const record = this.getMatchedRecordOf(key); if (parseScope(record[0]).account == null && isAccountDependentKey(key) && currentAccount != null) { - this.profile.preferences[key].push([makeScope({ + const newRecord = [makeScope({ server: host, account: currentAccount.id, - }), v, {}]); + }), v, { + modifiedAt: Date.now(), + }] as PrefRecord; + this.profile.preferences[key].push(newRecord); this.save(); - return; + return newRecord; } if (parseScope(record[0]).server == null && isServerDependentKey(key)) { - this.profile.preferences[key].push([makeScope({ + const newRecord = [makeScope({ server: host, - }), v, {}]); + }), v, { + modifiedAt: Date.now(), + }] as PrefRecord; + this.profile.preferences[key].push(newRecord); this.save(); - return; + return newRecord; } record[1] = v; + record[2].modifiedAt = Date.now(); this.save(); if (record[2].sync) { // awaitの必要なし // TODO: リクエストを間引く - this.io.cloudSet({ key, scope: record[0], value: record[1] }); + this.io.cloudSet({ key, scope: record[0], value: record[1], meta: { modifiedAt: record[2].modifiedAt } }); } + + return record; } /** @@ -360,8 +369,9 @@ export class PreferencesManager extends EventEmitter { 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); } @@ -503,12 +513,12 @@ export class PreferencesManager extends EventEmitter { newValue = resolvedValue; } - this.commit(key, newValue); + const commitedRecord = this.commit(key, newValue); const done = os.waiting(); try { - await this.io.cloudSet({ key, scope: record[0], value: newValue }); + await this.io.cloudSet({ key, scope: record[0], value: newValue, meta: { modifiedAt: record[2].modifiedAt } }); } catch (err) { done(); @@ -607,6 +617,11 @@ export class PreferencesManager extends EventEmitter { icon: 'ti ti-cloud-cog', text: i18n.ts.syncBetweenDevices, ref: sync, + }, { + type: 'divider', + }, { + type: 'label', + text: i18n.ts.modifiedAt + ': ' + (this.getMatchedRecordOf(key)[2].modifiedAt ? new Date(this.getMatchedRecordOf(key)[2].modifiedAt!).toLocaleString() : '-'), }]; } } diff --git a/packages/i18n/src/autogen/locale.ts b/packages/i18n/src/autogen/locale.ts index 33fdfdd1ff6..7e1f3192ed3 100644 --- a/packages/i18n/src/autogen/locale.ts +++ b/packages/i18n/src/autogen/locale.ts @@ -5260,6 +5260,10 @@ export interface Locale extends ILocale { * 変更あり */ "modified": string; + /** + * 変更日時 + */ + "modifiedAt": string; /** * 破棄 */ From 76389b684c969a476740aafa43ad4cde1d6eab6b Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Sat, 25 Jul 2026 19:07:26 +0900 Subject: [PATCH 07/26] wip --- packages/frontend/src/preferences/manager.ts | 35 +++++++++++++++++++- packages/frontend/src/preferences/utility.ts | 17 ++++++++-- 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/packages/frontend/src/preferences/manager.ts b/packages/frontend/src/preferences/manager.ts index 12b84706bbe..4b1ff1c3f36 100644 --- a/packages/frontend/src/preferences/manager.ts +++ b/packages/frontend/src/preferences/manager.ts @@ -75,7 +75,7 @@ export type PreferencesProfile = { id: string; version: string; type: 'main'; - modifiedAt: number; + modifiedAt: number; // 仕様が若干直感的ではない(syncされた値が降ってきたときは更新されないなど)ため、一応残してはいるが積極的な利用はしない方が無難 name: string; preferences: { [K in keyof PREF]: PrefRecord[]; @@ -181,6 +181,39 @@ function normalizePreferences(preferences: PossiblyNonNormalizedPreferencesProfi return data as PreferencesProfile['preferences']; } +// 各recordについて、modifiedAtが大きい方を採用する +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[]) = mergedRecords; + } + + return merged; +} + // eslint-disable-next-line @typescript-eslint/no-empty-object-type type PreferencesManagerEvents = { }; diff --git a/packages/frontend/src/preferences/utility.ts b/packages/frontend/src/preferences/utility.ts index c62e0cf13d4..d0791271f0e 100644 --- a/packages/frontend/src/preferences/utility.ts +++ b/packages/frontend/src/preferences/utility.ts @@ -4,6 +4,7 @@ */ import { ref, watch } from 'vue'; +import { mergeProfiles } from './manager.js'; import type { PreferencesProfile } from './manager.js'; import type { MenuItem } from '@/types/menu.js'; import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; @@ -180,14 +181,24 @@ export async function cloudBackup() { throw new Error('cannot auto backup for this profile'); } - // TODO: 同期有効時、既に新しいバージョンがバックアップされている場合は上書きしないようにする + let currentProfile = prefer.profile; - if (_DEV_) console.log('cloud backup', 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, }); } From 66498639bf18ecaa45d6455458e621a29d606ec0 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Sat, 25 Jul 2026 19:13:46 +0900 Subject: [PATCH 08/26] wip --- packages/frontend/src/preferences/manager.ts | 1 + packages/frontend/src/preferences/utility.ts | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/frontend/src/preferences/manager.ts b/packages/frontend/src/preferences/manager.ts index 4b1ff1c3f36..4f33993800d 100644 --- a/packages/frontend/src/preferences/manager.ts +++ b/packages/frontend/src/preferences/manager.ts @@ -182,6 +182,7 @@ function normalizePreferences(preferences: PossiblyNonNormalizedPreferencesProfi } // 各recordについて、modifiedAtが大きい方を採用する +// 引数の参照をmutateしないように注意すること export function mergeProfiles(a: PreferencesProfile, b: PreferencesProfile): PreferencesProfile { const merged = { ...a, diff --git a/packages/frontend/src/preferences/utility.ts b/packages/frontend/src/preferences/utility.ts index d0791271f0e..0c6e6631f1d 100644 --- a/packages/frontend/src/preferences/utility.ts +++ b/packages/frontend/src/preferences/utility.ts @@ -163,14 +163,14 @@ export async function cloudSync() { key: prefer.profile.name, }) as PreferencesProfile | null; - if (cloudProfile == null || cloudProfile.modifiedAt <= prefer.profile.modifiedAt) { - if (_DEV_) console.log('no new cloud profile found, skipping sync'); + if (cloudProfile == null) { + if (_DEV_) console.log('no backuped profile found, skipping sync'); return; } - if (_DEV_) console.log('new cloud profile found, restoring from cloud', cloudProfile); + if (_DEV_) console.log('backuped profile found, syncing', cloudProfile); - miLocalStorage.setItem('preferences', JSON.stringify(cloudProfile)); + miLocalStorage.setItem('preferences', JSON.stringify(mergeProfiles(prefer.profile, cloudProfile))); prefer.reloadProfile(); } From 6aad11766abbc3d08724fd8b6f8c218024df89b8 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Sat, 25 Jul 2026 19:51:02 +0900 Subject: [PATCH 09/26] wip --- packages/frontend/docs/preferences.md | 41 +++++++++++++++++++- packages/frontend/src/preferences/manager.ts | 2 +- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/packages/frontend/docs/preferences.md b/packages/frontend/docs/preferences.md index f36fd1ceebd..aadabd38bd0 100644 --- a/packages/frontend/docs/preferences.md +++ b/packages/frontend/docs/preferences.md @@ -1,6 +1,45 @@ # Preferences system -## ユースケース +ユーザーの環境設定を管理するシステム。 + +## 指針 + +実装上のミスで、**設定値が意図せず失われる(古い値で上書きされる)ことが絶対にあってはならない。** + +設定値が失われる、考えられるシナリオの例: + +- 複数のタブでMisskeyを開いていて、タブAで設定を編集した後、タブBを開いたところ、タブBの古い状態の内容で設定が保存され、タブAで行った編集が巻き戻ってしまった。 +- プロファイルの同期機能をオンにしたところ、サーバーに保存されていた古い設定でローカルが上書きされてしまった。 + +上記のシナリオが絶対に発生しない設計・実装にしなければならない。 + +上記以外にも考えられるシナリオがあれば、必ず適切な対処を行う設計・実装にしなければならない。 + +## 仕様 + +### タブ間同期 + +ブラウザで複数タブを開いているとき、あるタブで変更した設定が他のタブでも反映されるようにし、UXを向上させ、古い設定情報が上書き保存されることを防ぐ。 + +### 自動バックアップ(cloudBackup) + +定期的、または特定のタイミングなどで、プロファイルをサーバーに自動的にアップロードする。 + +### 自動同期(cloudSync) + +定期的、または特定のタイミングなどで、サーバーにアップロードされたプロファイルをダウンロードし、ローカルに適用する。 + +### 設定項目: デバイス間で同期(syncBetweenDevices, ValueMeta.sync) + +デバイス間・プロファイル横断でその設定項目の設定値を同期する。 + +当オプションを有効にするかどうかは設定項目ごとに設定可能。 + +## ユーザーストーリー(ユースケース) + +### 同じプロファイルを複数のデバイスで使いたい(同期したい) + +自動バックアップと自動同期をオンにする。 ### (新しいデバイスなどで)既存のプロファイルを継承した新しいプロファイルを作りたい diff --git a/packages/frontend/src/preferences/manager.ts b/packages/frontend/src/preferences/manager.ts index 4f33993800d..6373a2c308c 100644 --- a/packages/frontend/src/preferences/manager.ts +++ b/packages/frontend/src/preferences/manager.ts @@ -75,7 +75,7 @@ export type PreferencesProfile = { id: string; version: string; type: 'main'; - modifiedAt: number; // 仕様が若干直感的ではない(syncされた値が降ってきたときは更新されないなど)ため、一応残してはいるが積極的な利用はしない方が無難 + modifiedAt: number; // 仕様が若干直感的ではない(syncされた値が降ってきたときは更新されないなど)ため、一応残してはいるが積極的な利用はしない方が無難。設定値の新旧比較が必要なら項目ごとのmodifiedAtを使うべし name: string; preferences: { [K in keyof PREF]: PrefRecord[]; From 41c20ce140120913a501d32f6e688256c1746972 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Sat, 25 Jul 2026 20:00:15 +0900 Subject: [PATCH 10/26] Update preferences.md --- packages/frontend/docs/preferences.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/frontend/docs/preferences.md b/packages/frontend/docs/preferences.md index aadabd38bd0..b821c88efef 100644 --- a/packages/frontend/docs/preferences.md +++ b/packages/frontend/docs/preferences.md @@ -44,3 +44,7 @@ ### (新しいデバイスなどで)既存のプロファイルを継承した新しいプロファイルを作りたい 継承したいプロファイルをバックアップから復元した後、プロファイルの名前を変える。 + +## メモ + +自動バックアップと自動同期の違いがユーザーにとって分かりにくい可能性があるので、機能を一本化するか、個別にオンオフを切り替えられるのではなく「同期:『なし/バックアップのみ/同期』」みたいな選択方式にするなどがいいかもしれない? From 5b8fdabcf2076d4e3b1c894f17a4ff4e02878d92 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Sat, 25 Jul 2026 20:36:21 +0900 Subject: [PATCH 11/26] wip --- locales/ja-JP.yml | 2 +- packages/frontend/docs/preferences.md | 8 +++++--- packages/i18n/src/autogen/locale.ts | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index de5bc2cc3d8..86d6c92d1a0 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1599,7 +1599,7 @@ _preferencesBackup: autoPreferencesBackupIsNotEnabledForThisDevice: "このデバイスで設定の自動バックアップは有効になっていません。" backupFound: "設定のバックアップが見つかりました" forceBackup: "今すぐバックアップ" - autoSync: "自動同期" + autoSync: "デバイス間同期" forceSync: "今すぐ同期" _accountSettings: diff --git a/packages/frontend/docs/preferences.md b/packages/frontend/docs/preferences.md index b821c88efef..8a6ffb2e236 100644 --- a/packages/frontend/docs/preferences.md +++ b/packages/frontend/docs/preferences.md @@ -25,7 +25,7 @@ 定期的、または特定のタイミングなどで、プロファイルをサーバーに自動的にアップロードする。 -### 自動同期(cloudSync) +### デバイス間同期(cloudSync) 定期的、または特定のタイミングなどで、サーバーにアップロードされたプロファイルをダウンロードし、ローカルに適用する。 @@ -39,7 +39,7 @@ ### 同じプロファイルを複数のデバイスで使いたい(同期したい) -自動バックアップと自動同期をオンにする。 +autoBackupとautoSyncをオンにする。 ### (新しいデバイスなどで)既存のプロファイルを継承した新しいプロファイルを作りたい @@ -47,4 +47,6 @@ ## メモ -自動バックアップと自動同期の違いがユーザーにとって分かりにくい可能性があるので、機能を一本化するか、個別にオンオフを切り替えられるのではなく「同期:『なし/バックアップのみ/同期』」みたいな選択方式にするなどがいいかもしれない? +autoBackupとautoSyncの違いがユーザーにとって分かりにくい可能性があるので、機能を一本化するか、個別にオンオフを切り替えられるのではなく「同期:『なし/バックアップのみ/同期』」みたいな選択方式にするなどがいいかもしれない? + +autoBackupはするがautoSyncしたくないケースはあまりないと思うが、一本化すると複数のデバイスで同一のプロファイルを共有しない人(大多数だと思われる)にとっては無駄に通信量が増えるだけになる diff --git a/packages/i18n/src/autogen/locale.ts b/packages/i18n/src/autogen/locale.ts index 7e1f3192ed3..c3737003173 100644 --- a/packages/i18n/src/autogen/locale.ts +++ b/packages/i18n/src/autogen/locale.ts @@ -6344,7 +6344,7 @@ export interface Locale extends ILocale { */ "forceBackup": string; /** - * 自動同期 + * デバイス間同期 */ "autoSync": string; /** From a01b6af7c19ad1596ad84941964fc44d3dac811b Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Sat, 25 Jul 2026 20:53:45 +0900 Subject: [PATCH 12/26] Update preferences.md --- packages/frontend/docs/preferences.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/frontend/docs/preferences.md b/packages/frontend/docs/preferences.md index 8a6ffb2e236..5605d71c4e3 100644 --- a/packages/frontend/docs/preferences.md +++ b/packages/frontend/docs/preferences.md @@ -47,6 +47,6 @@ autoBackupとautoSyncをオンにする。 ## メモ -autoBackupとautoSyncの違いがユーザーにとって分かりにくい可能性があるので、機能を一本化するか、個別にオンオフを切り替えられるのではなく「同期:『なし/バックアップのみ/同期』」みたいな選択方式にするなどがいいかもしれない? +autoBackupとautoSyncの違いがユーザーにとって分かりにくい可能性があるので、機能を一本化するか、個別にオンオフを切り替えられるのではなく「自動バックアップ:『しない/有効/有効+同期』」みたいな選択方式にするなどがいいかもしれない? autoBackupはするがautoSyncしたくないケースはあまりないと思うが、一本化すると複数のデバイスで同一のプロファイルを共有しない人(大多数だと思われる)にとっては無駄に通信量が増えるだけになる From f42840ad03c55e219c0289081c26ce7f0b259694 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Sat, 25 Jul 2026 23:04:54 +0900 Subject: [PATCH 13/26] [ci skip] add note --- packages/frontend/src/preferences/manager.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/frontend/src/preferences/manager.ts b/packages/frontend/src/preferences/manager.ts index 6373a2c308c..213ce43e471 100644 --- a/packages/frontend/src/preferences/manager.ts +++ b/packages/frontend/src/preferences/manager.ts @@ -36,7 +36,7 @@ type Scope = Partial<{ type ValueMeta = Partial<{ sync: boolean; - modifiedAt?: number; + modifiedAt?: number; // 設定値を変更した日時。同期した日時などではない。つまり別のデバイスでA日に変更したものをB日に同期して取得したとしてもmodifiedAtはA日である必要がある }>; type PrefRecord = [scope: Scope, value: ValueOf, meta: ValueMeta]; From b1980f1287a82f5895c435a87d57a61ec05b45cf Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Sun, 26 Jul 2026 08:50:32 +0900 Subject: [PATCH 14/26] wip --- locales/ja-JP.yml | 2 ++ packages/frontend/src/preferences/utility.ts | 6 ++++++ packages/frontend/src/store.ts | 8 ++++++++ packages/i18n/src/autogen/locale.ts | 8 ++++++++ 4 files changed, 24 insertions(+) diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 86d6c92d1a0..3970d1bbcd9 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1422,6 +1422,8 @@ append: "末尾に追加" prepend: "先頭に追加" urlPreviewSensitiveList: "サムネイルの表示を制限するURL" urlPreviewSensitiveListDescription: "スペースで区切るとAND指定になり、改行で区切るとOR指定になります。スラッシュで囲むと正規表現になります。一致した場合、サムネイルが表示されなくなります。" +latestBackupAt: "最終バックアップ" +latestSyncAt: "最終同期" _imageEditing: _vars: diff --git a/packages/frontend/src/preferences/utility.ts b/packages/frontend/src/preferences/utility.ts index 0c6e6631f1d..ab6e0be04fa 100644 --- a/packages/frontend/src/preferences/utility.ts +++ b/packages/frontend/src/preferences/utility.ts @@ -66,11 +66,13 @@ export function getPreferencesProfileMenu(): MenuItem[] { type: 'switch', icon: 'ti ti-cloud-up', text: i18n.ts._preferencesBackup.autoBackup, + caption: i18n.ts.latestBackupAt + ': ' + (store.s.latestPreferencesBackupAt ? new Date(store.s.latestPreferencesBackupAt).toLocaleString() : '-'), ref: autoBackupEnabled, }, { type: 'switch', icon: 'ti ti-cloud-down', text: i18n.ts._preferencesBackup.autoSync, + caption: i18n.ts.latestSyncAt + ': ' + (store.s.latestPreferencesSyncAt ? new Date(store.s.latestPreferencesSyncAt).toLocaleString() : '-'), ref: autoSyncEnabled, }, { text: i18n.ts.export, @@ -173,6 +175,8 @@ export async function cloudSync() { miLocalStorage.setItem('preferences', JSON.stringify(mergeProfiles(prefer.profile, cloudProfile))); prefer.reloadProfile(); + + store.set('latestPreferencesSyncAt', Date.now()); } export async function cloudBackup() { @@ -200,6 +204,8 @@ export async function cloudBackup() { key: prefer.profile.name, value: currentProfile, }); + + store.set('latestPreferencesBackupAt', Date.now()); } export async function listCloudBackups() { diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts index 84ed36abcdb..6ff8f085118 100644 --- a/packages/frontend/src/store.ts +++ b/packages/frontend/src/store.ts @@ -122,6 +122,14 @@ export const store = markRaw(new Pizzax('base', { where: 'device', default: true, }, + latestPreferencesBackupAt: { + where: 'device', + default: 0, + }, + latestPreferencesSyncAt: { + where: 'device', + default: 0, + }, })); // TODO: 他のタブと永続化されたstateを同期 diff --git a/packages/i18n/src/autogen/locale.ts b/packages/i18n/src/autogen/locale.ts index c3737003173..c1aa6460de4 100644 --- a/packages/i18n/src/autogen/locale.ts +++ b/packages/i18n/src/autogen/locale.ts @@ -5703,6 +5703,14 @@ export interface Locale extends ILocale { * スペースで区切るとAND指定になり、改行で区切るとOR指定になります。スラッシュで囲むと正規表現になります。一致した場合、サムネイルが表示されなくなります。 */ "urlPreviewSensitiveListDescription": string; + /** + * 最終バックアップ + */ + "latestBackupAt": string; + /** + * 最終同期 + */ + "latestSyncAt": string; "_imageEditing": { "_vars": { /** From dad093c6f80a23689a161b78c8eeaea046897f75 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Sun, 26 Jul 2026 09:49:00 +0900 Subject: [PATCH 15/26] wip --- locales/ja-JP.yml | 3 +++ packages/frontend/src/components/MkMenu.vue | 1 + packages/frontend/src/preferences/utility.ts | 28 ++++++++++++-------- packages/i18n/src/autogen/locale.ts | 12 +++++++++ 4 files changed, 33 insertions(+), 11 deletions(-) diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 3970d1bbcd9..fe568f35f18 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1592,7 +1592,9 @@ _preferencesProfile: useSyncBetweenDevicesOptionIfYouWantToSyncSetting: "複数のデバイスで同期したい設定項目が存在する場合は、個別に「複数のデバイスで同期」オプションを有効にしてください。" _preferencesBackup: + backupAndSync: "バックアップと同期" autoBackup: "自動バックアップ" + autoBackup_description: "設定を自動でサーバーに保存し、いつでも復元できるようにします" restoreFromBackup: "バックアップから復元" noBackupsFoundTitle: "バックアップが見つかりませんでした" noBackupsFoundDescription: "自動で作成されたバックアップは見つかりませんでしたが、バックアップファイルを手動で保存している場合、それをインポートして復元することはできます。" @@ -1602,6 +1604,7 @@ _preferencesBackup: backupFound: "設定のバックアップが見つかりました" forceBackup: "今すぐバックアップ" autoSync: "デバイス間同期" + autoSync_description: "サーバーに保存された設定を自動で取得し、別のデバイスでの変更と同期できるようにします" forceSync: "今すぐ同期" _accountSettings: diff --git a/packages/frontend/src/components/MkMenu.vue b/packages/frontend/src/components/MkMenu.vue index bbe0c1c4dfa..055fbba992b 100644 --- a/packages/frontend/src/components/MkMenu.vue +++ b/packages/frontend/src/components/MkMenu.vue @@ -629,6 +629,7 @@ function guardMouseMove(ev: MouseEvent) { box-sizing: border-box; max-width: 100vw; min-width: 200px; + width: max-content; overflow: auto; overscroll-behavior: contain; diff --git a/packages/frontend/src/preferences/utility.ts b/packages/frontend/src/preferences/utility.ts index ab6e0be04fa..d697283c5a5 100644 --- a/packages/frontend/src/preferences/utility.ts +++ b/packages/frontend/src/preferences/utility.ts @@ -63,17 +63,23 @@ export function getPreferencesProfileMenu(): MenuItem[] { renameProfile(); }, }, { - type: 'switch', - icon: 'ti ti-cloud-up', - text: i18n.ts._preferencesBackup.autoBackup, - caption: i18n.ts.latestBackupAt + ': ' + (store.s.latestPreferencesBackupAt ? new Date(store.s.latestPreferencesBackupAt).toLocaleString() : '-'), - ref: autoBackupEnabled, - }, { - type: 'switch', - icon: 'ti ti-cloud-down', - text: i18n.ts._preferencesBackup.autoSync, - caption: i18n.ts.latestSyncAt + ': ' + (store.s.latestPreferencesSyncAt ? new Date(store.s.latestPreferencesSyncAt).toLocaleString() : '-'), - ref: autoSyncEnabled, + type: 'parent', + text: i18n.ts._preferencesBackup.backupAndSync, + caption: i18n.ts.latestBackupAt + ': ' + (store.s.latestPreferencesBackupAt !== 0 ? new Date(store.s.latestPreferencesBackupAt).toLocaleString() : '-'), + icon: 'ti ti-cloud', + children: [{ + type: 'switch', + icon: 'ti ti-cloud-up', + text: i18n.ts._preferencesBackup.autoBackup, + caption: i18n.ts._preferencesBackup.autoBackup_description, + ref: autoBackupEnabled, + }, { + type: 'switch', + icon: 'ti ti-cloud-down', + text: i18n.ts._preferencesBackup.autoSync, + caption: i18n.ts._preferencesBackup.autoSync_description, + ref: autoSyncEnabled, + }], }, { text: i18n.ts.export, icon: 'ti ti-download', diff --git a/packages/i18n/src/autogen/locale.ts b/packages/i18n/src/autogen/locale.ts index c1aa6460de4..c1a5c90d911 100644 --- a/packages/i18n/src/autogen/locale.ts +++ b/packages/i18n/src/autogen/locale.ts @@ -6315,10 +6315,18 @@ export interface Locale extends ILocale { "useSyncBetweenDevicesOptionIfYouWantToSyncSetting": string; }; "_preferencesBackup": { + /** + * バックアップと同期 + */ + "backupAndSync": string; /** * 自動バックアップ */ "autoBackup": string; + /** + * 設定を自動でサーバーに保存し、いつでも復元できるようにします + */ + "autoBackup_description": string; /** * バックアップから復元 */ @@ -6355,6 +6363,10 @@ export interface Locale extends ILocale { * デバイス間同期 */ "autoSync": string; + /** + * サーバーに保存された設定を自動で取得し、別のデバイスでの変更と同期できるようにします + */ + "autoSync_description": string; /** * 今すぐ同期 */ From 1057295d5df871fcc0cc76eb0f7dc535ab9ab178 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Sun, 26 Jul 2026 10:04:13 +0900 Subject: [PATCH 16/26] wip --- packages/frontend/src/components/MkMenu.vue | 2 ++ packages/frontend/src/preferences/utility.ts | 20 +++++++++++++++++++- packages/frontend/src/types/menu.ts | 1 + 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/frontend/src/components/MkMenu.vue b/packages/frontend/src/components/MkMenu.vue index 055fbba992b..4d0ddc2e891 100644 --- a/packages/frontend/src/components/MkMenu.vue +++ b/packages/frontend/src/components/MkMenu.vue @@ -191,6 +191,7 @@ SPDX-License-Identifier: AGPL-3.0-only role="menuitem" tabindex="0" :class="['_button', $style.item, { [$style.danger]: item.danger, [$style.active]: unref(item.active) }]" + :disabled="unref(item.disabled)" @click.prevent="unref(item.active) ? close(false) : clicked(item.action, $event)" @mouseenter.passive="onItemMouseEnter" @mouseleave.passive="onItemMouseLeave" @@ -702,6 +703,7 @@ function guardMouseMove(ev: MouseEvent) { &:disabled { cursor: not-allowed; + opacity: 0.5; } &.danger { diff --git a/packages/frontend/src/preferences/utility.ts b/packages/frontend/src/preferences/utility.ts index d697283c5a5..7f657383843 100644 --- a/packages/frontend/src/preferences/utility.ts +++ b/packages/frontend/src/preferences/utility.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { ref, watch } from 'vue'; +import { computed, ref, watch } from 'vue'; import { mergeProfiles } from './manager.js'; import type { PreferencesProfile } from './manager.js'; import type { MenuItem } from '@/types/menu.js'; @@ -73,12 +73,30 @@ export function getPreferencesProfileMenu(): MenuItem[] { text: i18n.ts._preferencesBackup.autoBackup, caption: i18n.ts._preferencesBackup.autoBackup_description, ref: autoBackupEnabled, + }, { + type: 'button', + icon: 'ti ti-cloud-up', + text: i18n.ts._preferencesBackup.forceBackup, + disabled: computed(() => !autoBackupEnabled.value), + action: () => { + cloudBackup(); + }, + }, { + type: 'divider', }, { type: 'switch', icon: 'ti ti-cloud-down', text: i18n.ts._preferencesBackup.autoSync, caption: i18n.ts._preferencesBackup.autoSync_description, ref: autoSyncEnabled, + }, { + type: 'button', + icon: 'ti ti-cloud-down', + text: i18n.ts._preferencesBackup.forceSync, + disabled: computed(() => !autoSyncEnabled.value), + action: () => { + cloudSync(); + }, }], }, { text: i18n.ts.export, diff --git a/packages/frontend/src/types/menu.ts b/packages/frontend/src/types/menu.ts index 1c037d80d70..3a534f1de4c 100644 --- a/packages/frontend/src/types/menu.ts +++ b/packages/frontend/src/types/menu.ts @@ -23,6 +23,7 @@ export interface MenuButton { danger?: boolean; active?: boolean | ComputedRef; avatar?: Misskey.entities.User; + disabled?: boolean | Ref; action: MenuAction; } From 440b1e442ab3438c20d9ead8aefb98c26383b969 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Sun, 26 Jul 2026 10:12:53 +0900 Subject: [PATCH 17/26] Update utility.ts --- packages/frontend/src/preferences/utility.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/frontend/src/preferences/utility.ts b/packages/frontend/src/preferences/utility.ts index 7f657383843..35f54cf6217 100644 --- a/packages/frontend/src/preferences/utility.ts +++ b/packages/frontend/src/preferences/utility.ts @@ -89,6 +89,7 @@ export function getPreferencesProfileMenu(): MenuItem[] { text: i18n.ts._preferencesBackup.autoSync, caption: i18n.ts._preferencesBackup.autoSync_description, ref: autoSyncEnabled, + disabled: computed(() => !autoBackupEnabled.value), }, { type: 'button', icon: 'ti ti-cloud-down', From 7daec626808e56d1536f6a908b88149666d4049b Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Sun, 26 Jul 2026 10:15:34 +0900 Subject: [PATCH 18/26] Update utility.ts --- packages/frontend/src/preferences/utility.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/frontend/src/preferences/utility.ts b/packages/frontend/src/preferences/utility.ts index 35f54cf6217..5a9ad8725ad 100644 --- a/packages/frontend/src/preferences/utility.ts +++ b/packages/frontend/src/preferences/utility.ts @@ -40,6 +40,8 @@ export function getPreferencesProfileMenu(): MenuItem[] { cloudBackup(); } else { store.set('enablePreferencesAutoCloudBackup', false); + + autoSyncEnabled.value = false; } }); From 9b2f90f681a36cd7ad54b567009cb94146623dca Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Sun, 26 Jul 2026 10:20:41 +0900 Subject: [PATCH 19/26] wip --- locales/ja-JP.yml | 2 ++ packages/frontend/src/preferences/utility.ts | 12 +++++++++++- packages/i18n/src/autogen/locale.ts | 8 ++++++++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index fe568f35f18..ad846291e7a 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1606,6 +1606,8 @@ _preferencesBackup: autoSync: "デバイス間同期" autoSync_description: "サーバーに保存された設定を自動で取得し、別のデバイスでの変更と同期できるようにします" forceSync: "今すぐ同期" + autoSyncAreYouSure: "デバイス間の同期をオンにしますか?" + autoSyncAreYouSure_description: "通信量が増えるため、他のデバイスとこのプロファイルを共有する予定がない場合はオンにしないでください。" _accountSettings: requireSigninToViewContents: "コンテンツの表示にログインを必須にする" diff --git a/packages/frontend/src/preferences/utility.ts b/packages/frontend/src/preferences/utility.ts index 5a9ad8725ad..4652148b251 100644 --- a/packages/frontend/src/preferences/utility.ts +++ b/packages/frontend/src/preferences/utility.ts @@ -47,8 +47,18 @@ export function getPreferencesProfileMenu(): MenuItem[] { const autoSyncEnabled = ref(store.s.enablePreferencesAutoCloudSync); - watch(autoSyncEnabled, () => { + watch(autoSyncEnabled, async () => { if (autoSyncEnabled.value) { + const confirm = await os.confirm({ + type: 'warning', + title: i18n.ts._preferencesBackup.autoSyncAreYouSure, + text: i18n.ts._preferencesBackup.autoSyncAreYouSure_description, + }); + if (confirm.canceled) { + autoSyncEnabled.value = false; + return; + } + store.set('enablePreferencesAutoCloudSync', true); } else { store.set('enablePreferencesAutoCloudSync', false); diff --git a/packages/i18n/src/autogen/locale.ts b/packages/i18n/src/autogen/locale.ts index c1a5c90d911..bd94115e979 100644 --- a/packages/i18n/src/autogen/locale.ts +++ b/packages/i18n/src/autogen/locale.ts @@ -6371,6 +6371,14 @@ export interface Locale extends ILocale { * 今すぐ同期 */ "forceSync": string; + /** + * デバイス間の同期をオンにしますか? + */ + "autoSyncAreYouSure": string; + /** + * 通信量が増えるため、他のデバイスとこのプロファイルを共有する予定がない場合はオンにしないでください。 + */ + "autoSyncAreYouSure_description": string; }; "_accountSettings": { /** From 597e634c81dd32b732c657b5d6d25eabdbfb015d Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:23:27 +0900 Subject: [PATCH 20/26] Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3dfd4cd175..e2b0cd662b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - ### Client +- Feat: 設定プロファイルを別のデバイスと同期できるように - Enhance: 画像ビューワーで、ピクセルアートの拡大表示に適したモードを追加(画像ビューワー起動時に画面上の詳細メニューから有効化できます) - Fix: 画像の表示時にBlurhashが描画されない場合があるのを修正 From 459477bd30bef93d8b6f22834a76fe52f611cc05 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Tue, 18 Aug 2026 20:19:07 +0900 Subject: [PATCH 21/26] fix --- packages/frontend/src/preferences/manager.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/frontend/src/preferences/manager.ts b/packages/frontend/src/preferences/manager.ts index 213ce43e471..e8612382afd 100644 --- a/packages/frontend/src/preferences/manager.ts +++ b/packages/frontend/src/preferences/manager.ts @@ -402,7 +402,7 @@ export class PreferencesManager extends EventEmitter { 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])) { + if (!deepEqual(cloudValue.value, record[1])) { this.rewriteRawState(key, cloudValue.value); record[1] = cloudValue.value; record[2].modifiedAt = cloudValue.meta.modifiedAt; From c351d89a7f25ff9bbba9e11e0aabed0689fe30dc Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Tue, 18 Aug 2026 20:23:35 +0900 Subject: [PATCH 22/26] Update utility.ts --- packages/frontend/src/preferences/utility.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/frontend/src/preferences/utility.ts b/packages/frontend/src/preferences/utility.ts index 4652148b251..e5b15f79dfd 100644 --- a/packages/frontend/src/preferences/utility.ts +++ b/packages/frontend/src/preferences/utility.ts @@ -216,6 +216,7 @@ export async function cloudSync() { store.set('latestPreferencesSyncAt', Date.now()); } +// TODO: Web Locks APIで良い感じにする export async function cloudBackup() { if ($i == null) return; if (!canAutoBackup()) { From 69d98c635f64cccb45da0caa30e3b88f450245b2 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Tue, 18 Aug 2026 20:51:05 +0900 Subject: [PATCH 23/26] Update utility.ts --- packages/frontend/src/preferences/utility.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/frontend/src/preferences/utility.ts b/packages/frontend/src/preferences/utility.ts index e5b15f79dfd..804135caf00 100644 --- a/packages/frontend/src/preferences/utility.ts +++ b/packages/frontend/src/preferences/utility.ts @@ -200,6 +200,11 @@ export async function cloudSync() { const cloudProfile = await misskeyApi('i/registry/get', { scope: ['client', 'preferences', 'backups'], key: prefer.profile.name, + }).catch(err => { + if (err.code === 'NO_SUCH_KEY') { + return null; + } + throw err; }) as PreferencesProfile | null; if (cloudProfile == null) { @@ -230,6 +235,11 @@ export async function cloudBackup() { const backupedProfile = await misskeyApi('i/registry/get', { scope: ['client', 'preferences', 'backups'], key: prefer.profile.name, + }).catch(err => { + if (err.code === 'NO_SUCH_KEY') { + return null; + } + throw err; }) as PreferencesProfile | null; // 古い設定で新しいバックアップを上書きしないようにマージ From f45a09fc04cacc2ce28c2d35db9b6f92084596cc Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:01:48 +0900 Subject: [PATCH 24/26] Update preferences.md --- packages/frontend/docs/preferences.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/frontend/docs/preferences.md b/packages/frontend/docs/preferences.md index 5605d71c4e3..1ab9190eff4 100644 --- a/packages/frontend/docs/preferences.md +++ b/packages/frontend/docs/preferences.md @@ -10,6 +10,7 @@ - 複数のタブでMisskeyを開いていて、タブAで設定を編集した後、タブBを開いたところ、タブBの古い状態の内容で設定が保存され、タブAで行った編集が巻き戻ってしまった。 - プロファイルの同期機能をオンにしたところ、サーバーに保存されていた古い設定でローカルが上書きされてしまった。 +- しばらく使用していなかったデバイスで起動したところ、まだバージョンが古く、当時のバージョンでは存在していなかった新しい設定項目を含むバックアップ情報がサーバーから降ってきたが、新しい設定項目が認識されず正規化時に除外され、その状態でバックアップが行われて結果的に設定項目の一部が失われてしまった。 上記のシナリオが絶対に発生しない設計・実装にしなければならない。 From d6f178131e03643f97c188ce988171676e08357d Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:36:24 +0900 Subject: [PATCH 25/26] wip --- packages/frontend/src/preferences.ts | 2 +- packages/frontend/src/preferences/manager.ts | 62 ++++++--- packages/frontend/src/preferences/utility.ts | 12 +- .../test/unit/preferences-manager.test.ts | 128 ++++++++++++++++++ .../test/unit/preferences-utility.test.ts | 109 +++++++++++++++ 5 files changed, 292 insertions(+), 21 deletions(-) create mode 100644 packages/frontend/test/unit/preferences-manager.test.ts create mode 100644 packages/frontend/test/unit/preferences-utility.test.ts diff --git a/packages/frontend/src/preferences.ts b/packages/frontend/src/preferences.ts index 6a03a917837..1099d7dced6 100644 --- a/packages/frontend/src/preferences.ts +++ b/packages/frontend/src/preferences.ts @@ -41,7 +41,7 @@ const io: StorageProvider = { if (target == null) return null; return { value: target[1], - meta: target[2], + meta: target[2] ?? {}, }; } catch (err: any) { if (err.code === 'NO_SUCH_KEY') { // TODO: いちいちエラーキャッチするのは面倒なのでキーが無くてもエラーにならない maybe-get のようなエンドポイントをバックエンドに実装する diff --git a/packages/frontend/src/preferences/manager.ts b/packages/frontend/src/preferences/manager.ts index e8612382afd..6f53c5e4e90 100644 --- a/packages/frontend/src/preferences/manager.ts +++ b/packages/frontend/src/preferences/manager.ts @@ -36,11 +36,17 @@ type Scope = Partial<{ type ValueMeta = Partial<{ sync: boolean; + // TODO: デバイスの時計がずれていた場合に不具合のもとになるため、対策を考える modifiedAt?: number; // 設定値を変更した日時。同期した日時などではない。つまり別のデバイスでA日に変更したものをB日に同期して取得したとしてもmodifiedAtはA日である必要がある + deleted: boolean; // 削除済みの設定値を同期時に他のデバイスに伝播させるためのtombstone }>; type PrefRecord = [scope: Scope, value: ValueOf, meta: ValueMeta]; +function isDeletedRecord(record: [scope: Scope, value: any, meta?: ValueMeta]): boolean { + return record[2]?.deleted === true; +} + function parseScope(scope: Scope): { server: string | null; account: string | null; @@ -141,7 +147,7 @@ function createEmptyProfile(): PossiblyNonNormalizedPreferencesProfile { } function normalizePreferences(preferences: PossiblyNonNormalizedPreferencesProfile['preferences'], account: { id: string } | null): PreferencesProfile['preferences'] { - const data = {} as Record; + const data = { ...preferences } as Record; for (const key in PREF_DEF) { const records = preferences[key]; if (records == null || records.length === 0) { @@ -160,14 +166,14 @@ function normalizePreferences(preferences: PossiblyNonNormalizedPreferencesProfi } continue; } else { - if (account && isAccountDependentKey(key as keyof typeof PREF_DEF) && !records.some(([scope]) => parseScope(scope).server === host && parseScope(scope).account === account.id)) { + if (account && isAccountDependentKey(key as keyof typeof PREF_DEF) && !records.some((record) => !isDeletedRecord(record) && parseScope(record[0]).server === host && parseScope(record[0]).account === account.id)) { data[key] = records.concat([[makeScope({ server: host, account: account.id, }), getInitialPrefValue(key as keyof typeof PREF_DEF), {}]]); continue; } - if (account && isServerDependentKey(key as keyof typeof PREF_DEF) && !records.some(([scope]) => parseScope(scope).server === host)) { + if (account && isServerDependentKey(key as keyof typeof PREF_DEF) && !records.some((record) => !isDeletedRecord(record) && parseScope(record[0]).server === host)) { data[key] = records.concat([[makeScope({ server: host, }), getInitialPrefValue(key as keyof typeof PREF_DEF), {}]]); @@ -190,10 +196,16 @@ export function mergeProfiles(a: PreferencesProfile, b: PreferencesProfile): Pre 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 keys = new Set([ + ...Object.keys(PREF_DEF), + ...Object.keys(a.preferences), + ...Object.keys(b.preferences), + ]); + const mergedPreferences = merged.preferences as Record; + + for (const key of keys) { + const aRecords = (a.preferences as Record)[key] ?? []; + const bRecords = (b.preferences as Record)[key] ?? []; const mergedRecords = [...aRecords]; @@ -203,13 +215,13 @@ export function mergeProfiles(a: PreferencesProfile, b: PreferencesProfile): Pre mergedRecords.push(bRecord); } else { const aRecord = mergedRecords[existingIndex]; - if ((bRecord[2].modifiedAt ?? 0) > (aRecord[2].modifiedAt ?? 0)) { + if ((bRecord[2]?.modifiedAt ?? 0) > (aRecord[2]?.modifiedAt ?? 0)) { mergedRecords[existingIndex] = bRecord; } } } - (merged.preferences[key] as PrefRecord[]) = mergedRecords; + mergedPreferences[key] = mergedRecords; } return merged; @@ -405,7 +417,7 @@ export class PreferencesManager extends EventEmitter { if (!deepEqual(cloudValue.value, record[1])) { this.rewriteRawState(key, cloudValue.value); record[1] = cloudValue.value; - record[2].modifiedAt = cloudValue.meta.modifiedAt; + record[2].modifiedAt = cloudValue.meta?.modifiedAt; modified = true; if (_DEV_) console.log('cloud fetched', key, cloudValue); } @@ -429,7 +441,7 @@ export class PreferencesManager extends EventEmitter { const records = this.profile.preferences[key]; if (currentAccount == null) { - const record = records.find(([scope, v]) => parseScope(scope).account == null); + const record = records.find((record) => !isDeletedRecord(record) && parseScope(record[0]).account == null); // 設計上あり得ないけどTSに怒られるため if (record == null) throw new Error(`no record found for key: ${key}`); @@ -437,13 +449,13 @@ export class PreferencesManager extends EventEmitter { return record; } - const accountOverrideRecord = records.find(([scope, v]) => parseScope(scope).server === host && parseScope(scope).account === currentAccount.id); + const accountOverrideRecord = records.find((record) => !isDeletedRecord(record) && parseScope(record[0]).server === host && parseScope(record[0]).account === currentAccount.id); if (accountOverrideRecord) return accountOverrideRecord; - const serverOverrideRecord = records.find(([scope, v]) => parseScope(scope).server === host && parseScope(scope).account == null); + const serverOverrideRecord = records.find((record) => !isDeletedRecord(record) && parseScope(record[0]).server === host && parseScope(record[0]).account == null); if (serverOverrideRecord) return serverOverrideRecord; - const record = records.find(([scope, v]) => parseScope(scope).account == null); + const record = records.find((record) => !isDeletedRecord(record) && parseScope(record[0]).account == null); // 設計上あり得ないけどTSに怒られるため if (record == null) throw new Error(`no record found for key: ${key}`); @@ -454,7 +466,7 @@ export class PreferencesManager extends EventEmitter { public isAccountOverrided(key: K): boolean { const currentAccount = this.currentAccount; // TSを黙らせるため if (currentAccount == null) return false; - return this.profile.preferences[key].some(([scope, v]) => parseScope(scope).server === host && parseScope(scope).account === currentAccount.id); + return this.profile.preferences[key].some((record) => !isDeletedRecord(record) && parseScope(record[0]).server === host && parseScope(record[0]).account === currentAccount.id); } public setAccountOverride(key: K) { @@ -464,10 +476,17 @@ export class PreferencesManager extends EventEmitter { if (this.isAccountOverrided(key)) return; const records = this.profile.preferences[key]; - records.push([makeScope({ + const scope = makeScope({ server: host, account: currentAccount.id, - }), this.s[key], {}]); + }); + const deletedRecord = records.find((record) => isSameScope(record[0], scope)); + if (deletedRecord) { + deletedRecord[1] = this.s[key]; + deletedRecord[2] = { modifiedAt: Date.now() }; + } else { + records.push([scope, this.s[key], { modifiedAt: Date.now() }]); + } this.save(); } @@ -482,7 +501,12 @@ export class PreferencesManager extends EventEmitter { const index = records.findIndex(([scope, v]) => parseScope(scope).server === host && parseScope(scope).account === currentAccount.id); if (index === -1) return; - records.splice(index, 1); + const record = records[index]; + record[2] = { + ...record[2], + modifiedAt: Date.now(), + deleted: true, + }; this.rewriteRawState(key, this.getMatchedRecordOf(key)[1]); @@ -547,7 +571,7 @@ export class PreferencesManager extends EventEmitter { newValue = resolvedValue; } - const commitedRecord = this.commit(key, newValue); + this.commit(key, newValue); const done = os.waiting(); diff --git a/packages/frontend/src/preferences/utility.ts b/packages/frontend/src/preferences/utility.ts index 804135caf00..423c758c5a2 100644 --- a/packages/frontend/src/preferences/utility.ts +++ b/packages/frontend/src/preferences/utility.ts @@ -59,7 +59,17 @@ export function getPreferencesProfileMenu(): MenuItem[] { return; } - store.set('enablePreferencesAutoCloudSync', true); + try { + await cloudSync(); + store.set('enablePreferencesAutoCloudSync', true); + } catch (err) { + autoSyncEnabled.value = false; + os.alert({ + type: 'error', + title: i18n.ts.somethingHappened, + }); + console.error(err); + } } else { store.set('enablePreferencesAutoCloudSync', false); } diff --git a/packages/frontend/test/unit/preferences-manager.test.ts b/packages/frontend/test/unit/preferences-manager.test.ts new file mode 100644 index 00000000000..92f851f5970 --- /dev/null +++ b/packages/frontend/test/unit/preferences-manager.test.ts @@ -0,0 +1,128 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { assert, describe, test } from 'vitest'; +import { host } from '@@/js/config.js'; +import { PREF_DEF } from '@/preferences/def.js'; +import { mergeProfiles, PreferencesManager } from '@/preferences/manager.js'; +import type { PossiblyNonNormalizedPreferencesProfile } from '@/preferences/manager.js'; + +function createProfile(id: string): PossiblyNonNormalizedPreferencesProfile { + const preferences: PossiblyNonNormalizedPreferencesProfile['preferences'] = {}; + for (const key of Object.keys(PREF_DEF)) { + preferences[key] = [[{}, `value:${key}`, {}]]; + } + + return { + id, + version: 'test', + type: 'main', + modifiedAt: 1, + name: 'test', + preferences, + }; +} + +function createManager(profile: PossiblyNonNormalizedPreferencesProfile, cloudValues: Record = {}) { + return new PreferencesManager({ + load: () => structuredClone(profile), + save: () => {}, + cloudGetBulk: async () => cloudValues as any, + cloudGet: async () => null, + cloudSet: async () => {}, + }, { id: 'user' }); +} + +describe('mergeProfiles', () => { + test('片方にない既知キーを許容し、未知の設定キーも保持する', () => { + const local = createProfile('local'); + const cloud = createProfile('cloud'); + delete cloud.preferences.accounts; + cloud.preferences['future.preference'] = [[{}, 'future', { modifiedAt: 2 }]]; + + const merged = mergeProfiles(local as any, cloud as any) as PossiblyNonNormalizedPreferencesProfile; + + assert.deepStrictEqual(merged.preferences.accounts, local.preferences.accounts); + assert.deepStrictEqual(merged.preferences['future.preference'], cloud.preferences['future.preference']); + }); + + test('新しい tombstone がアカウント上書きを無効化する', () => { + const local = createProfile('local'); + local.preferences.accounts = [ + [{}, 'global', { modifiedAt: 1 }], + [{ server: host, account: 'user' }, 'account', { modifiedAt: 1 }], + ]; + const cloud = createProfile('cloud'); + cloud.preferences.accounts = [ + [{}, 'global', { modifiedAt: 1 }], + [{ server: host, account: 'user' }, 'account', { modifiedAt: 2, deleted: true } as any], + ]; + + const merged = mergeProfiles(local as any, cloud as any); + const manager = createManager(merged); + + assert.strictEqual(manager.getMatchedRecordOf('accounts')[1], 'global'); + }); +}); + +describe('PreferencesManager account overrides', () => { + test('アカウント上書きの解除を tombstone として保存する', () => { + const profile = createProfile('local'); + profile.preferences.accounts = [ + [{}, 'global', { modifiedAt: 1 }], + [{ server: host, account: 'user' }, 'account', { modifiedAt: 1 }], + ]; + const manager = createManager(profile); + + manager.clearAccountOverride('accounts'); + + const deletedRecord = manager.profile.preferences.accounts.find(([scope]) => scope.server === host && scope.account === 'user'); + assert.strictEqual((deletedRecord?.[2] as any).deleted, true); + assert.strictEqual(manager.getMatchedRecordOf('accounts')[1], 'global'); + }); + + test('アカウント上書きの再有効化では tombstone を復活させる', () => { + const profile = createProfile('local'); + profile.preferences.accounts = [ + [{}, 'global', { modifiedAt: 1 }], + [{ server: host, account: 'user' }, 'old account', { modifiedAt: 2, deleted: true } as any], + ]; + const manager = createManager(profile); + + manager.setAccountOverride('accounts'); + + const accountRecords = manager.profile.preferences.accounts.filter(([scope]) => scope.server === host && scope.account === 'user'); + assert.strictEqual(accountRecords.length, 1); + assert.notStrictEqual((accountRecords[0][2] as any).deleted, true); + assert.strictEqual(accountRecords[0][1], 'global'); + }); +}); + +describe('PreferencesManager cloud values', () => { + test('旧形式の metadata がないクラウド値も取得できる', async () => { + const profile = createProfile('local'); + profile.preferences.accounts = [[{}, 'local', { sync: true, modifiedAt: 1 }]]; + const manager = createManager(profile, { + accounts: { + value: 'remote', + meta: undefined, + }, + }); + + await manager.cloudReady; + assert.strictEqual(manager.s.accounts, 'remote'); + }); +}); + +describe('PreferencesManager profile normalization', () => { + test('未知の設定キーを正規化後も保持する', () => { + const profile = createProfile('local'); + profile.preferences['future.preference'] = [[{}, 'future', { modifiedAt: 2 }]]; + + const manager = createManager(profile); + + assert.deepStrictEqual((manager.profile.preferences as any)['future.preference'], profile.preferences['future.preference']); + }); +}); diff --git a/packages/frontend/test/unit/preferences-utility.test.ts b/packages/frontend/test/unit/preferences-utility.test.ts new file mode 100644 index 00000000000..b35aa1eecb5 --- /dev/null +++ b/packages/frontend/test/unit/preferences-utility.test.ts @@ -0,0 +1,109 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { afterEach, assert, beforeEach, describe, test, vi } from 'vitest'; +import { nextTick } from 'vue'; + +const mocks = vi.hoisted(() => ({ + api: vi.fn(), + confirm: vi.fn(async () => ({ canceled: false })), + storeSet: vi.fn(), + reloadProfile: vi.fn(), +})); + +vi.mock('@/i.js', () => ({ + $i: { + createdAt: new Date().toISOString(), + }, +})); + +vi.mock('@/preferences.js', () => ({ + prefer: { + profile: { + id: 'local', + version: 'test', + type: 'main', + modifiedAt: 1, + name: 'test', + preferences: {}, + }, + s: { + devMode: false, + }, + renameProfile: vi.fn(), + reloadProfile: mocks.reloadProfile, + }, +})); + +vi.mock('@/store.js', () => ({ + store: { + s: { + enablePreferencesAutoCloudBackup: true, + enablePreferencesAutoCloudSync: false, + latestPreferencesBackupAt: 0, + }, + set: mocks.storeSet, + }, +})); + +vi.mock('@/os.js', () => ({ + alert: vi.fn(), + confirm: mocks.confirm, +})); + +vi.mock('@/utility/misskey-api.js', () => ({ + misskeyApi: mocks.api, +})); + +vi.mock('@/utility/unison-reload.js', () => ({ + unisonReload: vi.fn(), +})); + +describe('getPreferencesProfileMenu', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test('自動同期を有効化した直後にクラウド同期を行う', async () => { + mocks.api.mockResolvedValue({ + id: 'cloud', + version: 'test', + type: 'main', + modifiedAt: 2, + name: 'test', + preferences: {}, + }); + const { getPreferencesProfileMenu } = await import('@/preferences/utility.js'); + const menu = getPreferencesProfileMenu(); + const backupAndSync = menu.find((item) => item.type === 'parent') as any; + const autoSync = backupAndSync.children.find((item: any) => item.type === 'switch' && item.icon === 'ti ti-cloud-down'); + + autoSync.ref.value = true; + await nextTick(); + await Promise.resolve(); + + assert.strictEqual(mocks.api.mock.calls.some(([endpoint]) => endpoint === 'i/registry/get'), true); + }); + + test('有効化直後の同期に失敗した場合は自動同期を無効に戻す', async () => { + mocks.api.mockRejectedValue(new Error('network error')); + const { getPreferencesProfileMenu } = await import('@/preferences/utility.js'); + const menu = getPreferencesProfileMenu(); + const backupAndSync = menu.find((item) => item.type === 'parent') as any; + const autoSync = backupAndSync.children.find((item: any) => item.type === 'switch' && item.icon === 'ti ti-cloud-down'); + + autoSync.ref.value = true; + await nextTick(); + await vi.waitFor(() => { + assert.strictEqual(autoSync.ref.value, false); + assert.strictEqual(mocks.storeSet.mock.calls.some(([key, value]) => key === 'enablePreferencesAutoCloudSync' && value === false), true); + }); + }); +}); From 46282ab72923f6462317f9fbea26d6ffe27ce103 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Tue, 18 Aug 2026 22:02:06 +0900 Subject: [PATCH 26/26] wip --- packages/frontend/src/preferences/manager.ts | 26 ++++++++-- .../test/unit/preferences-manager.test.ts | 50 +++++++++++++++++-- 2 files changed, 68 insertions(+), 8 deletions(-) diff --git a/packages/frontend/src/preferences/manager.ts b/packages/frontend/src/preferences/manager.ts index 6f53c5e4e90..88d1bf25b2c 100644 --- a/packages/frontend/src/preferences/manager.ts +++ b/packages/frontend/src/preferences/manager.ts @@ -408,6 +408,7 @@ export class PreferencesManager extends EventEmitter { const cloudValues = await this.io.cloudGetBulk({ needs }); let modified = false; + const cloudUpdates: Promise[] = []; for (const _key in PREF_DEF) { const key = _key as keyof PREF; @@ -415,16 +416,31 @@ export class PreferencesManager extends EventEmitter { 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); + const localModifiedAt = record[2].modifiedAt; + const cloudModifiedAt = cloudValue.meta?.modifiedAt; + const shouldApplyCloudValue = localModifiedAt == null || (cloudModifiedAt != null && cloudModifiedAt >= localModifiedAt); + + if (shouldApplyCloudValue) { + this.rewriteRawState(key, cloudValue.value); + record[1] = cloudValue.value; + record[2].modifiedAt = cloudModifiedAt; + modified = true; + if (_DEV_) console.log('cloud fetched', key, cloudValue); + } else { + cloudUpdates.push(this.io.cloudSet({ + key, + scope: record[0], + value: record[1], + meta: { modifiedAt: localModifiedAt }, + })); + if (_DEV_) console.log('cloud updated', key, record); + } } } } if (modified) this.save(); + await Promise.all(cloudUpdates); if (_DEV_) console.log('cloud fetch completed'); } diff --git a/packages/frontend/test/unit/preferences-manager.test.ts b/packages/frontend/test/unit/preferences-manager.test.ts index 92f851f5970..e20d1eb52e3 100644 --- a/packages/frontend/test/unit/preferences-manager.test.ts +++ b/packages/frontend/test/unit/preferences-manager.test.ts @@ -25,13 +25,17 @@ function createProfile(id: string): PossiblyNonNormalizedPreferencesProfile { }; } -function createManager(profile: PossiblyNonNormalizedPreferencesProfile, cloudValues: Record = {}) { +function createManager( + profile: PossiblyNonNormalizedPreferencesProfile, + cloudValues: Record = {}, + cloudSet: (ctx: unknown) => Promise = async () => {}, +) { return new PreferencesManager({ load: () => structuredClone(profile), save: () => {}, cloudGetBulk: async () => cloudValues as any, cloudGet: async () => null, - cloudSet: async () => {}, + cloudSet, }, { id: 'user' }); } @@ -101,9 +105,49 @@ describe('PreferencesManager account overrides', () => { }); describe('PreferencesManager cloud values', () => { - test('旧形式の metadata がないクラウド値も取得できる', async () => { + test('ローカル値の方が新しい場合は上書きせずクラウドへ送信する', async () => { + const profile = createProfile('local'); + profile.preferences.accounts = [[{}, 'local', { sync: true, modifiedAt: 2 }]]; + const cloudSets: unknown[] = []; + const manager = createManager(profile, { + accounts: { + value: 'remote', + meta: { modifiedAt: 1 }, + }, + }, async (ctx) => { + cloudSets.push(ctx); + }); + + await manager.cloudReady; + + assert.strictEqual(manager.s.accounts, 'local'); + assert.deepStrictEqual(cloudSets, [{ + key: 'accounts', + scope: {}, + value: 'local', + meta: { modifiedAt: 2 }, + }]); + }); + + test('クラウド値の方が新しい場合はローカルへ適用する', async () => { const profile = createProfile('local'); profile.preferences.accounts = [[{}, 'local', { sync: true, modifiedAt: 1 }]]; + const manager = createManager(profile, { + accounts: { + value: 'remote', + meta: { modifiedAt: 2 }, + }, + }); + + await manager.cloudReady; + + assert.strictEqual(manager.s.accounts, 'remote'); + assert.strictEqual(manager.profile.preferences.accounts[0][2].modifiedAt, 2); + }); + + test('旧形式の metadata がないクラウド値も取得できる', async () => { + const profile = createProfile('local'); + profile.preferences.accounts = [[{}, 'local', { sync: true }]]; const manager = createManager(profile, { accounts: { value: 'remote',