From 0bbde336b3636f4135de54c0ed75c7aa208534fe Mon Sep 17 00:00:00 2001
From: syuilo <Syuilotan@yahoo.co.jp>
Date: Sat, 8 Jan 2022 20:30:01 +0900
Subject: [PATCH] =?UTF-8?q?refactor:=20Widget=E3=81=AEcomposition=20api?=
 =?UTF-8?q?=E7=A7=BB=E8=A1=8C=20(#8125)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* wip

* wip

* wip

* wip

* wip

* wip

* fix
---
 packages/client/src/components/widgets.vue    |  79 ++++---
 packages/client/src/scripts/form.ts           |  30 ++-
 packages/client/src/widgets/activity.vue      | 131 +++++------
 packages/client/src/widgets/aichan.vue        |  94 ++++----
 packages/client/src/widgets/aiscript.vue      | 176 ++++++++-------
 packages/client/src/widgets/button.vue        | 151 +++++++------
 packages/client/src/widgets/calendar.vue      | 148 ++++++------
 packages/client/src/widgets/clock.vue         |  73 +++---
 packages/client/src/widgets/define.ts         |  75 -------
 packages/client/src/widgets/digital-clock.vue | 123 +++++-----
 packages/client/src/widgets/federation.vue    | 108 +++++----
 packages/client/src/widgets/job-queue.vue     | 188 ++++++++--------
 packages/client/src/widgets/memo.vue          |  98 ++++----
 packages/client/src/widgets/notifications.vue |  95 ++++----
 packages/client/src/widgets/online-users.vue  |  80 ++++---
 packages/client/src/widgets/photos.vue        | 125 ++++++-----
 packages/client/src/widgets/post-form.vue     |  38 ++--
 packages/client/src/widgets/rss.vue           | 107 +++++----
 .../src/widgets/server-metric/index.vue       | 123 +++++-----
 packages/client/src/widgets/slideshow.vue     | 210 +++++++++---------
 packages/client/src/widgets/timeline.vue      | 201 +++++++++--------
 packages/client/src/widgets/trends.vue        |  86 +++----
 packages/client/src/widgets/widget.ts         |  71 ++++++
 23 files changed, 1389 insertions(+), 1221 deletions(-)
 delete mode 100644 packages/client/src/widgets/define.ts
 create mode 100644 packages/client/src/widgets/widget.ts

diff --git a/packages/client/src/components/widgets.vue b/packages/client/src/components/widgets.vue
index 12f7129253..ccde5fbe55 100644
--- a/packages/client/src/components/widgets.vue
+++ b/packages/client/src/components/widgets.vue
@@ -10,7 +10,7 @@
 			<MkButton inline @click="$emit('exit')">{{ $ts.close }}</MkButton>
 		</header>
 		<XDraggable
-			v-model="_widgets"
+			v-model="widgets_"
 			item-key="id"
 			animation="150"
 		>
@@ -18,7 +18,7 @@
 				<div class="customize-container">
 					<button class="config _button" @click.prevent.stop="configWidget(element.id)"><i class="fas fa-cog"></i></button>
 					<button class="remove _button" @click.prevent.stop="removeWidget(element)"><i class="fas fa-times"></i></button>
-					<component :is="`mkw-${element.name}`" :widget="element" :setting-callback="setting => settings[element.id] = setting" @updateProps="updateWidget(element.id, $event)"/>
+					<component :ref="el => widgetRefs[element.id] = el" :is="`mkw-${element.name}`" :widget="element" @updateProps="updateWidget(element.id, $event)"/>
 				</div>
 			</template>
 		</XDraggable>
@@ -28,7 +28,7 @@
 </template>
 
 <script lang="ts">
-import { defineComponent, defineAsyncComponent } from 'vue';
+import { defineComponent, defineAsyncComponent, reactive, ref, computed } from 'vue';
 import { v4 as uuid } from 'uuid';
 import MkSelect from '@/components/form/select.vue';
 import MkButton from '@/components/ui/button.vue';
@@ -54,50 +54,47 @@ export default defineComponent({
 
 	emits: ['updateWidgets', 'addWidget', 'removeWidget', 'updateWidget', 'exit'],
 
-	data() {
-		return {
-			widgetAdderSelected: null,
-			widgetDefs,
-			settings: {},
+	setup(props, context) {
+		const widgetRefs = reactive({});
+		const configWidget = (id: string) => {
+			widgetRefs[id].configure();
 		};
-	},
+		const widgetAdderSelected = ref(null);
+		const addWidget = () => {
+			if (widgetAdderSelected.value == null) return;
 
-	computed: {
-		_widgets: {
-			get() {
-				return this.widgets;
-			},
-			set(value) {
-				this.$emit('updateWidgets', value);
-			}
-		}
-	},
-
-	methods: {
-		configWidget(id) {
-			this.settings[id]();
-		},
-
-		addWidget() {
-			if (this.widgetAdderSelected == null) return;
-
-			this.$emit('addWidget', {
-				name: this.widgetAdderSelected,
+			context.emit('addWidget', {
+				name: widgetAdderSelected.value,
 				id: uuid(),
-				data: {}
+				data: {},
 			});
 
-			this.widgetAdderSelected = null;
-		},
+			widgetAdderSelected.value = null;
+		};
+		const removeWidget = (widget) => {
+			context.emit('removeWidget', widget);
+		};
+		const updateWidget = (id, data) => {
+			context.emit('updateWidget', { id, data });
+		};
+		const widgets_ = computed({
+			get: () => props.widgets,
+			set: (value) => {
+				context.emit('updateWidgets', value);
+			},
+		});
 
-		removeWidget(widget) {
-			this.$emit('removeWidget', widget);
-		},
-
-		updateWidget(id, data) {
-			this.$emit('updateWidget', { id, data });
-		},
-	}
+		return {
+			widgetRefs,
+			configWidget,
+			widgetAdderSelected,
+			widgetDefs,
+			addWidget,
+			removeWidget,
+			updateWidget,
+			widgets_,
+		};
+	},
 });
 </script>
 
diff --git a/packages/client/src/scripts/form.ts b/packages/client/src/scripts/form.ts
index 7bf6cec452..7f321cc0ae 100644
--- a/packages/client/src/scripts/form.ts
+++ b/packages/client/src/scripts/form.ts
@@ -21,11 +21,39 @@ export type FormItem = {
 	default: string | null;
 	hidden?: boolean;
 	enum: string[];
+} | {
+	label?: string;
+	type: 'radio';
+	default: unknown | null;
+	hidden?: boolean;
+	options: {
+		label: string;
+		value: unknown;
+	}[];
+} | {
+	label?: string;
+	type: 'object';
+	default: Record<string, unknown> | null;
+	hidden: true;
 } | {
 	label?: string;
 	type: 'array';
 	default: unknown[] | null;
-	hidden?: boolean;
+	hidden: true;
 };
 
 export type Form = Record<string, FormItem>;
+
+type GetItemType<Item extends FormItem> =
+	Item['type'] extends 'string' ? string :
+	Item['type'] extends 'number' ? number :
+	Item['type'] extends 'boolean' ? boolean :
+	Item['type'] extends 'radio' ? unknown :
+	Item['type'] extends 'enum' ? string :
+	Item['type'] extends 'array' ? unknown[] :
+	Item['type'] extends 'object' ? Record<string, unknown>
+	: never;
+
+export type GetFormResultType<F extends Form> = {
+	[P in keyof F]: GetItemType<F[P]>;
+};
diff --git a/packages/client/src/widgets/activity.vue b/packages/client/src/widgets/activity.vue
index d322f4758a..acbbb7a97a 100644
--- a/packages/client/src/widgets/activity.vue
+++ b/packages/client/src/widgets/activity.vue
@@ -1,82 +1,89 @@
 <template>
-<MkContainer :show-header="props.showHeader" :naked="props.transparent">
+<MkContainer :show-header="widgetProps.showHeader" :naked="widgetProps.transparent">
 	<template #header><i class="fas fa-chart-bar"></i>{{ $ts._widgets.activity }}</template>
 	<template #func><button class="_button" @click="toggleView()"><i class="fas fa-sort"></i></button></template>
 
 	<div>
 		<MkLoading v-if="fetching"/>
 		<template v-else>
-			<XCalendar v-show="props.view === 0" :data="[].concat(activity)"/>
-			<XChart v-show="props.view === 1" :data="[].concat(activity)"/>
+			<XCalendar v-show="widgetProps.view === 0" :data="[].concat(activity)"/>
+			<XChart v-show="widgetProps.view === 1" :data="[].concat(activity)"/>
 		</template>
 	</div>
 </MkContainer>
 </template>
 
-<script lang="ts">
-import { defineComponent } from 'vue';
+<script lang="ts" setup>
+import { onMounted, onUnmounted, reactive, ref, watch } from 'vue';
+import { GetFormResultType } from '@/scripts/form';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
+import * as os from '@/os';
 import MkContainer from '@/components/ui/container.vue';
-import define from './define';
 import XCalendar from './activity.calendar.vue';
 import XChart from './activity.chart.vue';
-import * as os from '@/os';
+import { $i } from '@/account';
 
-const widget = define({
-	name: 'activity',
-	props: () => ({
-		showHeader: {
-			type: 'boolean',
-			default: true,
-		},
-		transparent: {
-			type: 'boolean',
-			default: false,
-		},
-		view: {
-			type: 'number',
-			default: 0,
-			hidden: true,
-		},
-	})
+const name = 'activity';
+
+const widgetPropsDef = {
+	showHeader: {
+		type: 'boolean' as const,
+		default: true,
+	},
+	transparent: {
+		type: 'boolean' as const,
+		default: false,
+	},
+	view: {
+		type: 'number' as const,
+		default: 0,
+		hidden: true,
+	},
+};
+
+type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
+
+// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
+//const props = defineProps<WidgetComponentProps<WidgetProps>>();
+//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
+const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
+const emit = defineEmits<{ (e: 'updateProps', props: WidgetProps); }>();
+
+const { widgetProps, configure, save } = useWidgetPropsManager(name,
+	widgetPropsDef,
+	props,
+	emit,
+);
+
+const activity = ref(null);
+const fetching = ref(true);
+
+const toggleView = () => {
+	if (widgetProps.view === 1) {
+		widgetProps.view = 0;
+	} else {
+		widgetProps.view++;
+	}
+	save();
+};
+
+os.api('charts/user/notes', {
+	userId: $i.id,
+	span: 'day',
+	limit: 7 * 21,
+}).then(res => {
+	activity.value = res.diffs.normal.map((_, i) => ({
+		total: res.diffs.normal[i] + res.diffs.reply[i] + res.diffs.renote[i],
+		notes: res.diffs.normal[i],
+		replies: res.diffs.reply[i],
+		renotes: res.diffs.renote[i]
+	}));
+	fetching.value = false;
 });
 
-export default defineComponent({
-	components: {
-		MkContainer,
-		XCalendar,
-		XChart,
-	},
-	extends: widget,
-	data() {
-		return {
-			fetching: true,
-			activity: null,
-		};
-	},
-	mounted() {
-		os.api('charts/user/notes', {
-			userId: this.$i.id,
-			span: 'day',
-			limit: 7 * 21
-		}).then(activity => {
-			this.activity = activity.diffs.normal.map((_, i) => ({
-				total: activity.diffs.normal[i] + activity.diffs.reply[i] + activity.diffs.renote[i],
-				notes: activity.diffs.normal[i],
-				replies: activity.diffs.reply[i],
-				renotes: activity.diffs.renote[i]
-			}));
-			this.fetching = false;
-		});
-	},
-	methods: {
-		toggleView() {
-			if (this.props.view === 1) {
-				this.props.view = 0;
-			} else {
-				this.props.view++;
-			}
-			this.save();
-		}
-	}
+defineExpose<WidgetComponentExpose>({
+	name,
+	configure,
+	id: props.widget ? props.widget.id : null,
 });
 </script>
diff --git a/packages/client/src/widgets/aichan.vue b/packages/client/src/widgets/aichan.vue
index 891b7454d1..03e394b976 100644
--- a/packages/client/src/widgets/aichan.vue
+++ b/packages/client/src/widgets/aichan.vue
@@ -1,51 +1,65 @@
 <template>
-<MkContainer :naked="props.transparent" :show-header="false">
+<MkContainer :naked="widgetProps.transparent" :show-header="false">
 	<iframe ref="live2d" class="dedjhjmo" src="https://misskey-dev.github.io/mascot-web/?scale=1.5&y=1.1&eyeY=100" @click="touched"></iframe>
 </MkContainer>
 </template>
 
-<script lang="ts">
-import { defineComponent, markRaw } from 'vue';
-import define from './define';
-import MkContainer from '@/components/ui/container.vue';
-import * as os from '@/os';
+<script lang="ts" setup>
+import { onMounted, onUnmounted, reactive, ref } from 'vue';
+import { GetFormResultType } from '@/scripts/form';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
 
-const widget = define({
-	name: 'ai',
-	props: () => ({
-		transparent: {
-			type: 'boolean',
-			default: false,
-		},
-	})
+const name = 'ai';
+
+const widgetPropsDef = {
+	transparent: {
+		type: 'boolean' as const,
+		default: false,
+	},
+};
+
+type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
+
+// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
+//const props = defineProps<WidgetComponentProps<WidgetProps>>();
+//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
+const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
+const emit = defineEmits<{ (e: 'updateProps', props: WidgetProps); }>();
+
+const { widgetProps, configure } = useWidgetPropsManager(name,
+	widgetPropsDef,
+	props,
+	emit,
+);
+
+const live2d = ref<HTMLIFrameElement>();
+
+const touched = () => {
+	//if (this.live2d) this.live2d.changeExpression('gurugurume');
+};
+
+onMounted(() => {
+	const onMousemove = (ev: MouseEvent) => {
+		const iframeRect = live2d.value.getBoundingClientRect();
+		live2d.value.contentWindow.postMessage({
+			type: 'moveCursor',
+			body: {
+				x: ev.clientX - iframeRect.left,
+				y: ev.clientY - iframeRect.top,
+			}
+		}, '*');
+	};
+
+	window.addEventListener('mousemove', onMousemove, { passive: true });
+	onUnmounted(() => {
+		window.removeEventListener('mousemove', onMousemove);
+	});
 });
 
-export default defineComponent({
-	components: {
-		MkContainer,
-	},
-	extends: widget,
-	data() {
-		return {
-		};
-	},
-	mounted() {
-		window.addEventListener('mousemove', ev => {
-			const iframeRect = this.$refs.live2d.getBoundingClientRect();
-			this.$refs.live2d.contentWindow.postMessage({
-				type: 'moveCursor',
-				body: {
-					x: ev.clientX - iframeRect.left,
-					y: ev.clientY - iframeRect.top,
-				}
-			}, '*');
-		}, { passive: true });
-	},
-	methods: {
-		touched() {
-			//if (this.live2d) this.live2d.changeExpression('gurugurume');
-		}
-	}
+defineExpose<WidgetComponentExpose>({
+	name,
+	configure,
+	id: props.widget ? props.widget.id : null,
 });
 </script>
 
diff --git a/packages/client/src/widgets/aiscript.vue b/packages/client/src/widgets/aiscript.vue
index 46c5094ee9..0a5c0d614d 100644
--- a/packages/client/src/widgets/aiscript.vue
+++ b/packages/client/src/widgets/aiscript.vue
@@ -1,9 +1,9 @@
 <template>
-<MkContainer :show-header="props.showHeader">
+<MkContainer :show-header="widgetProps.showHeader">
 	<template #header><i class="fas fa-terminal"></i>{{ $ts._widgets.aiscript }}</template>
 
 	<div class="uylguesu _monospace">
-		<textarea v-model="props.script" placeholder="(1 + 1)"></textarea>
+		<textarea v-model="widgetProps.script" placeholder="(1 + 1)"></textarea>
 		<button class="_buttonPrimary" @click="run">RUN</button>
 		<div class="logs">
 			<div v-for="log in logs" :key="log.id" class="log" :class="{ print: log.print }">{{ log.text }}</div>
@@ -12,97 +12,109 @@
 </MkContainer>
 </template>
 
-<script lang="ts">
-import { defineComponent } from 'vue';
-import MkContainer from '@/components/ui/container.vue';
-import define from './define';
+<script lang="ts" setup>
+import { onMounted, onUnmounted, ref, watch } from 'vue';
+import { GetFormResultType } from '@/scripts/form';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
 import * as os from '@/os';
+import MkContainer from '@/components/ui/container.vue';
 import { AiScript, parse, utils } from '@syuilo/aiscript';
 import { createAiScriptEnv } from '@/scripts/aiscript/api';
+import { $i } from '@/account';
 
-const widget = define({
-	name: 'aiscript',
-	props: () => ({
-		showHeader: {
-			type: 'boolean',
-			default: true,
-		},
-		script: {
-			type: 'string',
-			multiline: true,
-			default: '(1 + 1)',
-			hidden: true,
-		},
-	})
-});
+const name = 'aiscript';
 
-export default defineComponent({
-	components: {
-		MkContainer
+const widgetPropsDef = {
+	showHeader: {
+		type: 'boolean' as const,
+		default: true,
 	},
-	extends: widget,
-
-	data() {
-		return {
-			logs: [],
-		};
+	script: {
+		type: 'string' as const,
+		multiline: true,
+		default: '(1 + 1)',
+		hidden: true,
 	},
+};
 
-	methods: {
-		async run() {
-			this.logs = [];
-			const aiscript = new AiScript(createAiScriptEnv({
-				storageKey: 'widget',
-				token: this.$i?.token,
-			}), {
-				in: (q) => {
-					return new Promise(ok => {
-						os.inputText({
-							title: q,
-						}).then(({ canceled, result: a }) => {
-							ok(a);
-						});
-					});
-				},
-				out: (value) => {
-					this.logs.push({
-						id: Math.random(),
-						text: value.type === 'str' ? value.value : utils.valToString(value),
-						print: true
-					});
-				},
-				log: (type, params) => {
-					switch (type) {
-						case 'end': this.logs.push({
-							id: Math.random(),
-							text: utils.valToString(params.val, true),
-							print: false
-						}); break;
-						default: break;
-					}
-				}
+type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
+
+// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
+//const props = defineProps<WidgetComponentProps<WidgetProps>>();
+//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
+const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
+const emit = defineEmits<{ (e: 'updateProps', props: WidgetProps); }>();
+
+const { widgetProps, configure } = useWidgetPropsManager(name,
+	widgetPropsDef,
+	props,
+	emit,
+);
+
+const logs = ref<{
+	id: string;
+	text: string;
+	print: boolean;
+}[]>([]);
+
+const run = async () => {
+	logs.value = [];
+	const aiscript = new AiScript(createAiScriptEnv({
+		storageKey: 'widget',
+		token: $i?.token,
+	}), {
+		in: (q) => {
+			return new Promise(ok => {
+				os.inputText({
+					title: q,
+				}).then(({ canceled, result: a }) => {
+					ok(a);
+				});
 			});
-
-			let ast;
-			try {
-				ast = parse(this.props.script);
-			} catch (e) {
-				os.alert({
-					type: 'error',
-					text: 'Syntax error :('
-				});
-				return;
-			}
-			try {
-				await aiscript.exec(ast);
-			} catch (e) {
-				os.alert({
-					type: 'error',
-					text: e
-				});
-			}
 		},
+		out: (value) => {
+			logs.value.push({
+				id: Math.random().toString(),
+				text: value.type === 'str' ? value.value : utils.valToString(value),
+				print: true,
+			});
+		},
+		log: (type, params) => {
+			switch (type) {
+				case 'end': logs.value.push({
+					id: Math.random().toString(),
+					text: utils.valToString(params.val, true),
+					print: false,
+				}); break;
+				default: break;
+			}
+		}
+	});
+
+	let ast;
+	try {
+		ast = parse(widgetProps.script);
+	} catch (e) {
+		os.alert({
+			type: 'error',
+			text: 'Syntax error :(',
+		});
+		return;
 	}
+	try {
+		await aiscript.exec(ast);
+	} catch (e) {
+		os.alert({
+			type: 'error',
+			text: e,
+		});
+	}
+};
+
+defineExpose<WidgetComponentExpose>({
+	name,
+	configure,
+	id: props.widget ? props.widget.id : null,
 });
 </script>
 
diff --git a/packages/client/src/widgets/button.vue b/packages/client/src/widgets/button.vue
index e98570862e..a33afd6e7a 100644
--- a/packages/client/src/widgets/button.vue
+++ b/packages/client/src/widgets/button.vue
@@ -1,90 +1,99 @@
 <template>
 <div class="mkw-button">
-	<MkButton :primary="props.colored" full @click="run">
-		{{ props.label }}
+	<MkButton :primary="widgetProps.colored" full @click="run">
+		{{ widgetProps.label }}
 	</MkButton>
 </div>
 </template>
 
-<script lang="ts">
-import { defineComponent } from 'vue';
-import define from './define';
-import MkButton from '@/components/ui/button.vue';
+<script lang="ts" setup>
+import { onMounted, onUnmounted, ref, watch } from 'vue';
+import { GetFormResultType } from '@/scripts/form';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
 import * as os from '@/os';
 import { AiScript, parse, utils } from '@syuilo/aiscript';
 import { createAiScriptEnv } from '@/scripts/aiscript/api';
+import { $i } from '@/account';
+import MkButton from '@/components/ui/button.vue';
 
-const widget = define({
-	name: 'button',
-	props: () => ({
-		label: {
-			type: 'string',
-			default: 'BUTTON',
-		},
-		colored: {
-			type: 'boolean',
-			default: true,
-		},
-		script: {
-			type: 'string',
-			multiline: true,
-			default: 'Mk:dialog("hello" "world")',
-		},
-	})
-});
+const name = 'button';
 
-export default defineComponent({
-	components: {
-		MkButton
+const widgetPropsDef = {
+	label: {
+		type: 'string' as const,
+		default: 'BUTTON',
 	},
-	extends: widget,
-	data() {
-		return {
-		};
+	colored: {
+		type: 'boolean' as const,
+		default: true,
 	},
-	methods: {
-		async run() {
-			const aiscript = new AiScript(createAiScriptEnv({
-				storageKey: 'widget',
-				token: this.$i?.token,
-			}), {
-				in: (q) => {
-					return new Promise(ok => {
-						os.inputText({
-							title: q,
-						}).then(({ canceled, result: a }) => {
-							ok(a);
-						});
-					});
-				},
-				out: (value) => {
-					// nop
-				},
-				log: (type, params) => {
-					// nop
-				}
+	script: {
+		type: 'string' as const,
+		multiline: true,
+		default: 'Mk:dialog("hello" "world")',
+	},
+};
+
+type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
+
+// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
+//const props = defineProps<WidgetComponentProps<WidgetProps>>();
+//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
+const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
+const emit = defineEmits<{ (e: 'updateProps', props: WidgetProps); }>();
+
+const { widgetProps, configure } = useWidgetPropsManager(name,
+	widgetPropsDef,
+	props,
+	emit,
+);
+
+const run = async () => {
+	const aiscript = new AiScript(createAiScriptEnv({
+		storageKey: 'widget',
+		token: $i?.token,
+	}), {
+		in: (q) => {
+			return new Promise(ok => {
+				os.inputText({
+					title: q,
+				}).then(({ canceled, result: a }) => {
+					ok(a);
+				});
 			});
-
-			let ast;
-			try {
-				ast = parse(this.props.script);
-			} catch (e) {
-				os.alert({
-					type: 'error',
-					text: 'Syntax error :('
-				});
-				return;
-			}
-			try {
-				await aiscript.exec(ast);
-			} catch (e) {
-				os.alert({
-					type: 'error',
-					text: e
-				});
-			}
 		},
+		out: (value) => {
+			// nop
+		},
+		log: (type, params) => {
+			// nop
+		}
+	});
+
+	let ast;
+	try {
+		ast = parse(widgetProps.script);
+	} catch (e) {
+		os.alert({
+			type: 'error',
+			text: 'Syntax error :(',
+		});
+		return;
 	}
+	try {
+		await aiscript.exec(ast);
+	} catch (e) {
+		os.alert({
+			type: 'error',
+			text: e,
+		});
+	}
+};
+
+defineExpose<WidgetComponentExpose>({
+	name,
+	configure,
+	id: props.widget ? props.widget.id : null,
 });
 </script>
 
diff --git a/packages/client/src/widgets/calendar.vue b/packages/client/src/widgets/calendar.vue
index c8b52d7afc..d16d3424b6 100644
--- a/packages/client/src/widgets/calendar.vue
+++ b/packages/client/src/widgets/calendar.vue
@@ -1,5 +1,5 @@
 <template>
-<div class="mkw-calendar" :class="{ _panel: !props.transparent }">
+<div class="mkw-calendar" :class="{ _panel: !widgetProps.transparent }">
 	<div class="calendar" :class="{ isHoliday }">
 		<p class="month-and-year">
 			<span class="year">{{ $t('yearX', { year }) }}</span>
@@ -32,77 +32,87 @@
 </div>
 </template>
 
-<script lang="ts">
-import { defineComponent } from 'vue';
-import define from './define';
+<script lang="ts" setup>
+import { onUnmounted, ref } from 'vue';
+import { GetFormResultType } from '@/scripts/form';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
+import { i18n } from '@/i18n';
 
-const widget = define({
-	name: 'calendar',
-	props: () => ({
-		transparent: {
-			type: 'boolean',
-			default: false,
-		},
-	})
+const name = 'calendar';
+
+const widgetPropsDef = {
+	transparent: {
+		type: 'boolean' as const,
+		default: false,
+	},
+};
+
+type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
+
+// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
+//const props = defineProps<WidgetComponentProps<WidgetProps>>();
+//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
+const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
+const emit = defineEmits<{ (e: 'updateProps', props: WidgetProps); }>();
+
+const { widgetProps, configure } = useWidgetPropsManager(name,
+	widgetPropsDef,
+	props,
+	emit,
+);
+
+const year = ref(0);
+const month = ref(0);
+const day = ref(0);
+const weekDay = ref('');
+const yearP = ref(0);
+const monthP = ref(0);
+const dayP = ref(0);
+const isHoliday = ref(false);
+const tick = () => {
+	const now = new Date();
+	const nd = now.getDate();
+	const nm = now.getMonth();
+	const ny = now.getFullYear();
+
+	year.value = ny;
+	month.value = nm + 1;
+	day.value = nd;
+	weekDay.value = [
+		i18n.locale._weekday.sunday,
+		i18n.locale._weekday.monday,
+		i18n.locale._weekday.tuesday,
+		i18n.locale._weekday.wednesday,
+		i18n.locale._weekday.thursday,
+		i18n.locale._weekday.friday,
+		i18n.locale._weekday.saturday
+	][now.getDay()];
+
+	const dayNumer   = now.getTime() - new Date(ny, nm, nd).getTime();
+	const dayDenom   = 1000/*ms*/ * 60/*s*/ * 60/*m*/ * 24/*h*/;
+	const monthNumer = now.getTime() - new Date(ny, nm, 1).getTime();
+	const monthDenom = new Date(ny, nm + 1, 1).getTime() - new Date(ny, nm, 1).getTime();
+	const yearNumer  = now.getTime() - new Date(ny, 0, 1).getTime();
+	const yearDenom  = new Date(ny + 1, 0, 1).getTime() - new Date(ny, 0, 1).getTime();
+
+	dayP.value   = dayNumer   / dayDenom   * 100;
+	monthP.value = monthNumer / monthDenom * 100;
+	yearP.value  = yearNumer  / yearDenom  * 100;
+
+	isHoliday.value = now.getDay() === 0 || now.getDay() === 6;
+};
+
+tick();
+
+const intervalId = setInterval(tick, 1000);
+onUnmounted(() => {
+	clearInterval(intervalId);
 });
 
-export default defineComponent({
-	extends: widget,
-	data() {
-		return {
-			now: new Date(),
-			year: null,
-			month: null,
-			day: null,
-			weekDay: null,
-			yearP: null,
-			dayP: null,
-			monthP: null,
-			isHoliday: null,
-			clock: null
-		};
-	},
-	created() {
-		this.tick();
-		this.clock = setInterval(this.tick, 1000);
-	},
-	beforeUnmount() {
-		clearInterval(this.clock);
-	},
-	methods: {
-		tick() {
-			const now = new Date();
-			const nd = now.getDate();
-			const nm = now.getMonth();
-			const ny = now.getFullYear();
-
-			this.year = ny;
-			this.month = nm + 1;
-			this.day = nd;
-			this.weekDay = [
-				this.$ts._weekday.sunday,
-				this.$ts._weekday.monday,
-				this.$ts._weekday.tuesday,
-				this.$ts._weekday.wednesday,
-				this.$ts._weekday.thursday,
-				this.$ts._weekday.friday,
-				this.$ts._weekday.saturday
-			][now.getDay()];
-
-			const dayNumer   = now.getTime() - new Date(ny, nm, nd).getTime();
-			const dayDenom   = 1000/*ms*/ * 60/*s*/ * 60/*m*/ * 24/*h*/;
-			const monthNumer = now.getTime() - new Date(ny, nm, 1).getTime();
-			const monthDenom = new Date(ny, nm + 1, 1).getTime() - new Date(ny, nm, 1).getTime();
-			const yearNumer  = now.getTime() - new Date(ny, 0, 1).getTime();
-			const yearDenom  = new Date(ny + 1, 0, 1).getTime() - new Date(ny, 0, 1).getTime();
-
-			this.dayP   = dayNumer   / dayDenom   * 100;
-			this.monthP = monthNumer / monthDenom * 100;
-			this.yearP  = yearNumer  / yearDenom  * 100;
-
-			this.isHoliday = now.getDay() === 0 || now.getDay() === 6;
-		}
-	}
+defineExpose<WidgetComponentExpose>({
+	name,
+	configure,
+	id: props.widget ? props.widget.id : null,
 });
 </script>
 
diff --git a/packages/client/src/widgets/clock.vue b/packages/client/src/widgets/clock.vue
index 6ca7ecd430..6acb10d74d 100644
--- a/packages/client/src/widgets/clock.vue
+++ b/packages/client/src/widgets/clock.vue
@@ -1,45 +1,56 @@
 <template>
-<MkContainer :naked="props.transparent" :show-header="false">
+<MkContainer :naked="widgetProps.transparent" :show-header="false">
 	<div class="vubelbmv">
-		<MkAnalogClock class="clock" :thickness="props.thickness"/>
+		<MkAnalogClock class="clock" :thickness="widgetProps.thickness"/>
 	</div>
 </MkContainer>
 </template>
 
-<script lang="ts">
-import { defineComponent } from 'vue';
-import define from './define';
+<script lang="ts" setup>
+import { } from 'vue';
+import { GetFormResultType } from '@/scripts/form';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
 import MkContainer from '@/components/ui/container.vue';
 import MkAnalogClock from '@/components/analog-clock.vue';
-import * as os from '@/os';
 
-const widget = define({
-	name: 'clock',
-	props: () => ({
-		transparent: {
-			type: 'boolean',
-			default: false,
-		},
-		thickness: {
-			type: 'radio',
-			default: 0.1,
-			options: [{
-				value: 0.1, label: 'thin'
-			}, {
-				value: 0.2, label: 'medium'
-			}, {
-				value: 0.3, label: 'thick'
-			}]
-		}
-	})
-});
+const name = 'clock';
 
-export default defineComponent({
-	components: {
-		MkContainer,
-		MkAnalogClock
+const widgetPropsDef = {
+	transparent: {
+		type: 'boolean' as const,
+		default: false,
 	},
-	extends: widget,
+	thickness: {
+		type: 'radio' as const,
+		default: 0.1,
+		options: [{
+			value: 0.1, label: 'thin'
+		}, {
+			value: 0.2, label: 'medium'
+		}, {
+			value: 0.3, label: 'thick'
+		}],
+	},
+};
+
+type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
+
+// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
+//const props = defineProps<WidgetComponentProps<WidgetProps>>();
+//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
+const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
+const emit = defineEmits<{ (e: 'updateProps', props: WidgetProps); }>();
+
+const { widgetProps, configure } = useWidgetPropsManager(name,
+	widgetPropsDef,
+	props,
+	emit,
+);
+
+defineExpose<WidgetComponentExpose>({
+	name,
+	configure,
+	id: props.widget ? props.widget.id : null,
 });
 </script>
 
diff --git a/packages/client/src/widgets/define.ts b/packages/client/src/widgets/define.ts
deleted file mode 100644
index 08a346d97c..0000000000
--- a/packages/client/src/widgets/define.ts
+++ /dev/null
@@ -1,75 +0,0 @@
-import { defineComponent } from 'vue';
-import { throttle } from 'throttle-debounce';
-import { Form } from '@/scripts/form';
-import * as os from '@/os';
-
-export default function <T extends Form>(data: {
-	name: string;
-	props?: () => T;
-}) {
-	return defineComponent({
-		props: {
-			widget: {
-				type: Object,
-				required: false
-			},
-			settingCallback: {
-				required: false
-			}
-		},
-
-		emits: ['updateProps'],
-
-		data() {
-			return {
-				props: this.widget ? JSON.parse(JSON.stringify(this.widget.data)) : {},
-				save: throttle(3000, () => {
-					this.$emit('updateProps', this.props);
-				}),
-			};
-		},
-
-		computed: {
-			id(): string {
-				return this.widget ? this.widget.id : null;
-			},
-		},
-
-		created() {
-			this.mergeProps();
-
-			this.$watch('props', () => {
-				this.mergeProps();
-			}, { deep: true });
-
-			if (this.settingCallback) this.settingCallback(this.setting);
-		},
-
-		methods: {
-			mergeProps() {
-				if (data.props) {
-					const defaultProps = data.props();
-					for (const prop of Object.keys(defaultProps)) {
-						if (this.props.hasOwnProperty(prop)) continue;
-						this.props[prop] = defaultProps[prop].default;
-					}
-				}
-			},
-
-			async setting() {
-				const form = data.props();
-				for (const item of Object.keys(form)) {
-					form[item].default = this.props[item];
-				}
-				const { canceled, result } = await os.form(data.name, form);
-				if (canceled) return;
-
-				for (const key of Object.keys(result)) {
-					this.props[key] = result[key];
-				}
-
-				this.save();
-			},
-		}
-	});
-}
diff --git a/packages/client/src/widgets/digital-clock.vue b/packages/client/src/widgets/digital-clock.vue
index fbf632d2de..637b0368be 100644
--- a/packages/client/src/widgets/digital-clock.vue
+++ b/packages/client/src/widgets/digital-clock.vue
@@ -1,73 +1,84 @@
 <template>
-<div class="mkw-digitalClock _monospace" :class="{ _panel: !props.transparent }" :style="{ fontSize: `${props.fontSize}em` }">
+<div class="mkw-digitalClock _monospace" :class="{ _panel: !widgetProps.transparent }" :style="{ fontSize: `${widgetProps.fontSize}em` }">
 	<span>
 		<span v-text="hh"></span>
 		<span :style="{ visibility: showColon ? 'visible' : 'hidden' }">:</span>
 		<span v-text="mm"></span>
 		<span :style="{ visibility: showColon ? 'visible' : 'hidden' }">:</span>
 		<span v-text="ss"></span>
-		<span v-if="props.showMs" :style="{ visibility: showColon ? 'visible' : 'hidden' }">:</span>
-		<span v-if="props.showMs" v-text="ms"></span>
+		<span v-if="widgetProps.showMs" :style="{ visibility: showColon ? 'visible' : 'hidden' }">:</span>
+		<span v-if="widgetProps.showMs" v-text="ms"></span>
 	</span>
 </div>
 </template>
 
-<script lang="ts">
-import { defineComponent } from 'vue';
-import define from './define';
-import * as os from '@/os';
+<script lang="ts" setup>
+import { onUnmounted, ref, watch } from 'vue';
+import { GetFormResultType } from '@/scripts/form';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
 
-const widget = define({
-	name: 'digitalClock',
-	props: () => ({
-		transparent: {
-			type: 'boolean',
-			default: false,
-		},
-		fontSize: {
-			type: 'number',
-			default: 1.5,
-			step: 0.1,
-		},
-		showMs: {
-			type: 'boolean',
-			default: true,
-		},
-	})
+const name = 'digitalClock';
+
+const widgetPropsDef = {
+	transparent: {
+		type: 'boolean' as const,
+		default: false,
+	},
+	fontSize: {
+		type: 'number' as const,
+		default: 1.5,
+		step: 0.1,
+	},
+	showMs: {
+		type: 'boolean' as const,
+		default: true,
+	},
+};
+
+type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
+
+// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
+//const props = defineProps<WidgetComponentProps<WidgetProps>>();
+//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
+const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
+const emit = defineEmits<{ (e: 'updateProps', props: WidgetProps); }>();
+
+const { widgetProps, configure } = useWidgetPropsManager(name,
+	widgetPropsDef,
+	props,
+	emit,
+);
+
+let intervalId;
+const hh = ref('');
+const mm = ref('');
+const ss = ref('');
+const ms = ref('');
+const showColon = ref(true);
+const tick = () => {
+	const now = new Date();
+	hh.value = now.getHours().toString().padStart(2, '0');
+	mm.value = now.getMinutes().toString().padStart(2, '0');
+	ss.value = now.getSeconds().toString().padStart(2, '0');
+	ms.value = Math.floor(now.getMilliseconds() / 10).toString().padStart(2, '0');
+	showColon.value = now.getSeconds() % 2 === 0;
+};
+
+tick();
+
+watch(() => widgetProps.showMs, () => {
+	if (intervalId) clearInterval(intervalId);
+	intervalId = setInterval(tick, widgetProps.showMs ? 10 : 1000);
+}, { immediate: true });
+
+onUnmounted(() => {
+	clearInterval(intervalId);
 });
 
-export default defineComponent({
-	extends: widget,
-	data() {
-		return {
-			clock: null,
-			hh: null,
-			mm: null,
-			ss: null,
-			ms: null,
-			showColon: true,
-		};
-	},
-	created() {
-		this.tick();
-		this.$watch(() => this.props.showMs, () => {
-			if (this.clock) clearInterval(this.clock);
-			this.clock = setInterval(this.tick, this.props.showMs ? 10 : 1000);
-		}, { immediate: true });
-	},
-	beforeUnmount() {
-		clearInterval(this.clock);
-	},
-	methods: {
-		tick() {
-			const now = new Date();
-			this.hh = now.getHours().toString().padStart(2, '0');
-			this.mm = now.getMinutes().toString().padStart(2, '0');
-			this.ss = now.getSeconds().toString().padStart(2, '0');
-			this.ms = Math.floor(now.getMilliseconds() / 10).toString().padStart(2, '0');
-			this.showColon = now.getSeconds() % 2 === 0;
-		}
-	}
+defineExpose<WidgetComponentExpose>({
+	name,
+	configure,
+	id: props.widget ? props.widget.id : null,
 });
 </script>
 
diff --git a/packages/client/src/widgets/federation.vue b/packages/client/src/widgets/federation.vue
index 736a91c52e..5d53b683b4 100644
--- a/packages/client/src/widgets/federation.vue
+++ b/packages/client/src/widgets/federation.vue
@@ -1,5 +1,5 @@
 <template>
-<MkContainer :show-header="props.showHeader" :foldable="foldable" :scrollable="scrollable">
+<MkContainer :show-header="widgetProps.showHeader" :foldable="foldable" :scrollable="scrollable">
 	<template #header><i class="fas fa-globe"></i>{{ $ts._widgets.federation }}</template>
 
 	<div class="wbrkwalb">
@@ -18,66 +18,64 @@
 </MkContainer>
 </template>
 
-<script lang="ts">
-import { defineComponent } from 'vue';
+<script lang="ts" setup>
+import { onMounted, onUnmounted, ref } from 'vue';
+import { GetFormResultType } from '@/scripts/form';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
 import MkContainer from '@/components/ui/container.vue';
-import define from './define';
 import MkMiniChart from '@/components/mini-chart.vue';
 import * as os from '@/os';
 
-const widget = define({
-	name: 'federation',
-	props: () => ({
-		showHeader: {
-			type: 'boolean',
-			default: true,
-		},
-	})
+const name = 'federation';
+
+const widgetPropsDef = {
+	showHeader: {
+		type: 'boolean' as const,
+		default: true,
+	},
+};
+
+type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
+
+// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
+//const props = defineProps<WidgetComponentProps<WidgetProps> & { foldable?: boolean; scrollable?: boolean; }>();
+//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
+const props = defineProps<{ widget?: Widget<WidgetProps>; foldable?: boolean; scrollable?: boolean; }>();
+const emit = defineEmits<{ (e: 'updateProps', props: WidgetProps); }>();
+
+const { widgetProps, configure } = useWidgetPropsManager(name,
+	widgetPropsDef,
+	props,
+	emit,
+);
+
+const instances = ref([]);
+const charts = ref([]);
+const fetching = ref(true);
+
+const fetch = async () => {
+	const instances = await os.api('federation/instances', {
+		sort: '+lastCommunicatedAt',
+		limit: 5
+	});
+	const charts = await Promise.all(instances.map(i => os.api('charts/instance', { host: i.host, limit: 16, span: 'hour' })));
+	instances.value = instances;
+	charts.value = charts;
+	fetching.value = false;
+};
+
+onMounted(() => {
+	fetch();
+	const intervalId = setInterval(fetch, 1000 * 60);
+	onUnmounted(() => {
+		clearInterval(intervalId);
+	});
 });
 
-export default defineComponent({
-	components: {
-		MkContainer, MkMiniChart
-	},
-	extends: widget,
-	props: {
-		foldable: {
-			type: Boolean,
-			required: false,
-			default: false
-		},
-		scrollable: {
-			type: Boolean,
-			required: false,
-			default: false
-		},
-	},
-	data() {
-		return {
-			instances: [],
-			charts: [],
-			fetching: true,
-		};
-	},
-	mounted() {
-		this.fetch();
-		this.clock = setInterval(this.fetch, 1000 * 60);
-	},
-	beforeUnmount() {
-		clearInterval(this.clock);
-	},
-	methods: {
-		async fetch() {
-			const instances = await os.api('federation/instances', {
-				sort: '+lastCommunicatedAt',
-				limit: 5
-			});
-			const charts = await Promise.all(instances.map(i => os.api('charts/instance', { host: i.host, limit: 16, span: 'hour' })));
-			this.instances = instances;
-			this.charts = charts;
-			this.fetching = false;
-		}
-	}
+defineExpose<WidgetComponentExpose>({
+	name,
+	configure,
+	id: props.widget ? props.widget.id : null,
 });
 </script>
 
diff --git a/packages/client/src/widgets/job-queue.vue b/packages/client/src/widgets/job-queue.vue
index 1b7c71de67..4a2a3cf233 100644
--- a/packages/client/src/widgets/job-queue.vue
+++ b/packages/client/src/widgets/job-queue.vue
@@ -1,134 +1,146 @@
 <template>
-<div class="mkw-jobQueue _monospace" :class="{ _panel: !props.transparent }">
+<div class="mkw-jobQueue _monospace" :class="{ _panel: !widgetProps.transparent }">
 	<div class="inbox">
-		<div class="label">Inbox queue<i v-if="inbox.waiting > 0" class="fas fa-exclamation-triangle icon"></i></div>
+		<div class="label">Inbox queue<i v-if="current.inbox.waiting > 0" class="fas fa-exclamation-triangle icon"></i></div>
 		<div class="values">
 			<div>
 				<div>Process</div>
-				<div :class="{ inc: inbox.activeSincePrevTick > prev.inbox.activeSincePrevTick, dec: inbox.activeSincePrevTick < prev.inbox.activeSincePrevTick }">{{ number(inbox.activeSincePrevTick) }}</div>
+				<div :class="{ inc: current.inbox.activeSincePrevTick > prev.inbox.activeSincePrevTick, dec: current.inbox.activeSincePrevTick < prev.inbox.activeSincePrevTick }">{{ number(current.inbox.activeSincePrevTick) }}</div>
 			</div>
 			<div>
 				<div>Active</div>
-				<div :class="{ inc: inbox.active > prev.inbox.active, dec: inbox.active < prev.inbox.active }">{{ number(inbox.active) }}</div>
+				<div :class="{ inc: current.inbox.active > prev.inbox.active, dec: current.inbox.active < prev.inbox.active }">{{ number(current.inbox.active) }}</div>
 			</div>
 			<div>
 				<div>Delayed</div>
-				<div :class="{ inc: inbox.delayed > prev.inbox.delayed, dec: inbox.delayed < prev.inbox.delayed }">{{ number(inbox.delayed) }}</div>
+				<div :class="{ inc: current.inbox.delayed > prev.inbox.delayed, dec: current.inbox.delayed < prev.inbox.delayed }">{{ number(current.inbox.delayed) }}</div>
 			</div>
 			<div>
 				<div>Waiting</div>
-				<div :class="{ inc: inbox.waiting > prev.inbox.waiting, dec: inbox.waiting < prev.inbox.waiting }">{{ number(inbox.waiting) }}</div>
+				<div :class="{ inc: current.inbox.waiting > prev.inbox.waiting, dec: current.inbox.waiting < prev.inbox.waiting }">{{ number(current.inbox.waiting) }}</div>
 			</div>
 		</div>
 	</div>
 	<div class="deliver">
-		<div class="label">Deliver queue<i v-if="deliver.waiting > 0" class="fas fa-exclamation-triangle icon"></i></div>
+		<div class="label">Deliver queue<i v-if="current.deliver.waiting > 0" class="fas fa-exclamation-triangle icon"></i></div>
 		<div class="values">
 			<div>
 				<div>Process</div>
-				<div :class="{ inc: deliver.activeSincePrevTick > prev.deliver.activeSincePrevTick, dec: deliver.activeSincePrevTick < prev.deliver.activeSincePrevTick }">{{ number(deliver.activeSincePrevTick) }}</div>
+				<div :class="{ inc: current.deliver.activeSincePrevTick > prev.deliver.activeSincePrevTick, dec: current.deliver.activeSincePrevTick < prev.deliver.activeSincePrevTick }">{{ number(current.deliver.activeSincePrevTick) }}</div>
 			</div>
 			<div>
 				<div>Active</div>
-				<div :class="{ inc: deliver.active > prev.deliver.active, dec: deliver.active < prev.deliver.active }">{{ number(deliver.active) }}</div>
+				<div :class="{ inc: current.deliver.active > prev.deliver.active, dec: current.deliver.active < prev.deliver.active }">{{ number(current.deliver.active) }}</div>
 			</div>
 			<div>
 				<div>Delayed</div>
-				<div :class="{ inc: deliver.delayed > prev.deliver.delayed, dec: deliver.delayed < prev.deliver.delayed }">{{ number(deliver.delayed) }}</div>
+				<div :class="{ inc: current.deliver.delayed > prev.deliver.delayed, dec: current.deliver.delayed < prev.deliver.delayed }">{{ number(current.deliver.delayed) }}</div>
 			</div>
 			<div>
 				<div>Waiting</div>
-				<div :class="{ inc: deliver.waiting > prev.deliver.waiting, dec: deliver.waiting < prev.deliver.waiting }">{{ number(deliver.waiting) }}</div>
+				<div :class="{ inc: current.deliver.waiting > prev.deliver.waiting, dec: current.deliver.waiting < prev.deliver.waiting }">{{ number(current.deliver.waiting) }}</div>
 			</div>
 		</div>
 	</div>
 </div>
 </template>
 
-<script lang="ts">
-import { defineComponent, markRaw } from 'vue';
-import define from './define';
-import * as os from '@/os';
+<script lang="ts" setup>
+import { onMounted, onUnmounted, reactive, ref } from 'vue';
+import { GetFormResultType } from '@/scripts/form';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
 import { stream } from '@/stream';
 import number from '@/filters/number';
 import * as sound from '@/scripts/sound';
+import * as os from '@/os';
 
-const widget = define({
-	name: 'jobQueue',
-	props: () => ({
-		transparent: {
-			type: 'boolean',
-			default: false,
-		},
-		sound: {
-			type: 'boolean',
-			default: false,
-		},
-	})
+const name = 'jobQueue';
+
+const widgetPropsDef = {
+	transparent: {
+		type: 'boolean' as const,
+		default: false,
+	},
+	sound: {
+		type: 'boolean' as const,
+		default: false,
+	},
+};
+
+type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
+
+// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
+//const props = defineProps<WidgetComponentProps<WidgetProps>>();
+//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
+const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
+const emit = defineEmits<{ (e: 'updateProps', props: WidgetProps); }>();
+
+const { widgetProps, configure } = useWidgetPropsManager(name,
+	widgetPropsDef,
+	props,
+	emit,
+);
+
+const connection = stream.useChannel('queueStats');
+const current = reactive({
+	inbox: {
+		activeSincePrevTick: 0,
+		active: 0,
+		waiting: 0,
+		delayed: 0,
+	},
+	deliver: {
+		activeSincePrevTick: 0,
+		active: 0,
+		waiting: 0,
+		delayed: 0,
+	},
+});
+const prev = reactive({} as typeof current);
+const jammedSound = sound.setVolume(sound.getAudio('syuilo/queue-jammed'), 1);
+
+for (const domain of ['inbox', 'deliver']) {
+	prev[domain] = JSON.parse(JSON.stringify(current[domain]));
+}
+
+const onStats = (stats) => {
+	for (const domain of ['inbox', 'deliver']) {
+		prev[domain] = JSON.parse(JSON.stringify(current[domain]));
+		current[domain].activeSincePrevTick = stats[domain].activeSincePrevTick;
+		current[domain].active = stats[domain].active;
+		current[domain].waiting = stats[domain].waiting;
+		current[domain].delayed = stats[domain].delayed;
+
+		if (current[domain].waiting > 0 && widgetProps.sound && jammedSound.paused) {
+			jammedSound.play();
+		}
+	}
+};
+
+const onStatsLog = (statsLog) => {
+	for (const stats of [...statsLog].reverse()) {
+		onStats(stats);
+	}
+};
+
+connection.on('stats', onStats);
+connection.on('statsLog', onStatsLog);
+
+connection.send('requestLog', {
+	id: Math.random().toString().substr(2, 8),
+	length: 1,
 });
 
-export default defineComponent({
-	extends: widget,
-	data() {
-		return {
-			connection: markRaw(stream.useChannel('queueStats')),
-			inbox: {
-				activeSincePrevTick: 0,
-				active: 0,
-				waiting: 0,
-				delayed: 0,
-			},
-			deliver: {
-				activeSincePrevTick: 0,
-				active: 0,
-				waiting: 0,
-				delayed: 0,
-			},
-			prev: {},
-			sound: sound.setVolume(sound.getAudio('syuilo/queue-jammed'), 1)
-		};
-	},
-	created() {
-		for (const domain of ['inbox', 'deliver']) {
-			this.prev[domain] = JSON.parse(JSON.stringify(this[domain]));
-		}
-	
-		this.connection.on('stats', this.onStats);
-		this.connection.on('statsLog', this.onStatsLog);
+onUnmounted(() => {
+	connection.off('stats', onStats);
+	connection.off('statsLog', onStatsLog);
+	connection.dispose();
+});
 
-		this.connection.send('requestLog', {
-			id: Math.random().toString().substr(2, 8),
-			length: 1
-		});
-	},
-	beforeUnmount() {
-		this.connection.off('stats', this.onStats);
-		this.connection.off('statsLog', this.onStatsLog);
-		this.connection.dispose();
-	},
-	methods: {
-		onStats(stats) {
-			for (const domain of ['inbox', 'deliver']) {
-				this.prev[domain] = JSON.parse(JSON.stringify(this[domain]));
-				this[domain].activeSincePrevTick = stats[domain].activeSincePrevTick;
-				this[domain].active = stats[domain].active;
-				this[domain].waiting = stats[domain].waiting;
-				this[domain].delayed = stats[domain].delayed;
-
-				if (this[domain].waiting > 0 && this.props.sound && this.sound.paused) {
-					this.sound.play();
-				}
-			}
-		},
-
-		onStatsLog(statsLog) {
-			for (const stats of [...statsLog].reverse()) {
-				this.onStats(stats);
-			}
-		},
-
-		number
-	}
+defineExpose<WidgetComponentExpose>({
+	name,
+	configure,
+	id: props.widget ? props.widget.id : null,
 });
 </script>
 
diff --git a/packages/client/src/widgets/memo.vue b/packages/client/src/widgets/memo.vue
index 9b51ada220..3dfc6eb5fa 100644
--- a/packages/client/src/widgets/memo.vue
+++ b/packages/client/src/widgets/memo.vue
@@ -1,5 +1,5 @@
 <template>
-<MkContainer :show-header="props.showHeader">
+<MkContainer :show-header="widgetProps.showHeader">
 	<template #header><i class="fas fa-sticky-note"></i>{{ $ts._widgets.memo }}</template>
 
 	<div class="otgbylcu">
@@ -9,56 +9,60 @@
 </MkContainer>
 </template>
 
-<script lang="ts">
-import { defineComponent } from 'vue';
-import MkContainer from '@/components/ui/container.vue';
-import define from './define';
+<script lang="ts" setup>
+import { onMounted, onUnmounted, reactive, ref, watch } from 'vue';
+import { GetFormResultType } from '@/scripts/form';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
 import * as os from '@/os';
+import MkContainer from '@/components/ui/container.vue';
+import { defaultStore } from '@/store';
 
-const widget = define({
-	name: 'memo',
-	props: () => ({
-		showHeader: {
-			type: 'boolean',
-			default: true,
-		},
-	})
+const name = 'memo';
+
+const widgetPropsDef = {
+	showHeader: {
+		type: 'boolean' as const,
+		default: true,
+	},
+};
+
+type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
+
+// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
+//const props = defineProps<WidgetComponentProps<WidgetProps>>();
+//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
+const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
+const emit = defineEmits<{ (e: 'updateProps', props: WidgetProps); }>();
+
+const { widgetProps, configure } = useWidgetPropsManager(name,
+	widgetPropsDef,
+	props,
+	emit,
+);
+
+const text = ref<string | null>(defaultStore.state.memo);
+const changed = ref(false);
+let timeoutId;
+
+const saveMemo = () => {
+	defaultStore.set('memo', text.value);
+	changed.value = false;
+};
+
+const onChange = () => {
+	changed.value = true;
+	clearTimeout(timeoutId);
+	timeoutId = setTimeout(saveMemo, 1000);
+};
+
+watch(() => defaultStore.reactiveState.memo, newText => {
+	text.value = newText.value;
 });
 
-export default defineComponent({
-	components: {
-		MkContainer
-	},
-	extends: widget,
-
-	data() {
-		return {
-			text: null,
-			changed: false,
-			timeoutId: null,
-		};
-	},
-
-	created() {
-		this.text = this.$store.state.memo;
-
-		this.$watch(() => this.$store.reactiveState.memo, text => {
-			this.text = text;
-		});
-	},
-
-	methods: {
-		onChange() {
-			this.changed = true;
-			clearTimeout(this.timeoutId);
-			this.timeoutId = setTimeout(this.saveMemo, 1000);
-		},
-
-		saveMemo() {
-			this.$store.set('memo', this.text);
-			this.changed = false;
-		}
-	}
+defineExpose<WidgetComponentExpose>({
+	name,
+	configure,
+	id: props.widget ? props.widget.id : null,
 });
 </script>
 
diff --git a/packages/client/src/widgets/notifications.vue b/packages/client/src/widgets/notifications.vue
index 568705b661..8cf29c9271 100644
--- a/packages/client/src/widgets/notifications.vue
+++ b/packages/client/src/widgets/notifications.vue
@@ -1,65 +1,68 @@
 <template>
-<MkContainer :style="`height: ${props.height}px;`" :show-header="props.showHeader" :scrollable="true">
+<MkContainer :style="`height: ${widgetProps.height}px;`" :show-header="widgetProps.showHeader" :scrollable="true">
 	<template #header><i class="fas fa-bell"></i>{{ $ts.notifications }}</template>
-	<template #func><button class="_button" @click="configure()"><i class="fas fa-cog"></i></button></template>
+	<template #func><button class="_button" @click="configureNotification()"><i class="fas fa-cog"></i></button></template>
 
 	<div>
-		<XNotifications :include-types="props.includingTypes"/>
+		<XNotifications :include-types="widgetProps.includingTypes"/>
 	</div>
 </MkContainer>
 </template>
 
-<script lang="ts">
-import { defineComponent } from 'vue';
+<script lang="ts" setup>
+import { GetFormResultType } from '@/scripts/form';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
 import MkContainer from '@/components/ui/container.vue';
 import XNotifications from '@/components/notifications.vue';
-import define from './define';
 import * as os from '@/os';
 
-const widget = define({
-	name: 'notifications',
-	props: () => ({
-		showHeader: {
-			type: 'boolean',
-			default: true,
-		},
-		height: {
-			type: 'number',
-			default: 300,
-		},
-		includingTypes: {
-			type: 'array',
-			hidden: true,
-			default: null,
-		},
-	})
-});
+const name = 'notifications';
 
-export default defineComponent({
-
-	components: {
-		MkContainer,
-		XNotifications,
+const widgetPropsDef = {
+	showHeader: {
+		type: 'boolean' as const,
+		default: true,
 	},
-	extends: widget,
-
-	data() {
-		return {
-		};
+	height: {
+		type: 'number' as const,
+		default: 300,
 	},
+	includingTypes: {
+		type: 'array' as const,
+		hidden: true,
+		default: null,
+	},
+};
 
-	methods: {
-		configure() {
-			os.popup(import('@/components/notification-setting-window.vue'), {
-				includingTypes: this.props.includingTypes,
-			}, {
-				done: async (res) => {
-					const { includingTypes } = res;
-					this.props.includingTypes = includingTypes;
-					this.save();
-				}
-			}, 'closed');
+type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
+
+// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
+//const props = defineProps<WidgetComponentProps<WidgetProps>>();
+//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
+const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
+const emit = defineEmits<{ (e: 'updateProps', props: WidgetProps); }>();
+
+const { widgetProps, configure, save } = useWidgetPropsManager(name,
+	widgetPropsDef,
+	props,
+	emit,
+);
+
+const configureNotification = () => {
+	os.popup(import('@/components/notification-setting-window.vue'), {
+		includingTypes: widgetProps.includingTypes,
+	}, {
+		done: async (res) => {
+			const { includingTypes } = res;
+			widgetProps.includingTypes = includingTypes;
+			save();
 		}
-	}
+	}, 'closed');
+};
+
+defineExpose<WidgetComponentExpose>({
+	name,
+	configure,
+	id: props.widget ? props.widget.id : null,
 });
 </script>
diff --git a/packages/client/src/widgets/online-users.vue b/packages/client/src/widgets/online-users.vue
index 5b889f4816..2d47688697 100644
--- a/packages/client/src/widgets/online-users.vue
+++ b/packages/client/src/widgets/online-users.vue
@@ -1,48 +1,60 @@
 <template>
-<div class="mkw-onlineUsers" :class="{ _panel: !props.transparent, pad: !props.transparent }">
+<div class="mkw-onlineUsers" :class="{ _panel: !widgetProps.transparent, pad: !widgetProps.transparent }">
 	<I18n v-if="onlineUsersCount" :src="$ts.onlineUsersCount" text-tag="span" class="text">
 		<template #n><b>{{ onlineUsersCount }}</b></template>
 	</I18n>
 </div>
 </template>
 
-<script lang="ts">
-import { defineComponent } from 'vue';
-import define from './define';
+<script lang="ts" setup>
+import { onMounted, onUnmounted, ref } from 'vue';
+import { GetFormResultType } from '@/scripts/form';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
 import * as os from '@/os';
 
-const widget = define({
-	name: 'onlineUsers',
-	props: () => ({
-		transparent: {
-			type: 'boolean',
-			default: true,
-		},
-	})
+const name = 'onlineUsers';
+
+const widgetPropsDef = {
+	transparent: {
+		type: 'boolean' as const,
+		default: true,
+	},
+};
+
+type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
+
+// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
+//const props = defineProps<WidgetComponentProps<WidgetProps>>();
+//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
+const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
+const emit = defineEmits<{ (e: 'updateProps', props: WidgetProps); }>();
+
+const { widgetProps, configure } = useWidgetPropsManager(name,
+	widgetPropsDef,
+	props,
+	emit,
+);
+
+const onlineUsersCount = ref(0);
+
+const tick = () => {
+	os.api('get-online-users-count').then(res => {
+		onlineUsersCount.value = res.count;
+	});
+};
+
+onMounted(() => {
+	tick();
+	const intervalId = setInterval(tick, 1000 * 15);
+	onUnmounted(() => {
+		clearInterval(intervalId);
+	});
 });
 
-export default defineComponent({
-	extends: widget,
-	data() {
-		return {
-			onlineUsersCount: null,
-			clock: null,
-		};
-	},
-	created() {
-		this.tick();
-		this.clock = setInterval(this.tick, 1000 * 15);
-	},
-	beforeUnmount() {
-		clearInterval(this.clock);
-	},
-	methods: {
-		tick() {
-			os.api('get-online-users-count').then(res => {
-				this.onlineUsersCount = res.count;
-			});
-		}
-	}
+defineExpose<WidgetComponentExpose>({
+	name,
+	configure,
+	id: props.widget ? props.widget.id : null,
 });
 </script>
 
diff --git a/packages/client/src/widgets/photos.vue b/packages/client/src/widgets/photos.vue
index 7a0b54027b..8f948dc643 100644
--- a/packages/client/src/widgets/photos.vue
+++ b/packages/client/src/widgets/photos.vue
@@ -1,5 +1,5 @@
 <template>
-<MkContainer :show-header="props.showHeader" :naked="props.transparent" :class="$style.root" :data-transparent="props.transparent ? true : null">
+<MkContainer :show-header="widgetProps.showHeader" :naked="widgetProps.transparent" :class="$style.root" :data-transparent="widgetProps.transparent ? true : null">
 	<template #header><i class="fas fa-camera"></i>{{ $ts._widgets.photos }}</template>
 
 	<div class="">
@@ -14,70 +14,77 @@
 </MkContainer>
 </template>
 
-<script lang="ts">
-import { defineComponent, markRaw } from 'vue';
-import MkContainer from '@/components/ui/container.vue';
-import define from './define';
+<script lang="ts" setup>
+import { onMounted, onUnmounted, reactive, ref } from 'vue';
+import { GetFormResultType } from '@/scripts/form';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
+import { stream } from '@/stream';
 import { getStaticImageUrl } from '@/scripts/get-static-image-url';
 import * as os from '@/os';
-import { stream } from '@/stream';
+import MkContainer from '@/components/ui/container.vue';
+import { defaultStore } from '@/store';
 
-const widget = define({
-	name: 'photos',
-	props: () => ({
-		showHeader: {
-			type: 'boolean',
-			default: true,
-		},
-		transparent: {
-			type: 'boolean',
-			default: false,
-		},
-	})
+const name = 'photos';
+
+const widgetPropsDef = {
+	showHeader: {
+		type: 'boolean' as const,
+		default: true,
+	},
+	transparent: {
+		type: 'boolean' as const,
+		default: false,
+	},
+};
+
+type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
+
+// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
+//const props = defineProps<WidgetComponentProps<WidgetProps>>();
+//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
+const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
+const emit = defineEmits<{ (e: 'updateProps', props: WidgetProps); }>();
+
+const { widgetProps, configure } = useWidgetPropsManager(name,
+	widgetPropsDef,
+	props,
+	emit,
+);
+
+const connection = stream.useChannel('main');
+const images = ref([]);
+const fetching = ref(true);
+
+const onDriveFileCreated = (file) => {
+	if (/^image\/.+$/.test(file.type)) {
+		images.value.unshift(file);
+		if (images.value.length > 9) images.value.pop();
+	}
+};
+
+const thumbnail = (image: any): string => {
+	return defaultStore.state.disableShowingAnimatedImages
+		? getStaticImageUrl(image.thumbnailUrl)
+		: image.thumbnailUrl;
+};
+
+os.api('drive/stream', {
+	type: 'image/*',
+	limit: 9
+}).then(res => {
+	images.value = res;
+	fetching.value = false;
 });
 
-export default defineComponent({
-	components: {
-		MkContainer,
-	},
-	extends: widget,
-	data() {
-		return {
-			images: [],
-			fetching: true,
-			connection: null,
-		};
-	},
-	mounted() {
-		this.connection = markRaw(stream.useChannel('main'));
+connection.on('driveFileCreated', onDriveFileCreated);
+onUnmounted(() => {
+	connection.dispose();
+});
 
-		this.connection.on('driveFileCreated', this.onDriveFileCreated);
-
-		os.api('drive/stream', {
-			type: 'image/*',
-			limit: 9
-		}).then(images => {
-			this.images = images;
-			this.fetching = false;
-		});
-	},
-	beforeUnmount() {
-		this.connection.dispose();
-	},
-	methods: {
-		onDriveFileCreated(file) {
-			if (/^image\/.+$/.test(file.type)) {
-				this.images.unshift(file);
-				if (this.images.length > 9) this.images.pop();
-			}
-		},
-
-		thumbnail(image: any): string {
-			return this.$store.state.disableShowingAnimatedImages
-				? getStaticImageUrl(image.thumbnailUrl)
-				: image.thumbnailUrl;
-		},
-	}
+defineExpose<WidgetComponentExpose>({
+	name,
+	configure,
+	id: props.widget ? props.widget.id : null,
 });
 </script>
 
diff --git a/packages/client/src/widgets/post-form.vue b/packages/client/src/widgets/post-form.vue
index 6de0574cc1..51aa8fcf6b 100644
--- a/packages/client/src/widgets/post-form.vue
+++ b/packages/client/src/widgets/post-form.vue
@@ -2,22 +2,34 @@
 <XPostForm class="_panel" :fixed="true" :autofocus="false"/>
 </template>
 
-<script lang="ts">
-import { defineComponent } from 'vue';
+<script lang="ts" setup>
+import { } from 'vue';
+import { GetFormResultType } from '@/scripts/form';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
 import XPostForm from '@/components/post-form.vue';
-import define from './define';
 
-const widget = define({
-	name: 'postForm',
-	props: () => ({
-	})
-});
+const name = 'postForm';
 
-export default defineComponent({
+const widgetPropsDef = {
+};
 
-	components: {
-		XPostForm,
-	},
-	extends: widget,
+type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
+
+// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
+//const props = defineProps<WidgetComponentProps<WidgetProps>>();
+//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
+const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
+const emit = defineEmits<{ (e: 'updateProps', props: WidgetProps); }>();
+
+const { widgetProps, configure } = useWidgetPropsManager(name,
+	widgetPropsDef,
+	props,
+	emit,
+);
+
+defineExpose<WidgetComponentExpose>({
+	name,
+	configure,
+	id: props.widget ? props.widget.id : null,
 });
 </script>
diff --git a/packages/client/src/widgets/rss.vue b/packages/client/src/widgets/rss.vue
index b2dc77854e..aa82054580 100644
--- a/packages/client/src/widgets/rss.vue
+++ b/packages/client/src/widgets/rss.vue
@@ -1,7 +1,7 @@
 <template>
-<MkContainer :show-header="props.showHeader">
+<MkContainer :show-header="widgetProps.showHeader">
 	<template #header><i class="fas fa-rss-square"></i>RSS</template>
-	<template #func><button class="_button" @click="setting"><i class="fas fa-cog"></i></button></template>
+	<template #func><button class="_button" @click="configure"><i class="fas fa-cog"></i></button></template>
 
 	<div class="ekmkgxbj">
 		<MkLoading v-if="fetching"/>
@@ -12,57 +12,66 @@
 </MkContainer>
 </template>
 
-<script lang="ts">
-import { defineComponent } from 'vue';
-import MkContainer from '@/components/ui/container.vue';
-import define from './define';
+<script lang="ts" setup>
+import { onMounted, onUnmounted, ref, watch } from 'vue';
+import { GetFormResultType } from '@/scripts/form';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
 import * as os from '@/os';
+import MkContainer from '@/components/ui/container.vue';
 
-const widget = define({
-	name: 'rss',
-	props: () => ({
-		showHeader: {
-			type: 'boolean',
-			default: true,
-		},
-		url: {
-			type: 'string',
-			default: 'http://feeds.afpbb.com/rss/afpbb/afpbbnews',
-		},
-	})
+const name = 'rss';
+
+const widgetPropsDef = {
+	showHeader: {
+		type: 'boolean' as const,
+		default: true,
+	},
+	url: {
+		type: 'string' as const,
+		default: 'http://feeds.afpbb.com/rss/afpbb/afpbbnews',
+	},
+};
+
+type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
+
+// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
+//const props = defineProps<WidgetComponentProps<WidgetProps>>();
+//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
+const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
+const emit = defineEmits<{ (e: 'updateProps', props: WidgetProps); }>();
+
+const { widgetProps, configure } = useWidgetPropsManager(name,
+	widgetPropsDef,
+	props,
+	emit,
+);
+
+const items = ref([]);
+const fetching = ref(true);
+
+const tick = () => {
+	fetch(`https://api.rss2json.com/v1/api.json?rss_url=${this.props.url}`, {}).then(res => {
+		res.json().then(feed => {
+			items.value = feed.items;
+			fetching.value = false;
+		});
+	});
+};
+
+watch(() => widgetProps.url, tick);
+
+onMounted(() => {
+	tick();
+	const intervalId = setInterval(tick, 60000);
+	onUnmounted(() => {
+		clearInterval(intervalId);
+	});
 });
 
-export default defineComponent({
-	components: {
-		MkContainer
-	},
-	extends: widget,
-	data() {
-		return {
-			items: [],
-			fetching: true,
-			clock: null,
-		};
-	},
-	mounted() {
-		this.fetch();
-		this.clock = setInterval(this.fetch, 60000);
-		this.$watch(() => this.props.url, this.fetch);
-	},
-	beforeUnmount() {
-		clearInterval(this.clock);
-	},
-	methods: {
-		fetch() {
-			fetch(`https://api.rss2json.com/v1/api.json?rss_url=${this.props.url}`, {
-			}).then(res => {
-				res.json().then(feed => {
-					this.items = feed.items;
-					this.fetching = false;
-				});
-			});
-		},
-	}
+defineExpose<WidgetComponentExpose>({
+	name,
+	configure,
+	id: props.widget ? props.widget.id : null,
 });
 </script>
 
diff --git a/packages/client/src/widgets/server-metric/index.vue b/packages/client/src/widgets/server-metric/index.vue
index 107b750906..2caa73fa74 100644
--- a/packages/client/src/widgets/server-metric/index.vue
+++ b/packages/client/src/widgets/server-metric/index.vue
@@ -1,21 +1,22 @@
 <template>
-<MkContainer :show-header="props.showHeader" :naked="props.transparent">
+<MkContainer :show-header="widgetProps.showHeader" :naked="widgetProps.transparent">
 	<template #header><i class="fas fa-server"></i>{{ $ts._widgets.serverMetric }}</template>
 	<template #func><button class="_button" @click="toggleView()"><i class="fas fa-sort"></i></button></template>
 
 	<div v-if="meta" class="mkw-serverMetric">
-		<XCpuMemory v-if="props.view === 0" :connection="connection" :meta="meta"/>
-		<XNet v-if="props.view === 1" :connection="connection" :meta="meta"/>
-		<XCpu v-if="props.view === 2" :connection="connection" :meta="meta"/>
-		<XMemory v-if="props.view === 3" :connection="connection" :meta="meta"/>
-		<XDisk v-if="props.view === 4" :connection="connection" :meta="meta"/>
+		<XCpuMemory v-if="widgetProps.view === 0" :connection="connection" :meta="meta"/>
+		<XNet v-else-if="widgetProps.view === 1" :connection="connection" :meta="meta"/>
+		<XCpu v-else-if="widgetProps.view === 2" :connection="connection" :meta="meta"/>
+		<XMemory v-else-if="widgetProps.view === 3" :connection="connection" :meta="meta"/>
+		<XDisk v-else-if="widgetProps.view === 4" :connection="connection" :meta="meta"/>
 	</div>
 </MkContainer>
 </template>
 
-<script lang="ts">
-import { defineComponent, markRaw } from 'vue';
-import define from '../define';
+<script lang="ts" setup>
+import { onMounted, onUnmounted, ref } from 'vue';
+import { GetFormResultType } from '@/scripts/form';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from '../widget';
 import MkContainer from '@/components/ui/container.vue';
 import XCpuMemory from './cpu-mem.vue';
 import XNet from './net.vue';
@@ -25,59 +26,61 @@ import XDisk from './disk.vue';
 import * as os from '@/os';
 import { stream } from '@/stream';
 
-const widget = define({
-	name: 'serverMetric',
-	props: () => ({
-		showHeader: {
-			type: 'boolean',
-			default: true,
-		},
-		transparent: {
-			type: 'boolean',
-			default: false,
-		},
-		view: {
-			type: 'number',
-			default: 0,
-			hidden: true,
-		},
-	})
+const name = 'serverMetric';
+
+const widgetPropsDef = {
+	showHeader: {
+		type: 'boolean' as const,
+		default: true,
+	},
+	transparent: {
+		type: 'boolean' as const,
+		default: false,
+	},
+	view: {
+		type: 'number' as const,
+		default: 0,
+		hidden: true,
+	},
+};
+
+type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
+
+// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
+//const props = defineProps<WidgetComponentProps<WidgetProps>>();
+//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
+const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
+const emit = defineEmits<{ (e: 'updateProps', props: WidgetProps); }>();
+
+const { widgetProps, configure, save } = useWidgetPropsManager(name,
+	widgetPropsDef,
+	props,
+	emit,
+);
+
+const meta = ref(null);
+
+os.api('server-info', {}).then(res => {
+	meta.value = res;
 });
 
-export default defineComponent({
-	components: {
-		MkContainer,
-		XCpuMemory,
-		XNet,
-		XCpu,
-		XMemory,
-		XDisk,
-	},
-	extends: widget,
-	data() {
-		return {
-			meta: null,
-			connection: null,
-		};
-	},
-	created() {
-		os.api('server-info', {}).then(res => {
-			this.meta = res;
-		});
-		this.connection = markRaw(stream.useChannel('serverStats'));
-	},
-	unmounted() {
-		this.connection.dispose();
-	},
-	methods: {
-		toggleView() {
-			if (this.props.view == 4) {
-				this.props.view = 0;
-			} else {
-				this.props.view++;
-			}
-			this.save();
-		},
+const toggleView = () => {
+	if (widgetProps.view == 4) {
+		widgetProps.view = 0;
+	} else {
+		widgetProps.view++;
 	}
+	save();
+};
+
+const connection = stream.useChannel('serverStats');
+onUnmounted(() => {
+	connection.dispose();
+});
+
+defineExpose<WidgetComponentExpose>({
+	name,
+	configure,
+	id: props.widget ? props.widget.id : null,
 });
 </script>
diff --git a/packages/client/src/widgets/slideshow.vue b/packages/client/src/widgets/slideshow.vue
index 0909bda67c..ac0c6c9e07 100644
--- a/packages/client/src/widgets/slideshow.vue
+++ b/packages/client/src/widgets/slideshow.vue
@@ -1,126 +1,116 @@
 <template>
-<div class="kvausudm _panel">
+<div class="kvausudm _panel" :style="{ height: widgetProps.height + 'px' }">
 	<div @click="choose">
-		<p v-if="props.folderId == null">
-			<template v-if="isCustomizeMode">{{ $t('folder-customize-mode') }}</template>
-			<template v-else>{{ $ts.folder }}</template>
+		<p v-if="widgetProps.folderId == null">
+			{{ $ts.folder }}
 		</p>
-		<p v-if="props.folderId != null && images.length === 0 && !fetching">{{ $t('no-image') }}</p>
+		<p v-if="widgetProps.folderId != null && images.length === 0 && !fetching">{{ $t('no-image') }}</p>
 		<div ref="slideA" class="slide a"></div>
 		<div ref="slideB" class="slide b"></div>
 	</div>
 </div>
 </template>
 
-<script lang="ts">
-import { defineComponent } from 'vue';
-import define from './define';
+<script lang="ts" setup>
+import { nextTick, onMounted, onUnmounted, reactive, ref } from 'vue';
+import { GetFormResultType } from '@/scripts/form';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
 import * as os from '@/os';
 
-const widget = define({
-	name: 'slideshow',
-	props: () => ({
-		height: {
-			type: 'number',
-			default: 300,
-		},
-		folderId: {
-			type: 'string',
-			default: null,
-			hidden: true,
-		},
-	})
+const name = 'slideshow';
+
+const widgetPropsDef = {
+	height: {
+		type: 'number' as const,
+		default: 300,
+	},
+	folderId: {
+		type: 'string' as const,
+		default: null,
+		hidden: true,
+	},
+};
+
+type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
+
+// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
+//const props = defineProps<WidgetComponentProps<WidgetProps>>();
+//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
+const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
+const emit = defineEmits<{ (e: 'updateProps', props: WidgetProps); }>();
+
+const { widgetProps, configure, save } = useWidgetPropsManager(name,
+	widgetPropsDef,
+	props,
+	emit,
+);
+
+const images = ref([]);
+const fetching = ref(true);
+const slideA = ref<HTMLElement>();
+const slideB = ref<HTMLElement>();
+
+const change = () => {
+	if (images.value.length == 0) return;
+
+	const index = Math.floor(Math.random() * images.value.length);
+	const img = `url(${ images.value[index].url })`;
+
+	slideB.value.style.backgroundImage = img;
+
+	slideB.value.classList.add('anime');
+	setTimeout(() => {
+		// 既にこのウィジェットがunmountされていたら要素がない
+		if (slideA.value == null) return;
+
+		slideA.value.style.backgroundImage = img;
+
+		slideB.value.classList.remove('anime');
+	}, 1000);
+};
+
+const fetch = () => {
+	fetching.value = true;
+
+	os.api('drive/files', {
+		folderId: widgetProps.folderId,
+		type: 'image/*',
+		limit: 100
+	}).then(res => {
+		images.value = res;
+		fetching.value = false;
+		slideA.value.style.backgroundImage = '';
+		slideB.value.style.backgroundImage = '';
+		change();
+	});
+};
+
+const choose = () => {
+	os.selectDriveFolder(false).then(folder => {
+		if (folder == null) {
+			return;
+		}
+		widgetProps.folderId = folder.id;
+		save();
+		fetch();
+	});
+};
+
+onMounted(() => {
+	if (widgetProps.folderId != null) {
+		fetch();
+	}
+
+	const intervalId = setInterval(change, 10000);
+	onUnmounted(() => {
+		clearInterval(intervalId);
+	});
 });
 
-export default defineComponent({
-	extends: widget,
-	data() {
-		return {
-			images: [],
-			fetching: true,
-			clock: null
-		};
-	},
-	mounted() {
-		this.$nextTick(() => {
-			this.applySize();
-		});
-
-		if (this.props.folderId != null) {
-			this.fetch();
-		}
-
-		this.clock = setInterval(this.change, 10000);
-	},
-	beforeUnmount() {
-		clearInterval(this.clock);
-	},
-	methods: {
-		applySize() {
-			let h;
-
-			if (this.props.size == 1) {
-				h = 250;
-			} else {
-				h = 170;
-			}
-
-			this.$el.style.height = `${h}px`;
-		},
-		resize() {
-			if (this.props.size == 1) {
-				this.props.size = 0;
-			} else {
-				this.props.size++;
-			}
-			this.save();
-
-			this.applySize();
-		},
-		change() {
-			if (this.images.length == 0) return;
-
-			const index = Math.floor(Math.random() * this.images.length);
-			const img = `url(${ this.images[index].url })`;
-
-			(this.$refs.slideB as any).style.backgroundImage = img;
-
-			this.$refs.slideB.classList.add('anime');
-			setTimeout(() => {
-				// 既にこのウィジェットがunmountされていたら要素がない
-				if ((this.$refs.slideA as any) == null) return;
-
-				(this.$refs.slideA as any).style.backgroundImage = img;
-
-				this.$refs.slideB.classList.remove('anime');
-			}, 1000);
-		},
-		fetch() {
-			this.fetching = true;
-
-			os.api('drive/files', {
-				folderId: this.props.folderId,
-				type: 'image/*',
-				limit: 100
-			}).then(images => {
-				this.images = images;
-				this.fetching = false;
-				(this.$refs.slideA as any).style.backgroundImage = '';
-				(this.$refs.slideB as any).style.backgroundImage = '';
-				this.change();
-			});
-		},
-		choose() {
-			os.selectDriveFolder(false).then(folder => {
-				if (folder == null) {
-					return;
-				}
-				this.props.folderId = folder.id;
-				this.save();
-				this.fetch();
-			});
-		}
-	}
+defineExpose<WidgetComponentExpose>({
+	name,
+	configure,
+	id: props.widget ? props.widget.id : null,
 });
 </script>
 
diff --git a/packages/client/src/widgets/timeline.vue b/packages/client/src/widgets/timeline.vue
index aee6a35b1d..fa700cc8ee 100644
--- a/packages/client/src/widgets/timeline.vue
+++ b/packages/client/src/widgets/timeline.vue
@@ -1,116 +1,129 @@
 <template>
-<MkContainer :show-header="props.showHeader" :style="`height: ${props.height}px;`" :scrollable="true">
+<MkContainer :show-header="widgetProps.showHeader" :style="`height: ${widgetProps.height}px;`" :scrollable="true">
 	<template #header>
 		<button class="_button" @click="choose">
-			<i v-if="props.src === 'home'" class="fas fa-home"></i>
-			<i v-else-if="props.src === 'local'" class="fas fa-comments"></i>
-			<i v-else-if="props.src === 'social'" class="fas fa-share-alt"></i>
-			<i v-else-if="props.src === 'global'" class="fas fa-globe"></i>
-			<i v-else-if="props.src === 'list'" class="fas fa-list-ul"></i>
-			<i v-else-if="props.src === 'antenna'" class="fas fa-satellite"></i>
-			<span style="margin-left: 8px;">{{ props.src === 'list' ? props.list.name : props.src === 'antenna' ? props.antenna.name : $t('_timelines.' + props.src) }}</span>
+			<i v-if="widgetProps.src === 'home'" class="fas fa-home"></i>
+			<i v-else-if="widgetProps.src === 'local'" class="fas fa-comments"></i>
+			<i v-else-if="widgetProps.src === 'social'" class="fas fa-share-alt"></i>
+			<i v-else-if="widgetProps.src === 'global'" class="fas fa-globe"></i>
+			<i v-else-if="widgetProps.src === 'list'" class="fas fa-list-ul"></i>
+			<i v-else-if="widgetProps.src === 'antenna'" class="fas fa-satellite"></i>
+			<span style="margin-left: 8px;">{{ widgetProps.src === 'list' ? widgetProps.list.name : widgetProps.src === 'antenna' ? widgetProps.antenna.name : $t('_timelines.' + widgetProps.src) }}</span>
 			<i :class="menuOpened ? 'fas fa-angle-up' : 'fas fa-angle-down'" style="margin-left: 8px;"></i>
 		</button>
 	</template>
 
 	<div>
-		<XTimeline :key="props.src === 'list' ? `list:${props.list.id}` : props.src === 'antenna' ? `antenna:${props.antenna.id}` : props.src" :src="props.src" :list="props.list ? props.list.id : null" :antenna="props.antenna ? props.antenna.id : null"/>
+		<XTimeline :key="widgetProps.src === 'list' ? `list:${widgetProps.list.id}` : widgetProps.src === 'antenna' ? `antenna:${widgetProps.antenna.id}` : widgetProps.src" :src="widgetProps.src" :list="widgetProps.list ? widgetProps.list.id : null" :antenna="widgetProps.antenna ? widgetProps.antenna.id : null"/>
 	</div>
 </MkContainer>
 </template>
 
-<script lang="ts">
-import { defineComponent } from 'vue';
+<script lang="ts" setup>
+import { onMounted, onUnmounted, reactive, ref, watch } from 'vue';
+import { GetFormResultType } from '@/scripts/form';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
+import * as os from '@/os';
 import MkContainer from '@/components/ui/container.vue';
 import XTimeline from '@/components/timeline.vue';
-import define from './define';
-import * as os from '@/os';
+import { $i } from '@/account';
+import { i18n } from '@/i18n';
 
-const widget = define({
-	name: 'timeline',
-	props: () => ({
-		showHeader: {
-			type: 'boolean',
-			default: true,
-		},
-		height: {
-			type: 'number',
-			default: 300,
-		},
-		src: {
-			type: 'string',
-			default: 'home',
-			hidden: true,
-		},
-		list: {
-			type: 'object',
-			default: null,
-			hidden: true,
-		},
-	})
-});
+const name = 'timeline';
 
-export default defineComponent({
-	components: {
-		MkContainer,
-		XTimeline,
+const widgetPropsDef = {
+	showHeader: {
+		type: 'boolean' as const,
+		default: true,
 	},
-	extends: widget,
-
-	data() {
-		return {
-			menuOpened: false,
-		};
+	height: {
+		type: 'number' as const,
+		default: 300,
 	},
+	src: {
+		type: 'string' as const,
+		default: 'home',
+		hidden: true,
+	},
+	antenna: {
+		type: 'object' as const,
+		default: null,
+		hidden: true,
+	},
+	list: {
+		type: 'object' as const,
+		default: null,
+		hidden: true,
+	},
+};
 
-	methods: {
-		async choose(ev) {
-			this.menuOpened = true;
-			const [antennas, lists] = await Promise.all([
-				os.api('antennas/list'),
-				os.api('users/lists/list')
-			]);
-			const antennaItems = antennas.map(antenna => ({
-				text: antenna.name,
-				icon: 'fas fa-satellite',
-				action: () => {
-					this.props.antenna = antenna;
-					this.setSrc('antenna');
-				}
-			}));
-			const listItems = lists.map(list => ({
-				text: list.name,
-				icon: 'fas fa-list-ul',
-				action: () => {
-					this.props.list = list;
-					this.setSrc('list');
-				}
-			}));
-			os.popupMenu([{
-				text: this.$ts._timelines.home,
-				icon: 'fas fa-home',
-				action: () => { this.setSrc('home') }
-			}, {
-				text: this.$ts._timelines.local,
-				icon: 'fas fa-comments',
-				action: () => { this.setSrc('local') }
-			}, {
-				text: this.$ts._timelines.social,
-				icon: 'fas fa-share-alt',
-				action: () => { this.setSrc('social') }
-			}, {
-				text: this.$ts._timelines.global,
-				icon: 'fas fa-globe',
-				action: () => { this.setSrc('global') }
-			}, antennaItems.length > 0 ? null : undefined, ...antennaItems, listItems.length > 0 ? null : undefined, ...listItems], ev.currentTarget || ev.target).then(() => {
-				this.menuOpened = false;
-			});
-		},
+type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
 
-		setSrc(src) {
-			this.props.src = src;
-			this.save();
-		},
-	}
+// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
+//const props = defineProps<WidgetComponentProps<WidgetProps>>();
+//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
+const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
+const emit = defineEmits<{ (e: 'updateProps', props: WidgetProps); }>();
+
+const { widgetProps, configure, save } = useWidgetPropsManager(name,
+	widgetPropsDef,
+	props,
+	emit,
+);
+
+const menuOpened = ref(false);
+
+const setSrc = (src) => {
+	widgetProps.src = src;
+	save();
+};
+
+const choose = async (ev) => {
+	menuOpened.value = true;
+	const [antennas, lists] = await Promise.all([
+		os.api('antennas/list'),
+		os.api('users/lists/list')
+	]);
+	const antennaItems = antennas.map(antenna => ({
+		text: antenna.name,
+		icon: 'fas fa-satellite',
+		action: () => {
+			widgetProps.antenna = antenna;
+			setSrc('antenna');
+		}
+	}));
+	const listItems = lists.map(list => ({
+		text: list.name,
+		icon: 'fas fa-list-ul',
+		action: () => {
+			widgetProps.list = list;
+			setSrc('list');
+		}
+	}));
+	os.popupMenu([{
+		text: i18n.locale._timelines.home,
+		icon: 'fas fa-home',
+		action: () => { setSrc('home') }
+	}, {
+		text: i18n.locale._timelines.local,
+		icon: 'fas fa-comments',
+		action: () => { setSrc('local') }
+	}, {
+		text: i18n.locale._timelines.social,
+		icon: 'fas fa-share-alt',
+		action: () => { setSrc('social') }
+	}, {
+		text: i18n.locale._timelines.global,
+		icon: 'fas fa-globe',
+		action: () => { setSrc('global') }
+	}, antennaItems.length > 0 ? null : undefined, ...antennaItems, listItems.length > 0 ? null : undefined, ...listItems], ev.currentTarget || ev.target).then(() => {
+		menuOpened.value = false;
+	});
+};
+
+defineExpose<WidgetComponentExpose>({
+	name,
+	configure,
+	id: props.widget ? props.widget.id : null,
 });
 </script>
diff --git a/packages/client/src/widgets/trends.vue b/packages/client/src/widgets/trends.vue
index ffad93c02b..3905daa673 100644
--- a/packages/client/src/widgets/trends.vue
+++ b/packages/client/src/widgets/trends.vue
@@ -1,5 +1,5 @@
 <template>
-<MkContainer :show-header="props.showHeader">
+<MkContainer :show-header="widgetProps.showHeader">
 	<template #header><i class="fas fa-hashtag"></i>{{ $ts._widgets.trends }}</template>
 
 	<div class="wbrkwala">
@@ -17,49 +17,59 @@
 </MkContainer>
 </template>
 
-<script lang="ts">
-import { defineComponent } from 'vue';
+<script lang="ts" setup>
+import { onMounted, onUnmounted, ref } from 'vue';
+import { GetFormResultType } from '@/scripts/form';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
 import MkContainer from '@/components/ui/container.vue';
-import define from './define';
 import MkMiniChart from '@/components/mini-chart.vue';
 import * as os from '@/os';
 
-const widget = define({
-	name: 'hashtags',
-	props: () => ({
-		showHeader: {
-			type: 'boolean',
-			default: true,
-		},
-	})
+const name = 'hashtags';
+
+const widgetPropsDef = {
+	showHeader: {
+		type: 'boolean' as const,
+		default: true,
+	},
+};
+
+type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
+
+// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
+//const props = defineProps<WidgetComponentProps<WidgetProps>>();
+//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
+const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
+const emit = defineEmits<{ (e: 'updateProps', props: WidgetProps); }>();
+
+const { widgetProps, configure } = useWidgetPropsManager(name,
+	widgetPropsDef,
+	props,
+	emit,
+);
+
+const stats = ref([]);
+const fetching = ref(true);
+
+const fetch = () => {
+	os.api('hashtags/trend').then(stats => {
+		stats.value = stats;
+		fetching.value = false;
+	});
+};
+
+onMounted(() => {
+	fetch();
+	const intervalId = setInterval(fetch, 1000 * 60);
+	onUnmounted(() => {
+		clearInterval(intervalId);
+	});
 });
 
-export default defineComponent({
-	components: {
-		MkContainer, MkMiniChart
-	},
-	extends: widget,
-	data() {
-		return {
-			stats: [],
-			fetching: true,
-		};
-	},
-	mounted() {
-		this.fetch();
-		this.clock = setInterval(this.fetch, 1000 * 60);
-	},
-	beforeUnmount() {
-		clearInterval(this.clock);
-	},
-	methods: {
-		fetch() {
-			os.api('hashtags/trend').then(stats => {
-				this.stats = stats;
-				this.fetching = false;
-			});
-		}
-	}
+defineExpose<WidgetComponentExpose>({
+	name,
+	configure,
+	id: props.widget ? props.widget.id : null,
 });
 </script>
 
diff --git a/packages/client/src/widgets/widget.ts b/packages/client/src/widgets/widget.ts
new file mode 100644
index 0000000000..81239bfb3b
--- /dev/null
+++ b/packages/client/src/widgets/widget.ts
@@ -0,0 +1,71 @@
+import { reactive, watch } from 'vue';
+import { throttle } from 'throttle-debounce';
+import { Form, GetFormResultType } from '@/scripts/form';
+import * as os from '@/os';
+
+export type Widget<P extends Record<string, unknown>> = {
+	id: string;
+	data: Partial<P>;
+};
+
+export type WidgetComponentProps<P extends Record<string, unknown>> = {
+	widget?: Widget<P>;
+};
+
+export type WidgetComponentEmits<P extends Record<string, unknown>> = {
+	(e: 'updateProps', props: P);
+};
+
+export type WidgetComponentExpose = {
+	name: string;
+	id: string | null;
+	configure: () => void;
+};
+
+export const useWidgetPropsManager = <F extends Form & Record<string, { default: any; }>>(
+	name: string,
+	propsDef: F,
+	props: Readonly<WidgetComponentProps<GetFormResultType<F>>>,
+	emit: WidgetComponentEmits<GetFormResultType<F>>,
+): {
+	widgetProps: GetFormResultType<F>;
+	save: () => void;
+	configure: () => void;
+} => {
+	const widgetProps = reactive(props.widget ? JSON.parse(JSON.stringify(props.widget.data)) : {});
+
+	const mergeProps = () => {
+		for (const prop of Object.keys(propsDef)) {
+			if (widgetProps.hasOwnProperty(prop)) continue;
+			widgetProps[prop] = propsDef[prop].default;
+		}
+	};
+	watch(widgetProps, () => {
+		mergeProps();
+	}, { deep: true, immediate: true, });
+
+	const save = throttle(3000, () => {
+		emit('updateProps', widgetProps)
+	});
+
+	const configure = async () => {
+		const form = JSON.parse(JSON.stringify(propsDef));
+		for (const item of Object.keys(form)) {
+			form[item].default = widgetProps[item];
+		}
+		const { canceled, result } = await os.form(name, form);
+		if (canceled) return;
+
+		for (const key of Object.keys(result)) {
+			widgetProps[key] = result[key];
+		}
+
+		save();
+	};
+
+	return {
+		widgetProps,
+		save,
+		configure,
+	};
+};