Site cover image

ふつうのITエンジニアの独り言

本業はAndroidとiPhoneのアプリ開発のエンジニアです。将来はフリーランスで海の近くで妻とのんびり暮らすことを夢見て、幅広くIT技術に触れていきたいと思います。このブログはその備忘録と私のポートフォリオとして活動記録を記すものです。

MacのWi-Fi設定を接続先が変わる度に自動で設定変更できるようにしたい

目次


はじめに


 普段、会社のネットワークに繋ぐときはプロキシ設定が必要になりますが、自宅でリモートワークをするときはプロキシを解除してネットワークに繋いでいます。この作業は地味に面倒くさく、たまに切り替え操作を忘れてしまい、アプリのビルド時にエラーが発生し、プロキシが原因だったということがよくあります。そこで、Wi-Fiの切り替えを検出して、SSID毎に異なる設定変更を自動化できる仕組みをPythonを使って用意します。

現在のSSID検出方法


 MacのWi-Fiの情報は以下のコマンドで取得することができます。

% system_profiler SPAirPortDataType

大量の情報が表示されますが、ここからSSIDを探すと「Current Network Information:」という行より下に、現在のSSIDが記載されていることがわかります。そこで、SSIDのみを抽出するコマンドを作成します。

% system_profiler SPAirPortDataType | grep 'Current Network Information' -A1 | sed -n '2p'
「Current Network Information:」と次の行を抜き出し、2行目だけを取得する。

Pythonで自動化


Wi-Fiの変更監視

 Wi-Fiの接続が変わった時に自動で設定を変更するためには、状態を監視する仕組みが必要です。MacのAutomaterを使うことを考えましたが、AutomaterにはWi-Fiの変化イベントはありませんでした。そこで、Pythonを使って状態を監視する仕組みを使うことにしました。

 Pythonの状態監視は、以下コードによって実現できます。簡単にコードを説明すると、SCDynamicStoreCreateでネットワーク設定を監視するためのストアと呼ばれるものを作成します。ストアには、イベント発生時に呼び出す関数を第三引数で登録することができます。第二引数はストアの識別子として任意の名前をつけられるのですが、何に使われるのか理解できていません。

 SCDynamicStoreSetNotificationKeysでは、監視するネットワークの対象を設定しています。今回は”en0”を監視対象にしました。

 最後にSCDynamicStoreCreateRunLoopSource → CFRunLoopAddSource → CFRunLoopRunの順に実行することで監視が始まりますが、それぞれの関数が何を行っているのかは理解できていません。そういうものとして、実行しました・・・。詳しい方がいたら教えて下さい。

from SystemConfiguration import (
    SCDynamicStoreCreate,
    SCDynamicStoreSetNotificationKeys,
    SCDynamicStoreCreateRunLoopSource
)
from CoreFoundation import (
    CFRunLoopAddSource, 
    CFRunLoopGetCurrent,
    kCFRunLoopDefaultMode
)

# イベント監視のセットアップ
# SCDynamicStoreCreate は、macOS の SystemConfiguration フレームワークに含まれる関数。ネットワーク設定や状態の監視・取得を行うオブジェクトを作成する関数です。
store = SCDynamicStoreCreate(None, "SSIDMonitor", ssid_changed, None)
if store is None:
    raise RuntimeError("SCDynamicStoreCreate failed")
# どのネットワーク設定の変更を監視するかを指定する
SCDynamicStoreSetNotificationKeys(store, None, ["State:/Network/Interface/en0/AirPort"]) # en0 の Wi-Fi 状態の変化を監視

# RunLoop 開始
# イベントループを開始し、リアルタイムに変化を検出する。
rls = SCDynamicStoreCreateRunLoopSource(None, store, 0)
if rls is None:
    raise RuntimeError("SCDynamicStoreCreateRunLoopSource failed")
CFRunLoopAddSource(CFRunLoopGetCurrent(), rls, kCFRunLoopDefaultMode)
CFRunLoopRun()
Wi-Fiの変化を監視するためのRunLoopを開始する処理

PythonでSSIDを取得

 Wi-Fiの状態が変化したとき、”ssid_changed”が呼び出されるので、その中で現在のSSIDを取得します。取得はsubprocessを使ってコマンドを実行し、その戻り値からSSIDを取り出すという方法を取りました。なお、抽出したSSIDにはスペースや改行などの不要な文字が前後についています。なので、stripで除去するようにしました。

def get_current_ssid():
    """
    現在のssidを取得する
    """
    try:
        result = subprocess.run(
            ["bash", "-c", "system_profiler SPAirPortDataType | grep 'Current Network Information' -A1 | sed -n '2p'"],
            capture_output=True,
            text=True
        )
        ssid = result.stdout.strip("\n: ") #改行とコロンとスペースを除去
        return ssid
    except Exception as e:
        return None

SSID変化時の実行処理

 ここまででWi-Fiの変化を検出して、その時のSSIDを取得することができるようになりました。次は、SSIDが切り替わったときに設定を変更する仕組みを作ります。その仕組みというのは、SSIDと同じ名前を持ったフォルダを用意して、実行したいバッチ(.command)をフォルダに格納しておき、変化があった時に実行するという方法を取りました。

 まずは、ルートディレクトリからSSIDと同じフォルダを探す為の関数を用意します。

def getScriptFolderPath(name : str):
    """
    カレントディレクトリから指定した名前のフォルダパスを取得する。

    :param ssid: SSID名
    :type ssid: str
    """
    for path in Path(__file__).parent.iterdir():
        if path.is_dir() and path.name == f"{name}":
            logger.debug(f"getScriptFolderPath return {name}")
            return path
        
    logger.debug(f"getScriptFolderPath return None")
    return None
ルートディレクトリ(.pyのあるフォルダ)の直下から、指定した名前のフォルダを探す関数。

 SSIDと同じ名前のフォルダがない場合は、”default”という名前のフォルダを探すようにしました。”default”では、その他のSSIDで実行したい共通のバッチ置いておくと、どんなSSIDでも対応できるようになります。

# SSIDと同じ名前のフォルダを検索する。なければ"default"フォルダを検索する
ssid = get_current_ssid()
path = getScriptFolderPath(ssid) or getScriptFolderPath("default")

if not path:
    #実行するフォルダがない場合は何もしない
    return
else:
		# 指定のパスにある.commandを実行する。
    execute_all_commands_in_folder(path)

 最後に、見つかったフォルダの中のバッチ処理を実行する処理です。末尾が”.command”となっているファイルを見つけ出して、順番に実行していくようにしました。ただし、.commandの実行順番はコントロールしていないため、複数の.commandファイルを用意した場合に、お互いに依存関係がある場合には工夫が必要になります。

def execute_all_commands_in_folder(folder_path):
    """
    指定したフォルダ内のスクリプトをすべて実行する。
    順不定
    """
    for path in Path(folder_path).iterdir():
        if path.is_file() and path.name.endswith(".command"):
            subprocess.Popen(["/bin/bash", path])

参考コード

ここまでの実装コードをまとめた実装サンプルを公開しておきます。

loggingで出力しているログは、”tmp/Monitoring_WiFi.log”に出力されるようにしました。tmpフォルダは、Macではしばらくアクセスがない場合に自動で削除される領域です。ログを永続的に保存したい場合は、basicConfigで保存先を変更するようにしてください。

import subprocess
import objc
import os
from pathlib import Path

from CoreFoundation import CFRunLoopRun
from SystemConfiguration import (
    SCDynamicStoreCreate,
    SCDynamicStoreSetNotificationKeys,
    SCDynamicStoreCreateRunLoopSource
)
from CoreFoundation import (
    CFRunLoopAddSource, 
    CFRunLoopGetCurrent,
    kCFRunLoopDefaultMode
)

from logging import getLogger, basicConfig, DEBUG
basicConfig(filename='/tmp/Monitoring_WiFi.log', 
            encoding='utf-8', 
            level=DEBUG,
            format='%(asctime)s - %(levelname)s - %(message)s'
            )
logger = getLogger(__name__)

def getScriptFolderPath(name : str):
    """
    カレントディレクトリから指定した名前のフォルダパスを取得する。

    :param ssid: SSID名
    :type ssid: str
    """
    for path in Path(__file__).parent.iterdir():
        if path.is_dir() and path.name == f"{name}":
            logger.debug(f"getScriptFolderPath return {name}")
            return path
        
    logger.debug(f"getScriptFolderPath return None")
    return None

def execute_all_commands_in_folder(folder_path):
    """
    指定したフォルダ内のスクリプトをすべて実行する。
    順不定
    """
    for path in Path(folder_path).iterdir():
        if path.is_file() and path.name.endswith(".command"):
            logger.info(f"Executing: {path}")
            subprocess.Popen(["/bin/bash", path])

def get_current_ssid():
    """
    現在のssidを取得する
    """
    try:
        result = subprocess.run(
            ["bash", "-c", "system_profiler SPAirPortDataType | grep 'Current Network Information' -A1 | sed -n '2p'"],
            capture_output=True,
            text=True
        )
        # result例 = "CompletedProcess(args=['bash', '-c', 'system_profiler SPAirPortDataType | awk \'/Current Network Information:/ {getline; gsub(/^ +/, ""); print}\' | head -n 1'], returncode=0, stdout='name_of_ssid:\n', stderr='')"
        logger.debug(f"subprocess.run result: {result}")
        ssid = result.stdout.strip("\n: ") #改行とカンマとスペースを除去
        logger.debug(f"get_current_ssid return : {repr(ssid)}")
        return ssid
    except Exception as e:
        logger.debug(f"get_current_ssid err : {e}")
        logger.debug(f"get_current_ssid return : {e}")
        return None

last_ssid = "" #現在のssid名を保持
def ssid_changed(store, changed_keys, info):
    """
    SSIDの変化を検出した時に呼ばれる
    """
    logger.info(f"start ssid_changed")
    
    # 現在のssidを取得
    ssid = get_current_ssid()

    if not ssid:
        logger.debug(f"ssid is empty")
        return
    else:   
        # 前回から変化がない場合は何もしない。  
        global last_ssid
        if last_ssid == ssid:
            logger.debug(f"not change ssid: {ssid}")
            return
        
        logger.info(f"SSID change {last_ssid} to {ssid}")
        last_ssid = ssid

        # SSIDと同じ名前のフォルダを検索する。なければ"default"フォルダを検索する
        path = getScriptFolderPath(ssid) or getScriptFolderPath("default")
        
        if not path:
            #実行するフォルダがない場合は何もしない
            return
        else:
            execute_all_commands_in_folder(path)

def startWatchSSI():
    """
    SSID監視を開始
    """
    logger.debug(f"start startWatchSSI")

    # イベント監視のセットアップ
    # SCDynamicStoreCreate は、macOS の SystemConfiguration フレームワークに含まれる関数で、ネットワーク設定や状態の監視・取得を行うための「ダイナミックストア」オブジェクトを作成する関数です。
    store = SCDynamicStoreCreate(None, "SSIDMonitor", ssid_changed, None)
    if store is None:
        raise RuntimeError("SCDynamicStoreCreate failed")
    # SCDynamicStoreSetNotificationKeys は、macOS の SystemConfiguration フレームワークにおいて、どのネットワーク設定の変更を監視するかを指定する関数です
    SCDynamicStoreSetNotificationKeys(store, None, ["State:/Network/Interface/en0/AirPort"]) # en0 の Wi-Fi 状態の変化を監視

    # RunLoop 開始
    # SCDynamicStoreCreateRunLoopSource と CFRunLoopRun を使ってイベントループを開始することで、リアルタイムに変化を検出できます。
    rls = SCDynamicStoreCreateRunLoopSource(None, store, 0)
    if rls is None:
        raise RuntimeError("SCDynamicStoreCreateRunLoopSource failed")
    CFRunLoopAddSource(CFRunLoopGetCurrent(), rls, kCFRunLoopDefaultMode)
    CFRunLoopRun()

if __name__ == "__main__":
    startWatchSSI()
Monitoring_WiFi.pyの全コード。

Wi-Fiの設定変更


 Wi-Fiの設定は、networksetupコマンドで変更することができるので、実行するバッチファイルの中でnetworksetupコマンドを実行するようにします。プロキシ設定関係で実際に使ったコマンドは以下の通りです。各自の環境に合わせて、プロキシのON/OFFの設定をバッチファイルで用意してください。

# 自動プロキシ設定変更
networksetup -setproxyautodiscovery "Wi-Fi" "off"
#networksetup -setproxyautodiscovery "Wi-Fi" "on"

# プロキシ設定を使用しないホストとドメイン
networksetup -setproxybypassdomains "Wi-Fi" "*.local, 169.254/16"

# 自動プロキシ構成
networksetup -setautoproxystate "Wi-Fi" "off"
#networksetup -setautoproxystate "Wi-Fi" "on"
#networksetup -setautoproxyurl "Wi-Fi" "http://プロキシのURLを記述する"

# Webプロキシ(HTTP)
networksetup -setwebproxystate "Wi-Fi" "off"
#networksetup -setautoproxystate "Wi-Fi" "on"
#networksetup -setwebproxy "Wi-Fi" "IPアドレス" "ポート番号"

# 保護されたWebプロキシ(HTTPS)
networksetup -setsecurewebproxystate "Wi-Fi" "off"
#networksetup -setsecurewebproxystate "Wi-Fi" "on"
#networksetup -setsecurewebproxy "Wi-Fi" "IPアドレス" "ポート番号"

Mac起動時に自動で監視を始める


LaunchAgentsについて

 作成したWi-Fi監視用のPythonコードは実行しなければ監視は始まりません。そこでMacが起動するたびに自動で監視が始まるようにします。

 これには、MacのLaunchAgentsを使います。LaunchAgentsはバックヅラウンド処理を指定したタイミングで自動実行するための仕組みです。この機能を使ってPythonを自動実行させます。

LaunchAgentsのplist作成

LaunchAgentsを使うためには、plistファイルを用意します。以下は、plistのサンプルです。RunAtLoadを指定することで起動時に実行するようにしています。

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>ここには任意の名前をつけてください。例:com.tanaka.monitoringwifi</string>
    
    <key>UserName</key>
    <string>任意の名前。Tanaka</string>

    <key>ProgramArguments</key>
    <array>
        <string>/Library/Frameworks/Python.framework/Versions/3.13/bin/python3</string>
        <string>ここにはpyまでの絶対パスを書いてください/Monitoring_WiFi.py</string>
    </array>

    <key>RunAtLoad</key>
    <true/>
    <key>StandardOutPath</key>
    <string>/tmp/monitoringwifi.out</string>

    <key>StandardErrorPath</key>
    <string>/tmp/monitoringwifi.err</string>

</dict>
</plist>
com.tanaka.monitoringwifi.plist

LaunchAgentsへの登録

 ”~/Library/LaunchAgents/”のフォルダに、作成したplistを移動します。移動後にターミナルで次のコマンドを実行することで有効になります。

% launchctl load ~/Library/LaunchAgents/com.tanaka.monitoringwifi.plist
launchctl loadコマンドでplistをLaunchAgentsにロード

 これで、Macを再起動しても常にWi-Fiの監視が実行されるようになります。LaunchAgentsが動作しているかを確認したい場合は、次のコマンドで確認できます。

% launchctl list | grep com.tanaka.monitoringwifi
実行確認のコマンド。実行されていればリストに表示される。

 LaunchAgentsを止めたい場合は、unloadで止めることができます。

% launchctl unload ~/Library/LaunchAgents/com.tanaka.monitoringwifi.plist
launchctl unloadでplistの登録を解除する。

まとめ


 Macの現在のSSIDを取得する方法は以前使われていた方法はMacのセキュリティ強化のために取得ができなくなっており、取得する方法を調査するのにかなりの時間が掛かりました。ただし、ここで紹介したSSIDを取得する方法も未来永劫使える保証はなく、SSIDが取得できなくなる可能性があります。そうなると、また別の方法を探す必要がありますが、Appleが公式にSSID毎に設定できて保存できるようなればいいなと思います。

 この自動化によって、Androidのビルドエラー問題を減らすことができました。また、Wi-Fiのプロキシ設定だけでなく、gitなどのプロキシ設定もバッチ化してフォルダに置くと同時に実行されるので、毎回変更するのが面倒に思っている人には使ってみてほしいなと思います。

 今回の自動化は、Python + バッチ + LaunchAgentsを使って実現しました。もし、もっと簡単な方法を知っているという方がいたら是非教えて下さい。