🕌

electronでパスワード管理アプリ作成してみた by chatGPT

2023/04/23に公開

概要

長らくwindowsユーザーだったのだが、その際にはID Managerというアプリを利用していた。
無料で同様の機能を持っているアプリがmacでは見当たらなかったため、勉強がてら作ってしまおうという流れ。

本記事についてのまとめ

実装部分が後に続くので、まとめを最上部に記載しておきます。

総評

chatGPTを使う大前提として、細かい要件やアーキテクチャーを言語化できるスキルは必要です。
ステップバイステップで概要を掴んで、それの詳細部分を実装してもらうという手段もあるのですが、
抽象度が段々と高くなっていくため、最終的には全ての構造を把握しておく必要があります。

  • 細かいタスクの処理に関してはかなり有用だなと感じています。xxxという機能を作りたい。という粒度では最強です。
  • 全体のアーキテクチャーをプロンプトで指定してあげると、xxxでxxx処理を、yyyでyyy処理を、、のようにパブリッククラウドを絡ませた上で何で何を実施するか、という点も出力してくれます。
  • ただ、知見のないフレームワークやライブラリを用いて構築するという観点においては、まだまだ物足りないという印象です。
    • chatGPTのみで完結させたいのであれば、スコープを狭くしてあげる必要がある。
    • 反対に概要だけ知りたい、残りは自分で実装するというのであれば、step by stepで確認するプロンプトで十分だなと。

知見のないものに適用して困った点

  • 精通していないフレームワークやライブラリについて、LLMの学習時点移行に破壊的変更が入ったものに対応できない。
    • 今回、結局公式docsやらGitHubのリポジトリを見ることになったので、余計にコストがかかったと感じる。
  • 上記とは逆に、情報として溢れているものに関しての推論能力は抜群に高いので、自分がそうだと認識できるものについてはガンガン使っていくのがベスト。
  • プラグインでブラウジングできるようになると少し話は変わるのでそれは別途検証。

技術スタック

使いたいものをとりあえず使ってみる。一つも仕事で使ったことないので悪しからず(´・ω・`)

  • electron
  • TypeScript
  • Tailwind.css
  • sqlite
  • TypeORM
  • Next.js
  • chatGPT(もちろんですよね)

prompt

何度か試行錯誤した結果、以下でやってみることにした。
※うまくいかなかったらそれはそれで記事化する予定。

ちなみに、1回前は依存関係やら何やらが崩壊していたので仕切り直してます(´・ω・`)

ちなみに英語は特に翻訳かけずに思いついたのを書いてる部分が多いです。
それでも解釈してくれるのである程度適当でも大丈夫と思われる。

You are a world class software engineer.

I need you to draft a technical software spec for building the following:
I need a Password Manager App. It’s build by electron. Using tech stack is TypeScript,Tailwind.css,and Next.js.

You will get start, please use commad like below. let’s go.

**`npx create-next-app --example with-electron-typescript pasword-app`**

Think through how you would build it step by step.

Then, respond with the complete spec as a well-organized markdown file.

I will then reply with "build," and you will proceed to implement the exact spec, writing all of the code needed. I will periodically interject with "continue" to prompt you to keep going. Continue until complete.

一旦関連コードを全部出力しもらった。
最初の出力段階ではstylingしてくれないので追って指示。(treeで表示して欲しかったのでやっておいた)

Please output sylle all .tsx files, and directory structure as tree.

最終的にはtreeで表示するとこんな感じの構成。

password-app/
│
├── api/
│   ├── auth/
│   │   ├── login.ts
│   │   └── register.ts
│   └── passwords/
│       ├── create.ts
│       ├── delete.ts
│       ├── list.ts
│       └── update.ts
│
├── components/
│   ├── LoginForm.tsx
│   ├── PasswordForm.tsx
│   ├── PasswordItem.tsx
│   └── PasswordList.tsx
│
├── db/
│   └── connect.ts
│
├── lib/
│   └── encryption.ts
│
├── models/
│   ├── Password.ts
│   └── User.ts
│
├── pages/
│   ├── _app.tsx
│   ├── index.tsx
│   └── dashboard.tsx
│
├── services/
│   ├── auth.ts
│   └── passwords.ts
│
├── styles/
│   └── global.css
│
├── tailwind.config.js
├── tsconfig.json
├── tsconfig.server.json
└── package.json

環境構築

electron、Next.js、TypeScriptがラップされてるのを落としてきます。

npx create-next-app --example with-electron-typescript app-name
  • npm run devしても動かない、、
    • dev": "npm run build-electron && electron ."
    • 特にエラーもなし。
    • localhost:8000でアクセスできるかと思ったけどダメ。
    • 「electron .」を直接叩くと実行できることを確認。
    • 個別で叩くと問題ないんだけども、詳細な挙動がまとめて実行する際がダメな模様。
  • 一旦個別実行で進めていく。
"dev": "npm run build-electron && electron .",

ということで起動確認!(ここまでchatGPTで聞いた内容は関係なし)

関連パッケージインストール

tailwindの設定は以下のURLにおせわになりました。
https://yurupro.cloud/2514/

インストールコマンドはこちら

npm install typeorm sqlite3 tailwindcss@latest postcss@latest autoprefixer@latest

無事適用完了

いざ実装!

  • とりあえずrenderer配下に全てのファイルを集約させてます。
    • electron,Next.jsの挙動をよく知らないのでエラーの都度解消していく算段。
    • sqliteとの接続とかは外に出すべきだろうなと思うが一旦スルー。
      • ※rendere配下に置いても動作には問題なし。

型関連のエラーがちらほらと出ている程度で、ぐちゃぐちゃになっているエラーは無し。(このあたりはさすがGPTちゃん)
とりあえず全部解消してみました。

あとは出力されていなかったファイルなどを再出力してというのを繰り返して...
一通りコーディング終わった際の起動画面がこちら。

か、かっこいいやんけ。。。

ぱらぱらとコードを見た感じだと、機能自体は出来上がっているので、後は要件に沿って修正していけば問題なさそう。
ついでにbuildしてbuild errorも解消しておきました。それなりに動いてくれそうな予感がぷんぷんします。

そういえばsqlite関連の設定も出力されていなかったのでキリのいいタイミングで確認していきます。

SQlite設定

思いついた英単語を並べていきます。語彙力の無さがやばい。

ok, code is built in my project.
next, I must build sqlite server . Please tell me how to build.

教えに従って関連ファイルをいじっていきます。
この辺りは完全に知見がないので今後のためにも残しておきましょう。

ormcongig.js
const path = require("path");

module.exports = {
  type: "sqlite",
  database: "database.sqlite",
  synchronize: true,
  logging: false,
  entities: [path.join(__dirname, "renderer/models/*{.ts,.js}")],
  migrations: [path.join(__dirname, "renderer/migrations/*{.ts,.js}")],
  subscribers: [path.join(__dirname, "renderer/subscribers/*{.ts,.js}")],
  cli: {
    entitiesDir: "renderer/models",
    migrationsDir: "renderer/migrations",
    subscribersDir: "renderer/subscribers",
  },
};
index.ts
// Native
import { join } from "path";
import { format } from "url";

// Packages
import { BrowserWindow, app, ipcMain, IpcMainEvent } from "electron";
import isDev from "electron-is-dev";
import prepareNext from "electron-next";

import { createConnection } from "typeorm";
import { Password } from "../renderer/models/Password";
import { User } from "../renderer/models/User";

// Prepare the renderer once the app is ready
app.on("ready", async () => {
  await prepareNext("./renderer");

  const mainWindow = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
      nodeIntegration: false,
      contextIsolation: false,
      preload: join(__dirname, "preload.js"),
    },
  });

  const url = isDev
    ? "http://localhost:8000/"
    : format({
        pathname: join(__dirname, "../renderer/out/index.html"),
        protocol: "file:",
        slashes: true,
      });

  await createConnection({
    type: "sqlite",
    database: "password-app.sqlite",
    synchronize: true,
    logging: false,
    entities: [User, Password],
  })
    .then(() => {
      console.log("Connected to the SQLite database");
    })
    .catch((error) =>
      console.error("Error connecting to the SQLite database:", error)
    );

  mainWindow.loadURL(url);
});

// Quit the app once all windows are closed
app.on("window-all-closed", app.quit);

// listen the channel `message` and resend the received message to the renderer process
ipcMain.on("message", (event: IpcMainEvent, message: any) => {
  console.log(message);
  setTimeout(() => event.sender.send("message", "hi from electron"), 500);
});

ここまでは比較的順調でしたが、以下のエラーで見事にはまりました。
SQliteとの接続を試そうと思った際に生じています。

Error connecting to the SQLite database: /electron/password-app/renderer/models/Password.ts:1
import {
^^^^^^

importを使うこと自体は問題じゃないはずなんだが...(´・ω・`)

解決策

  • electronはメインプロセスとレンダープロセスのような形でプロセスが分かれているらしい。
  • tsのコンパイラが2箇所に存在している状態になっていた。(たぶんメインプロセスとNext.jsで2種類)
    • electron-src、renderer直下の2箇所。
    • electron-src直下のtscondig.jsonを修正で解決。
    • コンパイル方式変えたらmodelで型エラー出たけど、非nullを明示してエラー解消
/electron-src/tsconfig.json
-    "target": "esnext",
+    "target": "ES2020",
+    "experimentalDecorators": true,
+    "emitDecoratorMetadata": true

SQlite関連のエラーも無くなってウインドウ立ち上がりました(´・ω・`)ヤッタネ
・・・どうやってSQliteって確認するの?

SQlite

  • SQlite使おうと思ったけど、DBとのコネクションがうまく張れない...
  • せっかくなのでTypeORMの最新を使う。
    • 案の定chatGPTくんの情報が古いので普通に公式から入れました...(´・ω・`)
  • 0.3以降ではormconfig.jsではなく、data-source.tsなるものを利用するらしい。
    • 公式を参考にして組み込みました。サンプルリポジトリをクローンして中身をいじりました。

その結果が以下の通り。
ここまでchatGPTくん1ミリも役に立ってないですねえ。(適した使い方してないだけ)

index.ts
// Native
import { join } from "path";
import { format } from "url";

// Packages
import { BrowserWindow, app, ipcMain, IpcMainEvent } from "electron";
import isDev from "electron-is-dev";
import prepareNext from "electron-next";

import { AppDataSource } from "../renderer/data-source";
import "reflect-metadata";

// Prepare the renderer once the app is ready

app.on("ready", async () => {
  AppDataSource.initialize()
    .then(async () => {
      console.log("Yeahhhhhhhhhhhhhhhhhhhhhhh!!!!!!!!!!!!!!!!.");
    })
    .catch((error) => console.log(error));

  await prepareNext("./renderer");

  const mainWindow = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
      nodeIntegration: false,
      contextIsolation: false,
      preload: join(__dirname, "preload.js"),
    },
  });

  const url = isDev
    ? "http://localhost:8000/"
    : format({
        pathname: join(__dirname, "../renderer/out/index.html"),
        protocol: "file:",
        slashes: true,
      });

  mainWindow.loadURL(url);
});

// Quit the app once all windows are closed
app.on("window-all-closed", app.quit);

// listen the channel `message` and resend the received message to the renderer process
ipcMain.on("message", (event: IpcMainEvent, message: any) => {
  console.log(message);
  setTimeout(() => event.sender.send("message", "hi from electron"), 500);
});
data-source.ts
import "reflect-metadata";
import { DataSource } from "typeorm";

export const AppDataSource = new DataSource({
  type: "sqlite",
  database: "database.sqlite",
  synchronize: true,
  logging: true,
  entities: ["models/*{.ts,.js}"],
  migrations: ["migrations/*{.ts,.js}"],
  subscribers: ["subscribers/*{.ts,.js}"],
});

ここまででSQliteへの接続は確認できました。初めて使ったんですがMySQLとかとコマンドが違いすぎて戸惑いますね。
SQlite接続確認

次はmigrationば...出来ればやっとアプリケーションコードのターン...待っててchatGPTちゃん...(´・ω・`)

typeORM miggration(したかった)

electron起動したらクエリが走っちゃった。
おそらくdatasource.tsで指定したentiryをベースに作成していると思われる。ドキュメントは未確認で挙動から推察。
便利っちゃ便利だけど、、、という印象。今回はわざわざmigrationファイル作成しないで済んだので手間は省けた。

❯ electron .   
query: PRAGMA foreign_keys = OFF
query: BEGIN TRANSACTION
query: SELECT * FROM "sqlite_master" WHERE "type" = 'table' AND "name" IN ('user','password')
query: SELECT * FROM "sqlite_master" WHERE "type" = 'index' AND "tbl_name" IN ('user','password')
query: SELECT * FROM "sqlite_master" WHERE "type" = 'table' AND "name" = 'typeorm_metadata'
query: CREATE TABLE "user" ("id" varchar PRIMARY KEY NOT NULL, "email" varchar NOT NULL, "password" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))
query: CREATE TABLE "password" ("id" varchar PRIMARY KEY NOT NULL, "title" varchar NOT NULL, "titleId" varchar NOT NULL, "encryptedPassword" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" varchar)
query: CREATE TABLE "temporary_password" ("id" varchar PRIMARY KEY NOT NULL, "title" varchar NOT NULL, "titleId" varchar NOT NULL, "encryptedPassword" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" varchar, CONSTRAINT "FK_dc877602e08545367e6f85b02e5" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)
query: INSERT INTO "temporary_password"("id", "title", "titleId", "encryptedPassword", "createdAt", "updatedAt", "userId") SELECT "id", "title", "titleId", "encryptedPassword", "createdAt", "updatedAt", "userId" FROM "password"
query: DROP TABLE "password"
query: ALTER TABLE "temporary_password" RENAME TO "password"
query: COMMIT
query: PRAGMA foreign_keys = ON
Yeahhhhhhhhhhhhhhhhhhhhhhh!!!!!!!!!!!!!!!!.
event - compiled client and server successfully in 250 ms (168 modules)
wait  - compiling / (client and server)...

命名規則とかがごちゃごちゃしてたのでentity作り直しました。

User.ts
import {
  Entity,
  PrimaryGeneratedColumn,
  Column,
  OneToMany,
  CreateDateColumn,
  UpdateDateColumn,
} from "typeorm";
import { Password } from "./Password";

@Entity("users")
export class User {
  @PrimaryGeneratedColumn("increment")
  id!: number;

  @Column({ type: "varchar", unique: true })
  email!: string;

  @Column({ type: "varchar" })
  password!: string;

  @CreateDateColumn({ type: "datetime" })
  created_at!: Date;

  @UpdateDateColumn({ type: "datetime" })
  updated_at!: Date;

  @OneToMany(() => Password, (password) => password.user)
  passwords!: Password[];
}
Password.ts
import {
  Entity,
  PrimaryGeneratedColumn,
  Column,
  ManyToOne,
  CreateDateColumn,
  UpdateDateColumn,
} from "typeorm";
import { User } from "./User";

@Entity("passwords")
export class Password {
  @PrimaryGeneratedColumn("increment")
  id!: number;

  @Column({ type: "varchar" })
  title!: string;

  @Column({ type: "varchar" })
  title_id!: string;

  @Column({ type: "varchar" })
  encrypted_password!: string;

  @Column({ type: "integer" })
  user_id!: number;

  @CreateDateColumn({ type: "datetime" })
  created_at!: Date;

  @UpdateDateColumn({ type: "datetime" })
  updated_at!: Date;

  @ManyToOne(() => User, (user) => user.passwords, { onDelete: "CASCADE" })
  user!: User;
}

いざ実装

早速トラブル発生です。
DBへアクセスする箇所がNext.jsのAPIモードを利用する形式で書かれており、buildするとエラーが出ます。(API Routerはstaticじゃないよというエラー)
rendererの中に記載していると、typeORMのバグを踏んでいるようで、うまく動かせたりしない状態でした。

雑に構築したのが響いてきます。electronとNext.jsについての知見が無さすぎたのが原因です。
とりあえずNext.jsのAPIモードはやめて、メインプロセスと、レンダラープロセスそれぞれで責務をわけましょう。(最初からやっておけ問題)
公式は難しい言葉で書かれているのでchatGPTに聞きました。こういう時に使うと効率あがりますね(´・ω・`)

process

プロセス間の通信については以下の通り。
レンダラー側でnode.js実行させることもできるが、まあよろしくないよねということでメインプロセスに持たせるのが一般的らしい。(たぶん)

レンダラープロセス側では、Next.jsを使用してUIを構築し、IPCを使ってメインプロセスと通信します。
ElectronのipcRendererとipcMainモジュールを使用して、メインプロセスとレンダラープロセス間でデータのやり取りを行います。

そんなこんなでbuild通るように修正しました(´・ω・`)

※ipcでのパラメータ遷移はオブジェクトで渡してあげないとダメです。気づくのに時間かかりました:)

index.tx
ipcMain.on(
  "user-register",
  async (event: IpcMainEvent, data: { email: string; password: string }) => {
    const res = await userRegister(data.email, data.password);
    event.reply("user-register-response", res);
  }
);

サインアップ確認

registerを入力後にdashboardへ遷移させるように修正しました。(chatGPTくんはこのあたりのケアをしてくれていませんでした)
わくわくのdashboardです。どんな表示になるでしょうか。

dashboard

真っ白ですね(´・ω・`)
passwordsテーブルには何も存在していないのですが、登録するための画面ぐらいは出てくるのを期待していましたが残念(´・ω・`)

WIP

dashboardの表示とpasswordのCRUDが次の課題ですね。。。
動作優先でバリデーションとか全くしていないのでその辺りもやらないと。。。
FORKして機能追加してくれる人とかいないかなあ...|・ω・`)

https://github.com/purupurupu/PassVault

お気持ち

色々いじり倒したり、直近の流れを見ていると、
数年後にはprivateのリポジトリを指定して学習させたのちに、自然言語で既存資産の修正を行なっていくというものが実現されるんだろうなと個人的には思っています
現在、新規でコードを起こすことに優位性を持っているLLMが、既存コードの修正まで侵食してくると思います。
既存資産への知見に優位性はなくなるだろうとも思うので、どういうキャリアを進むのかは悩ましいなとつくづく思います。

  • 最先端技術に特化する。
  • ベースとなる技術に特化する。
  • 複合的に業務を回せる人になる(マーケとかマネジメントとか要素は様々)
    • 今もそういう人材は多いと思いますが、レイヤーの壁がどんどん薄くなっていくと感じています。

上記のように方針を考えないといけないんだろうなと思うのですが、そこらへんにいるレベルのエンジニアには難しい問題だなと思います。
業務ドメインに対して最適化している環境への適応は難しいと思うので、そういった環境に身を置くのがエンジニアの面ではメリットが大きそうだなと思いました。(食いっぱぐれないため)
大きい会社ではフロントエンドやバックエンドのようにロールを分けて専門性を高める必要がありそうですが、中小企業では動き方が変わってくるかもしれませんね。

GitHubで編集を提案

Discussion