mirror of
https://github.com/misskey-dev/misskey.git
synced 2026-07-30 11:55:49 +00:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b2f90f681 | |||
| 7daec62680 | |||
| 440b1e442a | |||
| 1057295d5d | |||
| dad093c6f8 | |||
| 4d85ea8657 | |||
| b1980f1287 | |||
| f42840ad03 | |||
| a01b6af7c1 | |||
| 5b8fdabcf2 | |||
| 41c20ce140 | |||
| 6aad11766a | |||
| 66498639bf | |||
| 76389b684c | |||
| 4b82eab304 | |||
| 3ead8d840e | |||
| c93b093bff | |||
| b33ec08cbf | |||
| 0d677f288a | |||
| 8a3e33218e |
+11
-1
@@ -1312,6 +1312,7 @@ noteOfThisUser: "このユーザーのノート一覧"
|
||||
clipNoteLimitExceeded: "これ以上このクリップにノートを追加できません。"
|
||||
performance: "パフォーマンス"
|
||||
modified: "変更あり"
|
||||
modifiedAt: "変更日時"
|
||||
discard: "破棄"
|
||||
thereAreNChanges: "{n}件の変更があります"
|
||||
signinWithPasskey: "パスキーでログイン"
|
||||
@@ -1421,6 +1422,8 @@ append: "末尾に追加"
|
||||
prepend: "先頭に追加"
|
||||
urlPreviewSensitiveList: "サムネイルの表示を制限するURL"
|
||||
urlPreviewSensitiveListDescription: "スペースで区切るとAND指定になり、改行で区切るとOR指定になります。スラッシュで囲むと正規表現になります。一致した場合、サムネイルが表示されなくなります。"
|
||||
latestBackupAt: "最終バックアップ"
|
||||
latestSyncAt: "最終同期"
|
||||
|
||||
_imageEditing:
|
||||
_vars:
|
||||
@@ -1589,7 +1592,9 @@ _preferencesProfile:
|
||||
useSyncBetweenDevicesOptionIfYouWantToSyncSetting: "複数のデバイスで同期したい設定項目が存在する場合は、個別に「複数のデバイスで同期」オプションを有効にしてください。"
|
||||
|
||||
_preferencesBackup:
|
||||
backupAndSync: "バックアップと同期"
|
||||
autoBackup: "自動バックアップ"
|
||||
autoBackup_description: "設定を自動でサーバーに保存し、いつでも復元できるようにします"
|
||||
restoreFromBackup: "バックアップから復元"
|
||||
noBackupsFoundTitle: "バックアップが見つかりませんでした"
|
||||
noBackupsFoundDescription: "自動で作成されたバックアップは見つかりませんでしたが、バックアップファイルを手動で保存している場合、それをインポートして復元することはできます。"
|
||||
@@ -1597,7 +1602,12 @@ _preferencesBackup:
|
||||
youNeedToNameYourProfileToEnableAutoBackup: "自動バックアップを有効にするにはプロファイル名の設定が必要です。"
|
||||
autoPreferencesBackupIsNotEnabledForThisDevice: "このデバイスで設定の自動バックアップは有効になっていません。"
|
||||
backupFound: "設定のバックアップが見つかりました"
|
||||
forceBackup: "設定の強制バックアップ"
|
||||
forceBackup: "今すぐバックアップ"
|
||||
autoSync: "デバイス間同期"
|
||||
autoSync_description: "サーバーに保存された設定を自動で取得し、別のデバイスでの変更と同期できるようにします"
|
||||
forceSync: "今すぐ同期"
|
||||
autoSyncAreYouSure: "デバイス間の同期をオンにしますか?"
|
||||
autoSyncAreYouSure_description: "通信量が増えるため、他のデバイスとこのプロファイルを共有する予定がない場合はオンにしないでください。"
|
||||
|
||||
_accountSettings:
|
||||
requireSigninToViewContents: "コンテンツの表示にログインを必須にする"
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# Preferences system
|
||||
|
||||
ユーザーの環境設定を管理するシステム。
|
||||
|
||||
## 指針
|
||||
|
||||
実装上のミスで、**設定値が意図せず失われる(古い値で上書きされる)ことが絶対にあってはならない。**
|
||||
|
||||
設定値が失われる、考えられるシナリオの例:
|
||||
|
||||
- 複数のタブでMisskeyを開いていて、タブAで設定を編集した後、タブBを開いたところ、タブBの古い状態の内容で設定が保存され、タブAで行った編集が巻き戻ってしまった。
|
||||
- プロファイルの同期機能をオンにしたところ、サーバーに保存されていた古い設定でローカルが上書きされてしまった。
|
||||
|
||||
上記のシナリオが絶対に発生しない設計・実装にしなければならない。
|
||||
|
||||
上記以外にも考えられるシナリオがあれば、必ず適切な対処を行う設計・実装にしなければならない。
|
||||
|
||||
## 仕様
|
||||
|
||||
### タブ間同期
|
||||
|
||||
ブラウザで複数タブを開いているとき、あるタブで変更した設定が他のタブでも反映されるようにし、UXを向上させ、古い設定情報が上書き保存されることを防ぐ。
|
||||
|
||||
### 自動バックアップ(cloudBackup)
|
||||
|
||||
定期的、または特定のタイミングなどで、プロファイルをサーバーに自動的にアップロードする。
|
||||
|
||||
### デバイス間同期(cloudSync)
|
||||
|
||||
定期的、または特定のタイミングなどで、サーバーにアップロードされたプロファイルをダウンロードし、ローカルに適用する。
|
||||
|
||||
### 設定項目: デバイス間で同期(syncBetweenDevices, ValueMeta.sync)
|
||||
|
||||
デバイス間・プロファイル横断でその設定項目の設定値を同期する。
|
||||
|
||||
当オプションを有効にするかどうかは設定項目ごとに設定可能。
|
||||
|
||||
## ユーザーストーリー(ユースケース)
|
||||
|
||||
### 同じプロファイルを複数のデバイスで使いたい(同期したい)
|
||||
|
||||
autoBackupとautoSyncをオンにする。
|
||||
|
||||
### (新しいデバイスなどで)既存のプロファイルを継承した新しいプロファイルを作りたい
|
||||
|
||||
継承したいプロファイルをバックアップから復元した後、プロファイルの名前を変える。
|
||||
|
||||
## メモ
|
||||
|
||||
autoBackupとautoSyncの違いがユーザーにとって分かりにくい可能性があるので、機能を一本化するか、個別にオンオフを切り替えられるのではなく「自動バックアップ:『しない/有効/有効+同期』」みたいな選択方式にするなどがいいかもしれない?
|
||||
|
||||
autoBackupはするがautoSyncしたくないケースはあまりないと思うが、一本化すると複数のデバイスで同一のプロファイルを共有しない人(大多数だと思われる)にとっては無駄に通信量が増えるだけになる
|
||||
@@ -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 {
|
||||
|
||||
@@ -144,7 +144,17 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
<MkButton v-if="storagePersistenceSupported && !storagePersisted" @click="enableStoragePersistence">{{ i18n.ts._settings.settingsPersistence_title }}</MkButton>
|
||||
|
||||
<MkButton @click="forceCloudBackup">{{ i18n.ts._preferencesBackup.forceBackup }}</MkButton>
|
||||
<SearchMarker :keywords="['profile', 'preferences']">
|
||||
<MkFolder>
|
||||
<template #icon><SearchIcon><i class="ti ti-cogs"></i></SearchIcon></template>
|
||||
<template #label><SearchLabel>{{ i18n.ts.preferencesProfile }}</SearchLabel></template>
|
||||
|
||||
<div class="_buttons">
|
||||
<MkButton @click="forceCloudBackup">{{ i18n.ts._preferencesBackup.forceBackup }}</MkButton>
|
||||
<MkButton @click="forceCloudSync">{{ i18n.ts._preferencesBackup.forceSync }}</MkButton>
|
||||
</div>
|
||||
</MkFolder>
|
||||
</SearchMarker>
|
||||
</div>
|
||||
</SearchMarker>
|
||||
</template>
|
||||
@@ -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(() => []);
|
||||
|
||||
@@ -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<Record<string, any>>;
|
||||
const res = {} as Partial<Record<string, { value: any; meta: any; }>>;
|
||||
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;
|
||||
|
||||
@@ -36,6 +36,7 @@ type Scope = Partial<{
|
||||
|
||||
type ValueMeta = Partial<{
|
||||
sync: boolean;
|
||||
modifiedAt?: number; // 設定値を変更した日時。同期した日時などではない。つまり別のデバイスでA日に変更したものをB日に同期して取得したとしてもmodifiedAtはA日である必要がある
|
||||
}>;
|
||||
|
||||
type PrefRecord<K extends keyof PREF> = [scope: Scope, value: ValueOf<K>, meta: ValueMeta];
|
||||
@@ -74,7 +75,7 @@ export type PreferencesProfile = {
|
||||
id: string;
|
||||
version: string;
|
||||
type: 'main';
|
||||
modifiedAt: number;
|
||||
modifiedAt: number; // 仕様が若干直感的ではない(syncされた値が降ってきたときは更新されないなど)ため、一応残してはいるが積極的な利用はしない方が無難。設定値の新旧比較が必要なら項目ごとのmodifiedAtを使うべし
|
||||
name: string;
|
||||
preferences: {
|
||||
[K in keyof PREF]: PrefRecord<K>[];
|
||||
@@ -88,9 +89,9 @@ export type PossiblyNonNormalizedPreferencesProfile = Omit<PreferencesProfile, '
|
||||
export type StorageProvider = {
|
||||
load: () => PossiblyNonNormalizedPreferencesProfile | null;
|
||||
save: (ctx: { profile: PreferencesProfile; }) => void;
|
||||
cloudGetBulk: <K extends keyof PREF>(ctx: { needs: { key: K; scope: Scope; }[] }) => Promise<Partial<Record<K, ValueOf<K>>>>;
|
||||
cloudGet: <K extends keyof PREF>(ctx: { key: K; scope: Scope; }) => Promise<{ value: ValueOf<K>; } | null>;
|
||||
cloudSet: <K extends keyof PREF>(ctx: { key: K; scope: Scope; value: ValueOf<K>; }) => Promise<void>;
|
||||
cloudGetBulk: <K extends keyof PREF>(ctx: { needs: { key: K; scope: Scope; }[] }) => Promise<Partial<Record<K, { value: ValueOf<K>; meta: { modifiedAt: ValueMeta['modifiedAt'] }; }>>>;
|
||||
cloudGet: <K extends keyof PREF>(ctx: { key: K; scope: Scope; }) => Promise<{ value: ValueOf<K>; meta: { modifiedAt: ValueMeta['modifiedAt'] }; } | null>;
|
||||
cloudSet: <K extends keyof PREF>(ctx: { key: K; scope: Scope; value: ValueOf<K>; meta: { modifiedAt: ValueMeta['modifiedAt'] }; }) => Promise<void>;
|
||||
};
|
||||
|
||||
type PreferencesDefinitionRecord<Default, T = Default extends (...args: any) => infer R ? R : Default> = {
|
||||
@@ -102,14 +103,6 @@ type PreferencesDefinitionRecord<Default, T = Default extends (...args: any) =>
|
||||
|
||||
export type PreferencesDefinition = Record<string, PreferencesDefinitionRecord<any>>;
|
||||
|
||||
type PreferencesManagerEvents = {
|
||||
'committed': <K extends keyof PREF>(ctx: {
|
||||
key: K;
|
||||
value: ValueOf<K>;
|
||||
oldValue: ValueOf<K>;
|
||||
}) => void;
|
||||
};
|
||||
|
||||
export function definePreferences<T extends Record<string, unknown>>(x: {
|
||||
[K in keyof T]: PreferencesDefinitionRecord<T[K]>
|
||||
}): {
|
||||
@@ -188,6 +181,44 @@ 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;
|
||||
|
||||
for (const _key in PREF_DEF) {
|
||||
const key = _key as keyof PREF;
|
||||
const aRecords = a.preferences[key];
|
||||
const bRecords = b.preferences[key];
|
||||
|
||||
const mergedRecords = [...aRecords];
|
||||
|
||||
for (const bRecord of bRecords) {
|
||||
const existingIndex = mergedRecords.findIndex(([scope]) => isSameScope(scope, bRecord[0]));
|
||||
if (existingIndex === -1) {
|
||||
mergedRecords.push(bRecord);
|
||||
} else {
|
||||
const aRecord = mergedRecords[existingIndex];
|
||||
if ((bRecord[2].modifiedAt ?? 0) > (aRecord[2].modifiedAt ?? 0)) {
|
||||
mergedRecords[existingIndex] = bRecord;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(merged.preferences[key] as PrefRecord<typeof key>[]) = mergedRecords;
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
type PreferencesManagerEvents = {
|
||||
};
|
||||
|
||||
// TODO: PreferencesManagerForGuest のような非ログイン専用のクラスを分離すればthis.currentAccountのnullチェックやaccountがnullであるスコープのレコード挿入などが不要になり綺麗になるかもしれない
|
||||
// と思ったけど操作アカウントが存在しない場合も考慮する現在の設計の方が汎用的かつ堅牢かもしれない
|
||||
// NOTE: accountDependentな設定は初期状態であってもアカウントごとのスコープでレコードを作成しておかないと、サーバー同期する際に正しく動作しなくなる
|
||||
@@ -247,13 +278,13 @@ export class PreferencesManager extends EventEmitter<PreferencesManagerEvents> {
|
||||
}
|
||||
|
||||
// TODO: desync対策 cloudの値のfetchが正常に完了していない状態でcommitすると多分値が上書きされる
|
||||
public commit<K extends keyof PREF>(key: K, value: ValueOf<K>) {
|
||||
public commit<K extends keyof PREF>(key: K, value: ValueOf<K>): PrefRecord<K> | 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 +293,40 @@ export class PreferencesManager extends EventEmitter<PreferencesManagerEvents> {
|
||||
|
||||
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<K>;
|
||||
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<K>;
|
||||
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 +395,29 @@ export class PreferencesManager extends EventEmitter<PreferencesManagerEvents> {
|
||||
|
||||
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);
|
||||
if (record[2].sync && Object.hasOwn(cloudValues, key) && cloudValues[key] !== undefined) {
|
||||
const cloudValue = cloudValues[key];
|
||||
if (!deepEqual(cloudValue, record[1])) {
|
||||
this.rewriteRawState(key, cloudValue);
|
||||
record[1] = cloudValue;
|
||||
this.rewriteRawState(key, cloudValue.value);
|
||||
record[1] = cloudValue.value;
|
||||
record[2].modifiedAt = cloudValue.meta.modifiedAt;
|
||||
modified = true;
|
||||
if (_DEV_) console.log('cloud fetched', key, cloudValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.save();
|
||||
if (modified) this.save();
|
||||
|
||||
if (_DEV_) console.log('cloud fetch completed');
|
||||
}
|
||||
|
||||
public save() {
|
||||
private save() {
|
||||
this.profile.modifiedAt = Date.now();
|
||||
this.profile.version = version;
|
||||
this.io.save({ profile: this.profile });
|
||||
@@ -511,12 +547,12 @@ export class PreferencesManager extends EventEmitter<PreferencesManagerEvents> {
|
||||
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();
|
||||
|
||||
@@ -554,20 +590,18 @@ export class PreferencesManager extends EventEmitter<PreferencesManagerEvents> {
|
||||
}
|
||||
|
||||
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<K extends keyof PREF>(key: K): MenuItem[] {
|
||||
@@ -617,6 +651,11 @@ export class PreferencesManager extends EventEmitter<PreferencesManagerEvents> {
|
||||
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() : '-'),
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,28 @@ 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;
|
||||
}
|
||||
|
||||
store.set('enablePreferencesAutoCloudSync', true);
|
||||
} else {
|
||||
store.set('enablePreferencesAutoCloudSync', false);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -52,10 +75,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 +194,55 @@ 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) {
|
||||
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());
|
||||
}
|
||||
|
||||
export async function cloudBackup() {
|
||||
if ($i == null) return;
|
||||
if (!canAutoBackup()) {
|
||||
throw new Error('cannot auto backup for this profile');
|
||||
}
|
||||
|
||||
let currentProfile = prefer.profile;
|
||||
|
||||
if (_DEV_) console.log('cloud backup', currentProfile);
|
||||
|
||||
const backupedProfile = await misskeyApi('i/registry/get', {
|
||||
scope: ['client', 'preferences', 'backups'],
|
||||
key: prefer.profile.name,
|
||||
}) as PreferencesProfile | null;
|
||||
|
||||
// 古い設定で新しいバックアップを上書きしないようにマージ
|
||||
if (backupedProfile != null) {
|
||||
currentProfile = mergeProfiles(currentProfile, backupedProfile);
|
||||
}
|
||||
|
||||
await misskeyApi('i/registry/set', {
|
||||
scope: ['client', 'preferences', 'backups'],
|
||||
key: prefer.profile.name,
|
||||
value: prefer.profile,
|
||||
value: currentProfile,
|
||||
});
|
||||
|
||||
store.set('latestPreferencesBackupAt', Date.now());
|
||||
}
|
||||
|
||||
export async function listCloudBackups() {
|
||||
@@ -186,7 +279,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,
|
||||
|
||||
@@ -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を同期
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface MenuButton {
|
||||
danger?: boolean;
|
||||
active?: boolean | ComputedRef<boolean>;
|
||||
avatar?: Misskey.entities.User;
|
||||
disabled?: boolean | Ref<boolean>;
|
||||
action: MenuAction;
|
||||
}
|
||||
|
||||
|
||||
@@ -5260,6 +5260,10 @@ export interface Locale extends ILocale {
|
||||
* 変更あり
|
||||
*/
|
||||
"modified": string;
|
||||
/**
|
||||
* 変更日時
|
||||
*/
|
||||
"modifiedAt": string;
|
||||
/**
|
||||
* 破棄
|
||||
*/
|
||||
@@ -5699,6 +5703,14 @@ export interface Locale extends ILocale {
|
||||
* スペースで区切るとAND指定になり、改行で区切るとOR指定になります。スラッシュで囲むと正規表現になります。一致した場合、サムネイルが表示されなくなります。
|
||||
*/
|
||||
"urlPreviewSensitiveListDescription": string;
|
||||
/**
|
||||
* 最終バックアップ
|
||||
*/
|
||||
"latestBackupAt": string;
|
||||
/**
|
||||
* 最終同期
|
||||
*/
|
||||
"latestSyncAt": string;
|
||||
"_imageEditing": {
|
||||
"_vars": {
|
||||
/**
|
||||
@@ -6303,10 +6315,18 @@ export interface Locale extends ILocale {
|
||||
"useSyncBetweenDevicesOptionIfYouWantToSyncSetting": string;
|
||||
};
|
||||
"_preferencesBackup": {
|
||||
/**
|
||||
* バックアップと同期
|
||||
*/
|
||||
"backupAndSync": string;
|
||||
/**
|
||||
* 自動バックアップ
|
||||
*/
|
||||
"autoBackup": string;
|
||||
/**
|
||||
* 設定を自動でサーバーに保存し、いつでも復元できるようにします
|
||||
*/
|
||||
"autoBackup_description": string;
|
||||
/**
|
||||
* バックアップから復元
|
||||
*/
|
||||
@@ -6336,9 +6356,29 @@ export interface Locale extends ILocale {
|
||||
*/
|
||||
"backupFound": string;
|
||||
/**
|
||||
* 設定の強制バックアップ
|
||||
* 今すぐバックアップ
|
||||
*/
|
||||
"forceBackup": string;
|
||||
/**
|
||||
* デバイス間同期
|
||||
*/
|
||||
"autoSync": string;
|
||||
/**
|
||||
* サーバーに保存された設定を自動で取得し、別のデバイスでの変更と同期できるようにします
|
||||
*/
|
||||
"autoSync_description": string;
|
||||
/**
|
||||
* 今すぐ同期
|
||||
*/
|
||||
"forceSync": string;
|
||||
/**
|
||||
* デバイス間の同期をオンにしますか?
|
||||
*/
|
||||
"autoSyncAreYouSure": string;
|
||||
/**
|
||||
* 通信量が増えるため、他のデバイスとこのプロファイルを共有する予定がない場合はオンにしないでください。
|
||||
*/
|
||||
"autoSyncAreYouSure_description": string;
|
||||
};
|
||||
"_accountSettings": {
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user