- New config parameter "resume_jobs" which can disable auto-resuming jobs at timeline open. (closes #159) - Renamed "a" to "app" in one method using "Rename symbol" (not "Change all occurrences"), which surprisingly updated the identifier in ALL methods. That must be new. Anyway, that's the huge diff. - Minor fix to metadata merge that does a more proper nil check to avoid a panic. - Changed some omitempty to omitzero
222 lines
6.1 KiB
Go
222 lines
6.1 KiB
Go
/*
|
|
Timelinize
|
|
Copyright (c) 2013 Matthew Holt
|
|
|
|
This program is free software: you can redistribute it and/or modify
|
|
it under the terms of the GNU Affero General Public License as published
|
|
by the Free Software Foundation, either version 3 of the License, or
|
|
(at your option) any later version.
|
|
|
|
This program is distributed in the hope that it will be useful,
|
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
GNU Affero General Public License for more details.
|
|
|
|
You should have received a copy of the GNU Affero General Public License
|
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
// Package tlcmd facilitates the command line interface (CLI)
|
|
// and implements the main().
|
|
package tlcmd
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io/fs"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"runtime"
|
|
"strings"
|
|
|
|
"github.com/timelinize/timelinize/timeline"
|
|
"github.com/timelinize/timelinize/tlzapp"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
func Main(embeddedWebsite fs.FS) {
|
|
cfg, err := loadConfigFile()
|
|
if err != nil {
|
|
timeline.Log.Fatal("failed loading config", zap.Error(err))
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
app, err := tlzapp.New(ctx, cfg, embeddedWebsite)
|
|
if err != nil {
|
|
timeline.Log.Fatal("failed to run application", zap.Error(err))
|
|
}
|
|
defer app.Shutdown() // close timelines, stop python server if running, etc.
|
|
|
|
flag.Parse()
|
|
|
|
// implement standard (CLI-only) flags
|
|
subCommand, subCommandFunc := getStandardSubcommand(app)
|
|
if subCommandFunc != nil {
|
|
if err := checkFlagParsing(); err != nil {
|
|
timeline.Log.Fatal("possible syntax error detected", zap.Error(err))
|
|
}
|
|
if err := subCommandFunc(); err != nil {
|
|
timeline.Log.Fatal("subcommand failed",
|
|
zap.String("subcommand", subCommand),
|
|
zap.Error(err))
|
|
}
|
|
return
|
|
}
|
|
|
|
// check for registered endpoint (API command)
|
|
if remaining := flag.Args(); len(remaining) > 0 {
|
|
if err := app.RunCommand(ctx, remaining); err != nil {
|
|
timeline.Log.Fatal("subcommand failed", zap.Error(err))
|
|
}
|
|
return
|
|
}
|
|
|
|
// start the application server
|
|
// TODO: Use a host like tlz.localhost to serve HTTP/2 over HTTPS... just need to automate the CA and cert... - or maybe a public domain like timelinize.app or timelinize.run or something
|
|
startedServer, err := app.Serve()
|
|
if err != nil {
|
|
timeline.Log.Fatal("could not start server", zap.Error(err))
|
|
}
|
|
|
|
if isDesktopAvailable() {
|
|
// once the server is running, open GUI in web browser
|
|
if err := openWebBrowser(ctx, "http://127.0.0.1:12002"); err != nil {
|
|
timeline.Log.Error("could not open web browser", zap.Error(err))
|
|
}
|
|
} else {
|
|
timeline.Log.Warn("desktop not available, so not opening web browser")
|
|
}
|
|
|
|
if startedServer {
|
|
select {}
|
|
}
|
|
}
|
|
|
|
func isDesktopAvailable() bool {
|
|
if runtime.GOOS == "linux" {
|
|
return os.Getenv("DISPLAY") != ""
|
|
}
|
|
return true
|
|
}
|
|
|
|
// openWebBrowser opens the web browser to loc, which must be a
|
|
// fully-qualified URL including a trailing slash even if there
|
|
// is no path (e.g. "http://host/" not "http://host"); if the
|
|
// trailing slash is not present, it will be appended.
|
|
func openWebBrowser(ctx context.Context, loc string) error {
|
|
osCommand := map[string][]string{
|
|
"darwin": {"open"},
|
|
"freebsd": {"xdg-open"},
|
|
"linux": {"xdg-open"}, // requires xdg-utils; TODO: also try sensible-browser, or gnome-open
|
|
"netbsd": {"xdg-open"},
|
|
"openbsd": {"xdg-open"},
|
|
"windows": {"cmd", "/c", "start"},
|
|
}
|
|
|
|
// ensure URL is valid and path ends with a trailing slash
|
|
u, err := url.Parse(loc)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !strings.HasSuffix(u.Path, "/") {
|
|
u.Path += "/"
|
|
}
|
|
loc = u.String()
|
|
|
|
if runtime.GOOS == "windows" {
|
|
// escape characters not allowed by cmd
|
|
loc = strings.ReplaceAll(loc, "&", `^&`)
|
|
}
|
|
|
|
all := append(osCommand[runtime.GOOS], loc) //nolint:gocritic
|
|
exe := all[0]
|
|
args := all[1:]
|
|
|
|
timeline.Log.Info("opening web browser to application",
|
|
zap.Strings("command", append([]string{exe}, args...)))
|
|
|
|
cmd := exec.CommandContext(ctx, exe, args...)
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
return cmd.Run()
|
|
}
|
|
|
|
// Gets CLI-only commands.
|
|
func getStandardSubcommand(app *tlzapp.App) (string, func() error) {
|
|
standardCommands := map[string]func() error{
|
|
"serve": func() error {
|
|
if err := app.MustServe(); err != nil {
|
|
return err
|
|
}
|
|
select {}
|
|
},
|
|
"reset": func() error {
|
|
cfg, err := loadConfigFile()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cfg.Repositories = nil
|
|
return cfg.Save()
|
|
},
|
|
"help": func() error { //nolint:unparam // bug filed: https://github.com/mvdan/unparam/issues/82
|
|
fmt.Println(app.CommandLineHelp())
|
|
return nil
|
|
},
|
|
"version": func() error {
|
|
fmt.Println("TODO: print version")
|
|
return nil
|
|
},
|
|
}
|
|
|
|
if len(flag.Args()) > 0 {
|
|
subCommand := flag.Arg(0)
|
|
subCommandFunc, ok := standardCommands[subCommand]
|
|
if ok {
|
|
return subCommand, subCommandFunc
|
|
}
|
|
}
|
|
return "", nil
|
|
}
|
|
|
|
// checkFlagParsing returns an error if it looks like the
|
|
// program may have been invoked with the flags in the
|
|
// wrong place. This should NOT be used when the program is
|
|
// invoked as an API client and the flags are arbitrary and
|
|
// "parsed" by the apicli package. This package intends to
|
|
// catch errors like running the program as:
|
|
// `command subcommand -flag value`
|
|
// where it actually needs to be run as:
|
|
// `command -flag value subcommand`
|
|
// in order to set the config variable properly. Failing to
|
|
// catch this error could result in a misconfiguration
|
|
// and undesirable results. Only for use when a standard
|
|
// command (something that we recognize, not as part of the
|
|
// API) is present.
|
|
func checkFlagParsing() error {
|
|
if len(os.Args) > 2 && flag.NFlag() == 0 {
|
|
return errors.New("it looks like you intended to specify flags, but none were parsed; make sure flags go before positional arguments")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func loadConfigFile() (*tlzapp.Config, error) {
|
|
cfgBytes, err := os.ReadFile(configFile)
|
|
if err != nil {
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
if configFile == tlzapp.DefaultConfigFilePath() {
|
|
err = nil
|
|
}
|
|
return new(tlzapp.Config), err
|
|
}
|
|
}
|
|
var cfg *tlzapp.Config
|
|
err = json.Unmarshal(cfgBytes, &cfg)
|
|
return cfg, err
|
|
}
|
|
|
|
var configFile = tlzapp.DefaultConfigFilePath()
|