Skip to content

Commit c5dfab7

Browse files
Release v1.0.5: crash on widget delete, window leaks, color and palette fixes
- Fix crash (EXC_BAD_ACCESS) when deleting/hiding/reloading a widget: isReleasedWhenClosed = false (close() was double-releasing under ARC) - Fix widget window/terminal leak: close() instead of orderOut, deferred past the context-menu action dispatch - Fix MilkDrop visualizer background thread: timer now stops correctly (CFRunLoopWakeUp + join on its own run loop), no more leaked 24fps threads - Fix wallpaper palette inconsistency across displays: whole color pipeline is now sRGB-tagged (no more oversaturation on P3 displays), deterministic palette ordering, per-screen wallpaper URL resolution - MilkDrop: smooth palette/color cross-fade on wallpaper change - Fix widget position drifting on restart (grid snap applied to restored frame) - Fix snap guides stuck on screen after mouse-up (final snap re-showed them) - Fix "show on all Spaces" not applying until restart (collectionBehavior) - Apply interface language before config load (localized default names) - Config.save: atomic write, log errors instead of silently swallowing - Remove Telegram item from the status menu Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
1 parent 1def9d5 commit c5dfab7

9 files changed

Lines changed: 190 additions & 57 deletions

File tree

MilkDropResources/milkdrop.html

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@
5656
// --- Rainbow state ---
5757
// Палитра обоев [[r,g,b], ...]. Пусто — режим выключен.
5858
let palette = [];
59+
// Отображаемая палитра: каждый кадр плавно тянется к `palette`, чтобы
60+
// смена обоев не переключала цвета резко (как cross-fade фона виджета).
61+
let displayedPalette = [];
5962
let rainbowEnabled = false;
6063
let rainbowWaveIndex = 0;
6164
// Beat-детектор низа (кик).
@@ -79,12 +82,14 @@
7982
let glowPulse = 0;
8083

8184
// Цвет палитры в непрерывной позиции t (циклично, с интерполяцией).
85+
// Берём отображаемую палитру, чтобы переход между палитрами был плавным.
8286
function samplePalette(t) {
83-
const N = palette.length;
87+
const src = displayedPalette.length > 0 ? displayedPalette : palette;
88+
const N = src.length;
8489
const f = (((t % 1) + 1) % 1) * N;
8590
const i = Math.floor(f);
8691
const frac = f - i;
87-
const a = palette[i % N], b = palette[(i + 1) % N];
92+
const a = src[i % N], b = src[(i + 1) % N];
8893
return [a[0] + (b[0] - a[0]) * frac,
8994
a[1] + (b[1] - a[1]) * frac,
9095
a[2] + (b[2] - a[2]) * frac];
@@ -127,7 +132,10 @@
127132
function advanceRainbow() {
128133
if (palette.length === 0) return;
129134
rainbowWaveIndex = (rainbowWaveIndex + 1) % palette.length;
130-
targetColor = palette[rainbowWaveIndex].slice();
135+
// Цель берём из отображаемой палитры: при смене обоев волна плавно
136+
// перетечёт в новые оттенки, а не прыгнет в ещё не переехавший цвет.
137+
const src = (displayedPalette.length === palette.length) ? displayedPalette : palette;
138+
targetColor = src[rainbowWaveIndex].slice();
131139
}
132140

133141

@@ -442,6 +450,25 @@
442450
glowColor[2] = waveColor[2];
443451
}
444452

453+
// Плавный переход палитры к новой (смена обоев): тянем каждый канал
454+
// каждого цвета. Темп 0.05/кадр ≈ то же ~1 с переливание, что и у фона.
455+
if (palette.length > 0) {
456+
if (displayedPalette.length !== palette.length) {
457+
// Стартуем от текущего цвета волны — переход без вспышки.
458+
displayedPalette = palette.map(() => waveColor.slice());
459+
}
460+
for (let i = 0; i < palette.length; i++) {
461+
for (let ch = 0; ch < 3; ch++) {
462+
const d = palette[i][ch] - displayedPalette[i][ch];
463+
displayedPalette[i][ch] = Math.abs(d) < 0.5
464+
? palette[i][ch]
465+
: displayedPalette[i][ch] + d * 0.05;
466+
}
467+
}
468+
} else if (displayedPalette.length > 0) {
469+
displayedPalette = [];
470+
}
471+
445472
// «Панч»: масштабируем всю картинку от центра на время удара.
446473
const scaled = punch > 0.0005;
447474
if (scaled) {
@@ -497,11 +524,15 @@
497524
? colors.filter(c => Array.isArray(c) && c.length === 3)
498525
.map(c => vivify([c[0] | 0, c[1] | 0, c[2] | 0]))
499526
: [];
527+
const wasEnabled = rainbowEnabled;
500528
rainbowEnabled = !!enabled && palette.length > 0;
501-
if (rainbowEnabled) {
502-
// Стартуем с первого цвета палитры.
529+
if (rainbowEnabled && !wasEnabled) {
530+
// Сбрасываем позицию ТОЛЬКО при включении режима. При обычной смене
531+
// обоев позицию по битам не трогаем — цвета сами плавно перетекут
532+
// через displayedPalette и интерполяцию волны.
503533
rainbowWaveIndex = 0;
504-
targetColor = palette[0].slice();
534+
const src = (displayedPalette.length === palette.length) ? displayedPalette : palette;
535+
targetColor = src[0].slice();
505536
beatCooldown = 0;
506537
idleFrames = 0;
507538
}

Sources/TermJ/AppDelegate.swift

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
5252
var editorCleanModeCheckbox: NSButton?
5353

5454
func applicationDidFinishLaunching(_ notification: Notification) {
55+
// Язык применяем ДО загрузки конфига: локализованные дефолтные
56+
// имена виджетов вычисляются в момент декода/создания конфига.
57+
if let lang = AppConfig.peekLanguage() {
58+
TermJPreferredLanguage = lang == "auto" ? nil : lang
59+
}
5560
appConfig = AppConfig.load()
5661
common = appConfig.common
5762

@@ -176,7 +181,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
176181
if let term = inst.contentView as? TermWidgetView {
177182
term.terminateProcessGroup()
178183
}
179-
inst.window.orderOut(nil)
184+
// Прячем сразу, а close() откладываем на следующий цикл runloop:
185+
// удаление приходит из контекстного меню окна, и AppKit ещё
186+
// работает с этим меню после возврата из action — немедленный
187+
// close() освобождал окно и меню прямо во время диспетча → краш.
188+
// При этом окно обязательно закрываем (не только orderOut), иначе
189+
// оно вместе с наблюдателями/терминалом живёт до конца сессии.
190+
let closingWindow = inst.window
191+
closingWindow.orderOut(nil)
192+
DispatchQueue.main.async { closingWindow.close() }
180193
appConfig.widgets.removeAll { $0.id == id }
181194
appConfig.save()
182195
updateOtherWidgets()
@@ -536,7 +549,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
536549
if let term = inst.contentView as? TermWidgetView {
537550
term.terminateProcessGroup()
538551
}
539-
inst.window.orderOut(nil)
552+
// close() обязательно (иначе окно утекает вместе с
553+
// наблюдателями), но отложенно — action может прийти из
554+
// контекстного меню этого же окна (см. destroyWidget).
555+
let closingWindow = inst.window
556+
closingWindow.orderOut(nil)
557+
DispatchQueue.main.async { closingWindow.close() }
540558
widgets.removeValue(forKey: id)
541559
updateOtherWidgets()
542560
}
@@ -817,7 +835,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
817835
term.terminateProcessGroup()
818836
}
819837
saveWidgetConfig(id: id, from: inst.window)
820-
inst.window.orderOut(nil)
838+
// close() обязательно, но отложенно — action может прийти из
839+
// контекстного меню этого же окна (см. destroyWidget).
840+
let closingWindow = inst.window
841+
closingWindow.orderOut(nil)
842+
DispatchQueue.main.async { closingWindow.close() }
821843
widgets.removeValue(forKey: id)
822844
}
823845

Sources/TermJ/Audio/AudioCaptureManager.swift

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ final class AudioCaptureManager: NSObject, SCStreamOutput, SCStreamDelegate {
9696
// MARK: - Таймер отображения
9797

9898
private func startDisplayTimerIfNeeded() {
99-
guard backgroundTimer == nil else { return }
99+
guard backgroundTimerThread == nil else { return }
100100
startBackgroundTimer()
101101
// Fallback-таймер на main нужен только на случай, если background thread
102102
// по какой-то причине остановится.
@@ -114,7 +114,11 @@ final class AudioCaptureManager: NSObject, SCStreamOutput, SCStreamDelegate {
114114
/// Запускает основной рендеринг на background RunLoop.
115115
/// NSMenu в статус-баре блокирует main RunLoop, но не этот поток.
116116
private func startBackgroundTimer() {
117-
guard backgroundTimer == nil else { return }
117+
// Проверяем именно поток, а не таймер: таймер присваивается
118+
// асинхронно на фоновом потоке, и проверка `backgroundTimer == nil`
119+
// давала гонку при быстром вкл/выкл виджета.
120+
guard backgroundTimerThread == nil else { return }
121+
shouldStopBackgroundTimer = false
118122

119123
let thread = Thread { [weak self] in
120124
guard let self = self else { return }
@@ -128,8 +132,11 @@ final class AudioCaptureManager: NSObject, SCStreamOutput, SCStreamDelegate {
128132
while !self.shouldStopBackgroundTimer {
129133
runLoop.run(mode: .default, before: Date(timeIntervalSinceNow: 0.1))
130134
}
131-
self.backgroundTimer?.invalidate()
135+
// Инвалидируем таймер на его собственном потоке — invalidate()
136+
// с другого потока для RunLoop-таймера недопустим.
137+
timer.invalidate()
132138
self.backgroundTimer = nil
139+
self.backgroundRunLoop = nil
133140
}
134141
thread.name = "termj.visualizer.renderer"
135142
thread.start()
@@ -139,11 +146,21 @@ final class AudioCaptureManager: NSObject, SCStreamOutput, SCStreamDelegate {
139146
private var shouldStopBackgroundTimer = false
140147

141148
private func stopBackgroundTimer() {
149+
guard let thread = backgroundTimerThread else { return }
142150
shouldStopBackgroundTimer = true
143-
backgroundRunLoop = nil
144-
backgroundTimer?.invalidate()
145-
backgroundTimer = nil
151+
// Будим RunLoop, чтобы цикл сразу увидел флаг и вышел.
152+
if let runLoop = backgroundRunLoop {
153+
CFRunLoopWakeUp(runLoop.getCFRunLoop())
154+
}
155+
// Ждём завершения потока: он сам инвалидирует таймер на своём RunLoop.
156+
var waited: TimeInterval = 0
157+
while !thread.isFinished, waited < 1.0 {
158+
Thread.sleep(forTimeInterval: 0.01)
159+
waited += 0.01
160+
}
146161
backgroundTimerThread = nil
162+
backgroundTimer = nil
163+
backgroundRunLoop = nil
147164
shouldStopBackgroundTimer = false
148165
}
149166

Sources/TermJ/Config.swift

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,19 @@ struct AppConfig: Codable {
319319
return dir.appendingPathComponent("config.json")
320320
}
321321

322+
/// Читает только `common.language` из файла конфига, не декодируя всё.
323+
/// Нужно ДО полной загрузки: локализованные дефолты (имена виджетов)
324+
/// вычисляются в момент декода и должны подхватить выбранный язык.
325+
static func peekLanguage() -> String? {
326+
struct LangPeek: Decodable {
327+
struct Common: Decodable { let language: String? }
328+
let common: Common?
329+
}
330+
guard let data = try? Data(contentsOf: fileURL),
331+
let peek = try? JSONDecoder().decode(LangPeek.self, from: data) else { return nil }
332+
return peek.common?.language
333+
}
334+
322335
/// Загружает конфиг с диска, при отсутствии — создаёт дефолтный.
323336
static func load() -> AppConfig {
324337
let url = fileURL
@@ -344,8 +357,13 @@ struct AppConfig: Codable {
344357
func save() {
345358
let enc = JSONEncoder()
346359
enc.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
347-
if let data = try? enc.encode(self) {
348-
try? data.write(to: AppConfig.fileURL)
360+
do {
361+
let data = try enc.encode(self)
362+
try data.write(to: AppConfig.fileURL, options: .atomic)
363+
} catch {
364+
// Не глотаем ошибку молча: иначе пользователь теряет layout
365+
// при следующем запуске без какой-либо диагностики.
366+
print("[TermJ] Failed to save config to \(AppConfig.fileURL.path): \(error)")
349367
}
350368
}
351369
}

Sources/TermJ/StatusMenu.swift

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -213,12 +213,6 @@ extension AppDelegate {
213213
githubItem.image = githubMenuIcon()
214214
menu.addItem(githubItem)
215215

216-
let supportItem = NSMenuItem(title: L("menu.support"),
217-
action: #selector(openSupport), keyEquivalent: "")
218-
supportItem.target = self
219-
supportItem.image = NSImage(systemSymbolName: "paperplane.fill", accessibilityDescription: nil)
220-
menu.addItem(supportItem)
221-
222216
menu.addItem(NSMenuItem.separator())
223217

224218
let quitItem = NSMenuItem(title: L("menu.quit"),

Sources/TermJ/TermWidgetView.swift

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -280,11 +280,12 @@ final class TermWidgetView: LocalProcessTerminalView, LocalProcessTerminalViewDe
280280
}
281281

282282
private func interpolateColor(_ a: NSColor, _ b: NSColor, _ t: CGFloat) -> NSColor {
283-
let a1 = a.usingColorSpace(.deviceRGB)
284-
let b1 = b.usingColorSpace(.deviceRGB)
283+
let a1 = a.usingColorSpace(.sRGB)
284+
let b1 = b.usingColorSpace(.sRGB)
285285
if let a1, let b1 {
286+
// sRGB, чтобы промежуточные кадры анимации не перенасыщались на P3.
286287
return NSColor(
287-
red: a1.redComponent + (b1.redComponent - a1.redComponent) * t,
288+
srgbRed: a1.redComponent + (b1.redComponent - a1.redComponent) * t,
288289
green: a1.greenComponent + (b1.greenComponent - a1.greenComponent) * t,
289290
blue: a1.blueComponent + (b1.blueComponent - a1.blueComponent) * t,
290291
alpha: a1.alphaComponent + (b1.alphaComponent - a1.alphaComponent) * t

0 commit comments

Comments
 (0)