docs: update README with detailed desktop features and setup requirements
This commit is contained in:
+597
@@ -0,0 +1,597 @@
|
||||
//go:build gtk
|
||||
|
||||
// Package gui implements Captioneer's GTK4 desktop application.
|
||||
package gui
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/diamondburned/gotk4/pkg/gdk/v4"
|
||||
"github.com/diamondburned/gotk4/pkg/gio/v2"
|
||||
"github.com/diamondburned/gotk4/pkg/glib/v2"
|
||||
"github.com/diamondburned/gotk4/pkg/gtk/v4"
|
||||
"tea.chunkbyte.com/kato/captioneer/src/app"
|
||||
"tea.chunkbyte.com/kato/captioneer/src/config"
|
||||
"tea.chunkbyte.com/kato/captioneer/src/diagnostics"
|
||||
"tea.chunkbyte.com/kato/captioneer/src/output"
|
||||
"tea.chunkbyte.com/kato/captioneer/src/output/history"
|
||||
"tea.chunkbyte.com/kato/captioneer/src/output/overlay"
|
||||
"tea.chunkbyte.com/kato/captioneer/src/output/terminal"
|
||||
"tea.chunkbyte.com/kato/captioneer/src/output/transcript"
|
||||
)
|
||||
|
||||
const (
|
||||
applicationID = "com.chunkbyte.captioneer"
|
||||
historyCapacity = 500
|
||||
consoleCapacity = 2000
|
||||
)
|
||||
|
||||
//go:embed style.css
|
||||
var applicationCSS string
|
||||
|
||||
type Application struct {
|
||||
launch config.LaunchConfig
|
||||
config config.AppConfig
|
||||
applied config.AppConfig
|
||||
stdout io.Writer
|
||||
stderr io.Writer
|
||||
|
||||
gtkApp *gtk.Application
|
||||
window *gtk.ApplicationWindow
|
||||
stack *gtk.Stack
|
||||
|
||||
router *output.Router
|
||||
history *history.Sink
|
||||
log *diagnostics.Log
|
||||
controller *app.Controller
|
||||
overlay *overlay.Sink
|
||||
transcript *transcript.Sink
|
||||
|
||||
statusLabel *gtk.Label
|
||||
statusDot *gtk.Label
|
||||
startStopButton *gtk.Button
|
||||
statusbarLabel *gtk.Label
|
||||
captionBuffer *gtk.TextBuffer
|
||||
captionView *gtk.TextView
|
||||
captionScroll *gtk.ScrolledWindow
|
||||
provisionalLabel *gtk.Label
|
||||
consoleBuffer *gtk.TextBuffer
|
||||
consoleView *gtk.TextView
|
||||
consoleScroll *gtk.ScrolledWindow
|
||||
settings settingsControls
|
||||
settingsError *gtk.Label
|
||||
applyButton *gtk.Button
|
||||
lastShownError string
|
||||
|
||||
statusUpdates <-chan app.Status
|
||||
stopStatus func()
|
||||
historyUpdates <-chan history.Snapshot
|
||||
stopHistory func()
|
||||
logUpdates <-chan diagnostics.Entry
|
||||
stopLog func()
|
||||
uiDone chan struct{}
|
||||
|
||||
closingMu sync.Mutex
|
||||
closing bool
|
||||
}
|
||||
|
||||
func Run(launch config.LaunchConfig, stdout, stderr io.Writer) int {
|
||||
application := newApplication(launch, stdout, stderr)
|
||||
application.gtkApp.ConnectActivate(application.activate)
|
||||
signals := make(chan os.Signal, 1)
|
||||
signal.Notify(signals, os.Interrupt, syscall.SIGTERM)
|
||||
defer signal.Stop(signals)
|
||||
go func() {
|
||||
select {
|
||||
case <-signals:
|
||||
glib.IdleAdd(application.shutdown)
|
||||
case <-application.uiDone:
|
||||
}
|
||||
}()
|
||||
return application.gtkApp.Run([]string{os.Args[0]})
|
||||
}
|
||||
|
||||
func newApplication(launch config.LaunchConfig, stdout, stderr io.Writer) *Application {
|
||||
mirror := io.Discard
|
||||
if launch.Config.Outputs.StderrDiagnostics {
|
||||
mirror = stderr
|
||||
}
|
||||
log := diagnostics.New(consoleCapacity, mirror)
|
||||
router := output.NewRouter()
|
||||
historySink := history.New(historyCapacity, launch.Config.Outputs.History)
|
||||
_ = router.Set("history", historySink)
|
||||
controller := app.NewController(router, log.Writer(diagnostics.Info, "engine"))
|
||||
return &Application{
|
||||
launch: launch,
|
||||
config: launch.Config,
|
||||
applied: launch.Config,
|
||||
stdout: stdout,
|
||||
stderr: stderr,
|
||||
gtkApp: gtk.NewApplication(applicationID, gio.ApplicationFlagsNone),
|
||||
router: router,
|
||||
history: historySink,
|
||||
log: log,
|
||||
controller: controller,
|
||||
uiDone: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Application) activate() {
|
||||
if a.window != nil {
|
||||
a.window.Present()
|
||||
return
|
||||
}
|
||||
a.installStyle()
|
||||
a.buildWindow()
|
||||
a.installActions()
|
||||
a.subscribeModels()
|
||||
if a.launch.Warning != nil {
|
||||
a.log.Add(diagnostics.Warning, "configuration", fmt.Sprintf("%v; using built-in defaults", a.launch.Warning))
|
||||
}
|
||||
if err := a.configureOutputs(a.config, true); err != nil {
|
||||
a.log.Add(diagnostics.Error, "outputs", err.Error())
|
||||
}
|
||||
a.window.Present()
|
||||
a.applyAlwaysOnTop(a.config.GUI.MainWindowAlwaysOnTop)
|
||||
if a.config.GUI.StartCaptions {
|
||||
a.startCaptions()
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Application) installStyle() {
|
||||
provider := gtk.NewCSSProvider()
|
||||
provider.LoadFromString(applicationCSS)
|
||||
gtk.StyleContextAddProviderForDisplay(
|
||||
gdk.DisplayGetDefault(),
|
||||
provider,
|
||||
gtk.STYLE_PROVIDER_PRIORITY_APPLICATION,
|
||||
)
|
||||
}
|
||||
|
||||
func (a *Application) buildWindow() {
|
||||
a.window = gtk.NewApplicationWindow(a.gtkApp)
|
||||
a.window.SetTitle("Captioneer")
|
||||
a.window.SetDefaultSize(980, 680)
|
||||
a.window.SetSizeRequest(720, 480)
|
||||
a.window.AddCSSClass("captioneer-main")
|
||||
a.window.ConnectCloseRequest(func() bool {
|
||||
a.shutdown()
|
||||
return true
|
||||
})
|
||||
|
||||
header := gtk.NewHeaderBar()
|
||||
header.AddCSSClass("captioneer-header")
|
||||
header.SetShowTitleButtons(true)
|
||||
|
||||
brand := gtk.NewBox(gtk.OrientationHorizontal, 7)
|
||||
if iconPath := findIconPath(); iconPath != "" {
|
||||
icon := gtk.NewImageFromFile(iconPath)
|
||||
icon.SetPixelSize(27)
|
||||
brand.Append(icon)
|
||||
}
|
||||
title := gtk.NewLabel("CAPTIONEER")
|
||||
title.AddCSSClass("captioneer-brand")
|
||||
brand.Append(title)
|
||||
header.PackStart(brand)
|
||||
|
||||
statusBox := gtk.NewBox(gtk.OrientationHorizontal, 6)
|
||||
statusBox.SetHAlign(gtk.AlignCenter)
|
||||
a.statusDot = gtk.NewLabel("●")
|
||||
a.statusDot.AddCSSClass("status-dot")
|
||||
a.statusLabel = gtk.NewLabel("STOPPED · VAD")
|
||||
a.statusLabel.AddCSSClass("status-text")
|
||||
statusBox.Append(a.statusDot)
|
||||
statusBox.Append(a.statusLabel)
|
||||
header.SetTitleWidget(statusBox)
|
||||
|
||||
a.startStopButton = gtk.NewButtonWithLabel("START")
|
||||
a.startStopButton.AddCSSClass("primary-action")
|
||||
a.startStopButton.SetTooltipText("Start or stop caption processing")
|
||||
a.startStopButton.ConnectClicked(a.toggleCaptions)
|
||||
header.PackEnd(a.startStopButton)
|
||||
a.window.SetTitlebar(header)
|
||||
|
||||
root := gtk.NewBox(gtk.OrientationVertical, 0)
|
||||
root.Append(a.buildMenuBar())
|
||||
a.stack = gtk.NewStack()
|
||||
a.stack.SetTransitionType(gtk.StackTransitionTypeNone)
|
||||
a.stack.SetHExpand(true)
|
||||
a.stack.SetVExpand(true)
|
||||
|
||||
tabs := gtk.NewStackSwitcher()
|
||||
tabs.SetStack(a.stack)
|
||||
tabs.AddCSSClass("captioneer-tabs")
|
||||
tabs.SetHAlign(gtk.AlignFill)
|
||||
root.Append(tabs)
|
||||
|
||||
a.stack.AddTitled(a.buildCaptionsPage(), "captions", "CAPTIONS")
|
||||
a.stack.AddTitled(a.buildConsolePage(), "console", "CONSOLE")
|
||||
a.stack.AddTitled(a.buildSettingsPage(), "settings", "SETTINGS")
|
||||
root.Append(a.stack)
|
||||
|
||||
statusbar := gtk.NewBox(gtk.OrientationHorizontal, 8)
|
||||
statusbar.AddCSSClass("statusbar")
|
||||
a.statusbarLabel = gtk.NewLabel("Source: not connected · Speech: quiet · Outputs: preparing")
|
||||
a.statusbarLabel.SetXAlign(0)
|
||||
a.statusbarLabel.SetEllipsize(3)
|
||||
statusbar.Append(a.statusbarLabel)
|
||||
root.Append(statusbar)
|
||||
a.window.SetChild(root)
|
||||
}
|
||||
|
||||
func (a *Application) buildMenuBar() *gtk.PopoverMenuBar {
|
||||
root := gio.NewMenu()
|
||||
file := gio.NewMenu()
|
||||
file.Append("Start Captions", "app.start")
|
||||
file.Append("Stop Captions", "app.stop")
|
||||
file.Append("Restart Captions", "app.restart")
|
||||
file.Append("Clear Caption History", "app.clear-history")
|
||||
file.Append("Exit", "app.exit")
|
||||
root.AppendSubmenu("File", file)
|
||||
|
||||
view := gio.NewMenu()
|
||||
view.Append("Captions", "app.view-captions")
|
||||
view.Append("Console", "app.view-console")
|
||||
view.Append("Settings", "app.view-settings")
|
||||
root.AppendSubmenu("View", view)
|
||||
|
||||
help := gio.NewMenu()
|
||||
help.Append("Copy Diagnostics", "app.copy-diagnostics")
|
||||
help.Append("Open Configuration Folder", "app.open-config-folder")
|
||||
help.Append("About Captioneer", "app.about")
|
||||
root.AppendSubmenu("Help", help)
|
||||
bar := gtk.NewPopoverMenuBarFromModel(root)
|
||||
bar.AddCSSClass("captioneer-menu")
|
||||
return bar
|
||||
}
|
||||
|
||||
func (a *Application) installActions() {
|
||||
a.addAction("start", a.startCaptions)
|
||||
a.addAction("stop", a.stopCaptions)
|
||||
a.addAction("restart", a.restartCaptions)
|
||||
a.addAction("clear-history", a.history.Clear)
|
||||
a.addAction("exit", a.shutdown)
|
||||
a.addAction("view-captions", func() { a.stack.SetVisibleChildName("captions") })
|
||||
a.addAction("view-console", func() { a.stack.SetVisibleChildName("console") })
|
||||
a.addAction("view-settings", func() { a.stack.SetVisibleChildName("settings") })
|
||||
a.addAction("copy-diagnostics", a.copyDiagnostics)
|
||||
a.addAction("open-config-folder", a.openConfigFolder)
|
||||
a.addAction("about", a.showAbout)
|
||||
a.gtkApp.SetAccelsForAction("app.start", []string{"F5"})
|
||||
a.gtkApp.SetAccelsForAction("app.stop", []string{"<Shift>F5"})
|
||||
a.gtkApp.SetAccelsForAction("app.restart", []string{"<Primary>r"})
|
||||
a.gtkApp.SetAccelsForAction("app.view-captions", []string{"<Alt>1"})
|
||||
a.gtkApp.SetAccelsForAction("app.view-console", []string{"<Alt>2"})
|
||||
a.gtkApp.SetAccelsForAction("app.view-settings", []string{"<Alt>3"})
|
||||
a.gtkApp.SetAccelsForAction("app.exit", []string{"<Primary>q"})
|
||||
}
|
||||
|
||||
func (a *Application) addAction(name string, callback func()) {
|
||||
action := gio.NewSimpleAction(name, nil)
|
||||
action.ConnectActivate(func(*glib.Variant) { callback() })
|
||||
a.gtkApp.AddAction(action)
|
||||
}
|
||||
|
||||
func (a *Application) subscribeModels() {
|
||||
a.statusUpdates, a.stopStatus = a.controller.Subscribe(32)
|
||||
a.historyUpdates, a.stopHistory = a.history.Subscribe(8)
|
||||
a.logUpdates, a.stopLog = a.log.Subscribe(128)
|
||||
go a.forwardStatus()
|
||||
go a.forwardHistory()
|
||||
go a.forwardLog()
|
||||
}
|
||||
|
||||
func (a *Application) forwardStatus() {
|
||||
for {
|
||||
select {
|
||||
case <-a.uiDone:
|
||||
return
|
||||
case status, ok := <-a.statusUpdates:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
copy := status
|
||||
glib.IdleAdd(func() { a.renderStatus(copy) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Application) forwardHistory() {
|
||||
for {
|
||||
select {
|
||||
case <-a.uiDone:
|
||||
return
|
||||
case snapshot, ok := <-a.historyUpdates:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
copy := snapshot
|
||||
glib.IdleAdd(func() { a.renderHistory(copy) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Application) forwardLog() {
|
||||
for {
|
||||
select {
|
||||
case <-a.uiDone:
|
||||
return
|
||||
case _, ok := <-a.logUpdates:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
glib.IdleAdd(a.renderConsole)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Application) toggleCaptions() {
|
||||
status := a.controller.Status()
|
||||
switch status.State {
|
||||
case app.StateStopped, app.StateError:
|
||||
a.startCaptions()
|
||||
default:
|
||||
a.stopCaptions()
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Application) startCaptions() {
|
||||
if err := a.controller.Start(a.applied.Settings); err != nil {
|
||||
a.log.Add(diagnostics.Error, "lifecycle", err.Error())
|
||||
return
|
||||
}
|
||||
a.log.Add(diagnostics.Info, "lifecycle", "Starting caption processing")
|
||||
}
|
||||
|
||||
func (a *Application) stopCaptions() {
|
||||
go func() {
|
||||
timeout := a.applied.Settings.ModelShutdownTimeout + time.Second
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
if err := a.controller.Stop(ctx); err != nil {
|
||||
a.log.Add(diagnostics.Error, "lifecycle", fmt.Sprintf("Stop captions: %v", err))
|
||||
return
|
||||
}
|
||||
a.log.Add(diagnostics.Info, "lifecycle", "Caption processing stopped")
|
||||
}()
|
||||
}
|
||||
|
||||
func (a *Application) restartCaptions() {
|
||||
go func() {
|
||||
timeout := a.applied.Settings.ModelShutdownTimeout + 2*time.Second
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
if err := a.controller.Restart(ctx, a.applied.Settings); err != nil {
|
||||
a.log.Add(diagnostics.Error, "lifecycle", fmt.Sprintf("Restart captions: %v", err))
|
||||
return
|
||||
}
|
||||
a.log.Add(diagnostics.Info, "lifecycle", "Caption processing restarted")
|
||||
}()
|
||||
}
|
||||
|
||||
func (a *Application) configureOutputs(next config.AppConfig, initial bool) error {
|
||||
var outputErr error
|
||||
a.history.SetEnabled(next.Outputs.History)
|
||||
if next.Outputs.StdoutCaptions {
|
||||
outputErr = joinError(outputErr, a.router.Set("stdout", terminal.New(a.stdout)))
|
||||
} else {
|
||||
outputErr = joinError(outputErr, a.router.Remove("stdout"))
|
||||
}
|
||||
|
||||
if next.TranscriptLog.Enabled {
|
||||
if initial || a.transcript == nil || next.TranscriptLog != a.applied.TranscriptLog {
|
||||
sink, err := transcript.New(next.TranscriptLog.Directory)
|
||||
if err != nil {
|
||||
outputErr = joinError(outputErr, err)
|
||||
} else {
|
||||
a.transcript = sink
|
||||
outputErr = joinError(outputErr, a.router.Set("transcript", sink))
|
||||
a.log.Add(diagnostics.Info, "transcript", "Saving captions to "+sink.Path())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
a.transcript = nil
|
||||
outputErr = joinError(outputErr, a.router.Remove("transcript"))
|
||||
}
|
||||
|
||||
if next.Outputs.Overlay {
|
||||
settings := next.Overlay
|
||||
if !initial && settings.Backend != a.applied.Overlay.Backend {
|
||||
settings.Backend = a.applied.Overlay.Backend
|
||||
}
|
||||
if initial || a.overlay == nil || settings != a.applied.Overlay {
|
||||
sink, warning, err := overlay.New(settings)
|
||||
if warning != "" {
|
||||
a.log.Add(diagnostics.Warning, "overlay", warning)
|
||||
}
|
||||
if err != nil {
|
||||
outputErr = joinError(outputErr, err)
|
||||
} else {
|
||||
a.overlay = sink
|
||||
outputErr = joinError(outputErr, a.router.Set("overlay", sink))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
a.overlay = nil
|
||||
outputErr = joinError(outputErr, a.router.Remove("overlay"))
|
||||
}
|
||||
|
||||
if next.Outputs.StderrDiagnostics {
|
||||
a.log.SetMirror(a.stderr)
|
||||
} else {
|
||||
a.log.SetMirror(io.Discard)
|
||||
}
|
||||
return outputErr
|
||||
}
|
||||
|
||||
func (a *Application) renderStatus(status app.Status) {
|
||||
a.updateApplyButton()
|
||||
state := strings.ToUpper(string(status.State))
|
||||
overloaded := !status.OverloadedAt.IsZero() && time.Since(status.OverloadedAt) < 5*time.Second
|
||||
if overloaded {
|
||||
state = "OVERLOADED"
|
||||
}
|
||||
mode := strings.ToUpper(string(status.Mode))
|
||||
if mode == "" {
|
||||
mode = strings.ToUpper(string(a.applied.Settings.Mode))
|
||||
}
|
||||
a.statusLabel.SetText(state + " · " + mode)
|
||||
a.statusDot.RemoveCSSClass("status-stopped")
|
||||
a.statusDot.RemoveCSSClass("status-running")
|
||||
a.statusDot.RemoveCSSClass("status-warning")
|
||||
a.statusDot.RemoveCSSClass("status-error")
|
||||
active := status.State != app.StateStopped && status.State != app.StateError
|
||||
switch {
|
||||
case status.State == app.StateError:
|
||||
a.statusDot.AddCSSClass("status-error")
|
||||
case overloaded:
|
||||
a.statusDot.AddCSSClass("status-warning")
|
||||
case active:
|
||||
a.statusDot.AddCSSClass("status-running")
|
||||
default:
|
||||
a.statusDot.AddCSSClass("status-stopped")
|
||||
}
|
||||
if active {
|
||||
a.startStopButton.SetLabel("STOP")
|
||||
a.startStopButton.RemoveCSSClass("primary-action")
|
||||
a.startStopButton.AddCSSClass("danger-action")
|
||||
} else {
|
||||
a.startStopButton.SetLabel("START")
|
||||
a.startStopButton.RemoveCSSClass("danger-action")
|
||||
a.startStopButton.AddCSSClass("primary-action")
|
||||
}
|
||||
speech := "quiet"
|
||||
if status.SpeechActive {
|
||||
speech = "detected"
|
||||
}
|
||||
source := status.Source
|
||||
if source == "" {
|
||||
source = "not connected"
|
||||
}
|
||||
overload := ""
|
||||
if overloaded {
|
||||
overload = " · OVERLOADED"
|
||||
}
|
||||
a.statusbarLabel.SetText(fmt.Sprintf(
|
||||
"Source: %s · Speech: %s · Outputs: %s%s",
|
||||
source,
|
||||
speech,
|
||||
a.outputSummary(),
|
||||
overload,
|
||||
))
|
||||
if status.LastError != "" && status.State == app.StateError && status.LastError != a.lastShownError {
|
||||
a.log.Add(diagnostics.Error, "engine", status.LastError)
|
||||
a.lastShownError = status.LastError
|
||||
} else if status.LastError == "" {
|
||||
a.lastShownError = ""
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Application) outputSummary() string {
|
||||
var active []string
|
||||
if a.config.Outputs.Overlay {
|
||||
active = append(active, "overlay")
|
||||
}
|
||||
if a.config.Outputs.History {
|
||||
active = append(active, "history")
|
||||
}
|
||||
if a.config.Outputs.StdoutCaptions {
|
||||
active = append(active, "stdout")
|
||||
}
|
||||
if a.config.TranscriptLog.Enabled {
|
||||
active = append(active, "log file")
|
||||
}
|
||||
if len(active) == 0 {
|
||||
return "none"
|
||||
}
|
||||
return strings.Join(active, ", ")
|
||||
}
|
||||
|
||||
func (a *Application) copyDiagnostics() {
|
||||
var text strings.Builder
|
||||
status := a.controller.Status()
|
||||
fmt.Fprintf(&text, "Captioneer diagnostics\nState: %s\nMode: %s\nSource: %s\nConfig: %s\n\n", status.State, status.Mode, status.Source, a.launch.Path)
|
||||
for _, entry := range a.log.Entries() {
|
||||
fmt.Fprintf(&text, "%s %-7s [%s] %s\n", entry.At.Format(time.RFC3339), strings.ToUpper(string(entry.Severity)), entry.Category, entry.Message)
|
||||
}
|
||||
gdk.DisplayGetDefault().Clipboard().SetText(text.String())
|
||||
a.log.Add(diagnostics.Info, "gui", "Diagnostics copied to clipboard")
|
||||
}
|
||||
|
||||
func (a *Application) openConfigFolder() {
|
||||
if err := exec.Command("xdg-open", filepath.Dir(a.launch.Path)).Start(); err != nil {
|
||||
a.log.Add(diagnostics.Error, "gui", fmt.Sprintf("Open configuration folder: %v", err))
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Application) showAbout() {
|
||||
dialog := gtk.NewAboutDialog()
|
||||
dialog.SetTransientFor(&a.window.Window)
|
||||
dialog.SetModal(true)
|
||||
dialog.SetProgramName("Captioneer")
|
||||
dialog.SetComments("Local live captions for Linux")
|
||||
dialog.SetLicenseType(gtk.LicenseApache20)
|
||||
dialog.SetWebsite("https://tea.chunkbyte.com/kato/captioneer")
|
||||
dialog.Present()
|
||||
}
|
||||
|
||||
func (a *Application) shutdown() {
|
||||
a.closingMu.Lock()
|
||||
if a.closing {
|
||||
a.closingMu.Unlock()
|
||||
return
|
||||
}
|
||||
a.closing = true
|
||||
a.closingMu.Unlock()
|
||||
a.log.Add(diagnostics.Info, "lifecycle", "Shutting down Captioneer")
|
||||
go func() {
|
||||
timeout := a.applied.Settings.ModelShutdownTimeout + 2*time.Second
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
_ = a.controller.Stop(ctx)
|
||||
cancel()
|
||||
glib.IdleAdd(func() {
|
||||
close(a.uiDone)
|
||||
if a.stopStatus != nil {
|
||||
a.stopStatus()
|
||||
a.stopHistory()
|
||||
a.stopLog()
|
||||
}
|
||||
_ = a.router.Close()
|
||||
a.gtkApp.Quit()
|
||||
})
|
||||
}()
|
||||
}
|
||||
|
||||
func joinError(current, next error) error {
|
||||
if current == nil {
|
||||
return next
|
||||
}
|
||||
if next == nil {
|
||||
return current
|
||||
}
|
||||
return fmt.Errorf("%v; %w", current, next)
|
||||
}
|
||||
|
||||
func findIconPath() string {
|
||||
candidates := []string{filepath.Join("assets", "icon", "icon.png")}
|
||||
if executable, err := os.Executable(); err == nil {
|
||||
candidates = append(candidates, filepath.Join(filepath.Dir(executable), "assets", "icon", "icon.png"))
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
Reference in New Issue
Block a user