非公式GUIアプリをひっそり作って社内公開してる話



メリークリスマス(こんにちは)
2023年10月よりクロスマートでバックエンドエンジニアとして参画している宮崎です。

今回はクリスマスにぴったりのElectron+Sveltekit+Skeleton+TypeScriptでデスクトップアプリを作った際の話をさせていただこうかと思います。
アーキテクト選定理由、環境構築、ポイントや詰まった箇所など冗長かつ乱文となっていますがご容赦いただければ幸甚です。

What for? (背景)

クロスマートでは
お客様の課題を解決するために、日々様々なサポートを行っています。
しかし、現在の本体プロダクトでは解決が難しいケースもあり、手作業でデータの調整を必要とする場面がどうしても出てきます。
その作業を行っているメンバーの負担を少しでも楽にできないかと考え、非公式アプリを日々作成しています。

使ってみたい技術スタックを試して形にでき、メンバーに感謝される is 最高

Why this?(選定理由)

Electron、Eel、Tauri、Flutterなどの様々なフレームワークを検討しましたが
以下の理由からElectron+Sveltekit+Skeleton+TypeScriptという結論に落ち着きました。

バックエンド(というかGUIフレームワーク)
  • Electron:
    Web技術が使える -> HTML, CSS, JavaScript大好き
    FileAPIが独自拡張されてる
    -> FileAPIの拡張でパスが取れるの革命だと思うんですよね…(唯一無二)

  • Eel: どうせなら…Tauri?というお気持ち
  • Tauri: 最後まで迷ったが、ファイルが絡むので…
  • Flutter: Dartでフロント書く気力が沸かなかった…CSS Love
  • flex, pyside... : 独自UI書く気力が…
フロントエンド
  • ネイティブのみ: 流石に味気ない
  • Vue: 色々触ったので今回は趣味ということもあり候補外
  • React: JSXが苦手なので最終手段(触らなければとは常々…)
  • Astro: 忘れてた。多分次使います
  • Svelte: 物理的に最短で記述できる!にちょっと興味が!

コンポーネントライブラリは当初利用予定がなく、CSSフレームワークのTailwind CSSを利用予定でしたが…
良さそうなライブラリ(Skeleton)を見つけてしまい勢いで採用 www.skeleton.dev

…と言った感じで非公式かつ業務外ならではの好み気分を重視して選定しています。


ちょっと前置きが長くなってしまいましたが、
ここまで読んでくれているような方が気になっているであろう環境構築は以下になります。

環境構築

SvelteKit + Skeletonのインストール

mkdir desktop-app
cd desktop-app

npm create skeleton-app@latest
# 対話式で色々聞かれるので、好みで。
# template: Bare Bones
# theme: Skeleton
# other packages: none
# TypeScript: Yes, using TypeScript syntax
# like setup: Add ESLint for code linting?, Add Prettier for code formatting?

# とりあえず一旦必要なファイルをインストール
npm i

# Electronなので追加(SSGで出力)
npm i -D @sveltejs/adapter-static

# Prettierのデフォルト戦争でTabになっているのでSpaceに変更
vi .prettierrc
> "useTabs": false,

prettier --write .
npm create svelte@latest
npm i -D @skeletonlabs/skeleton
...

最初はSveltekit入れてからSkeletonを組み込む手順で構築してましたが、
Sveltekit初体験ということもありディレクトリ構造など迷うポイントが多かったので素直にSkeleton側で用意してくれているCLIで構築しました。

Electronの組み込み

npm i -D electron concurrently

# 設定を保存するのに便利で使いやすいので比較的おすすめです。使わないなら必要ない
npm i electron-store

# mac用にしか吐き出さないので、zipとdmgのみ
npm i -D @electron-forge/cli @electron-forge/maker-zip @electron-forge/maker-dmg

有名なので知ってる人も多いと思いますが concurrently は、便利ですのでおすすめです。

パッケージの設定云々

多分一番躓く人が多いんじゃないかと思う各種設定ファイルを参考程度に抜粋+説明しておきます。

ディレクトリ構造
├── README.md
├── forge.config.cjs
├── package-lock.json
├── package.json
├── postcss.config.cjs
├── src
│   ├── app.d.ts
│   ├── app.html
│   ├── app.postcss
│   ├── electron.cts -> Electron: main
│   ├── global.d.ts -> Electron: Typeとか
│   ├── lib
│   │   └── index.ts
│   ├── preload.cts -> Electron: Rendererとの通信用
│   └── routes
│       ├── +layout.server.ts
│       ├── +layout.svelte
│       └── +page.svelte
├── static
├── svelte.config.js
├── tailwind.config.ts
├── tsconfig.electron.json -> Electron: Electron用のTSビルド設定
├── tsconfig.json
└── vite.config.ts

ある程度抜粋していますが、Electron関係のファイルも src にまとめて作っています。

Package.json
"main": "dist/electron.cjs",
"productName": "デスクトップなアプリ名",
"type": "module",
"scripts": {
  "tcs": "tsc -p tsconfig.electron.json",
  "dev:svelte": "vite dev",
  "dev:electron": "tsc -p tsconfig.electron.json && electron .",
  "dev": "concurrently -n=svelte,electron -c='#ff3e00',blue \"npm run dev:svelte\" \"npm run dev:electron\"",
  "build": "vite build",
  "preview": "vite preview",
  "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
  "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
  "lint": "prettier --check . && eslint .",
  "format": "prettier --write .",
  "make": "tsc -p tsconfig.electron.json && vite build && electron-forge make --arch=universal"
},

npm run dev:svelte
Sveltekitの起動、ブラウザで確認したい場合はこちら

npm run dev
Electronも絡めたい場合はこちら、concurrentlyでElectronとSveltekitの起動を同時に行う

npm run make
パッケージング、--arch=universal については後述

"main": "dist/electron.cjs",
Electronのエントリーポイントを指定
Electronはtsファイルを直接読み込めないためTSビルド成果物を読み込むように変更

"productName": "デスクトップなアプリ名",
アプリ名って大事ですよね…ネーミングセンス欲しい

"type": "module",
宗教戦争が起きるので、黙秘

"tcs": "tsc -p tsconfig.electron.json",
Sveltekit自体にTypeScriptを使っている関係上、ElectronのTSビルドで色々不都合が生じます。
(ElectronはデフォルトでCommonJSを使っているため、ES Moduleとの共存の部分で)
難しいことを考えず、Electron用のファイルを用意して読み込ませれば解決。

  "preview": "vite preview",
  "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
  "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
  "lint": "prettier --check . && eslint .",
  "format": "prettier --write .",

この辺は自分的には不要でしたが、消す理由もなかったので残してます。

tsconfig.electron.json
{
  "files": [
    "src/electron.cts",
    "src/preload.cts",
  ],
  "compilerOptions": {
    "module": "commonjs",
    "target": "ES2022",
    "outDir": "./dist",
    "allowJs": true,
    "checkJs": true,
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "skipLibCheck": true,
    "strict": true,
  }
}

デフォルトの設定を"type": "module"にしているので、Electron関係のtsファイルは明示的に.ctsにする必要があります。

svelte.config.js
import adapter from '@sveltejs/adapter-static';
...
  kit: {
    adapter: adapter({
      pages: 'dist/view',
      assets: 'dist/view',
      fallback: undefined,
      precompress: false,
      strict: true
    })
  }

pages: 'dist/view',
assets: 'dist/view',
Svelteをビルドした時(SSG)の出力先変更、意外と大事。
ちなみに 'dist/' 配下にしないとElectronが読み込めないので注意。

src/routes/+layout.ts or src/routes/+layout.server.ts
export const prerender = true;

1行だけですけど、めちゃくちゃ大事です。
+layout.svelte じゃなくて +layout.ts を別途作ってあげる必要があります。
.tsです。
.svelte じゃありません。 .tsです。
もう一度言います、 .tsです。

じゃないと延々と

@sveltejs/adapter-static: all routes must be fully prerenderable, but found the following routes that are dynamic:

と言われます。(言われました)

forge.config.cjs
module.exports = {
  packagerConfig: {
    asar: true,
    icon: './static/icon/icon.icns',
  },
  rebuildConfig: {},
  makers: [
    {
      name: '@electron-forge/maker-zip',
      platforms: ['darwin'],
    },
    {
      name: '@electron-forge/maker-dmg',
      config: {
        background: './static/icon/app-background.png',
        icon: './static/icon/icon.icns',
      },
    },
  ],
};

background: './static/icon/app-background.png',
正直これ、別に必要ないです。
作ったアプリをapplicationフォルダに移す際に表されるウィンドウの背景設定。

icon: './static/icon/icon.icns',
作ろう!作るべき!Figmaとかで簡単に作れます!
Figmaで作ったのをpngでエクスポートして、icns変換してくれるWEBサイトを利用すればあっという間に完成!
ちなみに、size: 400px*400px Radius: 80px を無責任におすすめしておきます!

src/electron.cts
const dev = !app.isPackaged
...
if (dev) {
    const promise = main.loadURL('http://localhost:5173/')
    main.webContents.openDevTools()
} else {
    const promise = main.loadFile("dist/view/index.html")
}

const dev = !app.isPackaged
Electronの起動時にパッケージ化されているかどうかを判定しています。
npm run dev で起動する場合は const dev = true になります。
npm run make で作成したパッケージを起動する場合は const dev = false になります。

起動時に cross-env DEV_ENV=true を書き
const isDevEnvironment = process.env.DEV_ENV === 'true' とかでも問題ないです。
が、上記のようにパッケージ判定で賄えます!便利

const promise = main.loadFile("dist/view/index.html")
ここがSSG化したファイルを見に行く場所になりますので変更したい場合は svelte.config.js も合わせて修正してください。
ちなみにpreloadは preload: path.join(__dirname, "preload.cjs"), のように書いてます。

最大の落とし穴について

Macはインターネットからダウンロードしたり、メールの添付ファイルとして受け取ったり、App Store以外の他のソースから取得したりするファイルやアプリにcom.apple.quarantineという拡張属性を必ず付与します。

Arm64でビルドしたアプリに対してはセキュリティ解除しなければインストール画面にすら辿り着けないようになっており、それなりの知識(そういうものと認識)が必要となります。
Apple Developer Programに加入して野良アプリから昇格させるという手もありますが有料($99/年)です。

そのため、自分のおすすめは electron-forge make --arch=universal になります。
universalですと警告は出ますが一応インストールを実行させてくれるので難易度はかなり低くなります。

まとめ

ここまでくれば、
Sveltekitで作ったものを読み込んで表示するElectron製のデスクトップアプリの作成環境が出来上がっているかと思います。

あとは各々好きな機能を実装し、良きクリスマスをお楽しみください。
メリークリスマス🎄(最後まで見ていただいてありがとうございました)

最後に

お決まりですが、クロスマートではバックエンド・フロントエンドエンジニアの方、さらにはBizDevなど一緒に働ける方を広く募集しています。
ご興味がある方は、是非こちらを御覧ください!一緒にクロスマート非公式アプリを量産しましょう。 xorder.notion.site