Nextronでリモコンアプリを作成
Nextron(Next.js + Electron)を使ってリモコンアプリケーションを開発しました。React Hooks、Tailwind CSS、Prisma、データベース操作など、実践的な技術を学びながらデスクトップアプリを作成する過程を紹介します。
目次
Nextron とは
Nextron は、Next.js と Electron を組み合わせたフレームワークです。Web 技術(React、Next.js)を使ってデスクトップアプリケーションを開発できます。
特徴
- ⚡ 高速開発: Next.js の開発体験
- 🖥️ クロスプラットフォーム: Windows、macOS、Linux 対応
- 🎨 豊富なUI: Tailwind CSS などの CSS フレームワークが使える
- 🔧 柔軟性: Web 技術の豊富なエコシステムを活用
環境構築
1. Nextron プロジェクトの作成
# Tailwind CSS 付きのテンプレートでプロジェクトを作成
npx create-nextron-app . --example with-tailwindcss
2. 依存関係のインストール
npm install
3. Prisma のセットアップ
npm install prisma @prisma/client
npx prisma init
4. Prisma スキーマの設定
prisma/schema.prisma
ファイルを作成または編集します:
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = "file:./dev.db"
}
model DeviceType {
id Int @id @default(autoincrement())
type Int // 0: Windows, 1: Mac
name String
description String?
commands Command[]
appSettings AppSettings[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Command {
id Int @id @default(autoincrement())
name String
description String?
command String
deviceTypeId Int
isActive Boolean @default(true)
deviceType DeviceType @relation(fields: [deviceTypeId], references: [id])
executions CommandExecution[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model CommandExecution {
id Int @id @default(autoincrement())
commandId Int
command Command @relation(fields: [commandId], references: [id])
success Boolean
output String?
error String?
executedAt DateTime @default(now())
}
model AppSettings {
id Int @id @default(autoincrement())
selectedDeviceTypeId Int?
deviceType DeviceType? @relation(fields: [selectedDeviceTypeId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
5. Next.js設定の確認
renderer/next.config.js
でoutput: 'export'
が設定されていないことを確認します。
// renderer/next.config.js
/** @type {import('next').NextConfig} */
module.exports = {
distDir: process.env.NODE_ENV === 'production' ? '../app' : '.next',
trailingSlash: true,
images: {
unoptimized: true,
},
webpack: (config) => {
return config
},
}
重要: output: 'export'
がある場合は削除してください。ElectronアプリではAPIルートが必要なため、静的エクスポートは使用できません。
6. シードファイルの作成
prisma/seed.js
ファイルを作成します:
// prisma/seed.js
const { PrismaClient } = require('@prisma/client')
const prisma = new PrismaClient()
async function main() {
// デバイスタイプの初期データ(マスターデータ)
const deviceTypes = [
{ type: 0, name: 'Windows', description: 'Windowsデバイス' },
{ type: 1, name: 'Mac', description: 'Macデバイス' }
]
// 既存のデバイスタイプを削除してから再作成(マスターデータなので)
await prisma.commandExecution.deleteMany({})
await prisma.command.deleteMany({})
await prisma.appSettings.deleteMany({})
await prisma.deviceType.deleteMany({})
await prisma.deviceType.deleteMany({})
for (const deviceType of deviceTypes) {
await prisma.deviceType.create({
data: deviceType
})
}
// デバイスタイプのIDを取得
const windowsDevice = await prisma.deviceType.findFirst({
where: { type: 0 }
})
const macDevice = await prisma.deviceType.findFirst({
where: { type: 1 }
})
// デフォルトコマンドの追加
const defaultCommands = [
// Windows用コマンド
{
name: 'シャットダウン',
description: 'Windowsをシャットダウンします',
command: 'shutdown /s /t 0',
deviceTypeId: windowsDevice.id
},
{
name: '再起動',
description: 'Windowsを再起動します',
command: 'shutdown /r /t 0',
deviceTypeId: windowsDevice.id
},
{
name: 'スリープ',
description: 'Windowsをスリープモードにします',
command: 'powercfg /hibernate off && rundll32.exe powrprof.dll,SetSuspendState 0,1,0',
deviceTypeId: windowsDevice.id
},
// Mac用コマンド
{
name: 'シャットダウン',
description: 'Macをシャットダウンします',
command: 'sudo shutdown -h now',
deviceTypeId: macDevice.id
},
{
name: '再起動',
description: 'Macを再起動します',
command: 'sudo reboot',
deviceTypeId: macDevice.id
},
{
name: 'スリープ',
description: 'Macをスリープモードにします',
command: 'pmset sleepnow',
deviceTypeId: macDevice.id
}
]
for (const cmd of defaultCommands) {
await prisma.command.create({
data: cmd
})
}
}
main()
.catch((e) => {
console.error(e)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
})
7. データベースの初期化とシード実行
# データベースのマイグレーション
npx prisma migrate dev --name init
npx prisma generate
# シードデータの実行(WindowsとMacのデフォルトデータを作成)
node prisma/seed.js
重要: シードデータはマスターデータとして、Windows(type: 0)とMac(type: 1)をデフォルトで作成します。アプリ初回起動時はWindowsがデフォルトで選択されます。
8. 開発サーバーの起動
npm run dev
9. ビルド
npm run build
10. メインページの確認
アプリを起動すると、renderer/pages/index.tsx
が自動的にメインページとして表示されます。
- 開発モード:
http://localhost:8888/
- Electronアプリ: 自動的にメインページが表示
リモコンアプリの設計
機能要件
- 📱 コマンド登録: カスタムコマンドの作成・編集・削除
- 🎮 コマンド実行: ワンクリックでコマンド実行
- 📊 実行履歴: コマンド実行の履歴表示
- ⚙️ 設定管理: デバイスタイプの設定
- 🎨 モダンUI: ダークテーマの美しいインターフェース
技術スタック
- フロントエンド: React + Next.js
- スタイリング: Tailwind CSS
- デスクトップ: Electron
- データベース: SQLite + Prisma
- 状態管理: React Hooks (useState, useEffect)
データベース設計
1. データベース設計の説明
DeviceType(デバイスタイプ)
type
: デバイスの種類(0: Windows, 1: Mac)name
: デバイス名(例: "Windows", "Mac")description
: 説明文(任意)
Command(コマンド)
deviceTypeId
: どのデバイスタイプ用のコマンドかisActive
: コマンドが有効かどうかcommand
: 実際に実行するコマンド
AppSettings(アプリ設定)
selectedDeviceTypeId
: 現在選択中のデバイスタイプ- 初回起動時はWindowsがデフォルトで選択される
CommandExecution(コマンド履歴)
- コマンド実行の履歴を記録
- 成功/失敗、出力、エラー情報を保存
実装
1. メインページ(index.tsx)
// renderer/pages/index.tsx
import React, { useState, useEffect } from 'react'
import Head from 'next/head'
import Link from 'next/link'
interface DeviceType {
id: number
type: number
name: string
description?: string
}
interface Command {
id: number
name: string
description?: string
command: string
deviceTypeId: number
isActive: boolean
}
interface AppSettings {
id: number
selectedDeviceTypeId: number | null
deviceType: DeviceType | null
}
export default function HomePage() {
const [appSettings, setAppSettings] = useState<AppSettings | null>(null)
const [commands, setCommands] = useState<Command[]>([])
const [executingCommand, setExecutingCommand] = useState<number | null>(null)
useEffect(() => {
// アプリ設定を取得
fetch('/api/app-settings')
.then(res => res.json())
.then(data => {
setAppSettings(data)
if (data.selectedDeviceTypeId) {
// 選択中のデバイスのコマンドを取得
fetch(`/api/commands?deviceTypeId=${data.selectedDeviceTypeId}`)
.then(res => res.json())
.then(cmds => setCommands(cmds))
.catch(err => console.error('コマンドの取得に失敗:', err))
}
})
.catch(err => console.error('アプリ設定の取得に失敗:', err))
}, [])
const executeCommand = async (commandId: number) => {
setExecutingCommand(commandId)
try {
const response = await fetch('/api/execute-command', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ commandId })
})
if (response.ok) {
const result = await response.json()
alert(`コマンドを実行しました: ${result.message}`)
} else {
alert('コマンドの実行に失敗しました')
}
} catch (error) {
console.error('コマンド実行エラー:', error)
alert('コマンドの実行に失敗しました')
} finally {
setExecutingCommand(null)
}
}
return (
<React.Fragment>
<Head>
<title>リモコンアプリ</title>
</Head>
<div className="min-h-screen bg-gradient-to-br from-slate-800 via-purple-800 to-slate-800 p-6">
<div className="max-w-6xl mx-auto">
{/* ヘッダー */}
<div className="flex justify-between items-center mb-8">
<div>
<h2 className="text-4xl font-bold text-white mb-2">🎮 リモコン</h2>
<p className="text-white">登録されたコマンドを実行してデバイスをコントロール</p>
</div>
<Link
href="/settings"
className="bg-slate-800 hover:bg-slate-700 text-white px-4 py-2 rounded-xl transition-all duration-300 border border-slate-600 hover:border-slate-500"
>
⚙️ 設定
</Link>
</div>
{/* 現在のデバイス表示 */}
{appSettings && (
<div className="mb-6 p-4 bg-slate-700/50 backdrop-blur-sm rounded-2xl border border-slate-600">
<h3 className="text-sm font-medium text-white mb-1">対象デバイス</h3>
<p className="text-white font-bold text-lg">{appSettings.deviceType?.name || '未選択'}</p>
</div>
)}
{/* 登録済みコマンド一覧 */}
<div className="bg-slate-700/50 backdrop-blur-sm rounded-2xl border border-slate-600 p-6">
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold text-white">コマンド</h2>
<span className="text-sm text-white bg-slate-700/50 px-3 py-1 rounded-full">
{commands.filter(c => c.isActive).length}個
</span>
</div>
{!appSettings?.selectedDeviceTypeId ? (
<div className="text-center py-8">
<p className="text-white mb-4">デバイスが設定されていません</p>
<Link
href="/settings"
className="bg-blue-600 hover:bg-blue-500 text-white px-4 py-2 rounded-xl text-sm transition-all duration-300"
>
設定
</Link>
</div>
) : commands.filter(c => c.isActive).length === 0 ? (
<div className="text-center py-8">
<p className="text-white mb-4">コマンドが登録されていません</p>
<Link
href="/command-register"
className="bg-green-600 hover:bg-green-500 text-white px-4 py-2 rounded-xl text-sm transition-all duration-300"
>
コマンド登録
</Link>
</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4">
{commands
.filter(command => command.isActive)
.map((command) => (
<button
key={command.id}
onClick={() => executeCommand(command.id)}
disabled={executingCommand !== null}
className={`p-4 rounded-2xl border transition-all duration-300 text-center group ${executingCommand === command.id
? 'bg-blue-600 border-blue-500 text-white shadow-lg shadow-blue-500/25'
: '!bg-teal-600 hover:!bg-teal-500 !border-teal-500 text-white hover:shadow-lg'
}`}
style={executingCommand !== command.id ? { backgroundColor: '#0d9488', borderColor: '#14b8a6' } : {}}
>
<div className="text-sm font-medium text-white truncate">
{command.name}
</div>
</button>
))}
</div>
)}
</div>
</div>
</div>
</React.Fragment>
)
}
2. コマンド登録ページ
// renderer/pages/command-register.tsx
import React, { useState, useEffect } from 'react'
import Head from 'next/head'
import Link from 'next/link'
interface DeviceType {
id: number
type: number
name: string
description?: string
}
interface Command {
id: number
name: string
description?: string
command: string
deviceTypeId: number
isActive: boolean
}
interface AppSettings {
id: number
selectedDeviceTypeId: number | null
deviceType: DeviceType | null
}
export default function CommandRegisterPage() {
const [commands, setCommands] = useState<Command[]>([])
const [appSettings, setAppSettings] = useState<AppSettings | null>(null)
const [isAddingCommand, setIsAddingCommand] = useState(false)
const [isEditingCommand, setIsEditingCommand] = useState(false)
const [editingCommandId, setEditingCommandId] = useState<number | null>(null)
const [showInactiveCommands, setShowInactiveCommands] = useState(false)
const [newCommand, setNewCommand] = useState({
name: '',
description: '',
command: '',
deviceTypeId: 0
})
useEffect(() => {
// アプリ設定を取得
fetch('/api/app-settings')
.then(res => res.json())
.then(data => {
setAppSettings(data)
if (data.selectedDeviceTypeId) {
// 選択中のデバイスのコマンドを取得
fetch(`/api/commands?deviceTypeId=${data.selectedDeviceTypeId}`)
.then(res => res.json())
.then(cmds => setCommands(cmds))
.catch(err => console.error('コマンドの取得に失敗:', err))
}
})
.catch(err => console.error('アプリ設定の取得に失敗:', err))
}, [])
const addCommand = async () => {
if (!newCommand.name || !newCommand.command) {
alert('コマンド名とコマンドを入力してください')
return
}
if (!appSettings?.selectedDeviceTypeId) {
alert('デバイスが設定されていません。先にデバイスを選択してください。')
return
}
try {
const commandData = {
...newCommand,
deviceTypeId: appSettings.selectedDeviceTypeId
}
const response = await fetch('/api/commands', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(commandData)
})
if (response.ok) {
setNewCommand({
name: '',
description: '',
command: '',
deviceTypeId: 0
})
setIsAddingCommand(false)
// コマンド一覧を再取得
const res = await fetch(`/api/commands?deviceTypeId=${appSettings.selectedDeviceTypeId}`)
const data = await res.json()
setCommands(data)
alert('コマンドを追加しました')
} else {
alert('コマンドの追加に失敗しました')
}
} catch (error) {
console.error('コマンド追加エラー:', error)
alert('コマンドの追加に失敗しました')
}
}
const toggleCommandStatus = async (commandId: number, isActive: boolean) => {
try {
const response = await fetch(`/api/commands/${commandId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ isActive: !isActive })
})
if (response.ok) {
// コマンド一覧を再取得
if (appSettings?.selectedDeviceTypeId) {
const res = await fetch(`/api/commands?deviceTypeId=${appSettings.selectedDeviceTypeId}`)
const data = await res.json()
setCommands(data)
}
} else {
alert('コマンドの更新に失敗しました')
}
} catch (error) {
console.error('コマンド更新エラー:', error)
alert('コマンドの更新に失敗しました')
}
}
const deleteCommand = async (commandId: number) => {
if (!confirm('このコマンドを削除しますか?')) return
try {
const response = await fetch(`/api/commands/${commandId}`, {
method: 'DELETE'
})
if (response.ok) {
// コマンド一覧を再取得
if (appSettings?.selectedDeviceTypeId) {
const res = await fetch(`/api/commands?deviceTypeId=${appSettings.selectedDeviceTypeId}`)
const data = await res.json()
setCommands(data)
}
} else {
alert('コマンドの削除に失敗しました')
}
} catch (error) {
console.error('コマンド削除エラー:', error)
alert('コマンドの削除に失敗しました')
}
}
const startEditCommand = (command: Command) => {
console.log('編集開始:', command)
setEditingCommandId(command.id)
setIsEditingCommand(true)
setNewCommand({
name: command.name,
description: command.description || '',
command: command.command,
deviceTypeId: command.deviceTypeId
})
console.log('編集状態設定完了:', {
editingCommandId: command.id,
isEditingCommand: true,
newCommand: {
name: command.name,
description: command.description || '',
command: command.command,
deviceTypeId: command.deviceTypeId
}
})
}
const cancelEdit = () => {
setIsEditingCommand(false)
setEditingCommandId(null)
setNewCommand({
name: '',
description: '',
command: '',
deviceTypeId: 0
})
}
const updateCommand = async () => {
if (!newCommand.name || !newCommand.command) {
alert('コマンド名とコマンドを入力してください')
return
}
if (!editingCommandId) {
alert('編集するコマンドが見つかりません')
return
}
console.log('コマンド更新開始:', {
commandId: editingCommandId,
newCommand: newCommand
})
try {
const response = await fetch(`/api/commands/${editingCommandId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: newCommand.name,
description: newCommand.description,
command: newCommand.command
})
})
console.log('API応答:', response.status, response.statusText)
if (response.ok) {
const updatedCommand = await response.json()
console.log('更新されたコマンド:', updatedCommand)
setNewCommand({
name: '',
description: '',
command: '',
deviceTypeId: 0
})
setIsEditingCommand(false)
setEditingCommandId(null)
// コマンド一覧を再取得
if (appSettings?.selectedDeviceTypeId) {
const res = await fetch(`/api/commands?deviceTypeId=${appSettings.selectedDeviceTypeId}`)
const data = await res.json()
setCommands(data)
}
alert('コマンドを更新しました')
} else {
const errorData = await response.json().catch(() => ({}))
console.error('APIエラー:', errorData)
alert(`コマンドの更新に失敗しました: ${response.status} ${response.statusText}`)
}
} catch (error) {
console.error('コマンド更新エラー:', error)
alert('コマンドの更新に失敗しました')
}
}
return (
<React.Fragment>
<Head>
<title>コマンド登録 - リモコンアプリ</title>
</Head>
<div className="min-h-screen bg-gradient-to-br from-slate-800 via-purple-800 to-slate-800 p-6">
<div className="max-w-4xl mx-auto">
<div className="flex justify-between items-center mb-8">
<h2 className="text-4xl font-bold text-white">➕ コマンド登録</h2>
<Link
href="/"
className="bg-slate-800 hover:bg-slate-700 text-white px-4 py-2 rounded-xl transition-all duration-300 border border-slate-600 hover:border-slate-500"
>
メニューに戻る
</Link>
</div>
{/* 現在のデバイス表示 */}
{appSettings ? (
<div className="mb-6 p-4 bg-slate-700/50 backdrop-blur-sm rounded-2xl border border-slate-600">
<h3 className="text-sm font-medium text-white mb-1">対象デバイス</h3>
<p className="text-white font-bold text-lg">{appSettings.deviceType?.name || '未選択'}</p>
<p className="text-white text-xs mt-1">このデバイス用のコマンドのみ追加できます</p>
</div>
) : (
<div className="mb-6 p-4 bg-red-900/50 backdrop-blur-sm rounded-2xl border border-red-700">
<h3 className="text-sm font-medium text-white mb-1">⚠️ デバイス未設定</h3>
<p className="text-white">コマンドを追加するには、先にデバイスを選択してください</p>
<Link href="/settings" className="text-white hover:text-slate-300 text-sm underline">
設定画面でデバイスを選択
</Link>
</div>
)}
{/* コマンド追加ボタン */}
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-semibold text-white">コマンド一覧</h2>
<div className="flex space-x-2">
<button
onClick={() => setShowInactiveCommands(!showInactiveCommands)}
className={`px-3 py-2 text-sm font-medium rounded-xl border-2 transition-all ${showInactiveCommands
? 'bg-slate-700 hover:bg-slate-600 text-white border-slate-600'
: 'bg-green-600/20 hover:bg-green-500/20 text-white border-green-500/30'
}`}
>
{showInactiveCommands ? '🔍 すべて表示' : '✅ 有効のみ'}
</button>
{!isEditingCommand && (
<button
onClick={() => setIsAddingCommand(true)}
disabled={!appSettings?.selectedDeviceTypeId}
className={`px-4 py-2 rounded-xl transition-all duration-300 ${appSettings?.selectedDeviceTypeId
? 'bg-green-600 hover:bg-green-500 text-white'
: 'bg-slate-700 text-slate-300 cursor-not-allowed'
}`}
>
コマンド追加
</button>
)}
</div>
</div>
{/* コマンド追加/編集フォーム */}
{(isAddingCommand || isEditingCommand) && appSettings?.selectedDeviceTypeId && (
<div className="bg-slate-700/50 backdrop-blur-sm rounded-2xl border border-slate-600 p-6 mb-6">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium text-white">
{isEditingCommand ? 'コマンド編集' : '新しいコマンド'}
</h3>
<span className="text-sm text-white bg-slate-700/50 px-2 py-1 rounded">
{appSettings.deviceType?.name}用
</span>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-white mb-1">
コマンド名 *
</label>
<input
type="text"
value={newCommand.name}
onChange={(e) => setNewCommand(prev => ({ ...prev, name: e.target.value }))}
className="w-full p-2 border border-slate-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="例: シャットダウン"
style={{
backgroundColor: '#1f2937',
color: '#ffffff',
fontSize: '16px'
}}
/>
</div>
<div>
<label className="block text-sm font-medium text-white mb-1">
説明
</label>
<input
type="text"
value={newCommand.description}
onChange={(e) => setNewCommand(prev => ({ ...prev, description: e.target.value }))}
className="w-full p-2 border border-slate-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="例: システムをシャットダウンします"
style={{
backgroundColor: '#1f2937',
color: '#ffffff',
fontSize: '16px'
}}
/>
</div>
<div>
<label className="block text-sm font-medium text-white mb-1">
コマンド *
</label>
<input
type="text"
value={newCommand.command}
onChange={(e) => setNewCommand(prev => ({ ...prev, command: e.target.value }))}
className="w-full p-2 border border-slate-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="例: shutdown /s /t 0"
style={{
backgroundColor: '#1f2937',
color: '#ffffff',
fontSize: '16px'
}}
/>
</div>
<div className="flex space-x-2">
<button
onClick={isEditingCommand ? updateCommand : addCommand}
className="bg-blue-600 hover:bg-blue-500 text-white px-4 py-2 rounded-xl transition-all duration-300"
>
{isEditingCommand ? '更新' : '追加'}
</button>
<button
onClick={isEditingCommand ? cancelEdit : () => setIsAddingCommand(false)}
className="bg-slate-700 hover:bg-slate-600 text-white px-4 py-2 rounded-xl transition-all duration-300"
>
キャンセル
</button>
</div>
</div>
</div>
)}
{/* コマンド一覧 */}
{appSettings?.selectedDeviceTypeId ? (
<div className="bg-slate-700/50 backdrop-blur-sm rounded-2xl border border-slate-600 p-6">
{commands.length === 0 ? (
<div className="text-center py-8">
<div className="text-white mb-4">
<div className="text-4xl mb-2">📝</div>
<p>まだコマンドが登録されていません</p>
</div>
</div>
) : (
<div className="space-y-4">
{commands
.filter(command => showInactiveCommands || command.isActive)
.map((command) => (
<div key={command.id} className="border border-slate-600 rounded-xl p-4 bg-slate-700/30">
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="flex items-center space-x-2">
<h3 className="text-lg font-bold text-white">{command.name}</h3>
<span className={`px-2 py-1 text-xs rounded-full font-bold ${command.isActive
? 'bg-green-600/20 text-white border border-green-500/30'
: 'bg-red-600/20 text-white border border-red-500/30'
}`}>
{command.isActive ? '有効' : '無効'}
</span>
</div>
{command.description && (
<p className="text-white mt-1 font-medium">{command.description}</p>
)}
<p className="text-sm text-white mt-2 font-mono bg-slate-800/50 p-2 rounded border border-slate-600">
{command.command}
</p>
</div>
<div className="flex space-x-2 ml-4">
<button
onClick={() => toggleCommandStatus(command.id, command.isActive)}
disabled={isEditingCommand}
className={`px-4 py-2 text-sm font-medium rounded-xl border-2 transition-all ${command.isActive
? 'bg-red-600/20 hover:bg-red-500/20 text-white border-red-500/30'
: 'bg-green-600/20 hover:bg-green-500/20 text-white border-green-500/30'
} ${isEditingCommand ? 'opacity-50 cursor-not-allowed' : 'hover:shadow-lg'}`}
>
{command.isActive ? '🔴 無効化' : '🟢 有効化'}
</button>
<button
onClick={() => startEditCommand(command)}
disabled={isEditingCommand}
className={`bg-blue-600/20 hover:bg-blue-500/20 text-white px-4 py-2 text-sm font-medium rounded-xl border-2 border-blue-500/30 transition-all ${isEditingCommand ? 'opacity-50 cursor-not-allowed' : 'hover:shadow-lg'}`}
>
✏️ 編集
</button>
<button
onClick={() => deleteCommand(command.id)}
disabled={isEditingCommand}
className={`bg-red-600/20 hover:bg-red-500/20 text-white px-4 py-2 text-sm font-medium rounded-xl border-2 border-red-500/30 transition-all ${isEditingCommand ? 'opacity-50 cursor-not-allowed' : 'hover:shadow-lg'}`}
>
🗑️ 削除
</button>
</div>
</div>
</div>
))}
{/* フィルター結果の表示 */}
{!showInactiveCommands && commands.filter(c => !c.isActive).length > 0 && (
<div className="mt-4 p-3 bg-slate-700/30 border border-slate-600 rounded-xl">
<p className="text-sm text-white text-center">
{commands.filter(c => !c.isActive).length}個の無効なコマンドが非表示です。
<button
onClick={() => setShowInactiveCommands(true)}
className="text-white hover:text-slate-300 underline ml-1"
>
すべて表示
</button>
</p>
</div>
)}
</div>
)}
</div>
) : (
<div className="bg-slate-700/50 backdrop-blur-sm rounded-2xl border border-slate-600 p-6">
<div className="text-center py-8">
<div className="text-white mb-4">
<div className="text-4xl mb-2">⚙️</div>
<p>デバイスが設定されていません</p>
</div>
<Link
href="/settings"
className="bg-blue-600 hover:bg-blue-500 text-white px-4 py-2 rounded-xl transition-all duration-300"
>
設定画面でデバイスを選択
</Link>
</div>
</div>
)}
</div>
</div>
</React.Fragment>
)
}
3. 設定ページ
// renderer/pages/settings.tsx
import React, { useState, useEffect } from 'react'
import Head from 'next/head'
import Link from 'next/link'
interface DeviceType {
id: number
type: number
name: string
description?: string
}
interface AppSettings {
id: number
selectedDeviceTypeId: number | null
deviceType: DeviceType | null
}
export default function SettingsPage() {
const [deviceTypes, setDeviceTypes] = useState<DeviceType[]>([])
const [appSettings, setAppSettings] = useState<AppSettings | null>(null)
const [isAddingDeviceType, setIsAddingDeviceType] = useState(false)
const [newDeviceType, setNewDeviceType] = useState({
type: 0,
name: '',
description: ''
})
useEffect(() => {
// アプリ設定を取得
fetch('/api/app-settings')
.then(res => res.json())
.then(data => {
setAppSettings(data)
})
.catch(err => console.error('アプリ設定の取得に失敗:', err))
// デバイスタイプを取得
fetchDeviceTypes()
}, [])
const fetchDeviceTypes = () => {
fetch('/api/device-types')
.then(res => res.json())
.then(data => {
setDeviceTypes(data)
})
.catch(err => console.error('デバイスタイプの取得に失敗:', err))
}
const addDeviceType = async () => {
if (!newDeviceType.name) {
alert('デバイス名を入力してください')
return
}
try {
const response = await fetch('/api/device-types', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(newDeviceType)
})
if (response.ok) {
setNewDeviceType({ type: 0, name: '', description: '' })
setIsAddingDeviceType(false)
fetchDeviceTypes()
alert('デバイスタイプを追加しました')
} else {
alert('デバイスタイプの追加に失敗しました')
}
} catch (error) {
console.error('デバイスタイプ追加エラー:', error)
alert('デバイスタイプの追加に失敗しました')
}
}
const updateSelectedDevice = async (deviceTypeId: number) => {
try {
const response = await fetch('/api/app-settings', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ selectedDeviceTypeId: deviceTypeId })
})
if (response.ok) {
const updatedSettings = await response.json()
setAppSettings(updatedSettings)
alert('デバイス設定を更新しました')
} else {
alert('デバイス設定の更新に失敗しました')
}
} catch (error) {
console.error('デバイス設定更新エラー:', error)
alert('デバイス設定の更新に失敗しました')
}
}
return (
<React.Fragment>
<Head>
<title>設定 - リモコンアプリ</title>
</Head>
<div className="min-h-screen bg-gradient-to-br from-slate-800 via-purple-800 to-slate-800 p-6">
<div className="max-w-4xl mx-auto">
<div className="flex justify-between items-center mb-8">
<h2 className="text-4xl font-bold text-white">⚙️ 設定</h2>
<Link
href="/"
className="bg-slate-800 hover:bg-slate-700 text-white px-4 py-2 rounded-xl transition-all duration-300 border border-slate-600 hover:border-slate-500"
>
メニューに戻る
</Link>
</div>
{/* デバイス設定 */}
<div className="bg-slate-700/50 backdrop-blur-sm rounded-2xl border border-slate-600 p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-2xl font-semibold text-white">デバイス設定</h2>
<button
onClick={() => setIsAddingDeviceType(!isAddingDeviceType)}
className="bg-green-600 hover:bg-green-500 text-white px-4 py-2 rounded-xl transition-all duration-300"
>
{isAddingDeviceType ? 'キャンセル' : '➕ デバイス追加'}
</button>
</div>
{/* デバイスタイプ追加フォーム */}
{isAddingDeviceType && (
<div className="mb-6 p-4 bg-slate-600/50 rounded-xl border border-slate-500">
<h3 className="text-lg font-medium text-white mb-4">新しいデバイスタイプを追加</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div>
<label className="block text-sm font-medium text-white mb-2">デバイス名</label>
<input
type="text"
value={newDeviceType.name}
onChange={(e) => setNewDeviceType({ ...newDeviceType, name: e.target.value })}
className="w-full p-2 bg-slate-700 border border-slate-500 rounded-lg text-white"
placeholder="例: Linux, Android, iOS"
/>
</div>
<div>
<label className="block text-sm font-medium text-white mb-2">デバイスタイプ</label>
<select
value={newDeviceType.type}
onChange={(e) => setNewDeviceType({ ...newDeviceType, type: Number(e.target.value) })}
className="w-full p-2 bg-slate-700 border border-slate-500 rounded-lg text-white"
>
<option value={0}>Windows</option>
<option value={1}>Mac</option>
</select>
</div>
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-white mb-2">説明(任意)</label>
<input
type="text"
value={newDeviceType.description}
onChange={(e) => setNewDeviceType({ ...newDeviceType, description: e.target.value })}
className="w-full p-2 bg-slate-700 border border-slate-500 rounded-lg text-white"
placeholder="例: Ubuntu 22.04 LTS"
/>
</div>
<button
onClick={addDeviceType}
className="bg-blue-600 hover:bg-blue-500 text-white px-4 py-2 rounded-xl transition-all duration-300"
>
追加
</button>
</div>
)}
<div className="mb-6">
<h3 className="text-lg font-medium text-white mb-4">使用するデバイスを選択</h3>
{deviceTypes.length === 0 ? (
<div className="text-center py-8">
<div className="text-white mb-4">
<div className="text-4xl mb-2">📱</div>
<p>デバイスタイプが登録されていません</p>
</div>
<button
onClick={() => setIsAddingDeviceType(true)}
className="bg-blue-600 hover:bg-blue-500 text-white px-4 py-2 rounded-xl transition-all duration-300"
>
最初のデバイスを追加
</button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{deviceTypes.map((device) => (
<div
key={device.id}
className={`p-4 rounded-xl border-2 transition-all duration-200 cursor-pointer ${appSettings?.selectedDeviceTypeId === device.id
? 'border-blue-500 bg-blue-600/20 text-white'
: 'border-slate-600 bg-slate-700/30 hover:border-slate-500 text-white'
}`}
onClick={() => updateSelectedDevice(device.id)}
>
<div className="flex justify-between items-center">
<div>
<div className="text-lg font-bold">{device.name}</div>
{device.description && (
<div className="text-sm text-white mt-1">{device.description}</div>
)}
<div className="mt-2 text-xs text-white">
デバイスタイプ: {
device.type === 0 ? 'Windows' :
device.type === 1 ? 'Mac' : 'その他'
}
</div>
</div>
{appSettings?.selectedDeviceTypeId === device.id && (
<div className="text-blue-400 text-2xl">✓</div>
)}
</div>
</div>
))}
</div>
)}
</div>
{appSettings && (
<div className="mt-6 p-4 bg-green-600/20 rounded-xl border border-green-500/30">
<h3 className="font-medium text-white mb-2">✅ 現在の設定</h3>
<p className="text-sm text-white">
選択中のデバイス: <strong>{appSettings.deviceType?.name || '未選択'}</strong>
</p>
<p className="text-sm text-white mt-1">
このデバイス用のコマンドが表示・実行されます。
</p>
</div>
)}
</div>
{/* 機能メニュー */}
<div className="bg-slate-700/50 backdrop-blur-sm rounded-2xl border border-slate-600 p-6 mt-6">
<h2 className="text-2xl font-semibold text-white mb-6">機能メニュー</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* コマンド登録 */}
<Link href="/command-register" className="group">
<div className="bg-purple-600/20 hover:bg-purple-500/20 rounded-xl p-6 transition-all duration-300 transform hover:scale-105 border-2 border-transparent hover:border-purple-500/30">
<div className="text-center">
<div className="text-4xl mb-3">➕</div>
<h3 className="text-xl font-bold text-white mb-2 group-hover:text-purple-300 transition-colors">
コマンド登録
</h3>
<p className="text-white text-sm">
新しいコマンドを追加・編集・削除
</p>
</div>
</div>
</Link>
{/* 実行履歴 */}
<Link href="/history" className="group">
<div className="bg-orange-600/20 hover:bg-orange-500/20 rounded-xl p-6 transition-all duration-300 transform hover:scale-105 border-2 border-transparent hover:border-orange-500/30">
<div className="text-center">
<div className="text-4xl mb-3">📊</div>
<h3 className="text-xl font-bold text-white mb-2 group-hover:text-orange-300 transition-colors">
実行履歴
</h3>
<p className="text-white text-sm">
コマンドの実行履歴を確認
</p>
</div>
</div>
</Link>
</div>
</div>
</div>
</div>
</React.Fragment>
)
}
4. 実行履歴ページ
// renderer/pages/history.tsx
import React, { useState, useEffect } from 'react'
import Head from 'next/head'
import Link from 'next/link'
interface CommandExecution {
id: number
commandId: number
success: boolean
output: string | null
error: string | null
executedAt: string
command: {
id: number
name: string
description: string | null
command: string
deviceType: {
id: number
name: string
type: number
}
}
}
export default function HistoryPage() {
const [history, setHistory] = useState<CommandExecution[]>([])
const [loading, setLoading] = useState(true)
const [filter, setFilter] = useState<'all' | 'success' | 'error'>('all')
useEffect(() => {
// コマンド一覧を取得
fetch('/api/device-types')
.then(res => res.json())
.then(deviceTypes => {
const allCommands: any[] = []
deviceTypes.forEach((device: any) => {
fetch(`/api/commands?deviceTypeId=${device.id}`)
.then(res => res.json())
.then(cmds => {
allCommands.push(...cmds)
if (allCommands.length === deviceTypes.length) {
// setCommands(allCommands) // This line was removed as per the new_code
}
})
})
})
// 履歴を取得
loadHistory()
}, [])
const loadHistory = () => {
setLoading(true)
fetch('/api/command-history?limit=50')
.then(res => res.json())
.then(data => {
setHistory(data)
setLoading(false)
})
.catch(err => {
console.error('履歴取得エラー:', err)
setLoading(false)
})
}
// フィルタリングされた履歴
const filteredHistory = history.filter(execution => {
if (filter === 'all') return true
if (filter === 'success') return execution.success
if (filter === 'error') return !execution.success
return true
})
const deleteHistory = async (id?: number) => {
if (!confirm('履歴を削除しますか?')) return
try {
const url = id ? `/api/command-history?id=${id}` : '/api/command-history'
const response = await fetch(url, { method: 'DELETE' })
if (response.ok) {
loadHistory()
} else {
alert('履歴の削除に失敗しました')
}
} catch (error) {
console.error('履歴削除エラー:', error)
alert('履歴の削除に失敗しました')
}
}
const formatDate = (dateString: string) => {
const date = new Date(dateString)
return date.toLocaleString('ja-JP')
}
return (
<React.Fragment>
<Head>
<title>実行履歴 - リモコンアプリ</title>
</Head>
<div className="min-h-screen bg-gradient-to-br from-slate-800 via-purple-800 to-slate-800 p-6">
<div className="max-w-6xl mx-auto">
<div className="flex justify-between items-center mb-8">
<h2 className="text-4xl font-bold text-white">📊 実行履歴</h2>
<Link
href="/"
className="bg-slate-800 hover:bg-slate-700 text-white px-4 py-2 rounded-xl transition-all duration-300 border border-slate-600 hover:border-slate-500"
>
メニューに戻る
</Link>
</div>
{/* フィルター */}
<div className="bg-slate-700/50 backdrop-blur-sm rounded-2xl border border-slate-600 p-6 mb-6">
<h2 className="text-xl font-semibold text-white mb-4">フィルター</h2>
<div className="flex space-x-4">
<button
onClick={() => setFilter('all')}
className={`px-4 py-2 rounded-xl transition-all duration-300 ${filter === 'all'
? 'bg-blue-600 text-white'
: 'bg-slate-700 text-slate-300 hover:bg-slate-600'
}`}
>
すべて
</button>
<button
onClick={() => setFilter('success')}
className={`px-4 py-2 rounded-xl transition-all duration-300 ${filter === 'success'
? 'bg-green-600 text-white'
: 'bg-slate-700 text-slate-300 hover:bg-slate-600'
}`}
>
成功
</button>
<button
onClick={() => setFilter('error')}
className={`px-4 py-2 rounded-xl transition-all duration-300 ${filter === 'error'
? 'bg-red-600 text-white'
: 'bg-slate-700 text-slate-300 hover:bg-slate-600'
}`}
>
エラー
</button>
</div>
</div>
{/* 実行履歴 */}
<div className="bg-slate-700/50 backdrop-blur-sm rounded-2xl border border-slate-600 p-6">
<h2 className="text-2xl font-semibold text-white">実行履歴</h2>
{loading ? (
<div className="text-center py-8">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto mb-4"></div>
<p className="text-white">読み込み中...</p>
</div>
) : filteredHistory.length === 0 ? (
<div className="text-center py-8">
<p className="text-white">実行履歴がありません</p>
</div>
) : (
<div className="space-y-4 mt-4">
{filteredHistory.map((execution) => (
<div key={execution.id} className="border border-slate-600 rounded-xl p-4 bg-slate-700/30">
<div className="flex justify-between items-start mb-2">
<div>
<h3 className="font-bold text-white">
{execution.command.name}
</h3>
<p className="text-sm text-white">
{new Date(execution.executedAt).toLocaleString()}
</p>
</div>
<span className={`px-2 py-1 text-xs rounded-full font-bold ${execution.success
? 'bg-green-600/20 text-white border border-green-500/30'
: 'bg-red-600/20 text-white border border-red-500/30'
}`}>
{execution.success ? '成功' : 'エラー'}
</span>
</div>
<div className="mt-3">
<p className="text-sm text-white mb-2 font-medium">
実行コマンド: {execution.command.command}
</p>
{execution.output && (
<div>
<p className="text-xs font-medium text-white mb-1">出力:</p>
<p className="text-xs text-white font-mono bg-slate-800/50 p-2 rounded border border-slate-600">{execution.output}</p>
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
</React.Fragment>
)
}
5. API ルートの実装
// renderer/pages/api/commands.ts
import { NextApiRequest, NextApiResponse } from 'next'
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === 'GET') {
const { deviceTypeId } = req.query
try {
const commands = await prisma.command.findMany({
where: {
deviceTypeId: Number(deviceTypeId)
},
include: {
deviceType: true
}
})
res.status(200).json(commands)
} catch (error) {
res.status(500).json({ error: 'コマンドの取得に失敗しました' })
}
} else if (req.method === 'POST') {
const { name, description, command, deviceTypeId } = req.body
try {
const newCommand = await prisma.command.create({
data: {
name,
description,
command,
deviceTypeId: Number(deviceTypeId),
isActive: true
}
})
res.status(201).json(newCommand)
} catch (error) {
res.status(500).json({ error: 'コマンドの作成に失敗しました' })
}
} else {
res.setHeader('Allow', ['GET', 'POST'])
res.status(405).end(`Method ${req.method} Not Allowed`)
}
}
// renderer/pages/api/commands/[id].ts
import { NextApiRequest, NextApiResponse } from 'next'
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { id } = req.query
const commandId = parseInt(id as string)
if (isNaN(commandId)) {
return res.status(400).json({ message: 'Invalid command ID' })
}
if (req.method === 'PATCH') {
// コマンド更新
try {
const { name, description, command, isActive } = req.body
// 更新するデータを構築
const updateData: any = {}
if (name !== undefined) updateData.name = name
if (description !== undefined) updateData.description = description
if (command !== undefined) updateData.command = command
if (isActive !== undefined) updateData.isActive = isActive
const updatedCommand = await prisma.command.update({
where: { id: commandId },
data: updateData
})
res.status(200).json(updatedCommand)
} catch (error) {
console.error('コマンド更新エラー:', error)
res.status(500).json({ message: 'Internal server error' })
}
} else if (req.method === 'DELETE') {
// コマンド削除
try {
await prisma.command.delete({
where: { id: commandId }
})
res.status(204).end()
} catch (error) {
console.error('コマンド削除エラー:', error)
res.status(500).json({ message: 'Internal server error' })
}
} else {
res.status(405).json({ message: 'Method not allowed' })
}
}
// renderer/pages/api/execute-command.ts
import { NextApiRequest, NextApiResponse } from 'next'
import { exec } from 'child_process'
import { promisify } from 'util'
import { PrismaClient } from '@prisma/client'
const execAsync = promisify(exec)
const prisma = new PrismaClient()
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'POST') {
return res.status(405).json({ message: 'Method not allowed' })
}
try {
const { commandId } = req.body
if (!commandId) {
return res.status(400).json({ message: 'commandId is required' })
}
// コマンドを取得
const command = await prisma.command.findUnique({
where: { id: parseInt(commandId) }
})
if (!command) {
return res.status(404).json({ message: 'Command not found' })
}
if (!command.isActive) {
return res.status(400).json({ message: 'Command is not active' })
}
// コマンドを実行
const { stdout, stderr } = await execAsync(command.command)
// 実行履歴を保存
await prisma.commandExecution.create({
data: {
commandId: command.id,
success: !stderr,
output: stdout || null,
error: stderr || null
}
})
if (stderr) {
console.error('コマンド実行エラー:', stderr)
return res.status(500).json({
message: 'Command execution failed',
error: stderr
})
}
res.status(200).json({
message: 'Command executed successfully',
output: stdout
})
} catch (error) {
console.error('コマンド実行エラー:', error)
// エラー時も履歴を保存
if (req.body.commandId) {
try {
await prisma.commandExecution.create({
data: {
commandId: parseInt(req.body.commandId),
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
}
})
} catch (historyError) {
console.error('履歴保存エラー:', historyError)
}
}
res.status(500).json({
message: 'Internal server error',
error: error instanceof Error ? error.message : 'Unknown error'
})
}
}
// renderer/pages/api/device-types.ts
import { NextApiRequest, NextApiResponse } from 'next'
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === 'GET') {
try {
const deviceTypes = await prisma.deviceType.findMany({
orderBy: {
type: 'asc'
}
})
res.status(200).json(deviceTypes)
} catch (error) {
res.status(500).json({ error: 'デバイスタイプの取得に失敗しました' })
}
} else if (req.method === 'POST') {
try {
const { type, name, description } = req.body
if (!type || !name) {
return res.status(400).json({ message: 'type and name are required' })
}
const deviceType = await prisma.deviceType.create({
data: {
type,
name,
description
}
})
res.status(201).json(deviceType)
} catch (error) {
console.error('デバイスタイプ作成エラー:', error)
res.status(500).json({ message: 'Internal server error' })
}
} else {
res.setHeader('Allow', ['GET', 'POST'])
res.status(405).json({ message: 'Method not allowed' })
}
}
// renderer/pages/api/app-settings.ts
import { NextApiRequest, NextApiResponse } from 'next'
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === 'GET') {
try {
let settings = await prisma.appSettings.findFirst({
include: {
deviceType: true
}
})
if (!settings) {
// デフォルト設定を作成(Windowsをデフォルト)
const defaultDeviceType = await prisma.deviceType.findFirst({
where: { type: 0 } // Windowsをデフォルト
})
settings = await prisma.appSettings.create({
data: {
selectedDeviceTypeId: defaultDeviceType?.id || null
},
include: {
deviceType: true
}
})
}
res.status(200).json(settings)
} catch (error) {
res.status(500).json({ error: 'アプリ設定の取得に失敗しました' })
}
} else if (req.method === 'PUT') {
const { selectedDeviceTypeId } = req.body
try {
let settings = await prisma.appSettings.findFirst()
if (settings) {
settings = await prisma.appSettings.update({
where: { id: settings.id },
data: { selectedDeviceTypeId },
include: {
deviceType: true
}
})
} else {
settings = await prisma.appSettings.create({
data: { selectedDeviceTypeId },
include: {
deviceType: true
}
})
}
res.status(200).json(settings)
} catch (error) {
res.status(500).json({ error: 'アプリ設定の更新に失敗しました' })
}
} else {
res.setHeader('Allow', ['GET', 'PUT'])
res.status(405).end(`Method ${req.method} Not Allowed`)
}
}
// renderer/pages/api/command-history.ts
import { NextApiRequest, NextApiResponse } from 'next'
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === 'GET') {
const { limit = '50', commandId } = req.query
try {
const where = commandId ? { commandId: Number(commandId) } : {}
const history = await prisma.commandExecution.findMany({
where,
include: {
command: {
include: {
deviceType: true
}
}
},
orderBy: {
executedAt: 'desc'
},
take: Number(limit)
})
res.status(200).json(history)
} catch (error) {
res.status(500).json({ error: 'コマンド履歴の取得に失敗しました' })
}
} else {
res.setHeader('Allow', ['GET'])
res.status(405).end(`Method ${req.method} Not Allowed`)
}
}
7. メインページの設定
Next.jsでは、renderer/pages/index.tsx
が自動的にルートページ(/
)になります。
8. Electron の設定
// main/background.ts
import path from 'path'
import { app, ipcMain } from 'electron'
import serve from 'electron-serve'
import { createWindow } from './helpers'
import { exec } from 'child_process'
import { promisify } from 'util'
const execAsync = promisify(exec)
const isProd = process.env.NODE_ENV === 'production'
if (isProd) {
serve({ directory: 'app' })
} else {
app.setPath('userData', `${app.getPath('userData')} (development)`)
}
; (async () => {
await app.whenReady()
const mainWindow = createWindow('main', {
width: 1000,
height: 600,
autoHideMenuBar: false,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
},
})
if (isProd) {
await mainWindow.loadURL('app://./')
} else {
const port = process.argv[2]
await mainWindow.loadURL(`http://localhost:${port}/`)
mainWindow.webContents.openDevTools()
// 開発者ツールが閉じられた場合のショートカットを追加
mainWindow.webContents.on('devtools-closed', () => {
mainWindow.webContents.openDevTools()
})
}
})()
app.on('window-all-closed', () => {
app.quit()
})
// コマンド実行のIPCハンドラー
ipcMain.handle('execute-command', async (event, command: string) => {
try {
console.log('コマンド実行:', command)
const { stdout, stderr } = await execAsync(command)
if (stderr) {
console.error('コマンド実行エラー:', stderr)
throw new Error(stderr)
}
return { success: true, output: stdout }
} catch (error) {
console.error('コマンド実行エラー:', error)
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
}
}
})
機能の詳細
📱 コマンド登録機能
- CRUD操作: 作成・読み取り・更新・削除
- デバイスタイプ別管理: PC、Mac などデバイス別にコマンドを管理
- アクティブ/非アクティブ: コマンドの有効/無効を切り替え
🎮 コマンド実行機能
- ワンクリック実行: ボタンクリックでコマンド実行
- 実行状態表示: 実行中のボタンの視覚的フィードバック
- エラーハンドリング: 実行失敗時の適切なエラー表示
📊 実行履歴機能
- 履歴記録: コマンド実行の日時と結果を記録
- 成功/失敗判定: 実行結果の成功/失敗を記録
- 履歴表示: 過去の実行履歴を一覧表示
⚙️ 設定管理機能
- デバイスタイプ選択: 使用するデバイスタイプの設定
- アプリ設定: アプリケーション全体の設定管理
🎨 モダンUI
- ダークテーマ: 目に優しいダークテーマデザイン
- レスポンシブ: 様々な画面サイズに対応
- アニメーション: スムーズなトランジション効果
学んだこと
🎯 React Hooks の活用
- useState: 複数の状態を効率的に管理
- useEffect: 副作用の適切な処理
- カスタムフック: 再利用可能なロジックの分離
🎨 Tailwind CSS の威力
- ユーティリティファースト: 高速なスタイリング
- レスポンシブデザイン: 簡単なレスポンシブ対応
- ダークテーマ: 美しいダークテーマの実装
🗄️ Prisma の活用
- 型安全: TypeScript との完全な統合
- マイグレーション: データベーススキーマの管理
- リレーション: 複雑なデータ関係の管理
⚡ Electron の利点
- Web 技術: 既存の Web 開発スキルを活用
- クロスプラットフォーム: 一度の開発で複数 OS 対応
- ネイティブ機能: ファイルシステムアクセスなど
🔧 開発体験
- ホットリロード: リアルタイムでの変更確認
- デバッグ: Chrome DevTools でのデバッグ
- ビルド: 簡単なビルドプロセス
今後の拡張案
🚀 追加機能
- キーボードショートカット: ホットキーでのコマンド実行
- コマンドグループ: コマンドのグループ化機能
- スケジュール実行: 定期的なコマンド実行
- リモート実行: ネットワーク経由でのコマンド実行
- プラグインシステム: サードパーティプラグイン対応
🎨 UI/UX 改善
- ドラッグ&ドロップ: コマンドの並び替え
- 検索機能: コマンドの検索・フィルタリング
- 統計表示: コマンド使用頻度の統計
- テーマカスタマイズ: カラーテーマの変更
🔧 技術的改善
- パフォーマンス: 大量データの最適化
- セキュリティ: コマンド実行の安全性向上
- バックアップ: 設定データのバックアップ機能
- 同期: 複数デバイス間の設定同期
トラブルシューティング
🐛 よくある問題と解決方法
1. コマンド編集時にテキストが見えない問題
症状: コマンド編集フォームで入力テキストが白くて見えない
原因: Tailwind CSSの設定とダークテーマの競合
解決方法: 入力フィールドにインラインスタイルを追加
// renderer/pages/command-register.tsx
<input
type="text"
value={newCommand.name}
onChange={(e) => setNewCommand(prev => ({ ...prev, name: e.target.value }))}
className="w-full p-2 border border-slate-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="例: シャットダウン"
style={{
backgroundColor: '#1f2937',
color: '#ffffff',
fontSize: '16px'
}}
/>
修正ポイント:
- Tailwind CSSクラス(
bg-slate-800 text-white
)を削除 - インラインスタイルで強制的に色を指定
backgroundColor: '#1f2937'
(暗いグレー背景)color: '#ffffff'
(純白のテキスト)
2. データベースロックエラー
症状: database is locked
エラーが発生
原因: データベースファイルが他のプロセスで使用中
解決方法:
# データベースファイルを削除して再作成
rm prisma/dev.db
npx prisma migrate reset
npx prisma db push
node prisma/seed.js
3. API 500エラー(/api/app-settings)
症状: /api/app-settings
で500 Internal Server Error
原因: デフォルトデバイスタイプが設定されていない
解決方法: seed.jsでデフォルトデバイスを確実に作成
// prisma/seed.js
const defaultDeviceType = await prisma.deviceType.upsert({
where: { id: 1 },
update: {},
create: {
id: 1,
type: 0,
name: 'Windows',
description: 'Windows PC用のコマンド'
}
});
4. 開発者ツールにアクセスできない
症状: Electronアプリで開発者ツールが開けない
解決方法: メインプロセスで開発者ツールを有効化
// main/background.ts
if (process.env.NODE_ENV === 'development') {
mainWindow.webContents.openDevTools();
}
5. ホットリロードが効かない
症状: コード変更時に自動更新されない
解決方法:
# 開発サーバーを再起動
npm run dev
# または
yarn dev
6. Prismaクライアントエラー
症状: PrismaClient is not defined
エラー
解決方法:
# Prismaクライアントを再生成
npx prisma generate
🔧 デバッグのコツ
-
コンソールログの活用
console.log('デバッグ情報:', { data, status });
-
開発者ツールでの確認
- Network タブでAPI通信を確認
- Console タブでエラーメッセージを確認
- Elements タブでDOM構造を確認
-
データベースの確認
# SQLiteデータベースを直接確認 sqlite3 prisma/dev.db .tables SELECT * FROM DeviceType;
まとめ
Nextron を使ったリモコンアプリの開発を通じて、以下のことを学びました:
- Nextron の基本: Electron + Next.js の組み合わせ
- React Hooks: モダンな React 開発手法
- Tailwind CSS: 効率的なスタイリング
- Prisma: 型安全なデータベース操作
- API 設計: RESTful API の実装
- 状態管理: 複雑な状態の管理方法
このプロジェクトは、デスクトップアプリケーション開発の良い入門として機能します。Web 技術の知識を活かしながら、ネイティブアプリのような体験を提供できます。
次回は、より高度な機能(キーボードショートカット、プラグインシステムなど)を追加して、さらに実用的なアプリケーションにしていきたいと思います!
技術スタック: Nextron, React, Next.js, Electron, Tailwind CSS, Prisma, SQLite
開発期間: 2日
難易度: 中級