FlutterでMacOSのメニューバーに常駐するアプリを作成

はじめに

こんにちは。コミューンでソフトウェアエンジニアをしているU2です。 少し前ですが、Flutter のバージョンアップデートでデスクトップアプリも stable になったという話を聞いたので、メニューバー(タスクトレイ/システムトレイ)に常駐するデスクトップアプリケーションを作成したのでご紹介します。

開発の背景

コミューンの現在のリリースフローは

  • ビッグバンリリースを避ける
  • 本番に近い(≒ローカルではない)環境で動作確認をする

という2点からPRを確認する環境があり、またその環境へマージできるPR数(=本番環境との差分)に制限を設ける運用を行なっています。

マージが空いているかの確認方法としては 一定数のPRがマージされると、Jira の特定チケットのサマリ(チケット名)がマージ空いている or 空いていないと分かる名前に変わります。また、マージが空いた(デプロイ完了 or リバート or etc...)タイミングで Slack に通知があるため、Jira チケットか Slack の投稿を見に行くか通知に気がつくという2種類の方法があります。

ですが集中して作業をしたいときに Slack の通知を一時停止設定してしまう時ありますよね?また、生産性を高めるための時間管理術としてポモドーロテクニックなんてものもあります。しかし集中しているときでも優先的にマージしたいPRがあるためマージの空き状況は知りたくて頭を抱えることも多いと思います。

そこで今回システムトレイで Jira のチケットを監視しつつ、ステータス変化に応じて通知をプッシュしてくれるアプリを作ることにしました。

ポモドーロテクニック、しましょうか!

作りたいもの

要件は

  • Jiraのチケットを監視
  • 監視結果をシステムトレイに表示
  • マージが空いたタイミングでデスクトップへ通知

となります。

*jira ticketイメージ図

jira ticket image

実装

ここからは実装パートです。

環境

今回の動作確認環境と Flutter の使用パッケージです。

  • Platform:
    • macOS 12.6
  • Flutter:
    • Flutter SDK: 3.3.1
    • system_tray: 2.0.1
    • bitsdojo_window: 0.1.4
    • flutter_local_notifications: 9.9.1
    • json_annotation: 4.6.0
    • retrofit: 3.0.1
    • dio: 4.0.6

Jira API の準備

まず Jira の準備をしていきます。

Flutter で利用できる atlassian api package もあるようなのですが、今回は特定のJQLを投げた結果を見たいだけなので Rest API をそのまま利用します。

pub.dev

developer.atlassian.com

developer.atlassian.com

下記を参考に、監視したいチケットを閲覧できる権限を持つトークンを生成します。

support.atlassian.com

次に、チケットを特定するためのJQLを作成します。

  • project
  • issuetype
  • epic link
  • status
  • reporter

といった値から監視したいチケットを絞っていきます。

今回は下記のようなJQLで特定できそうでした。

project = COMMMUNE AND status = Backlog AND reporter = xxxxxx

ここまでの情報を元にリクエストしてみます。

これを元に

$ curl -v https://mysite.atlassian.net --user me@example.com:my-api-token

実行します。

$ curl "https://{CORP}.atlassian.net/rest/api/3/search?jql=project%3DCOMMMUNE%20AND%20status%3DMerged%20AND%20reporter%3Dxxxxxx" \
    --user {USER_EMAIL}:{TOKEN} | jq

すると目当てのチケットを含むJSONが返ってきました。

{
  "expand": "names,schema",
  "startAt": 0,
  "maxResults": 50,
  "total": 1,
  "issues": [
    {
...

Jira側の準備はこれで完了です。

Flutter

次に Flutter 側の実装です。

  1. system_tray の導入
  2. Jira API 連携
  3. アイコン切り替え
  4. 通知

の順に実装していきます。

前準備として、マージステータスをシステムトレイアイコンとして表すために circle / cross / loading の3つの画像を用意し、asset としてプロジェクト内に配置します。

1. system_tray の導入

メインとなる system_tray のサンプルをベースにします。

pub.dev

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  runApp(const StatefulSystemtray());

  doWhenWindowReady(() {
    final win = appWindow;
    const initialSize = Size(600, 450);
    win.minSize = initialSize;
    win.size = initialSize;
    win.alignment = Alignment.center;
    win.title = "Merge status monitor app";
    win.show();
  });
}

win.show() を、各プラットフォーム毎の設定と合わせて削除することで、アプリケーション起動時のウィンドウ表示を消すこともできますが、今回はシステムトレイへ常駐させることを目標にするためこのままにします。

main で呼んでいる Widget です。

class StatefulSystemtray extends StatefulWidget {
  const StatefulSystemtray({Key? key}) : super(key: key);

  @override
  State<StatefulSystemtray> createState() => _StatefulSystemtrayState();
}

class _StatefulSystemtrayState extends State<StatefulSystemtray> {
  final AppWindow _appWindow = AppWindow(); // アプリケーションウィンドウのState
  final SystemTray _systemTray = SystemTray(); // システムトレイのState
  final Menu _menuMain = Menu(); // システムトレイをクリックした際のメニューState

  @override
  void initState() {
    super.initState();
    // システムトレイ設定の初期化
    initSystemTray();
  }

  Future<void> initSystemTray() async {
    final mainMenuList = MenuMain(appWindow: _appWindow).menuList;

    await _systemTray.initSystemTray(iconPath: getTrayImagePath('loading'));
    _systemTray.setTitle("merge status");
    _systemTray.setToolTip("How to use system tray with Flutter");

    _systemTray.registerSystemTrayEventHandler((eventName) {
      debugPrint("eventName: $eventName");
      if (eventName == kSystemTrayEventClick) {
        _systemTray.popUpContextMenu();
      }
    });

    await _menuMain.buildFrom(mainMenuList);
    _systemTray.setContextMenu(_menuMain);
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      home: Scaffold(
        body: WindowBorder(
          color: const Color(0xFF805306),
          width: 1,
          child: Column(
            children: [
              const TitleBar(),
              WindowBody(
                systemTray: _systemTray,
                menu: _menuMain,
              ),
            ],
          ),
        ),
      ),
    );
  }
}

アプリ起動時は Jira を確認できていないため loading アイコンを表示するようにしています。

また、見通しをよくするために _menuMain.buildForm() を別ファイルに切り出しています。

まずここでアプリケーションを実行してみるとシステムトレイに merge status の文字と共に、loading 用の画像が表示されています。

アプリケーションウィンドウも消していないため表示されています。

システムトレイをクリックしたときのメニューも気になるところですが、次に前準備した Jira API を Flutter に落とし込んでいきます。

2. Jira API 連携

curl でのリクエスト結果を元に、値を受け取るためのモデルを定義します。今回必要なデータはチケット名として用いられる summary フィールドなります。必要最低限の定義だけしていきます。

part 'jira_issue_fields.g.dart';

@JsonSerializable(explicitToJson: true)
class JiraIssueFields {
  String summary;

  JiraIssueFields({required this.summary});

  factory JiraIssueFields.fromJson(Map<String, dynamic> json) =>
      _$JiraIssueFieldsFromJson(json);

  Map<String, dynamic> toJson() => _$JiraIssueFieldsToJson(this);
}
part 'jira_issue.g.dart';

@JsonSerializable(explicitToJson: true)
class JiraIssue {
  String id;
  String key;
  JiraIssueFields fields;

  JiraIssue({required this.id, required this.key, required this.fields});

  factory JiraIssue.fromJson(Map<String, dynamic> json) =>
      _$JiraIssueFromJson(json);

  Map<String, dynamic> toJson() => _$JiraIssueToJson(this);
}
part 'jira_search_result.g.dart';

@JsonSerializable(explicitToJson: true)
class JiraSearchResult {
  // summary にマッチングさせたい文字列
  final String blockText = "ばつ";
  final String openText = "まる";

  int startAt;
  int maxResults;
  int total;
  List<JiraIssue> issues;

  JiraSearch(
      {required this.startAt,
      required this.maxResults,
      required this.total,
      required this.issues});

  String getMergeStatus() {
    return isMergeBlocked()
        ? MergeStatus.statusBlock
        : MergeStatus.statusOpen;
  }
  
  bool isMergeBlocked() {
    if (issues.isEmpty) {
      throw Error();
    }
    return issues[0].fields.summary == blockText;
  }

  factory JiraSearchResult.fromJson(Map<String, dynamic> json) =>
      _$JiraSearchResultFromJson(json);

  Map<String, dynamic> toJson() => _$JiraSearchResultToJson(this);
}

これらモデル定義に、APIリクエスト結果を取得する実装です。

class JiraSearchRepository {
  final JiraApiClient _client;

  JiraSearchRepositoryImpl([JiraApiClient? client])
      : _client = client ?? JiraApiClient(Dio());

  @override
  Future<JiraSearchResult> search(JiraConfig config) {
    const jql = "project = COMMMUNE AND status = Backlog";
    final username = config.userEmail;
    final apiToken = config.apiToken;

    final auth = "Basic ${base64.encode(utf8.encode('$username:$apiToken'))}";

    return _client.searchFromJql(auth, jql);
  }
}

JiraConfig はAPIリクエストに必要なメールアドレスとAPIトークンを持ったクラスです。

最後に JiraApiClient の実装です。

part 'jira_api_client.g.dart';

@RestApi(baseUrl: "https://{CORP}.atlassian.net/rest/api/3")
abstract class JiraApiClient {
  factory JiraApiClient(Dio dio, {String baseUrl}) = _JiraApiClient;

  @GET("/search")
  Future<JiraSearch> searchFromJql(
    @Header("authorization") String auth,
    @Query("jql") String jql,
  );
}

これで、JiraSearchRepository.search の結果からgetMergeStatus を実行することで Jira のチケットタイトルを Flutter から見る準備ができるようになりました。

3. アイコン切り替え

次に、マージステータスに応じたシステムトレイのアイコン切り替えの実装です。

まずは現在のマージ状態を入れる箱を定義します。

class MergeStatus {
  late String currentStatus;
  
  MergeStatus() {
    currentStatus = 'cross'
  }
}

Jira のステータスを取得する関数です。

Future<String> fetchMergeStatus() async {
  final config = await getJiraConfig();
  final jiraIssue = await JiraSearchRepositoryImpl().search(config);
  return jiraIssue.getMergeStatus();
}

これらを合わせ、StatefulSystemtray から呼ばれるシステムトレイアイコン切り替え関数の実装です。

Future<void> checkMergeStatus(
  SystemTray systemTray,
  MergeStatus mergeStatus,
) async {
  const duration = Duration(seconds: 30);
  
  Timer.periodic(duration, (_) async {
    final preMergeStatus = mergeStatus.getStatus();
    final postMergeStatus = await fetchMergeStatus();
    systemTray.setSystemTrayInfo(
        iconPath: getTrayImagePathFromMergeStatus(postMergeStatus));

    // Mergeステータスが異なるときに、ステータスを入れ替える
    if (preMergeStatus != postMergeStatus) {
      mergeStatus.changeMergeStatus();
    }
  });
}

ローカルアプリケーションだけで完結できるため、チケットの監視は30秒に1回リクエストを投げるポーリングの形式にしています。

これを initState から呼ぶと、「まる」「ばつ」というチケット名に応じたアイコンに切り替わるようになります。

まず試しに「まる」から

次に「ばつ」のとき

良い感じに、意図通りチケット名でアイコンが切り替わっていそうです。

4. 通知

最後に通知の実装をしていきます。

今回想定している使い方は、Slackの通知は切っていてもマージできるならPRをマージしたい、なのでマージが空いたタイミングにのみ通知が送られるようにしていきます。

flutter_local_notifications を使用します。

サンプルに倣って実装していきます。

pub.dev

まず、通知に関する初期化と権限リクエスト部分です。

Future<void> initializeNotifications(
    FlutterLocalNotificationsPlugin plugin) async {
  const MacOSInitializationSettings initSettingsMacOS =
      MacOSInitializationSettings(
    requestAlertPermission: false,
    requestBadgePermission: false,
    requestSoundPermission: false,
  );

  const InitializationSettings initializationSettings =
      InitializationSettings(macOS: initSettingsMacOS);
  await plugin.initialize(initializationSettings);
}
Future<void> requestPermissions(FlutterLocalNotificationsPlugin plugin) async {
  await plugin
      .resolvePlatformSpecificImplementation<
          MacOSFlutterLocalNotificationsPlugin>()
      ?.requestPermissions(
        alert: true,
        badge: false,
        sound: false,
      );
}

今回は通知のみ(音やバッジはなし)を想定しているので、alert のみ true にします。 これらを_StatefulSystemtrayStateから呼び出していきます。

class _StatefulSystemtrayState extends State<StatefulSystemtray> {
  final AppWindow _appWindow = AppWindow();
  final SystemTray _systemTray = SystemTray();
  final Menu _menuMain = Menu();

  final FlutterLocalNotificationsPlugin _notificationsPlugin =
      FlutterLocalNotificationsPlugin();
  final MergeStatus _mergeStatus = MergeStatus();

  @override
  void initState() {
    super.initState();
    
    initSystemTray();
    initializeNotifications(_notificationsPlugin);
    requestPermissions(_notificationsPlugin);
    
    checkMergeStatus(_systemTray, _mergeStatus, _notificationsPlugin);
  }
  ...
}

最後に checkMergeStatus で行なっているマージ状況の確認関数に、マージができるようになったタイミング(cross -> circle)で通知を出すように改修します。

先に通知の実体です。

Future<void> displayNotifications(
    FlutterLocalNotificationsPlugin plugin) async {
  await plugin.cancelAll();
  const messageId = 1;
  const title = 'Deploy Status Update';
  const body = 'Deploy status is now open';
  const notificationDetails =
      NotificationDetails(macOS: MacOSNotificationDetails());
  plugin.show(messageId, title, body, notificationDetails);
}

messageId で通知を管理できるのですが、都度新規で通知がされて良いものなので固定値を埋めています。

これを checkMergeStatus から呼びます。

Future<void> checkMergeStatus(
  SystemTray systemTray,
  MergeStatus mergeStatus,
  FlutterLocalNotificationsPlugin plugin,
) async {
  const duration = Duration(seconds: 10);

  Timer.periodic(duration, (timer) async {
    final preMergeStatus = mergeStatus.getStatus();
    final postMergeStatus = await fetchMergeStatus();
    systemTray.setSystemTrayInfo(
        iconPath: getTrayImagePathFromMergeStatus(postMergeStatus));

    if (preMergeStatus != postMergeStatus) {
      mergeStatus.changeMergeStatus();
    }

    // 追加
    if ((preMergeStatus == MergeStatus.statusBlock) &&
        (postMergeStatus == MergeStatus.statusOpen)) {
      displayNotifications(plugin);
    }
  });
}

改めてビルドし実行し、アイコンが cross -> circle となるよう設定をしてみると

通知が来ました!! これで、集中したいけどマージはしたい!という欲に応えることができそうです!!

今はJQLや Jira トークンを組み込んだソースコードになっていますが、外から渡せるようにして公開Repositoryに置けるようにしていきます。

参考サイト

フューチャーアーキテクト様Tech Blog

future-architect.github.io

コアテック様Tech Blog

core-tech.jp

dev.yakuza様

dev-yakuza.posstree.com

まとめ

簡単な仕様でクロスプラットフォーム対応の開発をする際の選択肢として Flutter は良いなと感じていましたが、今回の開発で改めて認識し直すことができました。

version2 から Null Safety になり version3 から各プラットフォームが stable になり、それに合わせ様々なパッケージがそれぞれのプラットフォームに対応するようアップデートしてくださっており、今後さらに開発しやすくなることが期待できます。

また、コミューンでは業務時間の20%までを使って、プロダクトロードマップには乗らない技術的な課題に取り組むことができる20%ルール制度を運用し始めています。 今回の開発は一部その時間を用いて行いました。小さな困りごと解決から開発組織全体に大きな影響があるような課題まで様々あり、それら課題に対し主体的に取り組める環境が出来つつあるように感じます(PRです)。

We are hiring!!

meety.net

commmune-careers.studio.site