diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cd354f7473..51b58f53bb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - Enhance: ノート翻訳時、CWも翻訳対象に含めるように ### Client +- Feat: 設定プロファイルを別のデバイスと同期できるように - Enhance: 画像ビューワーで、ピクセルアートの拡大表示に適したモードを追加(画像ビューワー起動時に画面上の詳細メニューから有効化できます) - Enhance: 音声プレイヤーを画像・動画ビューワーに統合 - Enhance: パフォーマンスの向上 diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 45368c3e2f0..17685ddebc7 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1313,6 +1313,7 @@ noteOfThisUser: "このユーザーのノート一覧" clipNoteLimitExceeded: "これ以上このクリップにノートを追加できません。" performance: "パフォーマンス" modified: "変更あり" +modifiedAt: "変更日時" discard: "破棄" thereAreNChanges: "{n}件の変更があります" signinWithPasskey: "パスキーでログイン" @@ -1422,6 +1423,8 @@ append: "末尾に追加" prepend: "先頭に追加" urlPreviewSensitiveList: "サムネイルの表示を制限するURL" urlPreviewSensitiveListDescription: "スペースで区切るとAND指定になり、改行で区切るとOR指定になります。スラッシュで囲むと正規表現になります。一致した場合、サムネイルが表示されなくなります。" +latestBackupAt: "最終バックアップ" +latestSyncAt: "最終同期" pixelatedZoom: "ピクセルアート拡大モード" _imageEditing: @@ -1591,7 +1594,9 @@ _preferencesProfile: useSyncBetweenDevicesOptionIfYouWantToSyncSetting: "複数のデバイスで同期したい設定項目が存在する場合は、個別に「複数のデバイスで同期」オプションを有効にしてください。" _preferencesBackup: + backupAndSync: "バックアップと同期" autoBackup: "自動バックアップ" + autoBackup_description: "設定を自動でサーバーに保存し、いつでも復元できるようにします" restoreFromBackup: "バックアップから復元" noBackupsFoundTitle: "バックアップが見つかりませんでした" noBackupsFoundDescription: "自動で作成されたバックアップは見つかりませんでしたが、バックアップファイルを手動で保存している場合、それをインポートして復元することはできます。" @@ -1599,7 +1604,12 @@ _preferencesBackup: youNeedToNameYourProfileToEnableAutoBackup: "自動バックアップを有効にするにはプロファイル名の設定が必要です。" autoPreferencesBackupIsNotEnabledForThisDevice: "このデバイスで設定の自動バックアップは有効になっていません。" backupFound: "設定のバックアップが見つかりました" - forceBackup: "設定の強制バックアップ" + forceBackup: "今すぐバックアップ" + autoSync: "デバイス間同期" + autoSync_description: "サーバーに保存された設定を自動で取得し、別のデバイスでの変更と同期できるようにします" + forceSync: "今すぐ同期" + autoSyncAreYouSure: "デバイス間の同期をオンにしますか?" + autoSyncAreYouSure_description: "通信量が増えるため、他のデバイスとこのプロファイルを共有する予定がない場合はオンにしないでください。" _accountSettings: requireSigninToViewContents: "コンテンツの表示にログインを必須にする" diff --git a/packages/frontend/docs/preferences.md b/packages/frontend/docs/preferences.md new file mode 100644 index 00000000000..36ea993db8b --- /dev/null +++ b/packages/frontend/docs/preferences.md @@ -0,0 +1,55 @@ +# Preferences system + +ユーザーの環境設定を管理するシステム。 + +## 指針 + +実装上のミスで、**設定値が意図せず失われる(古い値で上書きされる)ことが絶対にあってはならない。** + +設定値が失われる、考えられるシナリオの例: + +- 複数のタブでMisskeyを開いていて、タブAで設定を編集した後、タブBを開いたところ、タブBの古い状態の内容で設定が保存され、タブAで行った編集が巻き戻ってしまった。 +- プロファイルの同期機能をオンにしたところ、サーバーに保存されていた古い設定でローカルが上書きされてしまった。 +- しばらく使用していなかったデバイスで起動したところ、まだバージョンが古く、当時のバージョンでは存在していなかった新しい設定項目を含むバックアップ情報がサーバーから降ってきたが、新しい設定項目が認識されず正規化時に除外され、その状態でバックアップが行われて結果的に設定項目の一部が失われてしまった。 + +上記のシナリオが絶対に発生しない設計・実装にしなければならない。 + +上記以外にも考えられるシナリオがあれば、必ず適切な対処を行う設計・実装にしなければならない。 + +※「複数のデバイスで同時に開きつつ同時に同じ設定値を変更する」など、およそ意図して狙わない限りは発生しないようなケースは無視してもよい + +## 仕様 + +### タブ間同期 + +ブラウザで複数タブを開いているとき、あるタブで変更した設定が他のタブでも反映されるようにし、UXを向上させ、古い設定情報が上書き保存されることを防ぐ。 + +### 自動バックアップ(cloudBackup) + +定期的、または特定のタイミングなどで、プロファイルをサーバーに自動的にアップロードする。 + +### デバイス間同期(cloudSync) + +定期的、または特定のタイミングなどで、サーバーにアップロードされたプロファイルをダウンロードし、ローカルに適用する。 + +### 設定項目: デバイス間で同期(syncBetweenDevices, ValueMeta.sync) + +デバイス間・プロファイル横断でその設定項目の設定値を同期する。 + +当オプションを有効にするかどうかは設定項目ごとに設定可能。 + +## ユーザーストーリー(ユースケース) + +### 同じプロファイルを複数のデバイスで使いたい(同期したい) + +autoBackupとautoSyncをオンにする。 + +### (新しいデバイスなどで)既存のプロファイルを継承した新しいプロファイルを作りたい + +継承したいプロファイルをバックアップから復元した後、プロファイルの名前を変える。 + +## メモ + +autoBackupとautoSyncの違いがユーザーにとって分かりにくい可能性があるので、機能を一本化するか、個別にオンオフを切り替えられるのではなく「自動バックアップ:『しない/有効/有効+同期』」みたいな選択方式にするなどがいいかもしれない? + +autoBackupはするがautoSyncしたくないケースはあまりないと思うが、一本化すると複数のデバイスで同一のプロファイルを共有しない人(大多数だと思われる)にとっては無駄に通信量が増えるだけになる diff --git a/packages/frontend/src/components/MkMenu.vue b/packages/frontend/src/components/MkMenu.vue index bbe0c1c4dfa..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" @@ -629,6 +630,7 @@ function guardMouseMove(ev: MouseEvent) { box-sizing: border-box; max-width: 100vw; min-width: 200px; + width: max-content; overflow: auto; overscroll-behavior: contain; @@ -701,6 +703,7 @@ function guardMouseMove(ev: MouseEvent) { &:disabled { cursor: not-allowed; + opacity: 0.5; } &.danger { diff --git a/packages/frontend/src/pages/settings/other.vue b/packages/frontend/src/pages/settings/other.vue index 6ab645ed1ce..79f82752e67 100644 --- a/packages/frontend/src/pages/settings/other.vue +++ b/packages/frontend/src/pages/settings/other.vue @@ -143,8 +143,6 @@ SPDX-License-Identifier: AGPL-3.0-only {{ i18n.ts._settings.settingsPersistence_title }} - - {{ i18n.ts._preferencesBackup.forceBackup }} @@ -170,7 +168,6 @@ 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'; const $i = ensureSignin(); @@ -227,11 +224,6 @@ function readAllChatMessages() { os.apiWithDialog('chat/read-all', {}); } -async function forceCloudBackup() { - await cloudBackup(); - os.success(); -} - const headerActions = computed(() => []); const headerTabs = computed(() => []); diff --git a/packages/frontend/src/preferences.ts b/packages/frontend/src/preferences.ts index 91866f1fbdb..1099d7dced6 100644 --- a/packages/frontend/src/preferences.ts +++ b/packages/frontend/src/preferences.ts @@ -3,16 +3,14 @@ * 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'; +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'; import { $i } from '@/i.js'; import { misskeyApi } from '@/utility/misskey-api.js'; -import { TAB_ID } from '@/tab-id.js'; // クラウド同期用グループ名 const syncGroup = 'default'; @@ -38,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 のようなエンドポイントをバックエンドに実装する @@ -54,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 = []; @@ -71,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', { @@ -88,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]; } } @@ -102,41 +101,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 @@ -156,6 +124,13 @@ createVisibilityAwareInterval(() => { }, 1000 * 60 * 3); //#endregion +store.loaded.then(() => { + if (store.s.enablePreferencesAutoCloudSync) { + // TODO: 前回同期してから10分以上経過している場合のみ + cloudSync(); + } +}); + if (_DEV_) { (window as any).prefer = prefer; (window as any).cloudBackup = cloudBackup; diff --git a/packages/frontend/src/preferences/manager.ts b/packages/frontend/src/preferences/manager.ts index 05df4c70d97..fd899da55c1 100644 --- a/packages/frontend/src/preferences/manager.ts +++ b/packages/frontend/src/preferences/manager.ts @@ -36,10 +36,18 @@ type Scope = Partial<{ type ValueMeta = Partial<{ sync: boolean; + // TODO: デバイスの時計がずれていた場合に不具合のもとになるため、対策を考える + modifiedAt?: number; // 設定値を変更した日時。同期した日時などではない。つまり別のデバイスでA日に変更したものをB日に同期して取得したとしてもmodifiedAtはA日である必要がある + syncModifiedAt?: number; // syncを変更した日時。設定値のmodifiedAtとは独立して管理する(syncの変更をmodifiedAtに反映すると状況によっては古い設定値で新しい設定値が上書きされる可能性があるため) + 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; @@ -74,7 +82,7 @@ export type PreferencesProfile = { id: string; version: string; type: 'main'; - modifiedAt: number; + modifiedAt: number; // 仕様が若干直感的ではない(syncされた値が降ってきたときは更新されないなど)ため、一応残してはいるが積極的な利用はしない方が無難。設定値の新旧比較が必要なら項目ごとのmodifiedAtを使うべし name: string; preferences: { [K in keyof PREF]: PrefRecord[]; @@ -88,9 +96,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> = { @@ -102,14 +110,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 }): { @@ -148,7 +148,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) { @@ -167,14 +167,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), {}]]); @@ -188,6 +188,60 @@ function normalizePreferences(preferences: PossiblyNonNormalizedPreferencesProfi return data as PreferencesProfile['preferences']; } +// 各recordについて、modifiedAtが大きい方を採用する +// 引数の参照をmutateしないように注意すること +export function mergeProfiles(a: PreferencesProfile, b: PreferencesProfile): PreferencesProfile { + const merged = { + ...a, + modifiedAt: Math.max(a.modifiedAt, b.modifiedAt), + preferences: {}, + } as PreferencesProfile; + + // 片方にない設定項目を許容し、未知の設定項目も保持する + 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]; + + 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]; + const valueRecord = (bRecord[2]?.modifiedAt ?? 0) > (aRecord[2]?.modifiedAt ?? 0) ? bRecord : aRecord; + const syncRecord = (bRecord[2]?.syncModifiedAt ?? 0) > (aRecord[2]?.syncModifiedAt ?? 0) ? bRecord : aRecord; + + mergedRecords[existingIndex] = [ + valueRecord[0], + valueRecord[1], + { + ...valueRecord[2], + sync: syncRecord[2]?.sync, + syncModifiedAt: syncRecord[2]?.syncModifiedAt, + }, + ]; + } + } + + mergedPreferences[key] = mergedRecords; + } + + return merged; +} + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +type PreferencesManagerEvents = { +}; + // TODO: PreferencesManagerForGuest のような非ログイン専用のクラスを分離すればthis.currentAccountのnullチェックやaccountがnullであるスコープのレコード挿入などが不要になり綺麗になるかもしれない // と思ったけど操作アカウントが存在しない場合も考慮する現在の設計の方が汎用的かつ堅牢かもしれない // NOTE: accountDependentな設定は初期状態であってもアカウントごとのスコープでレコードを作成しておかないと、サーバー同期する際に正しく動作しなくなる @@ -247,13 +301,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); @@ -262,40 +316,40 @@ 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({ + const newRecord = [makeScope({ server: host, account: currentAccount.id, - }), v, {}]); - _save(); - return; + }), v, { + modifiedAt: Date.now(), + }] as PrefRecord; + this.profile.preferences[key].push(newRecord); + this.save(); + return newRecord; } if (parseScope(record[0]).server == null && isServerDependentKey(key)) { - this.profile.preferences[key].push([makeScope({ + const newRecord = [makeScope({ server: host, - }), v, {}]); - _save(); - return; + }), v, { + modifiedAt: Date.now(), + }] as PrefRecord; + this.profile.preferences[key].push(newRecord); + this.save(); + return newRecord; } record[1] = v; - _save(); + 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; } /** @@ -364,24 +418,45 @@ 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; 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; - if (_DEV_) console.log('cloud fetched', key, cloudValue); + if (!deepEqual(cloudValue.value, record[1])) { + 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); + } } } } - this.save(); + if (modified) this.save(); + await Promise.all(cloudUpdates); + 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 }); @@ -393,7 +468,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}`); @@ -401,13 +476,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}`); @@ -418,7 +493,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) { @@ -428,10 +503,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(); } @@ -446,7 +528,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]); @@ -516,7 +603,7 @@ export class PreferencesManager extends EventEmitter { 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(); @@ -533,6 +620,7 @@ export class PreferencesManager extends EventEmitter { done({ success: true }); record[2].sync = true; + record[2].syncModifiedAt = Date.now(); this.save(); return { enabled: true }; @@ -542,7 +630,8 @@ export class PreferencesManager extends EventEmitter { if (!this.isSyncEnabled(key)) return; const record = this.getMatchedRecordOf(key); - delete record[2].sync; + record[2].sync = false; + record[2].syncModifiedAt = Date.now(); this.save(); } @@ -554,20 +643,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[] { @@ -617,6 +704,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/frontend/src/preferences/utility.ts b/packages/frontend/src/preferences/utility.ts index 7507a071f43..423c758c5a2 100644 --- a/packages/frontend/src/preferences/utility.ts +++ b/packages/frontend/src/preferences/utility.ts @@ -3,7 +3,8 @@ * 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'; import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; @@ -39,6 +40,38 @@ export function getPreferencesProfileMenu(): MenuItem[] { cloudBackup(); } else { store.set('enablePreferencesAutoCloudBackup', false); + + autoSyncEnabled.value = false; + } + }); + + const autoSyncEnabled = ref(store.s.enablePreferencesAutoCloudSync); + + 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; + } + + 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); } }); @@ -52,10 +85,42 @@ export function getPreferencesProfileMenu(): MenuItem[] { renameProfile(); }, }, { - type: 'switch', - icon: 'ti ti-cloud-up', - text: i18n.ts._preferencesBackup.autoBackup, - ref: autoBackupEnabled, + 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: '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, + disabled: computed(() => !autoBackupEnabled.value), + }, { + type: 'button', + icon: 'ti ti-cloud-down', + text: i18n.ts._preferencesBackup.forceSync, + disabled: computed(() => !autoSyncEnabled.value), + action: () => { + cloudSync(); + }, + }], }, { text: i18n.ts.export, icon: 'ti ti-download', @@ -139,17 +204,66 @@ 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, + }).catch(err => { + if (err.code === 'NO_SUCH_KEY') { + return null; + } + throw err; + }) as PreferencesProfile | null; + + if (cloudProfile == null) { + if (_DEV_) console.log('no backuped profile found, skipping sync'); + return; + } + + if (_DEV_) console.log('backuped profile found, syncing', cloudProfile); + + miLocalStorage.setItem('preferences', JSON.stringify(mergeProfiles(prefer.profile, cloudProfile))); + + prefer.reloadProfile(); + + store.set('latestPreferencesSyncAt', Date.now()); +} + +// TODO: Web Locks APIで良い感じにする 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, + }).catch(err => { + if (err.code === 'NO_SUCH_KEY') { + return null; + } + throw err; + }) 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()); } export async function listCloudBackups() { @@ -186,7 +300,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..6ff8f085118 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, @@ -118,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/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; } diff --git a/packages/i18n/src/autogen/locale.ts b/packages/i18n/src/autogen/locale.ts index b56f7acc9b1..2647de0699c 100644 --- a/packages/i18n/src/autogen/locale.ts +++ b/packages/i18n/src/autogen/locale.ts @@ -5264,6 +5264,10 @@ export interface Locale extends ILocale { * 変更あり */ "modified": string; + /** + * 変更日時 + */ + "modifiedAt": string; /** * 破棄 */ @@ -5703,6 +5707,14 @@ export interface Locale extends ILocale { * スペースで区切るとAND指定になり、改行で区切るとOR指定になります。スラッシュで囲むと正規表現になります。一致した場合、サムネイルが表示されなくなります。 */ "urlPreviewSensitiveListDescription": string; + /** + * 最終バックアップ + */ + "latestBackupAt": string; + /** + * 最終同期 + */ + "latestSyncAt": string; /** * ピクセルアート拡大モード */ @@ -6311,10 +6323,18 @@ export interface Locale extends ILocale { "useSyncBetweenDevicesOptionIfYouWantToSyncSetting": string; }; "_preferencesBackup": { + /** + * バックアップと同期 + */ + "backupAndSync": string; /** * 自動バックアップ */ "autoBackup": string; + /** + * 設定を自動でサーバーに保存し、いつでも復元できるようにします + */ + "autoBackup_description": string; /** * バックアップから復元 */ @@ -6344,9 +6364,29 @@ export interface Locale extends ILocale { */ "backupFound": string; /** - * 設定の強制バックアップ + * 今すぐバックアップ */ "forceBackup": string; + /** + * デバイス間同期 + */ + "autoSync": string; + /** + * サーバーに保存された設定を自動で取得し、別のデバイスでの変更と同期できるようにします + */ + "autoSync_description": string; + /** + * 今すぐ同期 + */ + "forceSync": string; + /** + * デバイス間の同期をオンにしますか? + */ + "autoSyncAreYouSure": string; + /** + * 通信量が増えるため、他のデバイスとこのプロファイルを共有する予定がない場合はオンにしないでください。 + */ + "autoSyncAreYouSure_description": string; }; "_accountSettings": { /**