enhance: クライアント設定の初期値を変更できるように(簡易)

This commit is contained in:
kakkokari-gtyih 2024-11-03 01:32:37 +09:00
parent 224bbd486f
commit 1005d17313
19 changed files with 440 additions and 33 deletions

25
locales/index.d.ts vendored
View File

@ -5218,6 +5218,31 @@ export interface Locale extends ILocale {
* *
*/ */
"availableRoles": string; "availableRoles": string;
/**
*
*/
"clientSettingOverrides": string;
/**
*
*
*/
"clientSettingOverridesWarn": string;
/**
*
*/
"enableOverride": string;
/**
*
*/
"overrideValue": string;
/**
* trueとなります
*/
"onToTrue": string;
/**
*
*/
"reset": string;
"_accountSettings": { "_accountSettings": {
/** /**
* *

View File

@ -1300,6 +1300,12 @@ thisContentsAreMarkedAsSigninRequiredByAuthor: "投稿者により、表示に
lockdown: "ロックダウン" lockdown: "ロックダウン"
pleaseSelectAccount: "アカウントを選択してください" pleaseSelectAccount: "アカウントを選択してください"
availableRoles: "利用可能なロール" availableRoles: "利用可能なロール"
clientSettingOverrides: "クライアント設定の初期値"
clientSettingOverridesWarn: "全ユーザーに対するクライアント設定の初期値を変更できます。\n知識のない方が変更すると全ユーザーがクライアントにアクセスできなくなる可能性があります。"
enableOverride: "オーバーライドする"
overrideValue: "オーバーライドする値"
onToTrue: "スイッチをオンにするとtrueとなります。"
reset: "リセット"
_accountSettings: _accountSettings:
requireSigninToViewContents: "コンテンツの表示にログインを必須にする" requireSigninToViewContents: "コンテンツの表示にログインを必須にする"

View File

@ -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"`);
}
}

View File

@ -151,6 +151,7 @@ export class MetaEntityService {
const packDetailed: Packed<'MetaDetailed'> = { const packDetailed: Packed<'MetaDetailed'> = {
...packed, ...packed,
defaultClientSettingOverrides: instance.defaultClientSettingOverrides,
cacheRemoteFiles: instance.cacheRemoteFiles, cacheRemoteFiles: instance.cacheRemoteFiles,
cacheRemoteSensitiveFiles: instance.cacheRemoteSensitiveFiles, cacheRemoteSensitiveFiles: instance.cacheRemoteSensitiveFiles,
requireSetup: !await this.instanceActorService.realLocalUsersPresent(), requireSetup: !await this.instanceActorService.realLocalUsersPresent(),

View File

@ -409,6 +409,12 @@ export class MiMeta {
}) })
public defaultDarkTheme: string | null; public defaultDarkTheme: string | null;
@Column('varchar', {
length: 8192,
nullable: true,
})
public defaultClientSettingOverrides: string | null;
@Column('boolean', { @Column('boolean', {
default: false, default: false,
}) })

View File

@ -315,6 +315,10 @@ export const packedMetaDetailedOnlySchema = {
}, },
}, },
}, },
defaultClientSettingOverrides: {
type: 'string',
optional: false, nullable: true,
},
proxyAccountName: { proxyAccountName: {
type: 'string', type: 'string',
optional: false, nullable: true, optional: false, nullable: true,

View File

@ -420,6 +420,10 @@ export const meta = {
type: 'string', type: 'string',
optional: false, nullable: true, optional: false, nullable: true,
}, },
defaultClientSettingOverrides: {
type: 'string',
optional: false, nullable: true,
},
description: { description: {
type: 'string', type: 'string',
optional: false, nullable: true, optional: false, nullable: true,
@ -585,6 +589,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
logoImageUrl: instance.logoImageUrl, logoImageUrl: instance.logoImageUrl,
defaultLightTheme: instance.defaultLightTheme, defaultLightTheme: instance.defaultLightTheme,
defaultDarkTheme: instance.defaultDarkTheme, defaultDarkTheme: instance.defaultDarkTheme,
defaultClientSettingOverrides: instance.defaultClientSettingOverrides,
enableEmail: instance.enableEmail, enableEmail: instance.enableEmail,
enableServiceWorker: instance.enableServiceWorker, enableServiceWorker: instance.enableServiceWorker,
translatorAvailable: instance.deeplAuthKey != null, translatorAvailable: instance.deeplAuthKey != null,

View File

@ -67,6 +67,7 @@ export const paramDef = {
description: { type: 'string', nullable: true }, description: { type: 'string', nullable: true },
defaultLightTheme: { type: 'string', nullable: true }, defaultLightTheme: { type: 'string', nullable: true },
defaultDarkTheme: { type: 'string', nullable: true }, defaultDarkTheme: { type: 'string', nullable: true },
defaultClientSettingOverrides: { type: 'string', nullable: true },
cacheRemoteFiles: { type: 'boolean' }, cacheRemoteFiles: { type: 'boolean' },
cacheRemoteSensitiveFiles: { type: 'boolean' }, cacheRemoteSensitiveFiles: { type: 'boolean' },
emailRequiredForSignup: { type: 'boolean' }, emailRequiredForSignup: { type: 'boolean' },
@ -303,6 +304,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
set.defaultDarkTheme = ps.defaultDarkTheme; set.defaultDarkTheme = ps.defaultDarkTheme;
} }
if (ps.defaultClientSettingOverrides !== undefined) {
set.defaultClientSettingOverrides = ps.defaultClientSettingOverrides;
}
if (ps.cacheRemoteFiles !== undefined) { if (ps.cacheRemoteFiles !== undefined) {
set.cacheRemoteFiles = ps.cacheRemoteFiles; set.cacheRemoteFiles = ps.cacheRemoteFiles;
} }

View File

@ -14,7 +14,7 @@ import { isDeviceDarkmode } from '@/scripts/is-device-darkmode.js';
import { updateI18n, i18n } from '@/i18n.js'; import { updateI18n, i18n } from '@/i18n.js';
import { $i, refreshAccount, login } from '@/account.js'; import { $i, refreshAccount, login } from '@/account.js';
import { defaultStore, ColdDeviceStorage } from '@/store.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 { deviceKind } from '@/scripts/device-kind.js';
import { reloadChannel } from '@/scripts/unison-reload.js'; import { reloadChannel } from '@/scripts/unison-reload.js';
import { getUrlWithoutLoginId } from '@/scripts/login-id.js'; import { getUrlWithoutLoginId } from '@/scripts/login-id.js';
@ -116,14 +116,11 @@ export async function common(createVue: () => App<Element>) {
html.setAttribute('lang', lang); html.setAttribute('lang', lang);
//#endregion //#endregion
await initInstance();
await defaultStore.ready; await defaultStore.ready;
await deckStore.ready; await deckStore.ready;
const fetchInstanceMetaPromise = fetchInstance(); miLocalStorage.setItem('v', instance.version);
fetchInstanceMetaPromise.then(() => {
miLocalStorage.setItem('v', instance.version);
});
//#region loginId //#region loginId
const params = new URLSearchParams(location.search); const params = new URLSearchParams(location.search);
@ -177,13 +174,11 @@ export async function common(createVue: () => App<Element>) {
}); });
//#endregion //#endregion
fetchInstanceMetaPromise.then(() => { if (defaultStore.state.themeInitial) {
if (defaultStore.state.themeInitial) { if (instance.defaultLightTheme != null) ColdDeviceStorage.set('lightTheme', JSON.parse(instance.defaultLightTheme));
if (instance.defaultLightTheme != null) ColdDeviceStorage.set('lightTheme', JSON.parse(instance.defaultLightTheme)); if (instance.defaultDarkTheme != null) ColdDeviceStorage.set('darkTheme', JSON.parse(instance.defaultDarkTheme));
if (instance.defaultDarkTheme != null) ColdDeviceStorage.set('darkTheme', JSON.parse(instance.defaultDarkTheme)); defaultStore.set('themeInitial', false);
defaultStore.set('themeInitial', false); }
}
});
watch(defaultStore.reactiveState.useBlurEffectForModal, v => { watch(defaultStore.reactiveState.useBlurEffectForModal, v => {
document.documentElement.style.setProperty('--MI-modalBgFilter', v ? 'blur(4px)' : 'none'); document.documentElement.style.setProperty('--MI-modalBgFilter', v ? 'blur(4px)' : 'none');

View File

@ -16,7 +16,7 @@ const providedMetaEl = document.getElementById('misskey_meta');
let cachedMeta = miLocalStorage.getItem('instance') ? JSON.parse(miLocalStorage.getItem('instance')!) : null; let cachedMeta = miLocalStorage.getItem('instance') ? JSON.parse(miLocalStorage.getItem('instance')!) : null;
let cachedAt = miLocalStorage.getItem('instanceCachedAt') ? parseInt(miLocalStorage.getItem('instanceCachedAt')!) : 0; 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; const providedAt = providedMetaEl && providedMetaEl.dataset.generatedAt ? parseInt(providedMetaEl.dataset.generatedAt) : 0;
if (providedAt > cachedAt) { if (providedAt > cachedAt) {
miLocalStorage.setItem('instance', JSON.stringify(providedMeta)); miLocalStorage.setItem('instance', JSON.stringify(providedMeta));
@ -38,6 +38,19 @@ export const notFoundImageUrl = computed(() => instance.notFoundImageUrl ?? DEFA
export const isEnabledUrlPreview = computed(() => instance.enableUrlPreview ?? true); 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<Misskey.entities.MetaDetailed> { export async function fetchInstance(force = false): Promise<Misskey.entities.MetaDetailed> {
if (!force) { if (!force) {
const cachedAt = miLocalStorage.getItem('instanceCachedAt') ? parseInt(miLocalStorage.getItem('instanceCachedAt')!) : 0; const cachedAt = miLocalStorage.getItem('instanceCachedAt') ? parseInt(miLocalStorage.getItem('instanceCachedAt')!) : 0;
@ -60,3 +73,9 @@ export async function fetchInstance(force = false): Promise<Misskey.entities.Met
return instance; return instance;
} }
/** キャッシュだけ飛ばす(リロード後からは新しい設定を読み込む) */
export function pruneInstanceCache() {
miLocalStorage.removeItem('instance');
miLocalStorage.removeItem('instanceCachedAt');
}

View File

@ -0,0 +1,261 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div>
<MkStickyContainer>
<template #header><XHeader :tabs="headerTabs"/></template>
<MkSpacer :contentMax="700" :marginMin="16" :marginMax="32">
<div class="_gaps">
<MkInfo warn :class="$style.warn">{{ i18n.ts.clientSettingOverridesWarn }}</MkInfo>
<div v-if="fetching">
<MkLoading/>
</div>
<div v-else class="_gaps_s">
<MkInput v-model="query" type="search">
<template #prefix><i class="ti ti-search"></i></template>
</MkInput>
<MkFolder
v-for="def, key in clientSettingOverrides"
:key="key"
v-show="query === '' || key.toLowerCase().includes(query.toLowerCase())"
>
<template #label>{{ key }}</template>
<template #suffix>
<span v-if="def.enableOverride && def.overrideValue != null && def.overrideValue !== def.defaultValue" class="_warn">{{ i18n.ts.modified }}</span>
</template>
<div class="_gaps">
<MkKeyValue>
<template #key>{{ i18n.ts.default }}</template>
<template #value>
<MkCode v-bind="getMkCodeProps(def)"></MkCode>
</template>
</MkKeyValue>
<MkSwitch v-model="def.enableOverride">{{ i18n.ts.enableOverride }}</MkSwitch>
<MkInput v-if="def.formType === 'text'" v-model="def.overrideValue" :disabled="!def.enableOverride" type="text">
<template #label>{{ i18n.ts.overrideValue }}</template>
</MkInput>
<MkInput v-else-if="def.formType === 'number'" v-model="def.overrideValue" :disabled="!def.enableOverride" type="number">
<template #label>{{ i18n.ts.overrideValue }}</template>
</MkInput>
<MkSwitch v-else-if="def.formType === 'boolean'" v-model="def.overrideValue" :disabled="!def.enableOverride">
<template #label>{{ i18n.ts.overrideValue }}</template>
<template #caption>{{ i18n.ts.onToTrue }}</template>
</MkSwitch>
<MkTextarea v-else-if="def.formType === 'codeEditor'" v-model="def.overrideValue" :disabled="!def.enableOverride" pre>
<template #label>{{ i18n.ts.overrideValue }}</template>
</MkTextarea>
</div>
</MkFolder>
</div>
</div>
</MkSpacer>
<template #footer>
<div :class="$style.footer">
<div :class="$style.footerInner">
<div class="_buttons">
<MkButton primary @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton>
<MkButton danger @click="reset"><i class="ti ti-trash"></i> {{ i18n.ts.reset }}</MkButton>
</div>
</div>
</div>
</template>
</MkStickyContainer>
</div>
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue';
import XHeader from './_header_.vue';
import * as os from '@/os.js';
import { ColdDeviceStorage, defaultStore } from '@/store.js';
import { pruneInstanceCache } from '@/instance.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import MkInfo from '@/components/MkInfo.vue';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
import MkFolder from '@/components/MkFolder.vue';
import MkKeyValue from '@/components/MkKeyValue.vue';
import MkCode from '@/components/MkCode.vue';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { reloadAsk } from '@/scripts/reload-ask.js';
import MkSwitch from '@/components/MkSwitch.vue';
import MkTextarea from '@/components/MkTextarea.vue';
const query = ref('');
const notConfigurableDefaultStoreSettings = [
'accountSetupWizard',
'timelineTutorials',
'abusesTutorial',
'memo',
'mutedAds',
'statusbars',
'widgets',
'pinnedUserLists',
'recentlyUsedEmojis',
'recentlyUsedUsers',
'forceShowAds',
'additionalUnicodeEmojiIndexes',
'themeInitial',
] satisfies (keyof typeof defaultStore.def)[];
const notConfigurableColdDeviceStorageSettings = [
'darkTheme',
'lightTheme',
'plugins',
] satisfies (keyof typeof ColdDeviceStorage.default)[];
type ClientSettingOverridesUIDefObj = {
formType: 'text' | 'number' | 'boolean' | 'codeEditor';
enableOverride: boolean;
defaultValue: any;
overrideValue?: any;
}
const fetching = ref(true);
const clientSettingOverrides = ref<Record<string, ClientSettingOverridesUIDefObj>>();
function getMkCodeProps(def: ClientSettingOverridesUIDefObj) {
if (typeof def.defaultValue === 'string') {
return {
code: def.defaultValue,
forceShow: true,
};
} else {
return {
code: JSON.stringify(def.defaultValue, null, 4),
lang: 'json',
forceShow: true,
};
}
}
function typeSafeObjectEntries<T extends Record<string, any>>(obj: T) {
return Object.entries(obj) as [keyof T, T[keyof T]][];
}
function getClientSettingOverridesUIDefObj(def: unknown): ClientSettingOverridesUIDefObj {
return {
formType: (() => {
if (typeof def === 'boolean') {
return 'boolean';
} else if (typeof def === 'number') {
return 'number';
} else if (typeof def === 'object') {
return 'codeEditor';
} else {
return 'text';
}
})() satisfies ClientSettingOverridesUIDefObj['formType'] as ClientSettingOverridesUIDefObj['formType'],
enableOverride: false,
defaultValue: def,
overrideValue: def,
};
}
async function fetch() {
fetching.value = true;
const overrideDefs = Object.fromEntries([
...typeSafeObjectEntries(defaultStore.def)
.filter(([key, _]) => !(notConfigurableDefaultStoreSettings as string[]).includes(key))
.map(([key, def]) => [`defaultStore::${key}`, getClientSettingOverridesUIDefObj(def.default)]),
...typeSafeObjectEntries(ColdDeviceStorage.default)
.filter(([key, _]) => !(notConfigurableColdDeviceStorageSettings as string[]).includes(key))
.map(([key, def]) => [`ColdDeviceStorage::${key}`, getClientSettingOverridesUIDefObj(def)]),
]);
const res = await misskeyApi('admin/meta');
if (res.defaultClientSettingOverrides != null) {
try {
const parsed = JSON.parse(res.defaultClientSettingOverrides);
for (const key in parsed) {
if (key in overrideDefs) {
overrideDefs[key].enableOverride = true;
overrideDefs[key].overrideValue = parsed[key];
}
}
} catch (e) {
console.error(e);
}
}
clientSettingOverrides.value = overrideDefs;
fetching.value = false;
}
async function save() {
if (clientSettingOverrides.value == null) return;
const overrides = Object.fromEntries(
typeSafeObjectEntries(clientSettingOverrides.value)
.filter(([key, def]) => (
def.enableOverride &&
def.overrideValue !== def.defaultValue &&
JSON.stringify(def.overrideValue) !== JSON.stringify(def.defaultValue)
))
.map(([key, def]) => [key, def.overrideValue])
);
let defaultClientSettingOverrides: string | null = JSON.stringify(overrides);
if (Object.keys(overrides).length === 0) {
defaultClientSettingOverrides = null;
}
await os.apiWithDialog('admin/update-meta', {
defaultClientSettingOverrides,
});
await fetch();
pruneInstanceCache();
await reloadAsk({ reason: i18n.ts.reloadToApplySetting });
}
async function reset() {
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.ts.resetAreYouSure,
});
if (canceled) return;
await os.apiWithDialog('admin/update-meta', {
defaultClientSettingOverrides: null,
});
await fetch();
}
fetch();
const headerActions = computed(() => []);
const headerTabs = computed(() => []);
definePageMetadata(() => ({
title: i18n.ts.clientSettingOverrides,
icon: 'ti ti-checkbox',
}));
</script>
<style lang="scss" module>
.warn {
white-space: pre-wrap;
}
.footer {
backdrop-filter: var(--MI-blur, blur(15px));
background: var(--MI_THEME-acrylicBg);
border-top: solid .5px var(--MI_THEME-divider);
}
.footerInner {
max-width: 700px;
margin: 0 auto;
padding: 16px;
}
</style>

View File

@ -249,6 +249,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkButton primary @click="chooseProxyAccount">{{ i18n.ts.selectAccount }}</MkButton> <MkButton primary @click="chooseProxyAccount">{{ i18n.ts.selectAccount }}</MkButton>
</div> </div>
</MkFolder> </MkFolder>
<FormLink to="/admin/client-setting-overrides">{{ i18n.ts.clientSettingOverrides }} <span class="_beta">{{ i18n.ts.beta }}</span></FormLink>
</div> </div>
</MkSpacer> </MkSpacer>
</MkStickyContainer> </MkStickyContainer>
@ -274,6 +276,7 @@ import MkKeyValue from '@/components/MkKeyValue.vue';
import { useForm } from '@/scripts/use-form.js'; import { useForm } from '@/scripts/use-form.js';
import MkFormFooter from '@/components/MkFormFooter.vue'; import MkFormFooter from '@/components/MkFormFooter.vue';
import MkRadios from '@/components/MkRadios.vue'; import MkRadios from '@/components/MkRadios.vue';
import FormLink from '@/components/form/link.vue';
const meta = await misskeyApi('admin/meta'); const meta = await misskeyApi('admin/meta');

View File

@ -13,7 +13,7 @@ import { get, set } from '@/scripts/idb-proxy.js';
import { defaultStore } from '@/store.js'; import { defaultStore } from '@/store.js';
import { useStream } from '@/stream.js'; import { useStream } from '@/stream.js';
import { deepClone } from '@/scripts/clone.js'; import { deepClone } from '@/scripts/clone.js';
import { deepMerge } from '@/scripts/merge.js'; import { deepMerge, type DeepPartial } from '@/scripts/merge.js';
type StateDef = Record<string, { type StateDef = Record<string, {
where: 'account' | 'device' | 'deviceAccount'; where: 'account' | 'device' | 'deviceAccount';
@ -44,6 +44,7 @@ export class Storage<T extends StateDef> {
public readonly def: T; public readonly def: T;
// TODO: これが実装されたらreadonlyにしたい: https://github.com/microsoft/TypeScript/issues/37487 // TODO: これが実装されたらreadonlyにしたい: https://github.com/microsoft/TypeScript/issues/37487
private readonly defaultState: State<T>;
public readonly state: State<T>; public readonly state: State<T>;
public readonly reactiveState: ReactiveState<T>; public readonly reactiveState: ReactiveState<T>;
@ -60,7 +61,7 @@ export class Storage<T extends StateDef> {
return promise; return promise;
} }
constructor(key: string, def: T) { constructor(key: string, def: T, defaultOverrides?: DeepPartial<State<T>>) {
this.key = key; this.key = key;
this.deviceStateKeyName = `pizzax::${key}`; this.deviceStateKeyName = `pizzax::${key}`;
this.deviceAccountStateKeyName = $i ? `pizzax::${key}::${$i.id}` : ''; this.deviceAccountStateKeyName = $i ? `pizzax::${key}::${$i.id}` : '';
@ -69,25 +70,43 @@ export class Storage<T extends StateDef> {
this.pizzaxChannel = new BroadcastChannel(`pizzax::${key}`); this.pizzaxChannel = new BroadcastChannel(`pizzax::${key}`);
this.defaultState = {} as State<T>;
this.state = {} as State<T>; this.state = {} as State<T>;
this.reactiveState = {} as ReactiveState<T>; this.reactiveState = {} as ReactiveState<T>;
for (const [k, v] of Object.entries(def) as [keyof T, T[keyof T]['default']][]) { for (const [k, v] of Object.entries(def) as [keyof T, T[keyof T]['default']][]) {
this.state[k] = v.default; let _defaultState = v.default;
this.reactiveState[k] = ref(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.ready = this.init();
this.loaded = this.ready.then(() => this.load()); this.loaded = this.ready.then(() => this.load());
} }
private isPureObject(value: unknown): value is Record<string | number | symbol, unknown> { private isPureObject(value: unknown): value is Record<PropertyKey, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value); return typeof value === 'object' && value !== null && !Array.isArray(value);
} }
private mergeState<X>(value: X, def: X): X { private mergeState<X>(value: X, def: X): X {
if (this.isPureObject(value) && this.isPureObject(def)) { if (this.isPureObject(value) && this.isPureObject(def)) {
const merged = deepMerge(value, def); const merged = deepMerge(value as DeepPartial<X>, def);
if (_DEV_) console.log('Merging state. Incoming: ', value, ' Default: ', def, ' Result: ', merged); if (_DEV_) console.log('Merging state. Incoming: ', value, ' Default: ', def, ' Result: ', merged);
@ -105,14 +124,14 @@ export class Storage<T extends StateDef> {
for (const [k, v] of Object.entries(this.def) as [keyof T, T[keyof T]['default']][]) { 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)) { if (v.where === 'device' && Object.prototype.hasOwnProperty.call(deviceState, k)) {
this.reactiveState[k].value = this.state[k] = this.mergeState<T[keyof T]['default']>(deviceState[k], v.default); this.reactiveState[k].value = this.state[k] = this.mergeState<T[keyof T]['default']>(deviceState[k], this.defaultState[k]);
} else if (v.where === 'account' && $i && Object.prototype.hasOwnProperty.call(registryCache, k)) { } else if (v.where === 'account' && $i && Object.prototype.hasOwnProperty.call(registryCache, k)) {
this.reactiveState[k].value = this.state[k] = this.mergeState<T[keyof T]['default']>(registryCache[k], v.default); this.reactiveState[k].value = this.state[k] = this.mergeState<T[keyof T]['default']>(registryCache[k], this.defaultState[k]);
} else if (v.where === 'deviceAccount' && Object.prototype.hasOwnProperty.call(deviceAccountState, k)) { } else if (v.where === 'deviceAccount' && Object.prototype.hasOwnProperty.call(deviceAccountState, k)) {
this.reactiveState[k].value = this.state[k] = this.mergeState<T[keyof T]['default']>(deviceAccountState[k], v.default); this.reactiveState[k].value = this.state[k] = this.mergeState<T[keyof T]['default']>(deviceAccountState[k], this.defaultState[k]);
} else { } else {
this.reactiveState[k].value = this.state[k] = v.default; this.reactiveState[k].value = this.state[k] = this.defaultState[k];
if (_DEV_) console.log('Use default value', k, v.default); if (_DEV_) console.log('Use default value', k, this.defaultState[k]);
} }
} }

View File

@ -486,6 +486,10 @@ const routes: RouteDef[] = [{
path: '/system-webhook', path: '/system-webhook',
name: 'system-webhook', name: 'system-webhook',
component: page(() => import('@/pages/admin/system-webhook.vue')), 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: '/', path: '/',
component: page(() => import('@/pages/_empty_.vue')), component: page(() => import('@/pages/_empty_.vue')),

View File

@ -7,10 +7,10 @@ import { deepClone } from './clone.js';
import type { Cloneable } from './clone.js'; import type { Cloneable } from './clone.js';
export type DeepPartial<T> = { export type DeepPartial<T> = {
[P in keyof T]?: T[P] extends Record<string | number | symbol, unknown> ? DeepPartial<T[P]> : T[P]; [P in keyof T]?: T[P] extends Record<PropertyKey, unknown> ? DeepPartial<T[P]> : T[P];
}; };
function isPureObject(value: unknown): value is Record<string | number | symbol, unknown> { function isPureObject(value: unknown): value is Record<PropertyKey, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value); return typeof value === 'object' && value !== null && !Array.isArray(value);
} }
@ -18,14 +18,14 @@ function isPureObject(value: unknown): value is Record<string | number | symbol,
* valueにないキーをdefからもらう\ * valueにないキーをdefからもらう\
* nullはそのままundefinedはdefの値 * nullはそのままundefinedはdefの値
**/ **/
export function deepMerge<X extends Record<string | number | symbol, unknown>>(value: DeepPartial<X>, def: X): X { export function deepMerge<X extends Record<PropertyKey, unknown>>(value: DeepPartial<X>, def: X): X {
if (isPureObject(value) && isPureObject(def)) { if (isPureObject(value) && isPureObject(def)) {
const result = deepClone(value as Cloneable) as X; const result = deepClone(value as Cloneable) as X;
for (const [k, v] of Object.entries(def) as [keyof X, X[keyof 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) { if (!Object.prototype.hasOwnProperty.call(value, k) || value[k] === undefined) {
result[k] = v; result[k] = v;
} else if (isPureObject(v) && isPureObject(result[k])) { } else if (isPureObject(v) && isPureObject(result[k])) {
const child = deepClone(result[k] as Cloneable) as DeepPartial<X[keyof X] & Record<string | number | symbol, unknown>>; const child = deepClone(result[k] as Cloneable) as DeepPartial<X[keyof X] & Record<PropertyKey, unknown>>;
result[k] = deepMerge<typeof v>(child, v); result[k] = deepMerge<typeof v>(child, v);
} }
} }

View File

@ -12,7 +12,7 @@ let isReloadConfirming = false;
export async function reloadAsk(opts: { export async function reloadAsk(opts: {
unison?: boolean; unison?: boolean;
reason?: string; reason?: string;
}) { } = {}) {
if (isReloadConfirming) { if (isReloadConfirming) {
return; return;
} }

View File

@ -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;
}

View File

@ -12,6 +12,7 @@ import { miLocalStorage } from './local-storage.js';
import type { SoundType } from '@/scripts/sound.js'; import type { SoundType } from '@/scripts/sound.js';
import { Storage } from '@/pizzax.js'; import { Storage } from '@/pizzax.js';
import type { Ast } from '@syuilo/aiscript'; import type { Ast } from '@syuilo/aiscript';
import { getColdDeviceStorageOverrides, getDefaultStoreOverrides } from '@/scripts/store-overrides.js';
interface PostFormAction { interface PostFormAction {
title: string, title: string,
@ -502,7 +503,7 @@ export const defaultStore = markRaw(new Storage('base', {
where: 'device', where: 'device',
default: { type: 'syuilo/bubble2', volume: 1 } as SoundStore, default: { type: 'syuilo/bubble2', volume: 1 } as SoundStore,
}, },
})); }, await getDefaultStoreOverrides() ?? undefined));
// TODO: 他のタブと永続化されたstateを同期 // TODO: 他のタブと永続化されたstateを同期
@ -548,7 +549,8 @@ export class ColdDeviceStorage {
// (indexedDBはnullを保存できるため、ユーザーが意図してnullを格納した可能性がある) // (indexedDBはnullを保存できるため、ユーザーが意図してnullを格納した可能性がある)
const value = miLocalStorage.getItem(`${PREFIX}${key}`); const value = miLocalStorage.getItem(`${PREFIX}${key}`);
if (value == null) { if (value == null) {
return ColdDeviceStorage.default[key]; const override = getColdDeviceStorageOverrides();
return override != null ? override[key] ?? ColdDeviceStorage.default[key] : ColdDeviceStorage.default[key];
} else { } else {
return JSON.parse(value); return JSON.parse(value);
} }

View File

@ -5035,6 +5035,7 @@ export type components = {
/** @default true */ /** @default true */
miauth?: boolean; miauth?: boolean;
}; };
defaultClientSettingOverrides: string | null;
proxyAccountName: string | null; proxyAccountName: string | null;
/** @example false */ /** @example false */
requireSetup: boolean; requireSetup: boolean;
@ -5186,6 +5187,7 @@ export type operations = {
deeplIsPro: boolean; deeplIsPro: boolean;
defaultDarkTheme: string | null; defaultDarkTheme: string | null;
defaultLightTheme: string | null; defaultLightTheme: string | null;
defaultClientSettingOverrides: string | null;
description: string | null; description: string | null;
disableRegistration: boolean; disableRegistration: boolean;
impressumUrl: string | null; impressumUrl: string | null;
@ -9496,6 +9498,7 @@ export type operations = {
description?: string | null; description?: string | null;
defaultLightTheme?: string | null; defaultLightTheme?: string | null;
defaultDarkTheme?: string | null; defaultDarkTheme?: string | null;
defaultClientSettingOverrides?: string | null;
cacheRemoteFiles?: boolean; cacheRemoteFiles?: boolean;
cacheRemoteSensitiveFiles?: boolean; cacheRemoteSensitiveFiles?: boolean;
emailRequiredForSignup?: boolean; emailRequiredForSignup?: boolean;