diff --git a/locales/index.d.ts b/locales/index.d.ts index 440f24ac84..66fe6615ff 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -5218,6 +5218,31 @@ export interface Locale extends ILocale { * 利用可能なロール */ "availableRoles": string; + /** + * クライアント設定の初期値 + */ + "clientSettingOverrides": string; + /** + * 全ユーザーに対するクライアント設定の初期値を変更できます。 + * 知識のない方が変更すると全ユーザーがクライアントにアクセスできなくなる可能性があります。 + */ + "clientSettingOverridesWarn": string; + /** + * オーバーライドする + */ + "enableOverride": string; + /** + * オーバーライドする値 + */ + "overrideValue": string; + /** + * スイッチをオンにするとtrueとなります。 + */ + "onToTrue": string; + /** + * リセット + */ + "reset": string; "_accountSettings": { /** * コンテンツの表示にログインを必須にする diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 5d8e1a5e72..10923ed414 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1300,6 +1300,12 @@ thisContentsAreMarkedAsSigninRequiredByAuthor: "投稿者により、表示に lockdown: "ロックダウン" pleaseSelectAccount: "アカウントを選択してください" availableRoles: "利用可能なロール" +clientSettingOverrides: "クライアント設定の初期値" +clientSettingOverridesWarn: "全ユーザーに対するクライアント設定の初期値を変更できます。\n知識のない方が変更すると全ユーザーがクライアントにアクセスできなくなる可能性があります。" +enableOverride: "オーバーライドする" +overrideValue: "オーバーライドする値" +onToTrue: "スイッチをオンにするとtrueとなります。" +reset: "リセット" _accountSettings: requireSigninToViewContents: "コンテンツの表示にログインを必須にする" diff --git a/packages/backend/migration/1730552981368-ClientSettingOverrides.js b/packages/backend/migration/1730552981368-ClientSettingOverrides.js new file mode 100644 index 0000000000..456d8305d5 --- /dev/null +++ b/packages/backend/migration/1730552981368-ClientSettingOverrides.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class ClientSettingOverrides1730552981368 { + name = 'ClientSettingOverrides1730552981368' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "defaultClientSettingOverrides" character varying(8192)`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "defaultClientSettingOverrides"`); + } +} diff --git a/packages/backend/src/core/entities/MetaEntityService.ts b/packages/backend/src/core/entities/MetaEntityService.ts index 409dca3426..39db1ccf6c 100644 --- a/packages/backend/src/core/entities/MetaEntityService.ts +++ b/packages/backend/src/core/entities/MetaEntityService.ts @@ -151,6 +151,7 @@ export class MetaEntityService { const packDetailed: Packed<'MetaDetailed'> = { ...packed, + defaultClientSettingOverrides: instance.defaultClientSettingOverrides, cacheRemoteFiles: instance.cacheRemoteFiles, cacheRemoteSensitiveFiles: instance.cacheRemoteSensitiveFiles, requireSetup: !await this.instanceActorService.realLocalUsersPresent(), diff --git a/packages/backend/src/models/Meta.ts b/packages/backend/src/models/Meta.ts index ad5e31ad6f..f61229cb01 100644 --- a/packages/backend/src/models/Meta.ts +++ b/packages/backend/src/models/Meta.ts @@ -409,6 +409,12 @@ export class MiMeta { }) public defaultDarkTheme: string | null; + @Column('varchar', { + length: 8192, + nullable: true, + }) + public defaultClientSettingOverrides: string | null; + @Column('boolean', { default: false, }) diff --git a/packages/backend/src/models/json-schema/meta.ts b/packages/backend/src/models/json-schema/meta.ts index e3fd63464a..49b8f97272 100644 --- a/packages/backend/src/models/json-schema/meta.ts +++ b/packages/backend/src/models/json-schema/meta.ts @@ -315,6 +315,10 @@ export const packedMetaDetailedOnlySchema = { }, }, }, + defaultClientSettingOverrides: { + type: 'string', + optional: false, nullable: true, + }, proxyAccountName: { type: 'string', optional: false, nullable: true, diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts index 64e3cc33bd..beae8473aa 100644 --- a/packages/backend/src/server/api/endpoints/admin/meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/meta.ts @@ -420,6 +420,10 @@ export const meta = { type: 'string', optional: false, nullable: true, }, + defaultClientSettingOverrides: { + type: 'string', + optional: false, nullable: true, + }, description: { type: 'string', optional: false, nullable: true, @@ -585,6 +589,7 @@ export default class extends Endpoint { // eslint- logoImageUrl: instance.logoImageUrl, defaultLightTheme: instance.defaultLightTheme, defaultDarkTheme: instance.defaultDarkTheme, + defaultClientSettingOverrides: instance.defaultClientSettingOverrides, enableEmail: instance.enableEmail, enableServiceWorker: instance.enableServiceWorker, translatorAvailable: instance.deeplAuthKey != null, diff --git a/packages/backend/src/server/api/endpoints/admin/update-meta.ts b/packages/backend/src/server/api/endpoints/admin/update-meta.ts index 38ef0d1de8..16d37a1d68 100644 --- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -67,6 +67,7 @@ export const paramDef = { description: { type: 'string', nullable: true }, defaultLightTheme: { type: 'string', nullable: true }, defaultDarkTheme: { type: 'string', nullable: true }, + defaultClientSettingOverrides: { type: 'string', nullable: true }, cacheRemoteFiles: { type: 'boolean' }, cacheRemoteSensitiveFiles: { type: 'boolean' }, emailRequiredForSignup: { type: 'boolean' }, @@ -303,6 +304,10 @@ export default class extends Endpoint { // eslint- set.defaultDarkTheme = ps.defaultDarkTheme; } + if (ps.defaultClientSettingOverrides !== undefined) { + set.defaultClientSettingOverrides = ps.defaultClientSettingOverrides; + } + if (ps.cacheRemoteFiles !== undefined) { set.cacheRemoteFiles = ps.cacheRemoteFiles; } diff --git a/packages/frontend/src/boot/common.ts b/packages/frontend/src/boot/common.ts index 90ae49ee59..0f2824a180 100644 --- a/packages/frontend/src/boot/common.ts +++ b/packages/frontend/src/boot/common.ts @@ -14,7 +14,7 @@ import { isDeviceDarkmode } from '@/scripts/is-device-darkmode.js'; import { updateI18n, i18n } from '@/i18n.js'; import { $i, refreshAccount, login } from '@/account.js'; import { defaultStore, ColdDeviceStorage } from '@/store.js'; -import { fetchInstance, instance } from '@/instance.js'; +import { initInstance, instance } from '@/instance.js'; import { deviceKind } from '@/scripts/device-kind.js'; import { reloadChannel } from '@/scripts/unison-reload.js'; import { getUrlWithoutLoginId } from '@/scripts/login-id.js'; @@ -115,15 +115,12 @@ export async function common(createVue: () => App) { const html = document.documentElement; html.setAttribute('lang', lang); //#endregion - + + await initInstance(); await defaultStore.ready; await deckStore.ready; - const fetchInstanceMetaPromise = fetchInstance(); - - fetchInstanceMetaPromise.then(() => { - miLocalStorage.setItem('v', instance.version); - }); + miLocalStorage.setItem('v', instance.version); //#region loginId const params = new URLSearchParams(location.search); @@ -177,13 +174,11 @@ export async function common(createVue: () => App) { }); //#endregion - fetchInstanceMetaPromise.then(() => { - if (defaultStore.state.themeInitial) { - if (instance.defaultLightTheme != null) ColdDeviceStorage.set('lightTheme', JSON.parse(instance.defaultLightTheme)); - if (instance.defaultDarkTheme != null) ColdDeviceStorage.set('darkTheme', JSON.parse(instance.defaultDarkTheme)); - defaultStore.set('themeInitial', false); - } - }); + if (defaultStore.state.themeInitial) { + if (instance.defaultLightTheme != null) ColdDeviceStorage.set('lightTheme', JSON.parse(instance.defaultLightTheme)); + if (instance.defaultDarkTheme != null) ColdDeviceStorage.set('darkTheme', JSON.parse(instance.defaultDarkTheme)); + defaultStore.set('themeInitial', false); + } watch(defaultStore.reactiveState.useBlurEffectForModal, v => { document.documentElement.style.setProperty('--MI-modalBgFilter', v ? 'blur(4px)' : 'none'); diff --git a/packages/frontend/src/instance.ts b/packages/frontend/src/instance.ts index 71cb42b30c..9717c08f4d 100644 --- a/packages/frontend/src/instance.ts +++ b/packages/frontend/src/instance.ts @@ -16,7 +16,7 @@ const providedMetaEl = document.getElementById('misskey_meta'); let cachedMeta = miLocalStorage.getItem('instance') ? JSON.parse(miLocalStorage.getItem('instance')!) : null; let cachedAt = miLocalStorage.getItem('instanceCachedAt') ? parseInt(miLocalStorage.getItem('instanceCachedAt')!) : 0; -const providedMeta = providedMetaEl && providedMetaEl.textContent ? JSON.parse(providedMetaEl.textContent) : null; +const providedMeta: Misskey.entities.MetaDetailed | null = providedMetaEl && providedMetaEl.textContent ? JSON.parse(providedMetaEl.textContent) : null; const providedAt = providedMetaEl && providedMetaEl.dataset.generatedAt ? parseInt(providedMetaEl.dataset.generatedAt) : 0; if (providedAt > cachedAt) { miLocalStorage.setItem('instance', JSON.stringify(providedMeta)); @@ -38,6 +38,19 @@ export const notFoundImageUrl = computed(() => instance.notFoundImageUrl ?? DEFA export const isEnabledUrlPreview = computed(() => instance.enableUrlPreview ?? true); +/** instanceの中身が入っていることを保証する */ +export async function initInstance() { + if (instance == null || Object.keys(instance).length === 0) { + if (providedMeta != null) { + for (const [k, v] of Object.entries(providedMeta)) { + instance[k] = v; + } + } else { + await fetchInstance(true); + } + } +} + export async function fetchInstance(force = false): Promise { if (!force) { const cachedAt = miLocalStorage.getItem('instanceCachedAt') ? parseInt(miLocalStorage.getItem('instanceCachedAt')!) : 0; @@ -60,3 +73,9 @@ export async function fetchInstance(force = false): Promise + + + + + + diff --git a/packages/frontend/src/pages/admin/settings.vue b/packages/frontend/src/pages/admin/settings.vue index ea7603a45a..689b7aa165 100644 --- a/packages/frontend/src/pages/admin/settings.vue +++ b/packages/frontend/src/pages/admin/settings.vue @@ -249,6 +249,8 @@ SPDX-License-Identifier: AGPL-3.0-only {{ i18n.ts.selectAccount }} + + {{ i18n.ts.clientSettingOverrides }} {{ i18n.ts.beta }} @@ -274,6 +276,7 @@ import MkKeyValue from '@/components/MkKeyValue.vue'; import { useForm } from '@/scripts/use-form.js'; import MkFormFooter from '@/components/MkFormFooter.vue'; import MkRadios from '@/components/MkRadios.vue'; +import FormLink from '@/components/form/link.vue'; const meta = await misskeyApi('admin/meta'); diff --git a/packages/frontend/src/pizzax.ts b/packages/frontend/src/pizzax.ts index ac325e923f..b54203dc9d 100644 --- a/packages/frontend/src/pizzax.ts +++ b/packages/frontend/src/pizzax.ts @@ -13,7 +13,7 @@ import { get, set } from '@/scripts/idb-proxy.js'; import { defaultStore } from '@/store.js'; import { useStream } from '@/stream.js'; import { deepClone } from '@/scripts/clone.js'; -import { deepMerge } from '@/scripts/merge.js'; +import { deepMerge, type DeepPartial } from '@/scripts/merge.js'; type StateDef = Record { public readonly def: T; // TODO: これが実装されたらreadonlyにしたい: https://github.com/microsoft/TypeScript/issues/37487 + private readonly defaultState: State; public readonly state: State; public readonly reactiveState: ReactiveState; @@ -60,7 +61,7 @@ export class Storage { return promise; } - constructor(key: string, def: T) { + constructor(key: string, def: T, defaultOverrides?: DeepPartial>) { this.key = key; this.deviceStateKeyName = `pizzax::${key}`; this.deviceAccountStateKeyName = $i ? `pizzax::${key}::${$i.id}` : ''; @@ -69,25 +70,43 @@ export class Storage { this.pizzaxChannel = new BroadcastChannel(`pizzax::${key}`); + this.defaultState = {} as State; 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); + let _defaultState = v.default; + if ( + defaultOverrides != null && + this.isPureObject(defaultOverrides) && + defaultOverrides[k] !== undefined // ←意図的にnullになっている可能性があるためundefined判定 + ) { + if (this.isPureObject(defaultOverrides[k]) && this.isPureObject(v.default)) { + _defaultState = deepMerge(defaultOverrides[k], v.default); + } else if (Array.isArray(defaultOverrides[k]) && Array.isArray(v.default)) { + _defaultState = Array.from(new Set([...defaultOverrides[k], ...v.default])); + } else { + _defaultState = defaultOverrides[k]; + } + if (_DEV_) console.log('defaultState', k, _defaultState); + } + + this.defaultState[k] = _defaultState; + this.state[k] = _defaultState; + this.reactiveState[k] = ref(_defaultState); } this.ready = this.init(); this.loaded = this.ready.then(() => this.load()); } - private isPureObject(value: unknown): value is Record { + private isPureObject(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } private mergeState(value: X, def: X): X { if (this.isPureObject(value) && this.isPureObject(def)) { - const merged = deepMerge(value, def); + const merged = deepMerge(value as DeepPartial, def); if (_DEV_) console.log('Merging state. Incoming: ', value, ' Default: ', def, ' Result: ', merged); @@ -105,14 +124,14 @@ export class Storage { 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)) { - this.reactiveState[k].value = this.state[k] = this.mergeState(deviceState[k], v.default); + this.reactiveState[k].value = this.state[k] = this.mergeState(deviceState[k], this.defaultState[k]); } else if (v.where === 'account' && $i && Object.prototype.hasOwnProperty.call(registryCache, k)) { - this.reactiveState[k].value = this.state[k] = this.mergeState(registryCache[k], v.default); + this.reactiveState[k].value = this.state[k] = this.mergeState(registryCache[k], this.defaultState[k]); } else if (v.where === 'deviceAccount' && Object.prototype.hasOwnProperty.call(deviceAccountState, k)) { - this.reactiveState[k].value = this.state[k] = this.mergeState(deviceAccountState[k], v.default); + this.reactiveState[k].value = this.state[k] = this.mergeState(deviceAccountState[k], this.defaultState[k]); } else { - this.reactiveState[k].value = this.state[k] = v.default; - if (_DEV_) console.log('Use default value', k, v.default); + this.reactiveState[k].value = this.state[k] = this.defaultState[k]; + if (_DEV_) console.log('Use default value', k, this.defaultState[k]); } } diff --git a/packages/frontend/src/router/definition.ts b/packages/frontend/src/router/definition.ts index e98e0b59b1..ea2cfdc6b3 100644 --- a/packages/frontend/src/router/definition.ts +++ b/packages/frontend/src/router/definition.ts @@ -486,6 +486,10 @@ const routes: RouteDef[] = [{ path: '/system-webhook', name: 'system-webhook', component: page(() => import('@/pages/admin/system-webhook.vue')), + }, { + path: '/client-setting-overrides', + name: 'client-setting-overrides', + component: page(() => import('@/pages/admin/client-setting-overrides.vue')), }, { path: '/', component: page(() => import('@/pages/_empty_.vue')), diff --git a/packages/frontend/src/scripts/merge.ts b/packages/frontend/src/scripts/merge.ts index 9794a300da..004b6d42a4 100644 --- a/packages/frontend/src/scripts/merge.ts +++ b/packages/frontend/src/scripts/merge.ts @@ -7,10 +7,10 @@ import { deepClone } from './clone.js'; import type { Cloneable } from './clone.js'; export type DeepPartial = { - [P in keyof T]?: T[P] extends Record ? DeepPartial : T[P]; + [P in keyof T]?: T[P] extends Record ? DeepPartial : T[P]; }; -function isPureObject(value: unknown): value is Record { +function isPureObject(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } @@ -18,14 +18,14 @@ function isPureObject(value: unknown): value is Record>(value: DeepPartial, def: X): X { +export function deepMerge>(value: DeepPartial, def: X): X { if (isPureObject(value) && isPureObject(def)) { const result = deepClone(value as Cloneable) as X; for (const [k, v] of Object.entries(def) as [keyof X, X[keyof X]][]) { if (!Object.prototype.hasOwnProperty.call(value, k) || value[k] === undefined) { result[k] = v; } else if (isPureObject(v) && isPureObject(result[k])) { - const child = deepClone(result[k] as Cloneable) as DeepPartial>; + const child = deepClone(result[k] as Cloneable) as DeepPartial>; result[k] = deepMerge(child, v); } } diff --git a/packages/frontend/src/scripts/reload-ask.ts b/packages/frontend/src/scripts/reload-ask.ts index 733d91b85a..0e82cb06d8 100644 --- a/packages/frontend/src/scripts/reload-ask.ts +++ b/packages/frontend/src/scripts/reload-ask.ts @@ -12,7 +12,7 @@ let isReloadConfirming = false; export async function reloadAsk(opts: { unison?: boolean; reason?: string; -}) { +} = {}) { if (isReloadConfirming) { return; } diff --git a/packages/frontend/src/scripts/store-overrides.ts b/packages/frontend/src/scripts/store-overrides.ts new file mode 100644 index 0000000000..9dcba56e48 --- /dev/null +++ b/packages/frontend/src/scripts/store-overrides.ts @@ -0,0 +1,33 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { initInstance, instance } from '@/instance.js'; + +export async function getDefaultStoreOverrides() { + await initInstance(); + if (instance.defaultClientSettingOverrides != null) { + try { + const clientSettingOverrides = JSON.parse(instance.defaultClientSettingOverrides); + const out = Object.fromEntries(Object.keys(clientSettingOverrides).filter(key => key.startsWith('defaultStore::')).map(key => [key.split('::')[1], clientSettingOverrides[key]])); + return out; + } catch (err) { + return null; + } + } + return null; +} + +export function getColdDeviceStorageOverrides() { + if (instance.defaultClientSettingOverrides != null) { + try { + const clientSettingOverrides = JSON.parse(instance.defaultClientSettingOverrides); + const out = Object.fromEntries(Object.keys(clientSettingOverrides).filter(key => key.startsWith('ColdDeviceStorage::')).map(key => [key.split('::')[1], clientSettingOverrides[key]])); + return out; + } catch (err) { + return null; + } + } + return null; +} diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts index 911a463636..78328992ab 100644 --- a/packages/frontend/src/store.ts +++ b/packages/frontend/src/store.ts @@ -12,6 +12,7 @@ import { miLocalStorage } from './local-storage.js'; import type { SoundType } from '@/scripts/sound.js'; import { Storage } from '@/pizzax.js'; import type { Ast } from '@syuilo/aiscript'; +import { getColdDeviceStorageOverrides, getDefaultStoreOverrides } from '@/scripts/store-overrides.js'; interface PostFormAction { title: string, @@ -502,7 +503,7 @@ export const defaultStore = markRaw(new Storage('base', { where: 'device', default: { type: 'syuilo/bubble2', volume: 1 } as SoundStore, }, -})); +}, await getDefaultStoreOverrides() ?? undefined)); // TODO: 他のタブと永続化されたstateを同期 @@ -548,7 +549,8 @@ export class ColdDeviceStorage { // (indexedDBはnullを保存できるため、ユーザーが意図してnullを格納した可能性がある) const value = miLocalStorage.getItem(`${PREFIX}${key}`); if (value == null) { - return ColdDeviceStorage.default[key]; + const override = getColdDeviceStorageOverrides(); + return override != null ? override[key] ?? ColdDeviceStorage.default[key] : ColdDeviceStorage.default[key]; } else { return JSON.parse(value); } diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index a5333d4f93..48da4cec98 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -5035,6 +5035,7 @@ export type components = { /** @default true */ miauth?: boolean; }; + defaultClientSettingOverrides: string | null; proxyAccountName: string | null; /** @example false */ requireSetup: boolean; @@ -5186,6 +5187,7 @@ export type operations = { deeplIsPro: boolean; defaultDarkTheme: string | null; defaultLightTheme: string | null; + defaultClientSettingOverrides: string | null; description: string | null; disableRegistration: boolean; impressumUrl: string | null; @@ -9496,6 +9498,7 @@ export type operations = { description?: string | null; defaultLightTheme?: string | null; defaultDarkTheme?: string | null; + defaultClientSettingOverrides?: string | null; cacheRemoteFiles?: boolean; cacheRemoteSensitiveFiles?: boolean; emailRequiredForSignup?: boolean;