From 8a6f73c5ff3ff4d94f6dc21bca407b6bb640ada9 Mon Sep 17 00:00:00 2001 From: tamaina Date: Thu, 2 Feb 2023 16:43:56 +0900 Subject: [PATCH] =?UTF-8?q?enhance:=20Pizzax=E3=83=87=E3=83=BC=E3=82=BF?= =?UTF-8?q?=E3=82=92indexedDB=E3=81=AB=E4=BF=9D=E5=AD=98=E3=81=99=E3=82=8B?= =?UTF-8?q?=E3=82=88=E3=81=86=E3=81=AB=20(#9225)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Revert "Revert #8098" This reverts commit 8b9dc962ae7b04354c65f6e80ad0e9a6bafd57ea. * fix * use deepClone instead of deepclone * defaultStore.loaded * fix load * wait ready * use top-level await, await in device-kind.ts --- packages/frontend/src/init.ts | 5 + packages/frontend/src/pizzax.ts | 271 ++++++++++++------ packages/frontend/src/scripts/device-kind.ts | 2 + packages/frontend/src/store.ts | 10 + .../frontend/src/ui/_common_/sw-inject.ts | 2 - packages/frontend/src/ui/classic.vue | 2 +- packages/frontend/src/ui/universal.vue | 2 +- 7 files changed, 202 insertions(+), 92 deletions(-) diff --git a/packages/frontend/src/init.ts b/packages/frontend/src/init.ts index 8f4bbec11..4227f5cf4 100644 --- a/packages/frontend/src/init.ts +++ b/packages/frontend/src/init.ts @@ -43,6 +43,7 @@ import { reloadChannel } from '@/scripts/unison-reload'; import { reactionPicker } from '@/scripts/reaction-picker'; import { getUrlWithoutLoginId } from '@/scripts/login-id'; import { getAccountFromId } from '@/scripts/get-account-from-id'; +import { deckStore } from './ui/deck/deck-store'; import { miLocalStorage } from './local-storage'; import { claimAchievement, claimedAchievements } from './scripts/achievements'; import { fetchCustomEmojis } from './custom-emojis'; @@ -216,6 +217,8 @@ if (splash) splash.addEventListener('transitionend', () => { splash.remove(); }); +await deckStore.ready; + // https://github.com/misskey-dev/misskey/pull/8575#issuecomment-1114239210 // なぜかinit.tsの内容が2回実行されることがあるため、mountするdivを1つに制限する const rootEl = (() => { @@ -266,6 +269,8 @@ if (lastVersion !== version) { } } +await defaultStore.ready; + // NOTE: この処理は必ず↑のクライアント更新時処理より後に来ること(テーマ再構築のため) watch(defaultStore.reactiveState.darkMode, (darkMode) => { applyTheme(darkMode ? ColdDeviceStorage.get('darkTheme') : ColdDeviceStorage.get('lightTheme')); diff --git a/packages/frontend/src/pizzax.ts b/packages/frontend/src/pizzax.ts index 7ff09f75f..2ca89b735 100644 --- a/packages/frontend/src/pizzax.ts +++ b/packages/frontend/src/pizzax.ts @@ -1,136 +1,209 @@ // PIZZAX --- A lightweight store import { onUnmounted, Ref, ref, watch } from 'vue'; +import { BroadcastChannel } from 'broadcast-channel'; import { $i } from './account'; import { api } from './os'; +import { get, set } from './scripts/idb-proxy'; +import { defaultStore } from './store'; import { stream } from './stream'; +import { deepClone } from './scripts/clone'; type StateDef = Record; +type State = { [K in keyof T]: T[K]['default']; }; +type ReactiveState = { [K in keyof T]: Ref; }; + type ArrayElement = A extends readonly (infer T)[] ? T : never; +type PizzaxChannelMessage = { + where: 'device' | 'deviceAccount'; + key: keyof T; + value: T[keyof T]['default']; + userId?: string; +}; + const connection = $i && stream.useChannel('main'); export class Storage { + public readonly ready: Promise; + public readonly loaded: Promise; + public readonly key: string; - public readonly keyForLocalStorage: string; + public readonly deviceStateKeyName: `pizzax::${this['key']}`; + public readonly deviceAccountStateKeyName: `pizzax::${this['key']}::${string}` | ''; + public readonly registryCacheKeyName: `pizzax::${this['key']}::cache::${string}` | ''; public readonly def: T; // TODO: これが実装されたらreadonlyにしたい: https://github.com/microsoft/TypeScript/issues/37487 - public readonly state: { [K in keyof T]: T[K]['default'] }; - public readonly reactiveState: { [K in keyof T]: Ref }; - public readonly ready: Promise; - private markAsReady: () => void = () => {}; + public readonly state: State; + public readonly reactiveState: ReactiveState; + + private pizzaxChannel: BroadcastChannel>; + + // 簡易的にキューイングして占有ロックとする + private currentIdbJob: Promise = Promise.resolve(); + private addIdbSetJob(job: () => Promise) { + const promise = this.currentIdbJob.then(job, e => { + console.error('Pizzax failed to save data to idb!', e); + return job(); + }); + this.currentIdbJob = promise; + return promise; + } constructor(key: string, def: T) { - this.ready = new Promise((res) => { - this.markAsReady = res; - }); this.key = key; - this.keyForLocalStorage = 'pizzax::' + key; + this.deviceStateKeyName = `pizzax::${key}`; + this.deviceAccountStateKeyName = $i ? `pizzax::${key}::${$i.id}` : ''; + this.registryCacheKeyName = $i ? `pizzax::${key}::cache::${$i.id}` : ''; this.def = def; - // TODO: indexedDBにする - const deviceState = JSON.parse(localStorage.getItem(this.keyForLocalStorage) || '{}'); - const deviceAccountState = $i ? JSON.parse(localStorage.getItem(this.keyForLocalStorage + '::' + $i.id) || '{}') : {}; - const registryCache = $i ? JSON.parse(localStorage.getItem(this.keyForLocalStorage + '::cache::' + $i.id) || '{}') : {}; + this.pizzaxChannel = new BroadcastChannel(`pizzax::${key}`); - const state = {}; - const reactiveState = {}; - for (const [k, v] of Object.entries(def)) { + this.state = {} as State; + this.reactiveState = {} as ReactiveState; + + for (const [k, v] of Object.entries(def) as [keyof T, T[keyof T]['default']][]) { + this.state[k] = v.default; + this.reactiveState[k] = ref(v.default); + } + + this.ready = this.init(); + this.loaded = this.ready.then(() => this.load()); + } + + private async init(): Promise { + await this.migrate(); + + const deviceState: State = await get(this.deviceStateKeyName) || {}; + const deviceAccountState = $i ? await get(this.deviceAccountStateKeyName) || {} : {}; + const registryCache = $i ? await get(this.registryCacheKeyName) || {} : {}; + + for (const [k, v] of Object.entries(this.def) as [keyof T, T[keyof T]['default']][]) { if (v.where === 'device' && Object.prototype.hasOwnProperty.call(deviceState, k)) { - state[k] = deviceState[k]; + this.reactiveState[k].value = this.state[k] = deviceState[k]; } else if (v.where === 'account' && $i && Object.prototype.hasOwnProperty.call(registryCache, k)) { - state[k] = registryCache[k]; + this.reactiveState[k].value = this.state[k] = registryCache[k]; } else if (v.where === 'deviceAccount' && Object.prototype.hasOwnProperty.call(deviceAccountState, k)) { - state[k] = deviceAccountState[k]; + this.reactiveState[k].value = this.state[k] = deviceAccountState[k]; } else { - state[k] = v.default; + this.reactiveState[k].value = this.state[k] = v.default; if (_DEV_) console.log('Use default value', k, v.default); } } - for (const [k, v] of Object.entries(state)) { - reactiveState[k] = ref(v); - } - this.state = state as any; - this.reactiveState = reactiveState as any; - + + this.pizzaxChannel.addEventListener('message', ({ where, key, value, userId }) => { + // アカウント変更すればunisonReloadが効くため、このreturnが発火することは + // まずないと思うけど一応弾いておく + if (where === 'deviceAccount' && !($i && userId !== $i.id)) return; + this.reactiveState[key].value = this.state[key] = value; + }); + if ($i) { - // なぜかsetTimeoutしないとapi関数内でエラーになる(おそらく循環参照してることに原因がありそう) - window.setTimeout(() => { - api('i/registry/get-all', { scope: ['client', this.key] }).then(kvs => { - const cache = {}; - for (const [k, v] of Object.entries(def)) { - if (v.where === 'account') { - if (Object.prototype.hasOwnProperty.call(kvs, k)) { - state[k] = kvs[k]; - reactiveState[k].value = kvs[k]; - cache[k] = kvs[k]; - } else { - state[k] = v.default; - reactiveState[k].value = v.default; - } - } - } - localStorage.setItem(this.keyForLocalStorage + '::cache::' + $i.id, JSON.stringify(cache)); - this.markAsReady(); - }); - }, 1); // streamingのuser storage updateイベントを監視して更新 - connection?.on('registryUpdated', ({ scope, key, value }: { scope: string[], key: keyof T, value: T[typeof key]['default'] }) => { - if (scope.length !== 2 || scope[0] !== 'client' || scope[1] !== this.key || this.state[key] === value) return; + connection?.on('registryUpdated', ({ scope, key, value }: { scope?: string[], key: keyof T, value: T[typeof key]['default'] }) => { + if (!scope || scope.length !== 2 || scope[0] !== 'client' || scope[1] !== this.key || this.state[key] === value) return; - this.state[key] = value; - this.reactiveState[key].value = value; - - const cache = JSON.parse(localStorage.getItem(this.keyForLocalStorage + '::cache::' + $i.id) || '{}'); - if (cache[key] !== value) { - cache[key] = value; - localStorage.setItem(this.keyForLocalStorage + '::cache::' + $i.id, JSON.stringify(cache)); - } + this.reactiveState[key].value = this.state[key] = value; + + this.addIdbSetJob(async () => { + const cache = await get(this.registryCacheKeyName); + if (cache[key] !== value) { + cache[key] = value; + await set(this.registryCacheKeyName, cache); + } + }); }); - } else { - this.markAsReady(); } } - public set(key: K, value: T[K]['default']): void { - if (_DEV_) console.log('set', key, value); + private load(): Promise { + return new Promise((resolve, reject) => { + if ($i) { + // api関数と循環参照なので一応setTimeoutしておく + window.setTimeout(async () => { + await defaultStore.ready; - this.state[key] = value; - this.reactiveState[key].value = value; + api('i/registry/get-all', { scope: ['client', this.key] }) + .then(kvs => { + const cache: Partial = {}; + for (const [k, v] of Object.entries(this.def) as [keyof T, T[keyof T]['default']][]) { + if (v.where === 'account') { + if (Object.prototype.hasOwnProperty.call(kvs, k)) { + this.reactiveState[k].value = this.state[k] = (kvs as Partial)[k]; + cache[k] = (kvs as Partial)[k]; + } else { + this.reactiveState[k].value = this.state[k] = v.default; + } + } + } + + return set(this.registryCacheKeyName, cache); + }) + .then(() => resolve()); + }, 1); + } else { + resolve(); + } + }); + } - switch (this.def[key].where) { - case 'device': { - const deviceState = JSON.parse(localStorage.getItem(this.keyForLocalStorage) || '{}'); - deviceState[key] = value; - localStorage.setItem(this.keyForLocalStorage, JSON.stringify(deviceState)); - break; + public set(key: K, value: T[K]['default']): Promise { + // IndexedDBやBroadcastChannelで扱うために単純なオブジェクトにする + // (JSON.parse(JSON.stringify(value))の代わり) + const rawValue = deepClone(value); + + if (_DEV_) console.log('set', key, rawValue, value); + + this.reactiveState[key].value = this.state[key] = rawValue; + + return this.addIdbSetJob(async () => { + if (_DEV_) console.log(`set ${key} start`); + switch (this.def[key].where) { + case 'device': { + this.pizzaxChannel.postMessage({ + where: 'device', + key, + value: rawValue, + }); + const deviceState = await get(this.deviceStateKeyName) || {}; + deviceState[key] = rawValue; + await set(this.deviceStateKeyName, deviceState); + break; + } + case 'deviceAccount': { + if ($i == null) break; + this.pizzaxChannel.postMessage({ + where: 'deviceAccount', + key, + value: rawValue, + userId: $i.id, + }); + const deviceAccountState = await get(this.deviceAccountStateKeyName) || {}; + deviceAccountState[key] = rawValue; + await set(this.deviceAccountStateKeyName, deviceAccountState); + break; + } + case 'account': { + if ($i == null) break; + const cache = await get(this.registryCacheKeyName) || {}; + cache[key] = rawValue; + await set(this.registryCacheKeyName, cache); + await api('i/registry/set', { + scope: ['client', this.key], + key: key.toString(), + value: rawValue, + }); + break; + } } - case 'deviceAccount': { - if ($i == null) break; - const deviceAccountState = JSON.parse(localStorage.getItem(this.keyForLocalStorage + '::' + $i.id) || '{}'); - deviceAccountState[key] = value; - localStorage.setItem(this.keyForLocalStorage + '::' + $i.id, JSON.stringify(deviceAccountState)); - break; - } - case 'account': { - if ($i == null) break; - const cache = JSON.parse(localStorage.getItem(this.keyForLocalStorage + '::cache::' + $i.id) || '{}'); - cache[key] = value; - localStorage.setItem(this.keyForLocalStorage + '::cache::' + $i.id, JSON.stringify(cache)); - api('i/registry/set', { - scope: ['client', this.key], - key: key, - value: value, - }); - break; - } - } + if (_DEV_) console.log(`set ${key} complete`); + }); } public push(key: K, value: ArrayElement): void { @@ -140,6 +213,7 @@ export class Storage { public reset(key: keyof T) { this.set(key, this.def[key].default); + return this.def[key].default; } /** @@ -174,4 +248,25 @@ export class Storage { }, }; } + + // localStorage => indexedDBのマイグレーション + private async migrate() { + const deviceState = localStorage.getItem(this.deviceStateKeyName); + if (deviceState) { + await set(this.deviceStateKeyName, JSON.parse(deviceState)); + localStorage.removeItem(this.deviceStateKeyName); + } + + const deviceAccountState = $i && localStorage.getItem(this.deviceAccountStateKeyName); + if ($i && deviceAccountState) { + await set(this.deviceAccountStateKeyName, JSON.parse(deviceAccountState)); + localStorage.removeItem(this.deviceAccountStateKeyName); + } + + const registryCache = $i && localStorage.getItem(this.registryCacheKeyName); + if ($i && registryCache) { + await set(this.registryCacheKeyName, JSON.parse(registryCache)); + localStorage.removeItem(this.registryCacheKeyName); + } + } } diff --git a/packages/frontend/src/scripts/device-kind.ts b/packages/frontend/src/scripts/device-kind.ts index 6bb349c55..b575db960 100644 --- a/packages/frontend/src/scripts/device-kind.ts +++ b/packages/frontend/src/scripts/device-kind.ts @@ -1,5 +1,7 @@ import { defaultStore } from '@/store'; +await defaultStore.ready; + const ua = navigator.userAgent.toLowerCase(); const isTablet = /ipad/.test(ua) || (/mobile|iphone|android/.test(ua) && window.innerWidth > 700); const isSmartphone = !isTablet && /mobile|iphone|android/.test(ua); diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts index f9ad50b30..89a37ab08 100644 --- a/packages/frontend/src/store.ts +++ b/packages/frontend/src/store.ts @@ -333,6 +333,16 @@ export class ColdDeviceStorage { } } + public static getAll(): Partial { + return (Object.keys(this.default) as (keyof typeof this.default)[]).reduce((acc, key) => { + const value = localStorage.getItem(PREFIX + key); + if (value != null) { + acc[key] = JSON.parse(value); + } + return acc; + }, {} as any); + } + public static set(key: T, value: typeof ColdDeviceStorage.default[T]): void { // 呼び出し側のバグ等で undefined が来ることがある // undefined を文字列として miLocalStorage に入れると参照する際の JSON.parse でコケて不具合の元になるため無視 diff --git a/packages/frontend/src/ui/_common_/sw-inject.ts b/packages/frontend/src/ui/_common_/sw-inject.ts index 8676d2d48..a92a06bd3 100644 --- a/packages/frontend/src/ui/_common_/sw-inject.ts +++ b/packages/frontend/src/ui/_common_/sw-inject.ts @@ -1,7 +1,5 @@ -import { inject } from 'vue'; import { post } from '@/os'; import { $i, login } from '@/account'; -import { defaultStore } from '@/store'; import { getAccountFromId } from '@/scripts/get-account-from-id'; import { mainRouter } from '@/router'; diff --git a/packages/frontend/src/ui/classic.vue b/packages/frontend/src/ui/classic.vue index a5c2f8ca2..02dafcc3b 100644 --- a/packages/frontend/src/ui/classic.vue +++ b/packages/frontend/src/ui/classic.vue @@ -132,7 +132,7 @@ if (window.innerWidth < 1024) { document.documentElement.style.overflowY = 'scroll'; -defaultStore.ready.then(() => { +defaultStore.loaded.then(() => { if (defaultStore.state.widgets.length === 0) { defaultStore.set('widgets', [{ name: 'calendar', diff --git a/packages/frontend/src/ui/universal.vue b/packages/frontend/src/ui/universal.vue index a9bb85ab6..eac7e7e85 100644 --- a/packages/frontend/src/ui/universal.vue +++ b/packages/frontend/src/ui/universal.vue @@ -150,7 +150,7 @@ if (window.innerWidth > 1024) { } } -defaultStore.ready.then(() => { +defaultStore.loaded.then(() => { if (defaultStore.state.widgets.length === 0) { defaultStore.set('widgets', [{ name: 'calendar',