
Stream Deck から Mac の VPN 接続をコントロールする
前回、 Stream Deck から AppleScript を起動する方法をおぼえましたが、今度はこれの応用で VPN の接続状態をコントロールしてみます。
ただ、それだけじゃなくて、できれば VPN の接続状態が Stream Deck 上でわかるようにしたいので、今回は AppleScript だけじゃなくて、実際に Stream Deck のプラグインを作ってみます。
はじめに
作成時の環境ですが、
- MacBook Pro M5
- macOS Tahoe
- Stream Deck 7.4.0
です。
AppleScript など、OS依存の機能を使ってる部分もあるため、他の環境だと読み替えなどが必要になる可能性があります。
仕様を考える
- キーのアイコンで接続状態がわかる
- キーを押すとVPN接続状態をトグルできる(接続中なら切断、など)
- VPN 接続名を設定から入力できる
プラグイン開発の準備
Stream Deck のプラグインを開発するには、公式 SDK があるのでそれを使います。
エディタはお気に入りのやつを使えば OK なんですが、公式的には VSCode を推奨しているそうです。
Note
この後使う CLI ツールでも、プロジェクトの生成が完了したタイミングで、「このプロジェクトを VSCode で開くか?」みたいな質問をしてくるくらいには推奨してるっぽいです
ということで、早速その CLI ツールをインストールします。
npm ネットワークで公開されているので、npm とか、yarn とか、pnpm とか、なんかそのへんのお気に入りのパッケージマネージャを使ってインストールしちゃいましょう。
ローカルインストールでも動かせるかもしれませんが、私はこの先いろいろプラグイン作って遊びたいと思ってるので、説明通りにグローバルインストールしました。
npm install -g @elgato/cli
インストールが完了すると、streamdeck というコマンドが使えるようになります。
streamdeck -v
1.7.0
プロジェクトを作成する
streamdeck create コマンドでプロジェクト作成ウィザードが立ち上がりますので質問に答えてきます。
streamdeck create
___ _ ___ _
/ __| |_ _ _ ___ __ _ _ __ | \ ___ __| |__
\__ \ _| '_/ -_) _` | ' \ | |) / -_) _| / /
|___/\__|_| \___\__,_|_|_|_| |___/\___\__|_\_\
Welcome to the Stream Deck Plugin creation wizard.
This utility will guide you through creating a local development environment for a plugin.
For more information on building plugins see https://docs.elgato.com.
Press ^C at any time to quit.
✔ Author: #7
✔ Plugin Name: VPN Toggle
✔ Plugin UUID: space.no7.sdplugin.vpn-toggle
✔ Description: Toggle macOS VPN Connection
? Create Stream Deck plugin from information above? (Y/n)
| 名前 | 説明 | サンプル |
|---|---|---|
| Author | 作者名 | #7 |
| Plugin Name | プラグイン名 | VPN Toggle |
| Plugin UUID | プラグインの識別子(逆ドメイン名記法) | space.no7.sdplugin.vpn-toggle |
| Description | 説明文 | Toggle macOS VPN Connection |
- プラグイン名はこのあとプロジェクトのディレクトリの名前にもなります。
スペースも使えますが、その場合ディレクトリ名はハイフン区切りになるようです。 - プラグインの識別子(UUID)は、逆ドメイン名記法1と呼ばれる方法で書きます。
- 使える文字列は小文字の英数(
a-z,0-9)とハイフン(-)、そしてピリオド(.)のみです。 - もし Elgato のマーケットプレイスに公開したくなった場合、プラグインが公開されると UUID は変更不可になります。
- 使える文字列は小文字の英数(
プラグインのリンクとアンリンク
ローカルで開発中のプラグインを Stream Deck のアプリにインストールすることをリンクするといい、逆にアンインストールすることをアンリンクと言うみたいです。
もし開発中のプラグインを何らかの理由で Stream Deck のアプリからアンインストールしてしまった場合は、通常のプラグインのインストールフローではインストールできませんので、このリンク作業を行う必要があります。
まず、プロジェクトのディレクトリにターミナルで移動するか、VSCode でプロジェクトを開いた状態でターミナルを立ち上げます。
この状態で ls すると、プロジェクト作成時に決めた UUID に .sdPlugin という接尾辞のついたフォルダが見えていればOKです。
その状態で、以下のコマンドを入力します。
streamdeck link space.no7.sdplugin.vpn-toggle.sdPlugin
✔ Linked successfully って表示されれば再リンクできているので、Stream Deck アプリでもインストール済みの状態になっているはずです。
逆に、何らかの理由でプラグインをアンインストールしたい場合は、普通に Stream Deck アプリからアンインストールしても良いみたいですが、コマンドラインからアンインストールすることもできます。
streamdeck unlink space.no7.sdplugin.vpn-toggle
Important
リンクする際は、パスで指定(.sdPlugin で終わるディレクトリまでのパスを指定する)のに対して、アンリンクの場合は、対象となるプラグインの UUID を指定することに注意。
(前者は .sdPlugin まで含めて入力するけど、後者は含めない)
開発を開始する
TypeScript と HTML をつかって開発します。(バンドラーは Rollup を使ってるっぽいです)
npm run watch
プラグイン本体は、src/action/ 以下に TypeScript で記述します。
今回は VPN をトグルしたいので、toggle-vpn.ts としました。
サンプルコードに従って、 SingletonAction を継承した ToggleVPN クラスを作りました。
Settings 型は、このアクションが使う設定パラメータの形をいれるっぽいです。
今回は設定画面から VPN の表示名を入力してもらうのでそれ用の型を定義しました。
設定は他にはいらないのでシンプルですね。
@action でアクションごとの UUID を割り当ててます。基本的には自分のプラグインの UUID に続けて、アクション名とかを他と被らない感じにつけておくで良さそう?
import { action, SingletonAction } from "@elgato/streamdeck";
@action({ UUID: "space.no7.sdplugin.vpn-toggle.toggle" })
export class ToggleVPN extends SingletonAction<Settings> {
// my plugin code here
}
/**
* Settings for {@link ToggleVPN}.
*/
type Settings = {
vpnDisplayName: string;
};
初期化処理を作る
onWillAppear はアクションが Stream Deck に表示されるタイミングで発生するイベントです。
- 他のページからプラグインが登録されてるページに移動してきた
- プラグインが登録されているフォルダに入った
- 逆に、プラグインが登録されてるページにあるフォルダにいる状態からフォルダを閉じた
- Stream Deck アプリが起動したとき(接続が確立されたときとかもかな?)
など、とにかく、プラグインを登録したキーが Stream Deck に現れるタイミングで発火します。
逆に、他のページに移動するなどしてプラグインの割り当てられたキーが見えなくなると、onWillDisappear が発火しますので、後片付けなどの処理をここで行います。
プラグインは待機状態だと、現在の接続状態を知りたいので、まずここでは接続確認を行います。
接続確認は、初期化時と、あとそのあとプラグインが表示されている(動作している)間はずっと変更を監視しておきたいので、
関数 (updateVPNStatus()) として切り出して、初回と、それ以降は setInterval で定期的に監視するような感じにしようと思います。
チェック部分は後で実装するとして、まずはこの onWillAppear と onWillDisappear から updateVPNStatus() を呼び出す部分を作っちゃいます。
export class ToggleVPN extends SingletonAction<Settings> {
private intervalId: ReturnType<typeof global.setInterval> | null = null;
private readonly checkIntervalMs = 1000;
private updateVPNStatus = (
ev: WillAppearEvent<Settings>,
): void | Promise<void> => {
// TODO: VPN 接続状態を確認して、画面を更新する処理
};
override onWillAppear(ev: WillAppearEvent<Settings>): void | Promise<void> {
if (this.intervalId) {
global.clearInterval(this.intervalId);
this.intervalId = null;
}
this.updateVPNStatus(ev);
this.intervalId = global.setInterval(() => {
this.updateVPNStatus(ev);
}, this.checkIntervalMs);
}
override onWillDisappear(
ev: WillDisappearEvent<Settings>,
): void | Promise<void> {
if (this.intervalId) {
global.clearInterval(this.intervalId);
this.intervalId = null;
}
}
}
これで Stream Deck 上にアイコンが表示されると一度 updateVPNStatus が呼ばれて、それ以降は1秒に1回 updateVPNStatus を呼ぶようになり、
画面切り替えなどで見えなくなるとタイマー ID をクリアして監視を停止するようになります。
VPN ステータスの取得
ToggleVPN クラスに追記します。
ここでは、child_process を使って裏で VPN 接続状態を確認する AppleScript を動かして、その結果をもとにアイコンを付け替えています。
Stream Deck 上のアイコンをプラグイン内から動的に変更する場合は、setImage をつかえばOK。
画像自体はビルド済みファイルがはいる .sdPlugin で終わるディレクトリをルートとした相対パスで指定できます。
また、拡張子は不要みたいです。
なので、たとえば、space.no7.sdplugin.vpn-toggle.sdPlugin/imgs/actions/vpnstate/vpn-connected.png を表示させたいならば
ev.action.setImage("imgs/actions/vpnstate/vpn-connected")
となります。
また、処理に失敗したなどでエラーが発生した場合は、showAlert を使うと、黄色い三角に ! のアイコンを出せます
export class ToggleVPN extends SingletonAction<Settings> {
private currentVPNState: "connected" | "disconnected" | "unknown" = "unknown";
private updateVPNStatus = (
ev: WillAppearEvent<Settings>,
): void | Promise<void> => {
const params = ["scripts/vpn-connection-check.scpt"];
if (ev.payload.settings.vpnDisplayName) {
params.push(ev.payload.settings.vpnDisplayName);
}
const result = spawn("osascript", params);
result.on("error", (error) => {
streamDeck.logger.error(`exec error: ${error}`);
ev.action.showAlert();
ev.action.setImage("imgs/actions/vpnstate/vpn-unknown");
});
/**
* `stdout` データをリッスンして、VPN の状態を取得
* 前回チェックしたときの状態と異なる場合にのみ、アイコンを更新
*/
result.stdout.on("data", (data) => {
switch (data.toString().trim()) {
case "connected":
if (this.currentVPNState !== "connected") {
this.currentVPNState = "connected";
ev.action.setImage("imgs/actions/vpnstate/vpn-on");
}
return;
case "disconnected":
if (this.currentVPNState !== "disconnected") {
this.currentVPNState = "disconnected";
ev.action.setImage("imgs/actions/vpnstate/vpn-off");
}
return;
}
});
result.stderr.on("data", (data) => {
streamDeck.logger.error(`stderr: ${data}`);
ev.action.showAlert();
ev.action.setImage("imgs/actions/vpnstate/vpn-unknown");
});
};
}
ということで、適当なアイコンを用意します。
アイコンは 144x144px の正方形とし、対応してるフォーマットは JPG, PNG, SVG, GIF, WEBP となります。
GIFとWEBPを使えばアニメーション可能らしいです。
尚、読み込みを早くするためにもできるだけファイルは小さい方が良いみたいです。
アニメーションする場合は、フレームレートはあまり高くないほうが良いらしく、10 - 20fps 程度にすることが推奨されています。
また、アニメーションの長さ自体もあまり長くせず、5秒以下に収めるのが理想的とされているようです。
今回は、ICOOON MONO のアイコン素材を組み合わせてそれっぽくボタンを制作しました。

AppleScript の作成
つぎに、child_process が呼び出す、VPN 接続状態を確認するためのスクリプトを作ります。
引数に VPN の表示名を渡すとその VPN サービスに接続中かを確認して結果を返却します。
繋がってるかどうかの2択なので、 true か false でも良いかなと思ったのですが、
それだと Boolean なのか String なのかがわからなくなるといけない(コマンド内というよりは、それを受け取って処理する TS 側で混乱しないように)ので、あえて connected or disconnected とすることにしました。
on run argv
if (count argv) = 0 then
error "need vpn name"
else
set givenVPNName to item 1 of argv
tell application "System Events"
tell network preferences
set targetVPN to service givenVPNName
if connected of current configuration of targetVPN then
return "connected"
else
return "disconnected"
end if
end tell
end tell
end if
end run
これを .sdPlugin フォルダ内に scripts などと言うフォルダを作って、そこに vpn-connection-check.scpt として保存します。
試しにターミナルなどから
osascript /path/to/vpn-connection-check.scpt "My VPN Name"
って入れてみて、connected または disconnected って返ってくれば成功です。
(ちなみに多分初回実行時は例によってアクセス許可が必要になります)
設定画面を作る
さて、今回は VPN の名前を引数で受け取るようにしてますが、じゃあその接続名をプラグインに教えるにはどうしたらいいでしょうか。
実は設定画面の「タイトル」より下の部分は、HTMLになっていて、フォームを自分で作ってあげる必要があります。
といっても、専用の UI キットが用意されてるので作るのはメチャクチャ簡単です。
HTMLというよりは、カスタム要素なので、React とか Vue のコンポーネントを作る感覚に近いかもしれません。
<!DOCTYPE html>
<html>
<head lang="en">
<title>VPN Toggle Settings</title>
<meta charset="utf-8" />
<script src="https://sdpi-components.dev/releases/v4/sdpi-components.js"></script>
</head>
<body>
<sdpi-item label="VPN Name">
<sdpi-textfield setting="vpnDisplayName" placeholder="VPN Display Name" required></sdpi-textfield>
</sdpi-item>
</body>
</html>
この sdpi-textfield にある setting に、settings にわたすときの変数名を指定します。
すると、スクリプト側では各イベントの、 payload.settings.vpnDisplayName に格納されて届きます。
これ、クラス作るときに定義してた、
type Settings = {
vpnDisplayName: string;
};
とも対応していますので、型がズレてる場合は調整しておきましょう。
で、これもプラグインディレクトリのなかの、 ui/ ディレクトリ内に保存しておきます。
これも結局UIのHTMLはどこにありますよってパスを自分で指定できるので、厳密に守らなくても動くといえば動くのですが、わざわざ変えるメリットもないのでテンプレ通りにやっておきます。
ui/cfg-toggle-vpn.html
ここまでできると、Stream Deck アプリから入力した名前のついた VPN への接続状態によってちゃんとアイコンが変わるはずです。
アイコン出したまま、メニューバーなどから VPN に繋いだり、切断したりしてみて、アイコンが切り替わるのをテストしてみるとよいです。
スクリプトから VPN に接続・切断する
今回はこんな感じになりました。
on run argv
if (count argv) = 0 then
error "need vpn name"
else
set givenVPNName to item 1 of argv
tell application "System Events"
tell network preferences
set targetVPN to service givenVPNName
if connected of current configuration of targetVPN then
disconnect targetVPN
else
connect targetVPN
end if
end tell
end tell
end if
end run
これも適当な名前をつけて保存しておきます。私は vpn-connection-toggle.scpt としました。
これで、ターミナルなどから
osascript vpn-connection-toggle.scpt "My VPN Name"
って呼び出すたびに、My VPN Name に接続・切断をおこなってくれるようになります。
あとは、Stream Deck のキーを押したときに、このスクリプトを呼び出せばOK。
キーが押されると onKeyDown というイベントが発火するのでそこで呼び出します。
export class ToggleVPN extends SingletonAction<Settings> {
override async onKeyDown(ev: KeyDownEvent<Settings>): Promise<void> {
const params = ['scripts/vpn-connection-toggle.scpt'];
if (ev.payload.settings.vpnDisplayName) {
params.push(ev.payload.settings.vpnDisplayName);
}
const result = spawn('osascript', params);
result.on('error', (error) => {
streamDeck.logger.error(`exec error: ${error}`);
ev.action.showAlert();
});
}
}
これで一通りの設定は完了しました。
あとは、メタ情報をきちんと設定してあげれば完成です。
manifest.json の編集
自動生成される manifest.json には含まれてないんですけど、JSON Schema が提供されてるので追加しておくと捗ります
{
"$schema": "https://schemas.elgato.com/streamdeck/plugins/manifest.json",
"Name": "VPN Toggle",
"Version": "0.1.0.0",
"Author": "#7",
"Actions": [
{
"Name": "Toggle",
"UUID": "space.no7.sdplugin.vpn-toggle.toggle",
"Icon": "imgs/actions/vpnstate/toggle",
"Tooltip": "Toggle VPN Connection",
"PropertyInspectorPath": "ui/cfg-toggle-vpn.html",
"Controllers": [
"Keypad"
],
"States": [
{
"Image": "imgs/actions/vpnstate/vpn-unknown",
"TitleAlignment": "middle"
}
]
}
],
"Category": "VPN Toggle",
"CategoryIcon": "imgs/plugin/category-icon",
"CodePath": "bin/plugin.js",
"Description": "Toggle macOS VPN Connection",
"Icon": "imgs/plugin/marketplace",
"SDKVersion": 3,
"Software": {
"MinimumVersion": "6.9"
},
"OS": [
{
"Platform": "mac",
"MinimumVersion": "12"
}
],
"Nodejs": {
"Version": "20",
"Debug": "enabled"
},
"UUID": "space.no7.sdplugin.vpn-toggle"
}
Footnotes
-
ドメイン名を逆順(自分のドメインが
app.example.comならcom.example.app)にしてパッケージ名や識別子などの命名に使用する習慣で、一意性を確保して、名前の衝突を避けるためによく用いられているようです。Reverse domain name notation ↩
