M2 MacBook Airに買い換えてIntelから乗り換えたので、最初から環境を再設定した

M2 MacBook Air(M2, 13インチ)に買い換えました。

前使っていたのは2018年モデルのMacBook Pro 15インチでした。 個人的に5年買い換えサイクルにしていたのと、最近ファンが回る場面が多くなりそろそろintelは限界かと思った次第です。

元々アプリエンジニアなので前回はMacBook Pro 15インチにしていたのに、今回そうしなかったのは、M2は強力なので個人でやる開発ならProでなくても十分な性能ではなかろうかと考えたこと、外でStoryboardを操作する機会はなくなったので13インチで十分(必要なら家で4Kモニターつないでやればいい)、あと見た目が好きだったので13インチのMacBook Airとなりました。

IntelからApple Siliconへの乗り換えなので、一度全部設定をやり直そうと、今回はゼロから設定することにしました。 このエントリは設定手順のメモです。

旧端末での準備

続きを読む

アクセスキーを使わずにAWS SESを使って、Firebaseからメール送信できるようにする

GCPやFirebaseにはメール関連の現行サービスがなく、外部のメールサービスを使用する必要があります。 そのため、SendGridやAWS SESなどを組み合わせる必要があります。

今回はAWS SESを使うことになったのですが、そのためにアクセスキーやSMTPパスワードを管理するのはキーの管理方法を考える必要があり、手間です。 ですので、GCPのサービスアカウントでAWSのIAMロールに認証してメールを送信ようにしてみました。

今回の構成

メールを送りたいGCP側のサービスはFirebase Functionsで実装されています。Firebase FunctionsはApp Engineデフォルトサービスアカウントで動作します。

App EngineデフォルトサービスアカウントのIDトークンで、AWS STS(Security Token Service)を呼び出し、AWS側のIAMロールに対応する一時的な認証情報を取得します。

メール送信はnodemailerのSESトランスポートを使用しますので、プログラムからは通常のSMTPとそう変わらない使用感で送信できます。

GCPサービスアカウントの確認と設定

まずは、GCPのサービスアカウントの情報を確認します。 GCPのIAMコンソールで、App Engine デフォルトサービスアカウントの詳細情報を開き「一意のID」を調べます。

次に、サービスアカウントの詳細情報の「権限」タブを開き、「サービスアカウントトークン作成者」権限を付与しておきます。*1

AWS IAMの設定

次に、ターゲットとなるAWSアカウントで、IAMの管理画面を開き、設定を加えていきます。

メール送信のポリシーを作成

メール送信だけの権限があるポリシーを作成します。GCPSendEmailなどといったポリシー名で以下の内容で作成しました。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "SendEmail",
            "Effect": "Allow",
            "Action": [
                "ses:SendEmail",
                "ses:SendRawEmail"
            ],
            "Resource": "arn:aws:ses:*:000000000000:identity/*"
        }
    ]
}

ロールの追加

次に、GCPのサービスアカウントをロールとして追加します。

ロールを作成画面で「ウェブアイデンティティ」を指定し、アイデンティティプロバイダでGoogleを指定、Audienceにサービスアカウントの「一意のID」を指定しておきます。

許可を追加で、先ほど作っておいたポリシー(GCPSendEmail)を指定します。

最後のロールの詳細では、GCPSA_(サービスアカウントアドレス) としておくとよいでしょう。この名前はSTSAPIを呼び出すときに必要になります。

AWSの認証情報を得るプログラムを記述する

それでは、Firebase Function でプログラムを書いていきましょう。

必要となる情報

できるだけ実行環境から設定値を取って、定数を定義せずに済むようにしようと思いますが、定数化しておかなければならない情報は以下の通りです。

Cloud Functionsの環境変数を使って注入しても良いでしょう。

サービスアカウントメールアドレスの取得

gcp-metadata ライブラリを使うことで、実行中のサービスアカウントメールアドレスを取得できます。 ローカルで実行しているときは取得できないため、process.env.FUNCTIONS_EMULATOR が true の場合は定数からサービスアカウントメールアドレスを得るようにします。

import * as gcpMetadata from "gcp-metadata";

const isEmulator = Boolean(process.env.FUNCTIONS_EMULATOR);
const emulatorServiceAccount = process.env.EMULATOR_SA;

const getServiceAccountEmailAsync = async (): Promise<string> => {
  if (isEmulator) {
    return emulatorServiceAccount;
  } else {
    return await gcpMetadata.instance(
      "service-accounts/default/email",
    );
  }
};

サービスアカウントのIDトークン取得

サービスアカウントのIDトークンを取得します。

gcp-metadataライブラリを使って取得することもできますが、ローカル動作を考慮してGCPのIAM Credential APIを使用して取得します。(GCPAPIとサービスコンソールで、IAM Credentials APIの有効化が必要です)

import { IAMCredentialsClient } from "@google-cloud/iam-credentials";

const audience = "https://www.googleapis.com/";

const getServiceAccountIdTokenAsync = async (
  sa: string,
): Promise<string | undefined> => {
  const client = new IAMCredentialsClient();
  const [response] = await client.generateIdToken({
    name: `projects/-/serviceAccounts/${sa}`,
    audience,
  });
  if (!response.token) {
    return undefined;
  }
  return response.token;
};

サービスアカウントのIDトークンでAWSの認証情報を取得する

サービスアカウントのIDトークンを使ってAWSSTS APIを呼び出し、AWSの認証情報を取得します。

import {
  AssumeRoleWithWebIdentityCommand,
  STSClient,
} from "@aws-sdk/client-sts";

const awsAccountId = "000000000000";

type AWSCredentials = {
  accessKeyId: string;
  secretAccessKey: string;
  sessionToken: string;
  expiration?: Date;
};

const getAWSCredentialsByWebIdentityAsync = async (
  sa: string,
  gcpToken: string
): Promise<AWSCredential | undefined> => {
  const roleArn = `arn:aws:iam::${awsAccountId}:role/GCPSA_${sa}`;
  const sts = new STSClient({});
  const result = await sts.send(
    new AssumeRoleWithWebIdentityCommand({
      RoleArn: roleArn,
      WebIdentityToken: gcpToken,
      RoleSessionName: new Date().getTime().toString(),
    }),
  );
  if (!result.Credentials) {
    return undefined;
  }
  const { AccessKeyId, SecretAccessKey, SessionToken, Expiration } =
    result.Credentials;
  if (!AccessKeyId || !SecretAccessKey || !SessionToken) {
    return undefined;
  }
  return {
    accessKeyId: AccessKeyId,
    secretAccessKey: SecretAccessKey,
    sessionToken: SessionToken,
    expiration: Expiration,
  };
};

取得したAWS認証情報を使ってメールを送信する

それでは、取得した認証情報でメールを送信してみましょう。

import * as aws from "@aws-sdk/client-ses";
import { SES } from "@aws-sdk/client-ses";
import * as nodemailer from "nodemailer";

const sendMailAsync = async (): Promiose<void> => {
  const sa = await getServiceAccountEmailAsync();
  const token = await getServiceAccountIdTokenAsync(sa);
  if (!token) {
    return;
  }
  const awsCredentials = await getAWSCredentialsByWebIdentityAsync(sa);
  if (!awsCredentials) {
    return;
  }
  
  const ses = new SES({ credentials });
  const transporter = nodemailer.createTransport({
    SES: { ses, aws },
  });
  await transporter.sendMail({
    from: "from@example.com",
    to: "to@example.com",
    subject: "test mail",
    text: "this is a test mail",
  });
};

*1:より踏み込むのであれば、AWSアクセス専用のサービスアカウントを作り、サービスアカウントの権限借用で必要な場合にのみトークンを生成するようにしても良いでしょう。

別のディスプレイに表示されているウィンドウを強制召還するmacOSアプリを作った

僕はMacBook ProとPS5を同じディスプレイに繋ぎ、ディスプレイの入力をメニューで切り替えて使っている。 MacBook Proのディスプレイもサブディスプレイとして使っており、PS5を起動してディスプレイに表示しているときはMacBook攻略サイトを出したりDiscordを出したりしている。

とても便利に使えているのだが、一つ問題がある。

通常はMacBook Proの出力をディスプレイで表示している。ディスプレイ側にはブラウザを出していて、MacBook側ではDiscordを出していたとする。

f:id:iseebi:20220310012652p:plain

ゲームをするときはディスプレイの入力をPS5に切り替える。

f:id:iseebi:20220310012703p:plain

この状態で攻略サイトを見たくなってブラウザが使いたくなった場合、ディスプレイ側にいるので見えず、これを持ってくるためには一度入力を切り替えなければならず、とてもめんどくさいです…

f:id:iseebi:20220310012752p:plain

というわけで、これをなんとかするためにこういうツールを作りました。

f:id:iseebi:20220310012850p:plain

メニューバーに表示されて、クリックすると現在表示されているウィンドウを一覧表示、選択したウィンドウが現在のディスプレイになければ強制的に現在のディスプレイに移動させ、その上でウィンドウをアクティブにします。

github.com

macOSAPIは使い方を探すのが難しくて大変でした。

macOSで起動中のウィンドウの一覧を列挙する

macOSアプリ開発のお話し。

起動中のウィンドウを一覧するには、CGWindowListCopyWindowInfoを使う。

struct Window: CustomStringConvertible {
    let rawData: [String: AnyObject]
    
    var name: String? {
        get {
            return rawData[kCGWindowName as String] as? String
        }
    }
    var number: Int? {
        get {
            return rawData[kCGWindowNumber as String] as? Int
        }
    }
    var ownerName: String? {
        get {
            return rawData[kCGWindowOwnerName as String] as? String
        }
    }
    var ownerPID: Int? {
        get {
            return rawData[kCGWindowOwnerPID as String] as? Int
        }
    }
    var isOnScreen: Bool {
        get {
            return rawData[kCGWindowIsOnscreen as String] as? Bool ?? false
        }
    }
    var windowLayer: Int {
        get {
            return rawData[kCGWindowLayer as String] as? Int ?? 0
        }
    }
    var description: String {
        get {
            return "Window: \"\(name ?? "nil")\" (\(number ?? -1)) Owner: \"\(ownerName ?? "nil")\" (\(ownerPID ?? -1))"
        }
    }
}

func enumerateWindows() -> [Window] {
    let options = CGWindowListOption(arrayLiteral: CGWindowListOption.optionAll)
    guard let results = CGWindowListCopyWindowInfo(options, CGWindowID(0)),
          let windowList = results as NSArray? as? [[String: AnyObject]]
    else { return [] }
    
    return windowList.map { Window(rawData: $0) }
}

let windows = enumerateWindows()

for window in windows {
    // isOnScreen かつ windowLayer == 0 のものが、通常のアプリのウィンドウであると判断する
    if window.isOnScreen && window.windowLayer == 0 {
        print(window)
    }
}

ただし、このままだとウィンドウのタイトルがほとんど取れない。これはプライバシーの観点で取れないようになっている。

取得するためには画面収録のパーミッションが必要で、以下のコードを実行することでパーミッションを得られる。

CGRequestScreenCaptureAccess()

参考

無償版G Suiteが廃止されるのでAWS SESとDockerでメール送受信システムを作った

20日木曜、大きなニュースが飛び込んできました。2012年に提供が終了して、そのまま9年近く利用できていた旧無償版G Suiteが7月1日に廃止されてしまいます。

www.itmedia.co.jp

自分も黎明の頃から勉強会のメールアドレスや、友人に独自ドメインアドレスを提供したりと、便利に使わせてもらってきていたのですが、現状の利用規模的に個人ユースで月数千円の追加支出になるのはさすがに許容できません。既にGoogle OneやYoutube PremiumなどGoogleにはかなりお金を払っているので、これ以上Googleにお金取られるのは気分的にヤダというのもあります…。

そこでまじめに検討した結果、独自にメール受信システムを作ることにしました。

結果、月々100円以内におさまるシステムに仕上がりました。

2022/05/05(Thr) 追記

本記事では当初Oracle Cloud Infrastructureを使って使用していましたが、課金状態にあるにも関わらず突如理由もなくアカウントを停止されるという事態が発生しているとのことです。

zenn.dev

幸い僕の手元の環境は問題ない状態でしたが、このような状況では安心して使いつづけられませんので、OCIのコンピュートで建てていたVMAWS Lightsailに移しました。 それに伴い本記事は改題しておりますが、中の説明は引き続きOCIとなっています。

これにより、3.5ドル/月が加算され、トータルでは500円程度の維持費となりそうです。

要件検討

まず、要件を洗い出しました。

続きを読む

Apple USB-C Digital AV Multiport アダプタはアップデートされている

f:id:iseebi:20211029182046j:plain
使い込まれたUSB-C Digital AV Multiport アダプタ

標題の通りなのですが、Apple USB-C Digital AV Multiport アダプタは、Apple純正のUSB-CからUSB-A、HDMI、USB-Cの充電アダプタに変換してくれるアダプタです。MacBookユーザーなら1本は持っているはずです。

僕はMacBook Pro (13-inch, 2016, Four Thunderbolt 3 ports)を買ったときに一緒に買ったアダプタを一緒に使いつづけていましたが、この頃に買ったアダプタをお持ちの方でまだずっと使いつづけている方は買い替えを検討してみてもいいかもしれません。

続きを読む

Google Cloud Tasksから呼び出すApp Engine TaskとGAEバージョンの関係

Google Cloud TasksからApp Engine HTTPタスクでバックグラウンド処理をするアプリケーションを作っている。

トラフィック分割で様子を見ながらリリースしたい場合、App Engine HTTPはどのバージョンのタスクを実行するのかがわからなかったので実験してみた。

前提条件

続きを読む