diff --git a/.eslintrc b/.eslintrc
index 6caf8f532a..679d4f12db 100644
--- a/.eslintrc
+++ b/.eslintrc
@@ -13,6 +13,7 @@
 		"vue/html-self-closing": false,
 		"vue/no-unused-vars": false,
 		"no-console": 0,
-		"no-unused-vars": 0
+		"no-unused-vars": 0,
+		"no-empty": 0
 	}
 }
diff --git a/src/web/app/common/mios.ts b/src/web/app/common/mios.ts
index a98df1bc03..c4208aa913 100644
--- a/src/web/app/common/mios.ts
+++ b/src/web/app/common/mios.ts
@@ -16,6 +16,38 @@ declare const _API_URL_: string;
 declare const _SW_PUBLICKEY_: string;
 //#endregion
 
+export type API = {
+	chooseDriveFile: (opts: {
+		title?: string;
+		currentFolder?: any;
+		multiple?: boolean;
+	}) => Promise<any>;
+
+	chooseDriveFolder: (opts: {
+		title?: string;
+		currentFolder?: any;
+	}) => Promise<any>;
+
+	dialog: (opts: {
+		title: string;
+		text: string;
+		actions: Array<{
+			text: string;
+			id?: string;
+		}>;
+	}) => Promise<string>;
+
+	input: (opts: {
+		title: string;
+		placeholder?: string;
+		default?: string;
+	}) => Promise<string>;
+
+	post: () => void;
+
+	notify: (message: string) => void;
+};
+
 /**
  * Misskey Operating System
  */
@@ -49,6 +81,8 @@ export default class MiOS extends EventEmitter {
 		return localStorage.getItem('debug') == 'true';
 	}
 
+	public apis: API;
+
 	/**
 	 * A connection manager of home stream
 	 */
diff --git a/src/web/app/common/scripts/fuck-ad-block.ts b/src/web/app/common/scripts/fuck-ad-block.ts
new file mode 100644
index 0000000000..9bcf7deeff
--- /dev/null
+++ b/src/web/app/common/scripts/fuck-ad-block.ts
@@ -0,0 +1,21 @@
+require('fuckadblock');
+
+declare const fuckAdBlock: any;
+
+export default (os) => {
+	function adBlockDetected() {
+		os.apis.dialog({
+			title: '%fa:exclamation-triangle%広告ブロッカーを無効にしてください',
+			text: '<strong>Misskeyは広告を掲載していません</strong>が、広告をブロックする機能が有効だと一部の機能が利用できなかったり、不具合が発生する場合があります。',
+			actins: [{
+				text: 'OK'
+			}]
+		});
+	}
+
+	if (fuckAdBlock === undefined) {
+		adBlockDetected();
+	} else {
+		fuckAdBlock.onDetected(adBlockDetected);
+	}
+};
diff --git a/src/web/app/desktop/api/notify.ts b/src/web/app/desktop/api/notify.ts
new file mode 100644
index 0000000000..1f89f40ce6
--- /dev/null
+++ b/src/web/app/desktop/api/notify.ts
@@ -0,0 +1,10 @@
+import Notification from '../views/components/ui-notification.vue';
+
+export default function(message) {
+	const vm = new Notification({
+		propsData: {
+			message
+		}
+	}).$mount();
+	document.body.appendChild(vm.$el);
+}
diff --git a/src/web/app/desktop/api/update-avatar.ts b/src/web/app/desktop/api/update-avatar.ts
new file mode 100644
index 0000000000..eff0728348
--- /dev/null
+++ b/src/web/app/desktop/api/update-avatar.ts
@@ -0,0 +1,95 @@
+import OS from '../../common/mios';
+import { apiUrl } from '../../config';
+import CropWindow from '../views/components/crop-window.vue';
+import ProgressDialog from '../views/components/progress-dialog.vue';
+
+export default (os: OS) => (cb, file = null) => {
+	const fileSelected = file => {
+
+		const w = new CropWindow({
+			propsData: {
+				file: file,
+				title: 'アバターとして表示する部分を選択',
+				aspectRatio: 1 / 1
+			}
+		}).$mount();
+
+		w.$once('cropped', blob => {
+			const data = new FormData();
+			data.append('i', os.i.token);
+			data.append('file', blob, file.name + '.cropped.png');
+
+			os.api('drive/folders/find', {
+				name: 'アイコン'
+			}).then(iconFolder => {
+				if (iconFolder.length === 0) {
+					os.api('drive/folders/create', {
+						name: 'アイコン'
+					}).then(iconFolder => {
+						upload(data, iconFolder);
+					});
+				} else {
+					upload(data, iconFolder[0]);
+				}
+			});
+		});
+
+		w.$once('skipped', () => {
+			set(file);
+		});
+
+		document.body.appendChild(w.$el);
+	};
+
+	const upload = (data, folder) => {
+		const dialog = new ProgressDialog({
+			propsData: {
+				title: '新しいアバターをアップロードしています'
+			}
+		}).$mount();
+		document.body.appendChild(dialog.$el);
+
+		if (folder) data.append('folder_id', folder.id);
+
+		const xhr = new XMLHttpRequest();
+		xhr.open('POST', apiUrl + '/drive/files/create', true);
+		xhr.onload = e => {
+			const file = JSON.parse((e.target as any).response);
+			(dialog as any).close();
+			set(file);
+		};
+
+		xhr.upload.onprogress = e => {
+			if (e.lengthComputable) (dialog as any).updateProgress(e.loaded, e.total);
+		};
+
+		xhr.send(data);
+	};
+
+	const set = file => {
+		os.api('i/update', {
+			avatar_id: file.id
+		}).then(i => {
+			os.apis.dialog({
+				title: '%fa:info-circle%アバターを更新しました',
+				text: '新しいアバターが反映されるまで時間がかかる場合があります。',
+				actions: [{
+					text: 'わかった'
+				}]
+			});
+
+			if (cb) cb(i);
+		});
+	};
+
+	if (file) {
+		fileSelected(file);
+	} else {
+		os.apis.chooseDriveFile({
+			multiple: false,
+			title: '%fa:image%アバターにする画像を選択'
+		}).then(file => {
+			fileSelected(file);
+		});
+	}
+};
diff --git a/src/web/app/desktop/api/update-banner.ts b/src/web/app/desktop/api/update-banner.ts
new file mode 100644
index 0000000000..5751616581
--- /dev/null
+++ b/src/web/app/desktop/api/update-banner.ts
@@ -0,0 +1,95 @@
+import OS from '../../common/mios';
+import { apiUrl } from '../../config';
+import CropWindow from '../views/components/crop-window.vue';
+import ProgressDialog from '../views/components/progress-dialog.vue';
+
+export default (os: OS) => (cb, file = null) => {
+	const fileSelected = file => {
+
+		const w = new CropWindow({
+			propsData: {
+				file: file,
+				title: 'バナーとして表示する部分を選択',
+				aspectRatio: 16 / 9
+			}
+		}).$mount();
+
+		w.$once('cropped', blob => {
+			const data = new FormData();
+			data.append('i', os.i.token);
+			data.append('file', blob, file.name + '.cropped.png');
+
+			os.api('drive/folders/find', {
+				name: 'バナー'
+			}).then(bannerFolder => {
+				if (bannerFolder.length === 0) {
+					os.api('drive/folders/create', {
+						name: 'バナー'
+					}).then(iconFolder => {
+						upload(data, iconFolder);
+					});
+				} else {
+					upload(data, bannerFolder[0]);
+				}
+			});
+		});
+
+		w.$once('skipped', () => {
+			set(file);
+		});
+
+		document.body.appendChild(w.$el);
+	};
+
+	const upload = (data, folder) => {
+		const dialog = new ProgressDialog({
+			propsData: {
+				title: '新しいバナーをアップロードしています'
+			}
+		}).$mount();
+		document.body.appendChild(dialog.$el);
+
+		if (folder) data.append('folder_id', folder.id);
+
+		const xhr = new XMLHttpRequest();
+		xhr.open('POST', apiUrl + '/drive/files/create', true);
+		xhr.onload = e => {
+			const file = JSON.parse((e.target as any).response);
+			(dialog as any).close();
+			set(file);
+		};
+
+		xhr.upload.onprogress = e => {
+			if (e.lengthComputable) (dialog as any).updateProgress(e.loaded, e.total);
+		};
+
+		xhr.send(data);
+	};
+
+	const set = file => {
+		os.api('i/update', {
+			avatar_id: file.id
+		}).then(i => {
+			os.apis.dialog({
+				title: '%fa:info-circle%バナーを更新しました',
+				text: '新しいバナーが反映されるまで時間がかかる場合があります。',
+				actions: [{
+					text: 'わかった'
+				}]
+			});
+
+			if (cb) cb(i);
+		});
+	};
+
+	if (file) {
+		fileSelected(file);
+	} else {
+		os.apis.chooseDriveFile({
+			multiple: false,
+			title: '%fa:image%バナーにする画像を選択'
+		}).then(file => {
+			fileSelected(file);
+		});
+	}
+};
diff --git a/src/web/app/desktop/script.ts b/src/web/app/desktop/script.ts
index 2477f62f49..b647f4031d 100644
--- a/src/web/app/desktop/script.ts
+++ b/src/web/app/desktop/script.ts
@@ -6,7 +6,7 @@
 import './style.styl';
 
 import init from '../init';
-import fuckAdBlock from './scripts/fuck-ad-block';
+import fuckAdBlock from '../common/scripts/fuck-ad-block';
 import HomeStreamManager from '../common/scripts/streaming/home-stream-manager';
 import composeNotification from '../common/scripts/compose-notification';
 
@@ -15,6 +15,9 @@ import chooseDriveFile from './api/choose-drive-file';
 import dialog from './api/dialog';
 import input from './api/input';
 import post from './api/post';
+import notify from './api/notify';
+import updateAvatar from './api/update-avatar';
+import updateBanner from './api/update-banner';
 
 import MkIndex from './views/pages/index.vue';
 import MkUser from './views/pages/user/user.vue';
@@ -25,24 +28,27 @@ import MkDrive from './views/pages/drive.vue';
  * init
  */
 init(async (launch) => {
-	/**
-	 * Fuck AD Block
-	 */
-	fuckAdBlock();
-
 	// Register directives
 	require('./views/directives');
 
 	// Register components
 	require('./views/components');
 
-	const app = launch({
+	const [app, os] = launch(os => ({
 		chooseDriveFolder,
 		chooseDriveFile,
 		dialog,
 		input,
-		post
-	});
+		post,
+		notify,
+		updateAvatar: updateAvatar(os),
+		updateBanner: updateBanner(os)
+	}));
+
+	/**
+	 * Fuck AD Block
+	 */
+	fuckAdBlock(os);
 
 	/**
 	 * Init Notification
diff --git a/src/web/app/desktop/scripts/dialog.ts b/src/web/app/desktop/scripts/dialog.ts
deleted file mode 100644
index 816ba4b5f5..0000000000
--- a/src/web/app/desktop/scripts/dialog.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import * as riot from 'riot';
-
-export default (title, text, buttons, canThrough?, onThrough?) => {
-	const dialog = document.body.appendChild(document.createElement('mk-dialog'));
-	const controller = riot.observable();
-	(riot as any).mount(dialog, {
-		controller: controller,
-		title: title,
-		text: text,
-		buttons: buttons,
-		canThrough: canThrough,
-		onThrough: onThrough
-	});
-	controller.trigger('open');
-	return controller;
-};
diff --git a/src/web/app/desktop/scripts/fuck-ad-block.ts b/src/web/app/desktop/scripts/fuck-ad-block.ts
deleted file mode 100644
index ddeb600b6e..0000000000
--- a/src/web/app/desktop/scripts/fuck-ad-block.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-require('fuckadblock');
-import dialog from './dialog';
-
-declare const fuckAdBlock: any;
-
-export default () => {
-	if (fuckAdBlock === undefined) {
-		adBlockDetected();
-	} else {
-		fuckAdBlock.onDetected(adBlockDetected);
-	}
-};
-
-function adBlockDetected() {
-	dialog('%fa:exclamation-triangle%広告ブロッカーを無効にしてください',
-		'<strong>Misskeyは広告を掲載していません</strong>が、広告をブロックする機能が有効だと一部の機能が利用できなかったり、不具合が発生する場合があります。',
-	[{
-		text: 'OK'
-	}]);
-}
diff --git a/src/web/app/desktop/scripts/input-dialog.ts b/src/web/app/desktop/scripts/input-dialog.ts
deleted file mode 100644
index b06d011c6b..0000000000
--- a/src/web/app/desktop/scripts/input-dialog.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import * as riot from 'riot';
-
-export default (title, placeholder, defaultValue, onOk, onCancel) => {
-	const dialog = document.body.appendChild(document.createElement('mk-input-dialog'));
-	return (riot as any).mount(dialog, {
-		title: title,
-		placeholder: placeholder,
-		'default': defaultValue,
-		onOk: onOk,
-		onCancel: onCancel
-	});
-};
diff --git a/src/web/app/desktop/scripts/not-implemented-exception.ts b/src/web/app/desktop/scripts/not-implemented-exception.ts
deleted file mode 100644
index b4660fa62f..0000000000
--- a/src/web/app/desktop/scripts/not-implemented-exception.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-import dialog from './dialog';
-
-export default () => {
-	dialog('%fa:exclamation-triangle%Not implemented yet',
-		'要求された操作は実装されていません。<br>→<a href="https://github.com/syuilo/misskey" target="_blank">Misskeyの開発に参加する</a>', [{
-		text: 'OK'
-	}]);
-};
diff --git a/src/web/app/desktop/scripts/notify.ts b/src/web/app/desktop/scripts/notify.ts
deleted file mode 100644
index 2e6cbdeed8..0000000000
--- a/src/web/app/desktop/scripts/notify.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-import * as riot from 'riot';
-
-export default message => {
-	const notification = document.body.appendChild(document.createElement('mk-ui-notification'));
-	(riot as any).mount(notification, {
-		message: message
-	});
-};
diff --git a/src/web/app/desktop/scripts/scroll-follower.ts b/src/web/app/desktop/scripts/scroll-follower.ts
deleted file mode 100644
index 05072958ce..0000000000
--- a/src/web/app/desktop/scripts/scroll-follower.ts
+++ /dev/null
@@ -1,61 +0,0 @@
-/**
- * 要素をスクロールに追従させる
- */
-export default class ScrollFollower {
-	private follower: Element;
-	private containerTop: number;
-	private topPadding: number;
-
-	constructor(follower: Element, topPadding: number) {
-		//#region
-		this.follow = this.follow.bind(this);
-		//#endregion
-
-		this.follower = follower;
-		this.containerTop = follower.getBoundingClientRect().top;
-		this.topPadding = topPadding;
-
-		window.addEventListener('scroll', this.follow);
-		window.addEventListener('resize', this.follow);
-	}
-
-	/**
-	 * 追従解除
-	 */
-	public dispose() {
-		window.removeEventListener('scroll', this.follow);
-		window.removeEventListener('resize', this.follow);
-	}
-
-	private follow() {
-		const windowBottom = window.scrollY + window.innerHeight;
-		const windowTop = window.scrollY + this.topPadding;
-
-		const rect = this.follower.getBoundingClientRect();
-		const followerBottom = (rect.top + window.scrollY) + rect.height;
-		const screenHeight = window.innerHeight - this.topPadding;
-
-		// スクロールの上部(+余白)がフォロワーコンテナの上部よりも上方にある
-		if (window.scrollY + this.topPadding < this.containerTop) {
-			// フォロワーをコンテナの最上部に合わせる
-			(this.follower.parentNode as any).style.marginTop = '0px';
-			return;
-		}
-
-		// スクロールの下部がフォロワーの下部よりも下方にある かつ 表示領域の縦幅がフォロワーの縦幅よりも狭い
-		if (windowBottom > followerBottom && rect.height > screenHeight) {
-			// フォロワーの下部をスクロール下部に合わせる
-			const top = (windowBottom - rect.height) - this.containerTop;
-			(this.follower.parentNode as any).style.marginTop = `${top}px`;
-			return;
-		}
-
-		// スクロールの上部(+余白)がフォロワーの上部よりも上方にある または 表示領域の縦幅がフォロワーの縦幅よりも広い
-		if (windowTop < rect.top + window.scrollY || rect.height < screenHeight) {
-			// フォロワーの上部をスクロール上部(+余白)に合わせる
-			const top = windowTop - this.containerTop;
-			(this.follower.parentNode as any).style.marginTop = `${top}px`;
-			return;
-		}
-	}
-}
diff --git a/src/web/app/desktop/scripts/update-avatar.ts b/src/web/app/desktop/scripts/update-avatar.ts
deleted file mode 100644
index fea5db80bb..0000000000
--- a/src/web/app/desktop/scripts/update-avatar.ts
+++ /dev/null
@@ -1,88 +0,0 @@
-declare const _API_URL_: string;
-
-import * as riot from 'riot';
-import dialog from './dialog';
-import api from '../../common/scripts/api';
-
-export default (I, cb, file = null) => {
-	const fileSelected = file => {
-		const cropper = (riot as any).mount(document.body.appendChild(document.createElement('mk-crop-window')), {
-			file: file,
-			title: 'アバターとして表示する部分を選択',
-			aspectRatio: 1 / 1
-		})[0];
-
-		cropper.on('cropped', blob => {
-			const data = new FormData();
-			data.append('i', I.token);
-			data.append('file', blob, file.name + '.cropped.png');
-
-			api(I, 'drive/folders/find', {
-				name: 'アイコン'
-			}).then(iconFolder => {
-				if (iconFolder.length === 0) {
-					api(I, 'drive/folders/create', {
-						name: 'アイコン'
-					}).then(iconFolder => {
-						upload(data, iconFolder);
-					});
-				} else {
-					upload(data, iconFolder[0]);
-				}
-			});
-		});
-
-		cropper.on('skipped', () => {
-			set(file);
-		});
-	};
-
-	const upload = (data, folder) => {
-		const progress = (riot as any).mount(document.body.appendChild(document.createElement('mk-progress-dialog')), {
-			title: '新しいアバターをアップロードしています'
-		})[0];
-
-		if (folder) data.append('folder_id', folder.id);
-
-		const xhr = new XMLHttpRequest();
-		xhr.open('POST', _API_URL_ + '/drive/files/create', true);
-		xhr.onload = e => {
-			const file = JSON.parse((e.target as any).response);
-			progress.close();
-			set(file);
-		};
-
-		xhr.upload.onprogress = e => {
-			if (e.lengthComputable) progress.updateProgress(e.loaded, e.total);
-		};
-
-		xhr.send(data);
-	};
-
-	const set = file => {
-		api(I, 'i/update', {
-			avatar_id: file.id
-		}).then(i => {
-			dialog('%fa:info-circle%アバターを更新しました',
-				'新しいアバターが反映されるまで時間がかかる場合があります。',
-			[{
-				text: 'わかった'
-			}]);
-
-			if (cb) cb(i);
-		});
-	};
-
-	if (file) {
-		fileSelected(file);
-	} else {
-		const browser = (riot as any).mount(document.body.appendChild(document.createElement('mk-select-file-from-drive-window')), {
-			multiple: false,
-			title: '%fa:image%アバターにする画像を選択'
-		})[0];
-
-		browser.one('selected', file => {
-			fileSelected(file);
-		});
-	}
-};
diff --git a/src/web/app/desktop/scripts/update-banner.ts b/src/web/app/desktop/scripts/update-banner.ts
deleted file mode 100644
index 325775622d..0000000000
--- a/src/web/app/desktop/scripts/update-banner.ts
+++ /dev/null
@@ -1,88 +0,0 @@
-declare const _API_URL_: string;
-
-import * as riot from 'riot';
-import dialog from './dialog';
-import api from '../../common/scripts/api';
-
-export default (I, cb, file = null) => {
-	const fileSelected = file => {
-		const cropper = (riot as any).mount(document.body.appendChild(document.createElement('mk-crop-window')), {
-			file: file,
-			title: 'バナーとして表示する部分を選択',
-			aspectRatio: 16 / 9
-		})[0];
-
-		cropper.on('cropped', blob => {
-			const data = new FormData();
-			data.append('i', I.token);
-			data.append('file', blob, file.name + '.cropped.png');
-
-			api(I, 'drive/folders/find', {
-				name: 'バナー'
-			}).then(iconFolder => {
-				if (iconFolder.length === 0) {
-					api(I, 'drive/folders/create', {
-						name: 'バナー'
-					}).then(iconFolder => {
-						upload(data, iconFolder);
-					});
-				} else {
-					upload(data, iconFolder[0]);
-				}
-			});
-		});
-
-		cropper.on('skipped', () => {
-			set(file);
-		});
-	};
-
-	const upload = (data, folder) => {
-		const progress = (riot as any).mount(document.body.appendChild(document.createElement('mk-progress-dialog')), {
-			title: '新しいバナーをアップロードしています'
-		})[0];
-
-		if (folder) data.append('folder_id', folder.id);
-
-		const xhr = new XMLHttpRequest();
-		xhr.open('POST', _API_URL_ + '/drive/files/create', true);
-		xhr.onload = e => {
-			const file = JSON.parse((e.target as any).response);
-			progress.close();
-			set(file);
-		};
-
-		xhr.upload.onprogress = e => {
-			if (e.lengthComputable) progress.updateProgress(e.loaded, e.total);
-		};
-
-		xhr.send(data);
-	};
-
-	const set = file => {
-		api(I, 'i/update', {
-			banner_id: file.id
-		}).then(i => {
-			dialog('%fa:info-circle%バナーを更新しました',
-				'新しいバナーが反映されるまで時間がかかる場合があります。',
-			[{
-				text: 'わかりました。'
-			}]);
-
-			if (cb) cb(i);
-		});
-	};
-
-	if (file) {
-		fileSelected(file);
-	} else {
-		const browser = (riot as any).mount(document.body.appendChild(document.createElement('mk-select-file-from-drive-window')), {
-			multiple: false,
-			title: '%fa:image%バナーにする画像を選択'
-		})[0];
-
-		browser.one('selected', file => {
-			fileSelected(file);
-		});
-	}
-};
diff --git a/src/web/app/desktop/views/components/dialog.vue b/src/web/app/desktop/views/components/dialog.vue
index e92050dbad..f089b19a4e 100644
--- a/src/web/app/desktop/views/components/dialog.vue
+++ b/src/web/app/desktop/views/components/dialog.vue
@@ -5,7 +5,7 @@
 		<header v-html="title"></header>
 		<div class="body" v-html="text"></div>
 		<div class="buttons">
-			<button v-for="button in buttons" @click="click(button)" :key="button.id">{{ button.text }}</button>
+			<button v-for="button in buttons" @click="click(button)">{{ button.text }}</button>
 		</div>
 	</div>
 </div>
diff --git a/src/web/app/desktop/views/components/post-form.vue b/src/web/app/desktop/views/components/post-form.vue
index f117f8cc5e..c362d500ea 100644
--- a/src/web/app/desktop/views/components/post-form.vue
+++ b/src/web/app/desktop/views/components/post-form.vue
@@ -40,7 +40,6 @@ import Vue from 'vue';
 import * as Sortable from 'sortablejs';
 import Autocomplete from '../../scripts/autocomplete';
 import getKao from '../../../common/scripts/get-kao';
-import notify from '../../scripts/notify';
 
 export default Vue.extend({
 	props: ['reply', 'repost'],
@@ -200,13 +199,13 @@ export default Vue.extend({
 				this.clear();
 				this.deleteDraft();
 				this.$emit('posted');
-				notify(this.repost
+				(this as any).apis.notify(this.repost
 					? '%i18n:desktop.tags.mk-post-form.reposted%'
 					: this.reply
 						? '%i18n:desktop.tags.mk-post-form.replied%'
 						: '%i18n:desktop.tags.mk-post-form.posted%');
 			}).catch(err => {
-				notify(this.repost
+				(this as any).apis.notify(this.repost
 					? '%i18n:desktop.tags.mk-post-form.repost-failed%'
 					: this.reply
 						? '%i18n:desktop.tags.mk-post-form.reply-failed%'
diff --git a/src/web/app/desktop/views/components/repost-form.vue b/src/web/app/desktop/views/components/repost-form.vue
index d4a6186c4d..5bf7eaaf03 100644
--- a/src/web/app/desktop/views/components/repost-form.vue
+++ b/src/web/app/desktop/views/components/repost-form.vue
@@ -16,7 +16,6 @@
 
 <script lang="ts">
 import Vue from 'vue';
-import notify from '../../scripts/notify';
 
 export default Vue.extend({
 	props: ['post'],
@@ -33,9 +32,9 @@ export default Vue.extend({
 				repost_id: this.post.id
 			}).then(data => {
 				this.$emit('posted');
-				notify('%i18n:desktop.tags.mk-repost-form.success%');
+				(this as any).apis.notify('%i18n:desktop.tags.mk-repost-form.success%');
 			}).catch(err => {
-				notify('%i18n:desktop.tags.mk-repost-form.failure%');
+				(this as any).apis.notify('%i18n:desktop.tags.mk-repost-form.failure%');
 			}).then(() => {
 				this.wait = false;
 			});
diff --git a/src/web/app/desktop/views/components/settings.profile.vue b/src/web/app/desktop/views/components/settings.profile.vue
index c8834ca257..dcc031c27a 100644
--- a/src/web/app/desktop/views/components/settings.profile.vue
+++ b/src/web/app/desktop/views/components/settings.profile.vue
@@ -27,7 +27,6 @@
 
 <script lang="ts">
 import Vue from 'vue';
-import notify from '../../scripts/notify';
 
 export default Vue.extend({
 	data() {
@@ -59,7 +58,7 @@ export default Vue.extend({
 				description: this.description || null,
 				birthday: this.birthday || null
 			}).then(() => {
-				notify('プロフィールを更新しました');
+				(this as any).apis.notify('プロフィールを更新しました');
 			});
 		}
 	}
diff --git a/src/web/app/desktop/views/components/ui-notification.vue b/src/web/app/desktop/views/components/ui-notification.vue
index 6f7b46cb7a..9983f02c5e 100644
--- a/src/web/app/desktop/views/components/ui-notification.vue
+++ b/src/web/app/desktop/views/components/ui-notification.vue
@@ -11,24 +11,26 @@ import * as anime from 'animejs';
 export default Vue.extend({
 	props: ['message'],
 	mounted() {
-		anime({
-			targets: this.$el,
-			opacity: 1,
-			translateY: [-64, 0],
-			easing: 'easeOutElastic',
-			duration: 500
-		});
-
-		setTimeout(() => {
+		this.$nextTick(() => {
 			anime({
 				targets: this.$el,
-				opacity: 0,
-				translateY: -64,
-				duration: 500,
-				easing: 'easeInElastic',
-				complete: () => this.$destroy()
+				opacity: 1,
+				translateY: [-64, 0],
+				easing: 'easeOutElastic',
+				duration: 500
 			});
-		}, 6000);
+
+			setTimeout(() => {
+				anime({
+					targets: this.$el,
+					opacity: 0,
+					translateY: -64,
+					duration: 500,
+					easing: 'easeInElastic',
+					complete: () => this.$destroy()
+				});
+			}, 6000);
+		});
 	}
 });
 </script>
diff --git a/src/web/app/desktop/views/pages/user/user.header.vue b/src/web/app/desktop/views/pages/user/user.header.vue
index 81174f6570..67d110f2f3 100644
--- a/src/web/app/desktop/views/pages/user/user.header.vue
+++ b/src/web/app/desktop/views/pages/user/user.header.vue
@@ -22,7 +22,6 @@
 
 <script lang="ts">
 import Vue from 'vue';
-import updateBanner from '../../../scripts/update-banner';
 
 export default Vue.extend({
 	props: ['user'],
@@ -53,7 +52,7 @@ export default Vue.extend({
 		onBannerClick() {
 			if (!(this as any).os.isSignedIn || (this as any).os.i.id != this.user.id) return;
 
-			updateBanner((this as any).os.i, i => {
+			(this as any).apis.updateBanner((this as any).os.i, i => {
 				this.user.banner_url = i.banner_url;
 			});
 		}
diff --git a/src/web/app/init.ts b/src/web/app/init.ts
index 9e49c4f0fb..b814a18066 100644
--- a/src/web/app/init.ts
+++ b/src/web/app/init.ts
@@ -34,7 +34,7 @@ Vue.mixin({
 import App from './app.vue';
 
 import checkForUpdate from './common/scripts/check-for-update';
-import MiOS from './common/mios';
+import MiOS, { API } from './common/mios';
 
 /**
  * APP ENTRY POINT!
@@ -79,59 +79,32 @@ if (localStorage.getItem('should-refresh') == 'true') {
 	location.reload(true);
 }
 
-type API = {
-	chooseDriveFile: (opts: {
-		title?: string;
-		currentFolder?: any;
-		multiple?: boolean;
-	}) => Promise<any>;
-
-	chooseDriveFolder: (opts: {
-		title?: string;
-		currentFolder?: any;
-	}) => Promise<any>;
-
-	dialog: (opts: {
-		title: string;
-		text: string;
-		actions: Array<{
-			text: string;
-			id: string;
-		}>;
-	}) => Promise<string>;
-
-	input: (opts: {
-		title: string;
-		placeholder?: string;
-		default?: string;
-	}) => Promise<string>;
-
-	post: () => void;
-};
-
 // MiOSを初期化してコールバックする
-export default (callback: (launch: (api: API) => Vue) => void, sw = false) => {
+export default (callback: (launch: (api: (os: MiOS) => API) => [Vue, MiOS]) => void, sw = false) => {
 	const os = new MiOS(sw);
 
 	os.init(() => {
 		// アプリ基底要素マウント
 		document.body.innerHTML = '<div id="app"></div>';
 
-		const launch = (api: API) => {
+		const launch = (api: (os: MiOS) => API) => {
+			os.apis = api(os);
 			Vue.mixin({
 				created() {
 					(this as any).os = os;
 					(this as any).api = os.api;
-					(this as any).apis = api;
+					(this as any).apis = os.apis;
 				}
 			});
 
-			return new Vue({
+			const app = new Vue({
 				router: new VueRouter({
 					mode: 'history'
 				}),
 				render: createEl => createEl(App)
 			}).$mount('#app');
+
+			return [app, os] as [Vue, MiOS];
 		};
 
 		try {