Electronで作るロック付き
Slackクライアント
(Slackolon)

私は複数のSlackアカウントをパソコンの中で使い分けています。
仕事用とプライベート用、あるいは複数のプロジェクトごとに、それぞれ別のワークスペースにログインしている状態です。

ここで困るのが、公式のSlackアプリは基本的に1つのアカウントしか扱えないという点です。
複数アカウントを切り替える機能はありますが、それぞれを独立したウィンドウで常時開いておくことはできません。

さらに、私のパソコンは他の人も利用することがあります。
スマートフォンのメッセージアプリには起動時にパスコードを設定できますが、パソコンのSlackにはそうした機能がありません。
OSのログインパスワードとは別に、アプリごとにロックをかけたいと思いました。

関連記事

1. 自分用のSlackクライアントを作る

そこで考えたのが、自分用のSlackクライアントを作ることでした。

Electronで作るSlackクライアント Electron パスコード Slack パスコード保護付きSlackクライアント ✓ 起動時パスコード認証 ✓ 複数アカウント対応(実行ファイル分離)

求める機能はシンプルです。
Slackを表示できるブラウザ画面があって、起動時にパスコードを入れないと開けないようにする。
そういうアプリが欲しかったのです。

プロファイル切り替え機能についても最初は考えました。
複数のアカウントを1つのアプリで管理できれば便利そうです。
でも、よく考えると、アプリ自体を複数用意すれば済む話でした。それぞれのアプリに別々のパスコードを設定できれば、むしろ分かりやすい。

こうして、「1つのアプリが1つのSlackセッションを持つ」というシンプルな設計に落ち着きました。

1.1. 設計の基本方針

最初に決めたのは「必要最小限の機能に絞る」という方針です。

設計の基本方針 必要最小限 の機能に絞る 1 Slackの ブラウザ表示 2 パスコード 認証 3 実行ファイル 複数化

複雑な機能を増やせば増やすほど、予期しない動作が起きやすくなります。自分が日常的に使うツールだからこそ、安定して動くことを優先したいと考えました。核心は「Slackを表示するブラウザ画面と、起動時のパスコード認証」だけです。

たとえば、通知機能も不要です。Slackのスマホアプリも使っているので、わざわざデスクトップ通知の仕組みを作る必要はありません。

プロファイル切り替え機能も見送りました。これは冒頭で触れたように、実行ファイルを複数用意すれば済む話です。SlackWork.exeSlackPrivate.exeのように、ファイル名を変えて配置すればいい。その方が、どのアプリがどのアカウント用かも分かりやすい。

パスコードリセット機能も作りませんでした。忘れたらプロファイルを削除して、もう一度設定すればいい。なぜなら、Slack自体のパスワードログインはできるからです。そう割り切ることで、実装をシンプルに保てます。

1.2. 画面は3つだけ

アプリの画面構成も最小限です。

最初に表示されるのは「初期設定画面」。
ここでSlackのURL、ユーザー名、テーマカラー、パスコードを設定します。
パスコードは4桁の数字です。短すぎるかもしれませんが、これは「画面ロック」が目的であって、強固なセキュリティを保証するものではないからです。

入力時には数字以外の文字を自動的に除去する処理も入れています。

form.passcode.addEventListener('input', () => {
  const normalized = form.passcode.value.replace(/[^0-9]/g, '').slice(0, 4);
  if (form.passcode.value !== normalized) {
    form.passcode.value = normalized;
  }
});
Code language: JavaScript (javascript)

これで、誤って文字を入力してしまっても、すぐに数字だけに整形されます。

2回目以降の起動では「ロック画面」が出ます。
ここでパスコードを入力すると、Slackの画面に進みます。

そして「Slack画面」。
ElectronのBrowserWindowでSlackのWebを表示しているだけです。
ウィンドウを閉じればアプリも終了します。

2. パスコードの扱い方(bcrypt)

パスコードはそのまま保存するわけにはいきません。
設定ファイルを覗かれたら意味がないからです。

パスコードの扱い方 bcrypt ハッシュ化アルゴリズム ソルトラウンド: 12 計算コストとバランス 指数バックオフ 1000 × 2^(失敗回数-1) ms 上限: 15秒 総当たり攻撃対策 1 入力 2 ハッシュ化 3 照合 4 結果 ※メインプロセスで失敗回数を管理

今回はbcryptを使ってハッシュ化しました。
ソルトラウンド数は12に設定しています1
これは計算コストとセキュリティのバランスを考えた値です。

const SALT_ROUNDS = 12;

async function hashPass(passcode) {
  return bcrypt.hash(passcode, SALT_ROUNDS);
}

async function verifyPass(passcode, passHash) {
  return bcrypt.compare(passcode, passHash);
}
Code language: JavaScript (javascript)

パスコードを入力されたら、保存されているハッシュと照合します。
一致すればロック解除、不一致なら失敗です。

試行回数に制限も設けました2
何度も間違えると、待ち時間が指数的に伸びていきます。
計算式は1000 * 2^(失敗回数 - 1)ミリ秒で、上限は15秒です。

function calcDelayMs(failCount) {
  if (failCount <= 0) return 0;
  const delay = 1000 * Math.pow(2, failCount - 1);
  return Math.min(15000, delay);
}
Code language: JavaScript (javascript)

1回目の失敗で1秒、2回目で2秒、3回目で4秒、4回目で8秒。
5回目以降は15秒で固定されます。

この制御はメインプロセス側で行っています。
レンダラー側でタイマーを持たせる方法もありますが、そうすると改ざんのリスクがあります。

ただし、アプリを再起動すれば失敗回数はリセットされます。
完璧な防御ではありませんが、「画面ロック」という目的には十分だと捉えています。

2.1. セッションの分離(partition)

複数のSlackアカウントを使い分けるには、セッションを分離する必要があります。

セッションの分離 partition: ‘persist:slack’ Cookie・ストレージを個別管理 SlackA.exe ↓ SHA-256 execId: abc123… 独立セッション SlackB.exe ↓ SHA-256 execId: def456… 独立セッション ✓ ファイル移動に強い ✓ 複数アカウント同時利用

Electronにはpartitionという仕組みがあって、これを使うとCookieやストレージを別々に管理できます3
今回はpartition: 'persist:slack'を指定しました。
これでログイン状態がアプリ再起動後も維持されます。

slackWindow = new BrowserWindow({
  width: 1200,
  height: 800,
  show: false,
  webPreferences: {
    contextIsolation: true,
    sandbox: true,
    nodeIntegration: false,
    partition: 'persist:slack',
  },
});
Code language: JavaScript (javascript)

ここで問題になったのが、「どうやって複数の実行ファイルを区別するか」です。

最初は、実行ファイルのパス全体をハッシュ化して、それをもとに保存先を決める方法を考えました。
でも、これだと実行ファイルの場所を変えるだけでセッションが失われてしまいます。

次に考えたのが、実行ファイル名を使う方法です。
これなら、ファイルの場所を変えてもセッションは維持されます。
同時に、ファイル名を変えれば別のセッションとして扱われる。
これが理想的だと思いました。

実装はprofile.jsというモジュールにまとめました。
実行ファイル名からexecIdを生成し、それをもとにuserDataの保存先を決定します。

function resolveProfileDir(defaultUserData) {
  const execPath = process.execPath;
  const execName = path.basename(execPath, path.extname(execPath));
  const execId = crypto.createHash('sha256').update(execName).digest('hex').slice(0, 16);
  return path.join(defaultUserData, 'profiles', execId);
}
Code language: JavaScript (javascript)

アプリ起動時にこの関数を呼んで、userDataのパスを設定します。

function initProfileSelection() {
  profileDir = resolveProfileDir(defaultUserData);
  app.setPath('userData', profileDir);
}
Code language: JavaScript (javascript)

最終的に、userDataの保存先はuserData/profiles/<execId>という形式になりました。
execIdは実行ファイル名から生成した16文字の識別子です。

たとえばMySlackA.exeMySlackB.exeという2つのファイルを用意すれば、それぞれが独立したセッションを持つことになります。

2.2. 複数アプリの識別問題

さて、複数の実行ファイルを使い分けるとなると、新たな問題が出てきます。

複数アプリの識別方法 どのアカウント用? パスコード入力前に判別したい 解決策 仕事用 ユーザー名表示 青色テーマ 個人用 ユーザー名表示 ピンク色テーマ 開発用 ユーザー名表示 緑色テーマ 8色のパステルカラーから選択可能

起動したアプリが「どのアカウント用か」を、瞬時に判別できないと不便です。
パスコードを入れてからSlackの画面を見て「あ、違うアカウントだった」となると、せっかくのロックが煩わしく感じられます。

そこで考えたのが、ユーザー名とテーマカラーの表示です。
初期設定で、好きなユーザー名と、8色のパステルカラーから1色を選んでもらいます。

const THEME_COLORS = [
  '#f3b7c6', // ピンク
  '#f6c9a7', // オレンジ
  '#f7e3a3', // イエロー
  '#cbe8b0', // グリーン
  '#b7e0d8', // シアン
  '#bcd9f3', // ブルー
  '#c9c2f0', // パープル
  '#e3c0e8', // マゼンタ
];
Code language: JavaScript (javascript)

この情報はロック画面に表示されます。
たとえば「仕事用」という名前で青色を選んでいれば、ロック画面が青くなって「仕事用」と表示される。
これだけで、どのアカウントのアプリかが分かります。

テーマカラーの適用は、CSSのカスタムプロパティを使って実現しています。

body {
  background: radial-gradient(
    circle at top,
    var(--theme-accent, #ffffff) 0%,
    #eef1f6 45%,
    #e3e8f2 100%
  );
}
Code language: CSS (css)

設定画面でテーマカラーを変更すると、リアルタイムで背景色が変わります。
これはrenderPaletteという関数で実装していて、パレットのボタンをクリックすると、その色がCSSのカスタムプロパティにセットされる仕組みです。

function renderPalette(container, input, onChange) {
  container.innerHTML = '';
  THEME_COLORS.forEach((color, index) => {
    const button = document.createElement('button');
    button.type = 'button';
    button.className = 'palette-swatch';
    button.style.background = color;
    button.addEventListener('click', () => {
      input.value = color;
      if (onChange) onChange(color);
    });
    container.appendChild(button);
  });
}
Code language: JavaScript (javascript)

テーマカラーは後から変更できるようにしました。
メニューから「テーマカラーを変更」を選ぶと、設定画面が開きます。

2.3. リンクとナビゲーション

Slack内のリンクをクリックしたとき、どう動作させるかも考える必要がありました。

Electronセキュリティ設定 webPreferences contextIsolation true コンテキスト 分離 sandbox true プロセス サンドボックス化 nodeIntegration false Node.js API アクセス制限 レンダラープロセスを厳格に隔離

外部ブラウザで開くのがセキュリティ的には安全ですが、Slack内のチャンネルリンクなども外部に飛んでしまうと使い勝手が悪くなります。

今回は「Slack内のHTTP(S)リンクはすべてアプリ内で遷移させる」方針にしました。
ただし、window.openで新しいウィンドウが開かれようとしたときは、それを抑制して同じウィンドウで遷移させます。

slackWindow.webContents.setWindowOpenHandler(({ url }) => {
  if (isHttpUrl(url)) {
    slackWindow.loadURL(url);
  } else {
    shell.openExternal(url);
  }
  return { action: 'deny' };
});
Code language: JavaScript (javascript)

HTTP(S)以外のスキーム、たとえばmailto:などは、shell.openExternalでOSに渡します。
これでメールアプリが開くようになります。

さらに、ページ内での遷移についても同様の処理を入れました。

slackWindow.webContents.on('will-navigate', (event, url) => {
  if (!isHttpUrl(url)) {
    event.preventDefault();
    shell.openExternal(url);
  }
});
Code language: PHP (php)

この処理を入れることで、ウィンドウが増殖するのを防ぎつつ、Slack内の移動はスムーズにできるようにしました。

2.4. User-Agentの問題

開発中、Slackから「未サポートブラウザ」と判定される問題に遭遇しました4

ElectronはChromiumベースですが、デフォルトのUser-AgentにはElectronのバージョン情報が含まれています。Slackはこれを見て、サポート対象外と判断することがあるようです。

解決策は、User-AgentをChrome互換のものに固定することです。

app.userAgentFallback = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
Code language: JavaScript (javascript)

これに加えて、webContentsでページを読み込むときにもUser-Agentを指定しました。両方で設定することで、確実に回避できるようになりました。

3. 配布とビルドの注意点

Windows向けに配布するとき、いくつか気をつけることがありました。

まず、配布物はzipファイルにして、展開してから使ってもらう必要があります。実行ファイル単体だけを配布すると、icudtl.datというファイルが見つからずに起動しません。これはElectronが国際化処理に必要とするデータファイルです。

zipを展開すると、resourcesフォルダの中にこのファイルが含まれています。実行ファイルと一緒に配置されていることで、Electronが正しく動作します。

もうひとつ、ビルド環境での問題もありました。electron-builderを使ってWindows向けにビルドするとき、シンボリックリンクの作成でエラーが出ることがあります。

これはWindowsの権限設定の問題で、開発者モードを有効にすることで解決できます。それでもダメな場合は、electron-builderのキャッシュを削除したり、管理者権限でビルドを実行したりする必要があります。

こうした情報はREADMEに記載して、自分が後で困らないようにしました。

3.1. 開発環境の整備

開発を始めたとき、Node.jsのバージョンが古くてnpmが起動しないという問題がありました。

macOSではnvmを使ってNode.jsを管理するのが便利です。LTSバージョンをデフォルトに設定しておけば、安定した環境が保たれます。

また、HomebrewでインストールしたNodeと、nvmで管理するNodeが混在しないように注意が必要です。which nodeコマンドで確認して、意図したバージョンが使われているかをチェックします。

こうした環境の問題は、開発の最初に解決しておくと後がスムーズです。

3.2. テストの構成

テストはVitestを使いました。

Vitestは、JavaScriptのテストフレームワークで、ESM(ECMAScript Modules)を前提としています。そのため、テストファイルは.mjsという拡張子にして、import構文で書く必要がありました。

テストの対象は、パスコードの検証やディレイ計算、設定ファイルの読み書きなど、基本的なロジックです。

UIのテストは今回は実装していません。Electronのレンダラープロセスをテストするのはやや複雑なので、手動での確認にとどめました。最小構成を目指す中で、テストも必要最小限に絞ったという形です。

3.3. 仕様上の割り切り

このアプリのパスコードは、あくまで「画面ロック」のためのものです。

ローカルファイルレベルの攻撃を防ぐことはできません。設定ファイルはハッシュ化されていますが、userData全体を暗号化しているわけではありません。

OSのログインを突破されたら、設定ファイルを直接書き換えたり、Slackのセッションデータにアクセスしたりすることは可能です。

では、OSのログインパスワードとは別に、なぜアプリレベルのパスコードが必要なのか。

それは、スマートフォンのメッセージアプリと同じ使い心地を実現したかったからです。LINEやWhatsAppなどのアプリには、起動時にパスコードやFace IDを要求する機能があります。端末にはログインしているけれど、特定のアプリだけは別途ロックされている。この仕組みをパソコンでも使いたかったのです。

共用パソコンで「ちょっと席を離れるけど、Slackだけは見られたくない」という場面を想定しています。OS全体をロックするほどではないけれど、メッセージアプリには触れてほしくない。そういう使い方です。

一方で、Electronの基本的なセキュリティ設定は徹底しています。

webPreferences: {
  contextIsolation: true,
  sandbox: true,
  nodeIntegration: false,
  preload: path.join(__dirname, '../preload/index.js'),
}
Code language: CSS (css)

contextIsolationを有効にすることで、レンダラープロセスとプリロードスクリプトのコンテキストを分離します。sandboxも有効にして、レンダラープロセスの権限を制限しています5nodeIntegrationは無効にして、レンダラーから直接Node.jsのAPIを使えないようにしました6

プリロードスクリプトで必要最小限のAPIだけをレンダラーに公開する形です7

より強固な保護が必要なら、userData全体を暗号化するような拡張を検討する余地はあります。それは将来の課題として残しています。

3.4. 実装しなかった機能

意図的に実装しなかったものもいくつかあります。

デスクトップ通知、トレイ常駐、未読バッジ。これらはSlackクライアントとしては便利な機能ですが、今回は含めませんでした。

プロファイル切り替え機能も作りませんでした。その代わりに、実行ファイルを複数用意する運用にしました。

Slack APIを使った独自機能も考えませんでした。あくまでWebビューを表示するだけ、というシンプルな構成です。

こうした割り切りが、結果的に開発とメンテナンスをシンプルに保つことにつながりました。

4. 開発から得た教訓

この開発を通して、いくつかの教訓を得ました。

ひとつは、userDataの分離戦略は運用に合わせて単純化するほうがいい、ということです。最初は複雑な仕組みを考えましたが、実行ファイル名で分ける、というシンプルなルールに落ち着きました。

もうひとつは、Electronの設定は明示的に行うべき、ということです。User-Agentの問題もそうですし、セキュリティ設定もそうです。デフォルトの挙動に頼らず、自分で制御できる部分は制御する。

そして、ドキュメントを残すことの大切さ。開発ログに変更内容と理由を記録しておくと、後で「なぜこうしたんだっけ?」と迷うことがありません。仕様書も、初期版と現行版を分けて保存することで、変遷が追えるようにしました。

4.1. これからの可能性

今のところ、このアプリは私の日常で問題なく動いています。

ただ、いくつか改善の余地はあります。

たとえば、認証失敗回数の永続化。今はアプリを再起動すればリセットされますが、これを保存しておくこともできます。

セッションデータの暗号化も、セキュリティを高めたい場合には検討できます。

外部ブラウザ起動のポリシーを追加して、特定のドメインだけは外部で開く、という制御も可能です。

  1. bcryptのソルトラウンド数は推奨値が10以上とされており、12は一般的に推奨される値です。ラウンド数が大きいほど解読に時間がかかり安全性が高まりますが、処理負荷も増大します。 – Bcryptハッシュ
  2. 指数バックオフは、ブルートフォース攻撃への対策として有効な手法です。ただし、アプリ再起動で失敗回数がリセットされる場合、攻撃者による悪用の余地があります。 – bcryptを使用したパスワードのハッシュ化についてまとめた
  3. partitionが「persist:」で始まる場合、ページは永続的なセッションを使用し、同じpartitionを持つアプリ内のすべてのページで利用できます。「persist:」プレフィックスがない場合は、インメモリセッションとなりアプリ終了時にデータが失われます。 – session | Electron
  4. ElectronのデフォルトUser-Agentには「Electron/X.X.X」という文字列が含まれており、Webサービス側がこれを検出して未サポートと判断することがあります。Chrome互換のUser-Agentに変更することで回避できます。 – Security | Electron
  5. sandboxはElectron 20からデフォルトで有効化されており、レンダラープロセスをChromiumのOS レベルサンドボックスと互換性のある形で隔離します。nodeIntegrationをtrueにすると、sandboxは自動的に無効化されます。 – Process Sandboxing | Electron
  6. nodeIntegrationをfalseにすることで、レンダラープロセスからNode.jsのrequire関数やElectron内部APIへの直接アクセスを防ぎます。これはElectronのセキュリティベストプラクティスの基本です。 – Security | Electron
  7. contextIsolationはElectron 12からデフォルトで有効化されており、レンダラープロセスとプリロードスクリプトのコンテキストを分離することで、WebコンテンツがプリロードスクリプトやElectron内部にアクセスするのを防ぎます。 – Context Isolation | Electron