Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions .github/workflows/release-on-merge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,14 @@ jobs:
fi

# Версию подняли прямо в PR — уважаем её и не поднимаем второй раз.
if printf '%s\n' "$FILES" | grep -qx 'Sources/ClaudeWeekCore/Version.swift'; then
echo "Version.swift поднят в самом PR — беру его версию как есть"
# Но присутствие Version.swift в списке файлов этого не доказывает: PR
# «Локализация» правил в нём соседние строки, версия осталась
# выпущенной 0.1.10, разряд вышел keep, и релиз упал на занятом теге.
# Признак настоящий один — номер в файле ещё не выпущен тегом.
CURRENT="$(sed -n 's/.*static let version = "\(.*\)".*/\1/p' \
Sources/ClaudeWeekCore/Version.swift)"
if [ -n "$CURRENT" ] && ! git rev-parse -q --verify "refs/tags/v$CURRENT" >/dev/null; then
echo "версия $CURRENT в Version.swift ещё не выпущена — беру её как есть"
KIND=keep
fi

Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,15 @@
обязанность, она не пережила первого же выпуска — записи 0.1.10 уехали в
релиз, так и оставшись под «не выпущено»; заодно эта версия закрыта задним
числом.
- Разряд версии считает до 99 и переносится в старший: 0.1.99 → 0.2.0,
0.99.0 → 1.0.0. Раньше счёт шёл дальше — 0.1.100.

### Исправлено

- Разряд «версию подняли в самом PR» определяется по свободному тегу, а не по
тому, попал ли `Version.swift` в список изменённых файлов. PR с локализацией
правил в этом файле соседние строки, workflow счёл версию поднятой, оставил
выпущенную 0.1.10 и остановился на занятом теге — сборка не вышла.

## [0.1.10] — 2026-08-16

Expand Down
10 changes: 8 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,10 +96,16 @@ ModelsWindowController». Внутренняя правка, о которой
Релиз не выйдет и без метки `без-релиза`, если PR не тронул ничего, кроме
`*.md` и `docs/`: выпускать сборку ради правки текста незачем.

Разряд считает до 99 и переносится в старший: 0.1.99 → 0.2.0, 0.99.0 → 1.0.0.
Двузначные числа законны — 0.1.10 идёт после 0.1.9, а не наоборот.

Версию в `Sources/ClaudeWeekCore/Version.swift` руками поднимать не надо —
подняли в самом PR, workflow это увидит и выпустит ровно ту цифру, что в файле,
а не следующую. После слияния сделайте `git pull`: в `main` уехал коммит бота —
с новой версией и закрытым разделом журнала.
а не следующую. Видит он это по тегу: номер в файле ещё не выпущен — значит его
и берём. Правка соседних строк того же файла на разряд не влияет — раньше
влияла, и релиз локализации встал на уже занятом теге. После слияния сделайте
`git pull`: в `main` уехал коммит бота — с новой версией и закрытым разделом
журнала.

Посчитать версию локально — тем же скриптом, которым это делает workflow:

Expand Down
10 changes: 10 additions & 0 deletions scripts/bump-version.sh
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
# ./scripts/bump-version.sh keep версию не трогать, только показать
# ./scripts/bump-version.sh patch --dry-run посчитать, ничего не записывая
#
# Разряд считает до 99 и переносится в старший: 0.1.99 → 0.2.0, 0.99.0 → 1.0.0,
# 0.99.99 → 1.0.0. Двузначные числа при этом законны — 0.1.10 идёт после 0.1.9,
# и в приложении версии сравниваются числами, а не строками (см. Updater.swift).
#
# Единственный источник версии — Version.swift: оттуда её берёт make-app.sh для
# Info.plist, оттуда же release.yml сверяет тег. Поэтому скрипт правит один
# файл и больше ничего: не коммитит, не тегирует, не пушит — это делает тот,
Expand Down Expand Up @@ -51,6 +55,12 @@ case "$KIND" in
patch) PATCH=$((PATCH + 1)) ;;
esac

# Больше 99 в разряде не бывает: переполнение уходит в старший, как в счётчике.
# Патч, добравшийся до сотни на 0.99.99, поднимает и минор, и мажор — отсюда
# две проверки подряд, а не одна.
if [ "$PATCH" -gt 99 ]; then PATCH=0; MINOR=$((MINOR + 1)); fi
if [ "$MINOR" -gt 99 ]; then MINOR=0; MAJOR=$((MAJOR + 1)); fi

VERSION="$MAJOR.$MINOR.$PATCH"
echo "версия: $CURRENT → $VERSION ($KIND)" >&2

Expand Down
139 changes: 136 additions & 3 deletions scripts/probe-panel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@
// `collectionBehavior` у `MenuPanel`: проверять такое чтением кода
// бесполезно, см. docs/SPACES.md.
//
// swift scripts/probe-panel.swift --displays
// То же самое для двух мониторов: по полоске на каждый, поведение окна —
// как у панели. Отвечает на вопросы, от которых зависит весь показ на
// втором мониторе: что говорит `isOnActiveSpace` про окно на соседнем
// мониторе, когда столы переключают на этом, и остаётся ли окно на своём
// мониторе при показе. Заодно печатает, где лежит окно значка
// ClaudeWeek — панель считает место от него.
//
// В обоих режимах пройдитесь по рабочим столам — свайпом или ⌃→. Разбор
// собранного и что с ним делать: docs/SPACES.md.
//
Expand Down Expand Up @@ -121,8 +129,11 @@ func watch() -> Never {

// MARK: - Режим --behaviors

func makeProbePanel(tag: String, behavior: NSWindow.CollectionBehavior, y: CGFloat) -> NSPanel {
let screen = NSScreen.main!.frame
func makeProbePanel(
tag: String, behavior: NSWindow.CollectionBehavior, y: CGFloat,
on display: NSScreen = NSScreen.main ?? NSScreen.screens[0]
) -> NSPanel {
let screen = display.frame
let panel = NSPanel(
contentRect: NSRect(x: screen.midX - 200, y: y, width: 400, height: 40),
styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false
Expand Down Expand Up @@ -190,12 +201,134 @@ func compareBehaviors() -> Never {
exit(0)
}

// MARK: - Режим --displays

/// Слой окна пункта строки меню: `NSWindow.Level.statusBar`. По нему в списке
/// окон находится значок ClaudeWeek — панель считает место от его рамки, и на
/// двух мониторах важно, где эта рамка лежит в момент щелчка.
let statusLayer = 25

/// Номер монитора под точкой (координаты AppKit, снизу вверх).
func display(at point: NSPoint) -> String {
guard let index = NSScreen.screens.firstIndex(where: { $0.frame.contains(point) })
else { return "—" }
return "\(index + 1)"
}

/// Номер монитора под окном из списка окон. Список отдаёт координаты сверху
/// вниз от главного экрана, NSScreen — снизу вверх; без приведения к одному
/// виду монитор определяется неверно ровно на втором экране.
func display(ofListed rect: CGRect) -> String {
let top = NSScreen.screens[0].frame.maxY
return display(at: NSPoint(x: rect.midX, y: top - rect.midY))
}

/// Монитор, который окно считает своим (`NSWindow.screen` — тот, где лежит
/// большая часть окна). «нет» у снятого с экрана окна.
func display(ofWindow window: NSWindow) -> String {
guard let screen = window.screen,
let index = NSScreen.screens.firstIndex(of: screen)
else { return "нет" }
return "\(index + 1)"
}

func claudeWeekPID() -> pid_t? {
NSWorkspace.shared.runningApplications.first {
$0.localizedName == "ClaudeWeek" || $0.executableURL?.lastPathComponent == "ClaudeWeek"
}?.processIdentifier
}

/// Рамка окна значка — того самого `anchor`, от которого считается панель.
func statusItemFrame(pid: pid_t) -> CGRect? {
guard let list = CGWindowListCopyWindowInfo(.optionAll, kCGNullWindowID) as? [[String: Any]],
let item = list.first(where: {
($0[kCGWindowOwnerPID as String] as? pid_t) == pid
&& ($0[kCGWindowLayer as String] as? Int) == statusLayer
}),
let bounds = item[kCGWindowBounds as String] as? [String: CGFloat]
else { return nil }
return CGRect(x: bounds["X"] ?? 0, y: bounds["Y"] ?? 0,
width: bounds["Width"] ?? 0, height: bounds["Height"] ?? 0)
}

func compareDisplays() -> Never {
let screens = NSScreen.screens
guard screens.count > 1 else {
say("подключён один монитор — сравнивать нечего. Подключите второй и повторите")
exit(1)
}

// По полоске на каждый монитор, поведение окна — как у панели.
let strips = screens.enumerated().map { index, screen in
makeProbePanel(
tag: "D\(index + 1) монитор \(index + 1) — поведение панели",
behavior: [.moveToActiveSpace, .fullScreenAuxiliary, .stationary, .ignoresCycle],
y: screen.frame.maxY - 140,
on: screen
)
}
let pid = claudeWeekPID()
if pid == nil { say("ClaudeWeek не запущен — про значок строки меню данных не будет") }

func status() -> String {
var parts = ["активный стол:\(activeSpace())"]
if let pid, let rect = statusItemFrame(pid: pid) {
parts.append(String(format: "значок: монитор %@ x%.0f", display(ofListed: rect), rect.midX))
}
for (index, strip) in strips.enumerated() {
parts.append("D\(index + 1): монитор=\(display(ofWindow: strip))"
+ " на своём столе=\(strip.isOnActiveSpace ? "да " : "НЕТ")"
+ " столы=\(spaces(ofWindow: strip.windowNumber))")
}
return parts.joined(separator: " | ")
}

say("по полоске на каждом мониторе, 70 секунд. Переключайте столы на обоих"
+ " мониторах и щёлкайте по значку — на том мониторе и на соседнем")

NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { _ in
let point = NSEvent.mouseLocation
say(String(format: "%@ клик x%.0f y%.0f монитор %@ | %@",
stamp(), point.x, point.y, display(at: point), status()))
// Значок переезжает вслед за строкой меню, и вопрос в том, успевает ли
// он к обработке щелчка: панель считает место от его рамки.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
say("\(stamp()) через 0.3 с | \(status())")
}
}

var previous = ""
var ticks = 0
let timer = Timer(timeInterval: 0.5, repeats: true) { _ in
ticks += 1
let line = status()
if line != previous {
previous = line
say("\(stamp()) \(line)")
}
// Раз в четыре секунды показываем заново — так показывается панель.
// Здесь и видно, остаётся окно на своём мониторе или уезжает.
if ticks.isMultiple(of: 8) {
for strip in strips {
strip.orderOut(nil)
strip.orderFrontRegardless()
}
}
if ticks >= 140 { exit(0) }
}
RunLoop.main.add(timer, forMode: .common)
NSApplication.shared.setActivationPolicy(.accessory)
NSApplication.shared.run()
exit(0)
}

// MARK: - Запуск

switch CommandLine.arguments.dropFirst().first {
case "--watch", nil: watch()
case "--behaviors": compareBehaviors()
case "--displays": compareDisplays()
case let other?:
say("не знаю такого режима: \(other). Есть --watch и --behaviors")
say("не знаю такого режима: \(other). Есть --watch, --behaviors и --displays")
exit(2)
}