メニューバーにPhotoshop/Illustrator/InDesignなどのカラー設定を表示する③ pythonアプリ版

:::note alert
Photoshop 2025（26.8）のバグ（の可能性大）で、設定ファイルを読むけれども設定内容を書き込まないため、このアプリでは、Photoshop2025（26.8以降）のみメニューバーには出てこないので注意してください。また、古い設定が残っていると
[Ps]古いプリセット名
がそのまま出ている状態になりますので、注意してください。
※2025（26.7）では正しく動作します。
:::

![2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/35477/a52aae86-b0c1-46f4-9387-2eeffd071810.png)

## アプリ

AppleSilicon用のアプリはこちら：[ACEmenu.app](https://d.pr/f/AJrt4T)
IntelMacの用アプリはこちら：[ACEmenu.app](https://d.pr/f/rkpRY7)

一応公証通しているので普通に起動するはずです。
※Intel用の動作確認はRosetta上でしかしていません。

:::note warn
Macが壊れた、設定内容が間違っていた、表示が違っていた、その他について、鰯屋は何の保証もしませんしする気もありません。自己責任で使用してください。
自己責任で使用してください（再

:::

:::note
変更履歴
2025/03/20

+ intel用をビルド

2025/03/19

+ バイナリ処理を最適化
+ 設定ファイルをApplication Support/ACEmenuに保存し次回起動時選択アプリを復元
+ アプリのグルーピング
+ Apple公証アプリ化
+ クソダサアイコン
:::

:::note
この記事は、[前回](https://qiita.com/yamo74/items/17f0a09e125bf1c7ac74)のアプリ版です。複数のコマンドを新規導入したり、設定ファイルをたくさん作ったりせず、1つのアプリで完結します。
:::

# メニューバーにカラー設定を一覧表示
カラー設定はドキュメント埋め込みプロファイルに問題（ない、無視する等）があったときのフォールバック用であり、Photoshopでは確認チェックが入っていれば通常問題にはなりません。ただ効率を上げるためにチェック無しにしてダイアログをスキップするカラー設定内容であったりすると、気づかず色域変換してしまったりすることがあります。
今の状態をすぐに把握できるように、メニューバーにカラー設定内容を表示させます。

## ACEConfifCache2.lstのパース
<details><summary>旧版の処理（od使用）</summary>
pythonで全てできれば速くていいんですが、pyのバイナリ読み込み＆エンコードでは日本語は正しく読めても、この設定ファイルのゼロパディングが無視されるなどで後処理が面倒になったため、また元々そんなに重い処理でもないのでpy内でodを走らせます。

```
od -A n -c -v /Users/yamo/Library/Preferences/Adobe/Color/ACEConfigCache2.lst
```
得られた結果は、

- カスタムか否かのフラグ
- アプリパス
- csfファイルパス（フラグが立っていれば無し）

がアプリごとに、特に区切りなく収まります。ここからまずゼロパディングで改行してアプリを分け、それぞれ正規表現でアプリ名とプリセット名を取り出して処理します。

</details>

バイナリを直接pythonに処理させる方法にしました。これでだいぶ高速化しています。
だいぶ遠回りしましたが、バイナリの読み方が少し分かった……かな………テキスト化せずにバイナリエディタで読めばよかった

+ 先頭76バイト固定長データのうち、最後の1バイトがカスタムフラグ
+ 2バイトでアプリパスデータの長さ
+ アプリパス
+ 2バイトで設定ファイルパスの長さ
+ 8バイト固定長のなにか

という構造になっています。

![名称未設定-1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/35477/1835c6be-ccb4-4d25-afb3-b75235da3ca8.png)

固定長部分にいろんなフラグありそうです。Bridge同期直後のフラグとか。

正規表現でアプリ名称（アプリの入っているフォルダ名称……Illustratorがバージョン名無しなので）と、.csfファイルの名称のみ抽出しています。「カスタム」フラッグがある場合はcsfでなく「カスタム」を出力します。

:::note
「カスタム」とは、プリセットを持たない設定のことを言います。プリセット名のところが「カスタム」になっている状態です。カラー設定はきちんと名前を付けて保存しておくのがいいですね。その際絵文字は使わないでください。こいつが落ちますし、カラー設定でも正しく表示されません。
:::

一応watchdogで変更があったときのみ動作するようにしているので、システムへの負荷はほとんどないはずです。

## rumpsでメニューバー常駐にする
まずpyスクリプトを書いて実行させ、動くかどうか試します。
仮想環境のほうが動かしやすいと思います。

python3を使います。ライブラリは以下で導入
```shell
pip install setuptools==70.3.0 wheel py2app rumps watchdog
```

:::note
setuptoolsのみ、py2appがコケるためバージョンダウンしています。既に開発を行っている方は注意してください。
:::
下記スクリプトを保存して、実行するとメニューバーに現われます。アプリにする前に動作するか確認してください。

コード中の`DEFAULT_APP`に自分の使用するメインアプリ名をいれておいてください。

:::note
lowerにするので大文字小文字はどうでもいいですが、Illustrator、Photoshop、InDesignとアプリの基本名称のみで。
:::



最新：2025/03/19版（ver 2.0.1）は以下

```python:ACEmenu.py
#!/usr/bin/env python3
# Ver 2.0.1

import os	# OS 関数
import re	# 正規表現
import json	# JSON 処理
import rumps	# macOS ステータスバーアプリ用ライブラリ
import threading	# スレッディング
from watchdog.observers import Observer	# ファイル監視オブザーバ
from watchdog.events import FileSystemEventHandler	# ファイルイベントハンドラ
from dataclasses import dataclass	# データクラスサポート
import mmap	# メモリマップドファイルサポート

DEBOUNCE_DELAY = 1	# デバウンス用遅延
EXTRA_DELAY = 0.5	# 追加遅延
DEFAULT_APP = "Photoshop"	# デフォルトアプリ名
CUSTOM_CSF = "▶ custom"	# カスタム CSF 文字列
MARKER_OFFSET = 75	# セグメント先頭のオフセット
TAIL_OFFSET = 8	# セグメント末尾のオフセット

COLOR_TRANSLATION = {	# ローカライズ用辞書
	"Japan Web Internet": "Web・インターネット用 - 日本",
	"Japan Prepress": "プリプレス用 - 日本２",
	"Monitor Color": "モニタのカラー設定に合わせる",
	"Japan General Purpose": "一般用 - 日本２",
	"Japan Magazine": "日本 - 雑誌広告用",
	"Japan Newspaper": "日本 - 新聞用",
	"Europe Web Internet 2": "Web・インターネット用 - ヨーロッパ２",
	"North America Web Internet": "Web・インターネット用 - 北米",
	"Europe Prepress 3": "プリプレス用 - ヨーロッパ３",
	"North America Prepress": "プリプレス用 - 北米２",
	"Europe General Purpose 3": "一般用 - ヨーロッパ３",
	"North America General Purpose": "一般用 - 北米２",
	"Japan General Purpose 3": "一般用 - 日本３",
	"North America Newspaper": "新聞用 - 北米"
}

CONFIG_DIR = os.path.expanduser("~/Library/Application Support/ACEmenu")	# 設定ファイルのディレクトリパス
CONFIG_FILE = os.path.join(CONFIG_DIR, "config.json")	# 設定ファイルパス
ACE_FILE = os.path.expanduser("~/Library/Preferences/Adobe/Color/ACEConfigCache2.lst")	# ACE ファイルパス

#サンプルファイル用テストコード
#import sys
#ACE_FILE = sys.argv[1]

#設定ファイルの処理関数
def load_config():
	# 設定ファイルから選択済みアプリ名を読み込む（存在しなければ None を返す）
	if not os.path.exists(CONFIG_FILE):
		return None
	try:
		with open(CONFIG_FILE, "r", encoding="utf-8") as f:
			data = json.load(f)
			return data.get("selected_app")
	except Exception as e:
		print(f"設定読み込みエラー: {e}")
		return None

def save_config(selected_app):
	# 選択済みアプリ名を設定ファイルに保存する
	os.makedirs(CONFIG_DIR, exist_ok=True)
	try:
		with open(CONFIG_FILE, "w", encoding="utf-8") as f:
			json.dump({"selected_app": selected_app}, f, ensure_ascii=False, indent=2)
	except Exception as e:
		print(f"設定保存エラー: {e}")

def get_group_key(app_name: str) -> str:
	# アプリ名からバージョンなどを除いたグループキーを返す（例: PhotoshopCC -> Photoshop）
	products = ["Photoshop", "Illustrator", "InDesign", "Bridge", "Acrobat"]
	for product in products:
		if app_name.lower().startswith(product.lower()):
			return product
	return app_name

#ACEファイルの読み込み
@dataclass
class Segment:
	# セグメント用データクラス
	app_name: str
	csf_file: str

# ACEConfigCache2.lstの読み込み
# 先頭75バイトをスキップ（いろいろフラグがありそう）
# 76バイト目が1ならカラー設定は「カスタム」。プリセットファイルがない
# 77から2バイトで次の可変長データの長さを指定
# 可変長1（アプリパス）
# 直後の2バイトで次の可変長データの長さを指定
# 可変長2（設定ファイルパス）
# 8バイトをスキップして次へ（いろいろフラグがありそう）

def extract_segments(mm: mmap.mmap) -> list:
	# メモリマップドファイルからセグメントを抽出する
	segments = []
	pos = 0
	while pos < len(mm):
		try:
			pos += MARKER_OFFSET	# オフセット分進める
			flag = mm[pos]
			pos += 1
			length_app = int.from_bytes(mm[pos:pos+2], 'big')
			pos += 2
			app_data = mm[pos:pos+length_app]
			pos += length_app
			length_setting = int.from_bytes(mm[pos:pos+2], 'big')
			pos += 2
			setting_data = mm[pos:pos+length_setting]
			pos += length_setting
			pos += TAIL_OFFSET	# 末尾オフセット分進める
			app_text = app_data.decode('utf-8', errors='ignore')
			setting_text = setting_data.decode('utf-8', errors='ignore')
			app_match = re.search(r"/(?:Adobe )?([^/]+)/[^/]+\.app", app_text)
			app_name = app_match.group(1) if app_match else "Unknown"
			if flag == 0:
				csf_match = re.search(r"/([^/]+?)\.csf", setting_text)
				if csf_match:
					csf_name = csf_match.group(1)
					#ローカライズファイルと同じなら置き換える
					csf_file = COLOR_TRANSLATION.get(csf_name, csf_name)
				else:
					csf_file = "Unknown"
			else:
				csf_file = CUSTOM_CSF
			segments.append(Segment(app_name, csf_file))
		except Exception:
			break
	return segments

def extract_results(ace_file: str) -> list:
	# ACE ファイルからセグメントを抽出し、統合結果を返す
	try:
		with open(ace_file, "rb") as f:
			with mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as mm:
				segments = extract_segments(mm)
	except Exception as e:
		print(f"ファイル読み込みエラー: {e}")
		return []
	merged = {}
	for seg in segments:
		merged.setdefault(seg.app_name, []).append(seg.csf_file)
	result = []
	for app, csf_list in merged.items():
		unique = set(csf_list)
		if len(unique) == 1:
			csf = unique.pop()
		else:
			csf = CUSTOM_CSF if CUSTOM_CSF in unique else list(unique)[0] + " (複数)"
		result.append((app, csf))
	result.sort(key=lambda x: x[0])
	return result

class AceFileEventHandler(FileSystemEventHandler):
	# ACE ファイルの変更を監視するイベントハンドラ
	def __init__(self, update_callback):
		super().__init__()
		self.update_callback = update_callback
		self._timer = None
		self._lock = threading.Lock()
	def on_modified(self, event):
		# ACE ファイルが変更された場合、更新コールバックを遅延実行する
		if os.path.abspath(event.src_path) == os.path.abspath(ACE_FILE):
			with self._lock:
				if self._timer:
					self._timer.cancel()
				total_delay = DEBOUNCE_DELAY + EXTRA_DELAY
				self._timer = threading.Timer(total_delay, self.update_callback)
				self._timer.start()

class AceMenuApp(rumps.App):
	# メニューバーアプリ本体
	def __init__(self):
		super().__init__("ACEmenu", menu=[], quit_button=None)
		self.top_item = None
		self.config_loaded = False	# 設定読み込み済みフラグ
		self.update_pending = False	# 更新保留フラグ
		self.update_menu()	# 初回メニュー構築
		self.update_timer = rumps.Timer(self.check_update, 1)
		self.update_timer.start()
		event_handler = AceFileEventHandler(self.set_update_pending)
		observer = Observer()
		observer.schedule(event_handler, os.path.dirname(ACE_FILE), recursive=False)
		observer.daemon = True
		observer.start()
		self.observer = observer
	def update_menu(self, _=None):
		# ACE ファイルから結果を抽出し、メニューとタイトルを更新する
		new_results = extract_results(ACE_FILE)
		if not new_results:
			self.title = "No data"
			self.menu.clear()
			self.menu.add(rumps.MenuItem("Quit", callback=on_click_quit))
			return
		groups = {}
		for item in new_results:
			group = get_group_key(item[0])
			groups.setdefault(group, []).append(item)
		for group in groups:
			groups[group].sort(key=lambda x: x[0])
		sorted_groups = sorted(groups.items(), key=lambda x: x[0])
		flat_list = []
		for idx, (group, items) in enumerate(sorted_groups):
			flat_list.extend(items)
			if idx != len(sorted_groups) - 1:
				flat_list.append("separator")
		if not self.config_loaded:
			recorded_app = load_config()
			if recorded_app:
				match = next((item for item in new_results if item[0] == recorded_app), None)
				if match:
					self.top_item = match
				else:
					fallback = next((item for item in new_results if get_group_key(item[0]) == get_group_key(recorded_app)), None)
					if fallback is None:
						photoshop_items = [item for item in new_results if get_group_key(item[0]) == "Photoshop"]
						fallback = sorted(photoshop_items, key=lambda x: x[0])[-1] if photoshop_items else new_results[0]
					alert_msg = (f"設定ファイルがありません\n({recorded_app}) が見つからないため、\n"
						f"({fallback[0]}) を選択します。")
					rumps.alert(title="設定", message=alert_msg)
					self.top_item = fallback
					save_config(self.top_item[0])	# fallback 選択後、設定ファイルを書き換える
			else:
				ps_candidates = [item for item in new_results if DEFAULT_APP.lower() in item[0].lower()]
				self.top_item = ps_candidates[0] if ps_candidates else new_results[0]
			self.config_loaded = True
		else:
			if self.top_item:
				updated_item = next((item for item in new_results if item[0] == self.top_item[0]), None)
				if updated_item:
					self.top_item = updated_item
		csf_all = [csf for (app, csf) in new_results]
		mark = "✓" if len(set(csf_all)) == 1 else ""
		app, csf = self.top_item
		prefix = self._make_prefix(app)
		self.title = f"{prefix} {mark} {csf}".strip()
		self.menu.clear()
		for item in flat_list:
			if item == "separator":
				self.menu.add(rumps.separator)
			else:
				app_name, csf_name = item
				mi = rumps.MenuItem(f"{app_name} : {csf_name}", callback=self.on_item_clicked)
				mi.appcsf = (app_name, csf_name)
				mi.state = 1 if (app_name, csf_name) == self.top_item else 0
				self.menu.add(mi)
		self.menu.add(rumps.separator)	# 設定メニュー上部の横線
		setting_item = rumps.MenuItem("設定")
		setting_item.add(rumps.MenuItem("ACEキャッシュ削除", callback=self.on_clear_ace))
		self.menu.add(setting_item)
		self.menu.add(rumps.separator)	# 設定メニュー下部の横線
		self.menu.add(rumps.MenuItem("Quit", callback=self.on_quit))
	def set_update_pending(self):
		# 更新保留フラグをセットする
		self.update_pending = True
	def check_update(self, sender):
		# 更新保留フラグが立っていればメニューを更新する
		if self.update_pending:
			self.update_menu()
			self.update_pending = False
	def _make_prefix(self, app_name: str) -> str:
		# アプリ名に基づくプレフィックスを作成する
		aname = app_name.lower()
		if "photoshop" in aname:
			return "[Ps]"
		elif "illustrator" in aname or "airobbin" in aname:
			return "[Ai]"
		elif "indesign" in aname:
			return "[ID]"
		elif "bridge" in aname:
			return "[Br]"
		elif "acrobat" in aname:
			return "[Ac]"
		return aname
	def on_item_clicked(self, sender):
		# メニュー項目クリック時の処理：選択済みアプリを更新し設定を保存する
		self.top_item = sender.appcsf
		save_config(self.top_item[0])
		self.update_menu()
	def on_clear_ace(self, _):
		# ACE キャッシュ削除要求時の処理
		response = rumps.alert(
			title="確認",
			message="ACEConfigCache2.lstを消去します。\n（Adobeアプリ起動で再作成されます）",
			ok="消去",
			cancel="キャンセル"
		)
		if response == 1:
			try:
				os.remove(ACE_FILE)
				rumps.notification("削除完了", "", "ACEConfigCache2.lst を消去しました。")
			except Exception as e:
				rumps.notification("削除エラー", "", f"エラー: {e}")
		self.update_menu()
	def on_quit(self, _):
		# アプリを終了する
		rumps.quit_application()

def on_click_quit(_):
	# アプリ終了用ヘルパー
	rumps.quit_application()

if __name__ == "__main__":
	AceMenuApp().run()

```

実行

```shell
python3 ACEmenu.py
```

こうなるはずです。

![SS_CleanShot_20250315-170703.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/35477/a5f6955a-2ad9-425f-9b42-51c6ea146ca6.png)

内容はACEConfigCache2.lstによって変わります。
選択したアプリがメニューバー上に表示されます。ACEConfigCache2.lstはただのキャッシュファイルなので、問題があれば気軽に削除して構いません。アプリを起動したりアクティブにしたタイミングですぐに再生成されます。

全てのアプリで同じカラー設定になっていればチェックマークがメニューバー内に表示されますが、上記の理由により各アプリがアクティブにならないとカラー設定は変わらないため、同期直後にはBridge分しか変わりません。

:::note
ACEConfigChache2.lstはカラー設定の同期用のファイルです。Bridgeカラー設定で同期すると、このファイルにBridgeの設定が書き込まれます。「同期」の時点では何も変わらず、そのあとアプリごとの次回起動時やアクティブになったタイミングでカラー設定がBridgeの設定（csfプリセットファイル）に書き換えられます。この書き換えは一度だけ行われます……という動作をします。
一応、バージョン違いのアプリは追記されていきます。Photoshop(beta）は最新Photoshopの記述を置き換えます。
:::

## py2appでアプリにする
先に設定ファイルを作ります。

```python:setup.py
from setuptools import setup

APP = ['ACEmenu.py']
OPTIONS = {
	'iconfile': 'myicon.icns',
	'plist': {
		'CFBundleName': "ACEMenu",
		'CFBundleIdentifier': "com.iwashi.acemenu",
		'CFBundleShortVersionString': "2.0.1",
		'CFBundleVersion': "2",
		'LSUIElement': True,
		'CFBundleIconFile': 'myicon.icns'
	},
}

setup(
	app=APP,
	options={'py2app': OPTIONS},
	setup_requires=['py2app'],
)
```

`'LSUIElement': True`にするとDockにアイコンを出しません。

ACEmenu.pyと同じフォルダに入れて、そのフォルダ内でビルドします。
![SS_Finder_20250315-171757.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/35477/d2875d11-d606-4e20-b477-faaa9684d86b.png)

```shell:
python3 setup.py py2app
```
ビルド成功するとdistフォルダ内にアプリができます。これを実行して、同じ動作になるか確認してください。

![SS_Finder_20250315-172033.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/35477/e40a0469-5202-457e-aadd-fd4397633ba9.png)

## ビルドしたアプリ

一応[ビルドしたもの](https://d.pr/f/AJrt4T)も置いておきます（2025/03/19 Ver 2.0.1）
AppleSilicon用です。Intel対応はちょっと面倒だった。
公証済み！になっているはずです。

## IntelMacの方

~~pyを実行するか、ご自身でビルドしてください。
※対応させるの結構面倒だった~~

作成しました。[ACEmenu2_intel](https://d.pr/f/rkpRY7)
Rosetta上で動作確認はしていますが、Intel機がもうないので……
むしろユニバーサルバイナリ化が面倒そうで未着手。