Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
- Enhance: ノート翻訳時、CWも翻訳対象に含めるように

### Client
- Feat: 設定プロファイルを別のデバイスと同期できるように
- Enhance: 画像ビューワーで、ピクセルアートの拡大表示に適したモードを追加(画像ビューワー起動時に画面上の詳細メニューから有効化できます)
- Fix: 画像の表示時にBlurhashが描画されない場合があるのを修正
- Fix: モバイルでの利用時に一部のテキストが選択できない問題を修正
Expand Down
12 changes: 11 additions & 1 deletion locales/ja-JP.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1312,6 +1312,7 @@ noteOfThisUser: "このユーザーのノート一覧"
clipNoteLimitExceeded: "これ以上このクリップにノートを追加できません。"
performance: "パフォーマンス"
modified: "変更あり"
modifiedAt: "変更日時"
discard: "破棄"
thereAreNChanges: "{n}件の変更があります"
signinWithPasskey: "パスキーでログイン"
Expand Down Expand Up @@ -1421,6 +1422,8 @@ append: "末尾に追加"
prepend: "先頭に追加"
urlPreviewSensitiveList: "サムネイルの表示を制限するURL"
urlPreviewSensitiveListDescription: "スペースで区切るとAND指定になり、改行で区切るとOR指定になります。スラッシュで囲むと正規表現になります。一致した場合、サムネイルが表示されなくなります。"
latestBackupAt: "最終バックアップ"
latestSyncAt: "最終同期"
pixelatedZoom: "ピクセルアート拡大モード"

_imageEditing:
Expand Down Expand Up @@ -1590,15 +1593,22 @@ _preferencesProfile:
useSyncBetweenDevicesOptionIfYouWantToSyncSetting: "複数のデバイスで同期したい設定項目が存在する場合は、個別に「複数のデバイスで同期」オプションを有効にしてください。"

_preferencesBackup:
backupAndSync: "バックアップと同期"
autoBackup: "自動バックアップ"
autoBackup_description: "設定を自動でサーバーに保存し、いつでも復元できるようにします"
restoreFromBackup: "バックアップから復元"
noBackupsFoundTitle: "バックアップが見つかりませんでした"
noBackupsFoundDescription: "自動で作成されたバックアップは見つかりませんでしたが、バックアップファイルを手動で保存している場合、それをインポートして復元することはできます。"
selectBackupToRestore: "復元するバックアップを選択してください"
youNeedToNameYourProfileToEnableAutoBackup: "自動バックアップを有効にするにはプロファイル名の設定が必要です。"
autoPreferencesBackupIsNotEnabledForThisDevice: "このデバイスで設定の自動バックアップは有効になっていません。"
backupFound: "設定のバックアップが見つかりました"
forceBackup: "設定の強制バックアップ"
forceBackup: "今すぐバックアップ"
autoSync: "デバイス間同期"
autoSync_description: "サーバーに保存された設定を自動で取得し、別のデバイスでの変更と同期できるようにします"
forceSync: "今すぐ同期"
autoSyncAreYouSure: "デバイス間の同期をオンにしますか?"
autoSyncAreYouSure_description: "通信量が増えるため、他のデバイスとこのプロファイルを共有する予定がない場合はオンにしないでください。"

_accountSettings:
requireSigninToViewContents: "コンテンツの表示にログインを必須にする"
Expand Down
53 changes: 53 additions & 0 deletions packages/frontend/docs/preferences.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Preferences system

ユーザーの環境設定を管理するシステム。

## 指針

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

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

- 複数のタブでMisskeyを開いていて、タブAで設定を編集した後、タブBを開いたところ、タブBの古い状態の内容で設定が保存され、タブAで行った編集が巻き戻ってしまった。
- プロファイルの同期機能をオンにしたところ、サーバーに保存されていた古い設定でローカルが上書きされてしまった。
- しばらく使用していなかったデバイスで起動したところ、まだバージョンが古く、当時のバージョンでは存在していなかった新しい設定項目を含むバックアップ情報がサーバーから降ってきたが、新しい設定項目が認識されず正規化時に除外され、その状態でバックアップが行われて結果的に設定項目の一部が失われてしまった。

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

上記以外にも考えられるシナリオがあれば、必ず適切な対処を行う設計・実装にしなければならない。
Comment thread
coderabbitai[bot] marked this conversation as resolved.

## 仕様

### タブ間同期

ブラウザで複数タブを開いているとき、あるタブで変更した設定が他のタブでも反映されるようにし、UXを向上させ、古い設定情報が上書き保存されることを防ぐ。

### 自動バックアップ(cloudBackup)

定期的、または特定のタイミングなどで、プロファイルをサーバーに自動的にアップロードする。

### デバイス間同期(cloudSync)

定期的、または特定のタイミングなどで、サーバーにアップロードされたプロファイルをダウンロードし、ローカルに適用する。

### 設定項目: デバイス間で同期(syncBetweenDevices, ValueMeta.sync)

デバイス間・プロファイル横断でその設定項目の設定値を同期する。

当オプションを有効にするかどうかは設定項目ごとに設定可能。

## ユーザーストーリー(ユースケース)

### 同じプロファイルを複数のデバイスで使いたい(同期したい)

autoBackupとautoSyncをオンにする。
Comment on lines +41 to +43

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

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

Repository: misskey-dev/misskey

Length of output: 12033


🏁 Script executed:

#!/bin/bash
set -euo pipefail

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

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

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

Repository: misskey-dev/misskey

Length of output: 43442


🏁 Script executed:

#!/bin/bash
set -euo pipefail

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

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

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

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

Repository: misskey-dev/misskey

Length of output: 28247


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

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

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

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


### (新しいデバイスなどで)既存のプロファイルを継承した新しいプロファイルを作りたい

継承したいプロファイルをバックアップから復元した後、プロファイルの名前を変える。

## メモ

autoBackupとautoSyncの違いがユーザーにとって分かりにくい可能性があるので、機能を一本化するか、個別にオンオフを切り替えられるのではなく「自動バックアップ:『しない/有効/有効+同期』」みたいな選択方式にするなどがいいかもしれない?

autoBackupはするがautoSyncしたくないケースはあまりないと思うが、一本化すると複数のデバイスで同一のプロファイルを共有しない人(大多数だと思われる)にとっては無駄に通信量が増えるだけになる
3 changes: 3 additions & 0 deletions packages/frontend/src/components/MkMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -701,6 +703,7 @@ function guardMouseMove(ev: MouseEvent) {

&:disabled {
cursor: not-allowed;
opacity: 0.5;
}

&.danger {
Expand Down
19 changes: 17 additions & 2 deletions packages/frontend/src/pages/settings/other.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand All @@ -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();

Expand Down Expand Up @@ -232,6 +242,11 @@ async function forceCloudBackup() {
os.success();
}

async function forceCloudSync() {
await cloudSync();
os.success();
}

const headerActions = computed(() => []);

const headerTabs = computed(() => []);
Expand Down
63 changes: 19 additions & 44 deletions packages/frontend/src/preferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 のようなエンドポイントをバックエンドに実装する
Expand All @@ -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 = [];
Expand All @@ -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', {
Expand All @@ -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];
}
}

Expand All @@ -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
Expand All @@ -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;
Expand Down
Loading
Loading