From 0689a28cfb27dc376df4df86579a562b21a93fd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B0=E7=95=91=E3=81=B2=E3=81=A8=E3=81=97?= Date: Sat, 26 Sep 2026 23:20:31 +0900 Subject: [PATCH 1/2] =?UTF-8?q?metrics:=20views/clones=E3=81=AE=E3=83=88?= =?UTF-8?q?=E3=83=A9=E3=83=95=E3=82=A3=E3=83=83=E3=82=AF=E3=82=82=E6=97=A5?= =?UTF-8?q?=E6=AC=A1=E3=82=B9=E3=83=8A=E3=83=83=E3=83=97=E3=82=B7=E3=83=A7?= =?UTF-8?q?=E3=83=83=E3=83=88=E3=81=AB=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 既存のdownloads.csv記録の仕組みに、GitHub Traffic API(views/clones)を 同じ安全策(失敗したらCSVに触らない)で追記するステップを追加。 MrkAppReleaseのcollect-metrics.shと同様、Administration:read権限が 要るためTRAFFIC_TOKENを優先しGITHUB_TOKENへフォールバックする。 Co-Authored-By: Claude Sonnet 5 --- .github/workflows/metrics.yml | 16 ++++++-- scripts/traffic_snapshot.sh | 74 +++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 4 deletions(-) create mode 100644 scripts/traffic_snapshot.sh diff --git a/.github/workflows/metrics.yml b/.github/workflows/metrics.yml index f743ec5..50dc168 100644 --- a/.github/workflows/metrics.yml +++ b/.github/workflows/metrics.yml @@ -1,4 +1,4 @@ -name: Snapshot download metrics +name: Snapshot download & traffic metrics # GitHub は「今の累計」しか返さないので、定期的に撮って差分を残す。 # 手元の Mac が起動していなくても回るように Actions で撮る。 @@ -42,17 +42,25 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: OUT=.metrics/downloads.csv sh scripts/metrics_snapshot.sh + # Traffic API は Administration:read が要る。GITHUB_TOKEN では取れないことがある + # ので TRAFFIC_TOKEN を優先し、未設定なら GITHUB_TOKEN にフォールバックする + # (その場合 gh api が失敗し、スクリプトの安全策で CSV には触らず落ちるだけ)。 + - name: views/clones を追記 + env: + GH_TOKEN: ${{ secrets.TRAFFIC_TOKEN || secrets.GITHUB_TOKEN }} + run: OUT=.metrics/traffic.csv sh scripts/traffic_snapshot.sh + - name: 変化があれば metrics へコミット working-directory: .metrics run: | - if git diff --quiet -- downloads.csv; then + if git diff --quiet -- downloads.csv traffic.csv; then echo "変化なし。コミットしない。" exit 0 fi git config user.name 'github-actions[bot]' git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add downloads.csv - git commit -m "メトリクス: ダウンロード数のスナップショット ($(date -u +%Y-%m-%dT%H:%MZ))" + git add downloads.csv traffic.csv + git commit -m "メトリクス: ダウンロード数・トラフィックのスナップショット ($(date -u +%Y-%m-%dT%H:%MZ))" # 順番待ちの間に metrics が進んでいることがある。撮った行は捨てずに乗せ直す。 for i in 1 2 3; do git push origin HEAD:metrics && exit 0 diff --git a/scripts/traffic_snapshot.sh b/scripts/traffic_snapshot.sh new file mode 100644 index 0000000..995bf6d --- /dev/null +++ b/scripts/traffic_snapshot.sh @@ -0,0 +1,74 @@ +#!/bin/sh +# GitHub Traffic API(views/clones)を、直近14日ローリング集計のスナップショットとして記録する。 +# +# downloads.csv(metrics_snapshot.sh)と違い、views/clones は「今の累計」ではなく +# 「直近14日間の集計」なので、前回より減っても異常ではない。よって減少チェックはしない。 +# 撮り続けること自体に意味がある(撮り漏らした日は API 側にも残らず復元できない)。 +# +# 正はブランチ `metrics`(main には無い。理由は metrics_snapshot.sh 参照)。 +# +# 使い方: +# sh scripts/traffic_snapshot.sh +# DRY_RUN=1 sh scripts/traffic_snapshot.sh # CSV に書かずに値だけ見る +# OUT=/tmp/x.csv sh scripts/traffic_snapshot.sh +# +# Traffic API は Administration:read 権限が要る。既定の GITHUB_TOKEN では取れないことが +# ある(MrkAppRelease で実際に token_required が発生した実績あり)ため、呼び出し側の +# ワークフローで secrets.TRAFFIC_TOKEN を優先し、無ければ GITHUB_TOKEN にフォールバックする。 +# +# 壊れた値を書かないための約束: +# - gh api が失敗したら書かない(パイプで握り潰さない)。 +set -eu + +REPO="${REPO:-$(gh repo view --json nameWithOwner -q .nameWithOwner)}" +DATA_BRANCH="${DATA_BRANCH:-metrics}" +OUT="${OUT:-}" +DRY_RUN="${DRY_RUN:-}" + +# OUT が指定されていなければ、正(ブランチ metrics)を作業コピーへ引いてくる。 +if [ -z "$OUT" ]; then + OUT=".metrics/traffic.csv" + mkdir -p "$(dirname "$OUT")" + if git rev-parse --git-dir >/dev/null 2>&1; then + git fetch -q origin "$DATA_BRANCH" 2>/dev/null || true + if git cat-file -e "FETCH_HEAD:traffic.csv" 2>/dev/null; then + git show "FETCH_HEAD:traffic.csv" > "$OUT" + echo "ブランチ $DATA_BRANCH から $OUT へ引いた(手元の結果は push しない)" + fi + fi +fi +AT="${AT:-$(date -u +%Y-%m-%dT%H:%MZ)}" + +mkdir -p "$(dirname "$OUT")" +[ -f "$OUT" ] || printf 'taken_at,views_14d,unique_views_14d,clones_14d,unique_clones_14d\n' > "$OUT" + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +if ! views_json="$(gh api "repos/$REPO/traffic/views" 2>"$WORK/err")"; then + echo "views の取得に失敗した。CSV は変更していない。" >&2 + cat "$WORK/err" >&2 + exit 1 +fi +if ! clones_json="$(gh api "repos/$REPO/traffic/clones" 2>"$WORK/err")"; then + echo "clones の取得に失敗した。CSV は変更していない。" >&2 + cat "$WORK/err" >&2 + exit 1 +fi + +views="$(printf '%s' "$views_json" | jq -r '.count')" +unique_views="$(printf '%s' "$views_json" | jq -r '.uniques')" +clones="$(printf '%s' "$clones_json" | jq -r '.count')" +unique_clones="$(printf '%s' "$clones_json" | jq -r '.uniques')" + +if [ -z "$DRY_RUN" ]; then + grep -v "^\"$AT\"," "$OUT" > "$WORK/out" 2>/dev/null || true + printf '"%s",%s,%s,%s,%s\n' "$AT" "$views" "$unique_views" "$clones" "$unique_clones" >> "$WORK/out" + mv "$WORK/out" "$OUT" +fi + +printf '%s views=%s(uniq %s) clones=%s(uniq %s)' "$AT" "$views" "$unique_views" "$clones" "$unique_clones" +if [ -n "$DRY_RUN" ]; then + printf ' [DRY_RUN: CSV は書いていない]' +fi +printf '\n' From f140cbe8bbc7308ddc0b957622699337958187f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B0=E7=95=91=E3=81=B2=E3=81=A8=E3=81=97?= Date: Sun, 27 Sep 2026 10:07:28 +0900 Subject: [PATCH 2/2] =?UTF-8?q?core:=20=E4=BB=96=E3=83=89=E3=83=A1?= =?UTF-8?q?=E3=82=A4=E3=83=B3=E3=81=8B=E3=82=89=E8=A8=AD=E5=AE=9A=E3=82=92?= =?UTF-8?q?=E8=AA=AD=E3=82=80=E5=8F=A3=E3=82=92=E8=BF=BD=E5=8A=A0=EF=BC=88?= =?UTF-8?q?Pro=E5=B0=82=E7=94=A8=E6=A9=9F=E8=83=BD=E3=81=AE=E4=B8=8B?= =?UTF-8?q?=E3=81=94=E3=81=97=E3=82=89=E3=81=88=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AppSettings/EditorTheme/EditorFontの各getterを「自ドメイン向けcomputed var」と 「任意のUserDefaultsから読む関数」に分離し、FreeSettingsImportから同じ復号ロジックを 再利用できるようにした。無料版のメニュー・実行ファイルはFreeSettingsImportを一切 参照しないため、Pro専用の機能でありながらPro.allows()のゲートは不要(check_consistency.py のpro_gate検査でも確認済み)。 Co-Authored-By: Claude Sonnet 5 --- Sources/MrEditorCore/AppSettings.swift | 83 ++++++++++++-- .../Core/FreeSettingsImport.swift | 106 ++++++++++++++++++ Sources/MrEditorCore/EditorFont.swift | 8 ++ Sources/MrEditorCore/EditorTheme.swift | 52 +++++++-- 4 files changed, 229 insertions(+), 20 deletions(-) create mode 100644 Sources/MrEditorCore/Core/FreeSettingsImport.swift diff --git a/Sources/MrEditorCore/AppSettings.swift b/Sources/MrEditorCore/AppSettings.swift index aef7c7b..fa98235 100644 --- a/Sources/MrEditorCore/AppSettings.swift +++ b/Sources/MrEditorCore/AppSettings.swift @@ -164,22 +164,29 @@ enum AppSettings { } static var saveProgressStyle: SaveProgressStyle { - get { SaveProgressStyle(rawValue: defaults.string(forKey: saveProgressKey) ?? "") ?? .sheet } + get { saveProgressStyle(from: defaults) } set { defaults.set(newValue.rawValue, forKey: saveProgressKey) } } + private static func saveProgressStyle(from source: UserDefaults) -> SaveProgressStyle { + SaveProgressStyle(rawValue: source.string(forKey: saveProgressKey) ?? "") ?? .sheet + } /// 長い行を折り返すか。false=折り返さず横スクロール(既定)、true=内容幅で折り返す。 static var lineWrap: Bool { - get { defaults.bool(forKey: lineWrapKey) } + get { lineWrap(from: defaults) } set { defaults.set(newValue, forKey: lineWrapKey); NotificationCenter.default.post(name: .mrEditorLineWrapChanged, object: nil) } } + private static func lineWrap(from source: UserDefaults) -> Bool { source.bool(forKey: lineWrapKey) } /// 一致行だけ表示のとき、前後に足す行数(`grep -C` 相当)。既定 0。 /// ファイルごとではなくアプリの設定として覚える(一度決めたら次のファイルでも同じ見え方になる)。 static var filterContextLines: Int { - get { min(max(0, defaults.integer(forKey: filterContextKey)), FilterContext.maxContext) } + get { filterContextLines(from: defaults) } set { defaults.set(min(max(0, newValue), FilterContext.maxContext), forKey: filterContextKey) } } + private static func filterContextLines(from source: UserDefaults) -> Int { + min(max(0, source.integer(forKey: filterContextKey)), FilterContext.maxContext) + } /// ⌘F を「一致行だけ表示」で開くか。**漏斗を最後に自分で操作した状態**を覚える。 /// @@ -188,46 +195,63 @@ enum AppSettings { /// (構造化・JSON)へ移ると強制的に off になり、戻ってきても off のままだった。 /// 覚えるのは**意図**で、使えるかどうかはペインが決める。既定 false(従来どおり)。 static var searchFilterOn: Bool { - get { defaults.bool(forKey: searchFilterKey) } + get { searchFilterOn(from: defaults) } set { defaults.set(newValue, forKey: searchFilterKey) } } + private static func searchFilterOn(from source: UserDefaults) -> Bool { source.bool(forKey: searchFilterKey) } /// タブの表示幅(文字数)。既定 4。選択肢は 2/4/8。 static var tabWidth: Int { - get { let v = defaults.integer(forKey: tabWidthKey); return v > 0 ? v : 4 } + get { tabWidth(from: defaults) } set { defaults.set(newValue, forKey: tabWidthKey); postDisplayChanged() } } + private static func tabWidth(from source: UserDefaults) -> Int { + let v = source.integer(forKey: tabWidthKey); return v > 0 ? v : 4 + } /// 行間。 static var lineSpacing: LineSpacing { - get { LineSpacing(rawValue: defaults.string(forKey: lineSpacingKey) ?? "") ?? .standard } + get { lineSpacing(from: defaults) } set { defaults.set(newValue.rawValue, forKey: lineSpacingKey); postDisplayChanged() } } + private static func lineSpacing(from source: UserDefaults) -> LineSpacing { + LineSpacing(rawValue: source.string(forKey: lineSpacingKey) ?? "") ?? .standard + } /// キャレット行を淡い帯で強調するか。既定 true。 static var highlightCurrentLine: Bool { - get { defaults.object(forKey: highlightCurrentLineKey) as? Bool ?? true } + get { highlightCurrentLine(from: defaults) } set { defaults.set(newValue, forKey: highlightCurrentLineKey); postDisplayChanged() } } + private static func highlightCurrentLine(from source: UserDefaults) -> Bool { + source.object(forKey: highlightCurrentLineKey) as? Bool ?? true + } /// 行番号(ガター)を表示するか。既定 true。 /// 巨大ファイル側は自前ガター、小ファイル側は NSRulerView が担うが、設定は 1 つ。 static var showLineNumbers: Bool { - get { defaults.object(forKey: showLineNumbersKey) as? Bool ?? true } + get { showLineNumbers(from: defaults) } set { defaults.set(newValue, forKey: showLineNumbersKey); postDisplayChanged() } } + private static func showLineNumbers(from source: UserDefaults) -> Bool { + source.object(forKey: showLineNumbersKey) as? Bool ?? true + } /// 不可視文字(タブ・改行・全角スペース・行末の半角スペース)を記号で見せるか。既定 false。 static var showInvisibles: Bool { - get { defaults.bool(forKey: showInvisiblesKey) } + get { showInvisibles(from: defaults) } set { defaults.set(newValue, forKey: showInvisiblesKey); postDisplayChanged() } } + private static func showInvisibles(from source: UserDefaults) -> Bool { source.bool(forKey: showInvisiblesKey) } /// キャレット形状。 static var cursorShape: CursorShape { - get { CursorShape(rawValue: defaults.string(forKey: cursorShapeKey) ?? "") ?? .bar } + get { cursorShape(from: defaults) } set { defaults.set(newValue.rawValue, forKey: cursorShapeKey); postDisplayChanged() } } + private static func cursorShape(from source: UserDefaults) -> CursorShape { + CursorShape(rawValue: source.string(forKey: cursorShapeKey) ?? "") ?? .bar + } /// 前回終了時のセッション(左サイドバーの並び順・アクティブ位置)。次回起動時に復元する。 static var session: SessionState? { @@ -314,9 +338,12 @@ enum AppSettings { /// **オフでも変更は知らせる**(本文下端のバナー)。黙って古い内容を見せ続けることはしない。 /// 未保存の変更があるときは、オンでも自動では取り込まない(潰してしまうため)。 static var autoReloadExternalChanges: Bool { - get { defaults.object(forKey: autoReloadKey) as? Bool ?? true } + get { autoReloadExternalChanges(from: defaults) } set { defaults.set(newValue, forKey: autoReloadKey) } } + private static func autoReloadExternalChanges(from source: UserDefaults) -> Bool { + source.object(forKey: autoReloadKey) as? Bool ?? true + } /// 起動時に新しい版が出ていないか自動で調べるか。既定 true。 /// App Store 配布ではないため、これが無いと利用者は新版に気づけない。 @@ -392,6 +419,40 @@ enum AppSettings { private static func postDisplayChanged() { NotificationCenter.default.post(name: .mrEditorDisplayChanged, object: nil) } + + // MARK: - 他ドメインからの読み取り([[FreeSettingsImport]] 専用) + + /// [[FreeSettingsImport]] が運ぶ、外観を除いた挙動設定の値だけの束。 + struct BehaviorSnapshot { + var saveProgressStyle: SaveProgressStyle + var lineWrap: Bool + var filterContextLines: Int + var searchFilterOn: Bool + var tabWidth: Int + var lineSpacing: LineSpacing + var highlightCurrentLine: Bool + var showLineNumbers: Bool + var showInvisibles: Bool + var cursorShape: CursorShape + var autoReloadExternalChanges: Bool + } + + /// 任意の UserDefaults ドメイン(他アプリの設定)から読む。**書き込みはしない。** + /// セッション・AI 設定・更新チェックはここに含まれない([[FreeSettingsImport]] のコメント参照)。 + static func behaviorSnapshot(from source: UserDefaults) -> BehaviorSnapshot { + BehaviorSnapshot( + saveProgressStyle: saveProgressStyle(from: source), + lineWrap: lineWrap(from: source), + filterContextLines: filterContextLines(from: source), + searchFilterOn: searchFilterOn(from: source), + tabWidth: tabWidth(from: source), + lineSpacing: lineSpacing(from: source), + highlightCurrentLine: highlightCurrentLine(from: source), + showLineNumbers: showLineNumbers(from: source), + showInvisibles: showInvisibles(from: source), + cursorShape: cursorShape(from: source), + autoReloadExternalChanges: autoReloadExternalChanges(from: source)) + } } extension Notification.Name { diff --git a/Sources/MrEditorCore/Core/FreeSettingsImport.swift b/Sources/MrEditorCore/Core/FreeSettingsImport.swift new file mode 100644 index 0000000..45fabf8 --- /dev/null +++ b/Sources/MrEditorCore/Core/FreeSettingsImport.swift @@ -0,0 +1,106 @@ +import AppKit + +/// 同じ Mac にインストールされた無料版(MrEditor)から、Pro 側の設定へ安全に取り込む。 +/// +/// **Pro 専用の機能。** 呼び出し口は Pro 側(`MrkPro`)にしか存在しない。core 自身のメニュー・ +/// 実行ファイルはこの型を一切参照しないので、無料版に機能が漏れることはない +/// (`scripts/check_consistency.py` の pro_gate 検査は「core 内で `ProFeature` の +/// ケースを未ゲートで使っていないか」を見るものだが、この型はそもそも `ProFeature` の +/// ケースではないので対象外 —— ゲートの必要が無い形にしてある)。 +/// +/// 意図的に含めないもの: +/// - セッション・下書き([[AppSettings.session]])… 開いていたファイルの復元情報 +/// - ファイルごとの列定義(`MrEditor.columnFields`)… 絶対パスをキーに持つため +/// - 自動更新チェックの設定・最終チェック時刻 … アプリごとに更新経路が別 +/// - AI プロバイダ設定 … 既に Keychain 共有アカウントで解決済み([[AppSettings.aiConfig]])。 +/// ここでも UserDefaults 経由で運ぶと、真実の源が二重になる +/// - 秘密情報(API キー等) … そもそも UserDefaults には無く Keychain にしかない +public struct FreeSettingsImport: Equatable { + // 外観([[EditorTheme]] / [[EditorFont]]) + public var themePresetRawValue: String + /// `EditorTheme.ColorKey.rawValue` → 色。 + public var customColors: [String: NSColor] + public var backgroundOpacity: Double + public var ansiColors: Bool + public var fontName: String? + public var fontSize: Double + + // 挙動([[AppSettings]]) + public var saveProgressStyleRawValue: String + public var lineWrap: Bool + public var filterContextLines: Int + public var searchFilterOn: Bool + public var tabWidth: Int + public var lineSpacingRawValue: String + public var highlightCurrentLine: Bool + public var showLineNumbers: Bool + public var showInvisibles: Bool + public var cursorShapeRawValue: String + public var autoReloadExternalChanges: Bool + + /// 指定したバンドル ID の UserDefaults ドメインから読む。 + /// そのアプリが一度も起動されていない等でドメインが空なら nil。 + public static func capture(fromBundleID bundleID: String) -> FreeSettingsImport? { + guard let source = UserDefaults(suiteName: bundleID), + let domain = source.persistentDomain(forName: bundleID), !domain.isEmpty else { return nil } + + let theme = EditorTheme.appearanceSnapshot(from: source) + let font = EditorFont.snapshot(from: source) + let behavior = AppSettings.behaviorSnapshot(from: source) + + return FreeSettingsImport( + themePresetRawValue: theme.preset.rawValue, + customColors: theme.customColors, + backgroundOpacity: Double(theme.backgroundOpacity), + ansiColors: theme.ansiColorsEnabled, + fontName: font.name, + fontSize: Double(font.size), + saveProgressStyleRawValue: behavior.saveProgressStyle.rawValue, + lineWrap: behavior.lineWrap, + filterContextLines: behavior.filterContextLines, + searchFilterOn: behavior.searchFilterOn, + tabWidth: behavior.tabWidth, + lineSpacingRawValue: behavior.lineSpacing.rawValue, + highlightCurrentLine: behavior.highlightCurrentLine, + showLineNumbers: behavior.showLineNumbers, + showInvisibles: behavior.showInvisibles, + cursorShapeRawValue: behavior.cursorShape.rawValue, + autoReloadExternalChanges: behavior.autoReloadExternalChanges) + } + + /// 自分自身(呼び出し側アプリ)のグローバル設定へ適用する。 + /// 既存の setter 経由なので、開いているウィンドウへの通知も既存の仕組みでそのまま飛ぶ。 + public func apply() { + // custom 色を先に入れると preset が一旦 .custom に倒れるため、preset は最後に確定する + // ([[SettingsBundle.apply]] と同じ順序)。 + for key in EditorTheme.ColorKey.allCases { + if let color = customColors[key.rawValue] { + EditorTheme.setCustomColor(key, color) + } + } + EditorTheme.preset = ThemePreset(rawValue: themePresetRawValue) ?? .system + EditorTheme.backgroundOpacity = CGFloat(backgroundOpacity) + EditorTheme.ansiColorsEnabled = ansiColors + EditorFont.setName(fontName) + EditorFont.setSize(CGFloat(fontSize)) + + AppSettings.saveProgressStyle = SaveProgressStyle(rawValue: saveProgressStyleRawValue) ?? .sheet + AppSettings.lineWrap = lineWrap + AppSettings.filterContextLines = filterContextLines + AppSettings.searchFilterOn = searchFilterOn + AppSettings.tabWidth = tabWidth + AppSettings.lineSpacing = LineSpacing(rawValue: lineSpacingRawValue) ?? .standard + AppSettings.highlightCurrentLine = highlightCurrentLine + AppSettings.showLineNumbers = showLineNumbers + AppSettings.showInvisibles = showInvisibles + AppSettings.cursorShape = CursorShape(rawValue: cursorShapeRawValue) ?? .bar + AppSettings.autoReloadExternalChanges = autoReloadExternalChanges + } + + /// 確認シート用の短い説明([[SettingsBundle.summaryLines]] と同じ見せ方)。 + public func summaryLines() -> [String] { + let themeName = L("prefs.theme.\(themePresetRawValue)") + let fontDesc = (fontName ?? L("prefs.font.system")) + " \(Int(fontSize)) pt" + return ["\(L("prefs.theme")): \(themeName)", "\(L("prefs.font")): \(fontDesc)"] + } +} diff --git a/Sources/MrEditorCore/EditorFont.swift b/Sources/MrEditorCore/EditorFont.swift index 7d07e7f..b88c697 100644 --- a/Sources/MrEditorCore/EditorFont.swift +++ b/Sources/MrEditorCore/EditorFont.swift @@ -62,6 +62,14 @@ enum EditorFont { } return names.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending } } + + // MARK: - 他ドメインからの読み取り([[FreeSettingsImport]] 専用) + + /// 任意の UserDefaults ドメイン(他アプリの設定)から読む。書き込みはしない。 + static func snapshot(from source: UserDefaults) -> (name: String?, size: CGFloat) { + let v = source.double(forKey: sizeKey) + return (source.string(forKey: nameKey), v > 0 ? CGFloat(v) : defaultSize) + } } extension Notification.Name { diff --git a/Sources/MrEditorCore/EditorTheme.swift b/Sources/MrEditorCore/EditorTheme.swift index 9c3914a..6e621b6 100644 --- a/Sources/MrEditorCore/EditorTheme.swift +++ b/Sources/MrEditorCore/EditorTheme.swift @@ -68,22 +68,26 @@ enum EditorTheme { // MARK: - プリセット選択 static var preset: ThemePreset { - get { ThemePreset(rawValue: defaults.string(forKey: presetKey) ?? "") ?? .system } + get { preset(from: defaults) } set { defaults.set(newValue.rawValue, forKey: presetKey); postChanged() } } + private static func preset(from source: UserDefaults) -> ThemePreset { + ThemePreset(rawValue: source.string(forKey: presetKey) ?? "") ?? .system + } // MARK: - 背景の不透明度(ウィンドウ全体・iTerm 風) /// 本文+周辺 UI の背景を透かす度合い。1.0=完全不透明(既定・従来と同一)、 /// 0.30 まで下げられる。透明時は窓を非不透明にして背後のデスクトップを見せる。 static var backgroundOpacity: CGFloat { - get { - guard defaults.object(forKey: opacityKey) != nil else { return 1.0 } - let v = CGFloat(defaults.double(forKey: opacityKey)) - return min(1.0, max(0.30, v)) - } + get { backgroundOpacity(from: defaults) } set { defaults.set(Double(min(1.0, max(0.30, newValue))), forKey: opacityKey); postChanged() } } + private static func backgroundOpacity(from source: UserDefaults) -> CGFloat { + guard source.object(forKey: opacityKey) != nil else { return 1.0 } + let v = CGFloat(source.double(forKey: opacityKey)) + return min(1.0, max(0.30, v)) + } /// 背景が完全不透明か(不透明度 1.0)。透明プラミングの有効化判定に使う。 static var isOpaqueBackground: Bool { backgroundOpacity >= 0.999 } @@ -101,9 +105,12 @@ enum EditorTheme { /// ログ中の ANSI SGR エスケープ(`ESC[…m`)を色に変換して表示するか。 /// 既定 ON(生のエスケープ列は可読でないため)。閲覧経路でのみ適用する。 static var ansiColorsEnabled: Bool { - get { defaults.object(forKey: ansiKey) == nil ? true : defaults.bool(forKey: ansiKey) } + get { ansiColorsEnabled(from: defaults) } set { defaults.set(newValue, forKey: ansiKey); postChanged() } } + private static func ansiColorsEnabled(from source: UserDefaults) -> Bool { + source.object(forKey: ansiKey) == nil ? true : source.bool(forKey: ansiKey) + } // MARK: - 現在の配色 @@ -182,8 +189,10 @@ enum EditorTheme { } /// custom の 1 色を読む。未設定なら system プリセットの対応色にフォールバック。 - static func customColor(_ key: ColorKey) -> NSColor { - if let data = defaults.data(forKey: customPrefix + key.rawValue), + static func customColor(_ key: ColorKey) -> NSColor { customColor(key, from: defaults) } + + private static func customColor(_ key: ColorKey, from source: UserDefaults) -> NSColor { + if let data = source.data(forKey: customPrefix + key.rawValue), let color = try? NSKeyedUnarchiver.unarchivedObject(ofClass: NSColor.self, from: data) { return color } @@ -249,4 +258,29 @@ enum EditorTheme { private static func postChanged() { NotificationCenter.default.post(name: .mrEditorDisplayChanged, object: nil) } + + // MARK: - 他ドメインからの読み取り([[FreeSettingsImport]] 専用) + + /// [[FreeSettingsImport]] が運ぶ、外観設定の値だけの束。書き込みはしない。 + struct AppearanceSnapshot { + var preset: ThemePreset + /// `ColorKey.rawValue` → 色。custom プリセットでなくても常に埋めて返す + /// (後から custom に切り替えても迷わないように)。 + var customColors: [String: NSColor] + var backgroundOpacity: CGFloat + var ansiColorsEnabled: Bool + } + + /// 任意の UserDefaults ドメイン(他アプリの設定)から読む。 + static func appearanceSnapshot(from source: UserDefaults) -> AppearanceSnapshot { + var colors: [String: NSColor] = [:] + for key in ColorKey.allCases { + colors[key.rawValue] = customColor(key, from: source) + } + return AppearanceSnapshot( + preset: preset(from: source), + customColors: colors, + backgroundOpacity: backgroundOpacity(from: source), + ansiColorsEnabled: ansiColorsEnabled(from: source)) + } }