github.com/ChicK00o/awgo@v0.29.4/workflow.go (about)

     1  // Copyright (c) 2018 Dean Jackson <deanishe@deanishe.net>
     2  // MIT Licence - http://opensource.org/licenses/MIT
     3  
     4  package aw
     5  
     6  import (
     7  	"fmt"
     8  	"io"
     9  	"log"
    10  	"os"
    11  	"os/exec"
    12  	"path/filepath"
    13  	"runtime/debug"
    14  	"sync"
    15  	"time"
    16  
    17  	"go.deanishe.net/fuzzy"
    18  
    19  	"github.com/ChicK00o/awgo/keychain"
    20  	"github.com/ChicK00o/awgo/util"
    21  )
    22  
    23  // AwGoVersion is the semantic version number of this library.
    24  const AwGoVersion = "0.27.1"
    25  
    26  // Default Workflow settings. Can be changed with the corresponding Options.
    27  //
    28  // See the Options and Workflow documentation for more information.
    29  const (
    30  	DefaultLogPrefix   = "\U0001F37A"    // Beer mug
    31  	DefaultMaxLogSize  = 1048576         // 1 MiB
    32  	DefaultMaxResults  = 0               // No limit, i.e. send all results to Alfred
    33  	DefaultSessionName = "AW_SESSION_ID" // Workflow variable session ID is stored in
    34  	DefaultMagicPrefix = "workflow:"     // Prefix to call "magic" actions
    35  )
    36  
    37  var (
    38  	startTime time.Time // Time execution started
    39  
    40  	// The workflow object operated on by top-level functions.
    41  	// wf *Workflow
    42  
    43  	// Flag, as we only want to set up logging once
    44  	// TODO: Better, more pluggable logging
    45  	logInitialized bool
    46  )
    47  
    48  // init creates the default Workflow.
    49  func init() {
    50  	startTime = time.Now()
    51  }
    52  
    53  // Mockable function to run commands
    54  type commandRunner func(name string, arg ...string) error
    55  
    56  // Run command via exec.Command
    57  func runCommand(name string, arg ...string) error {
    58  	return exec.Command(name, arg...).Run()
    59  }
    60  
    61  // Mockable exit function
    62  var exitFunc = os.Exit
    63  
    64  // Workflow provides a consolidated API for building Script Filters.
    65  //
    66  // As a rule, you should create a Workflow in init or main and call your main
    67  // entry-point via Workflow.Run(), which catches panics, and logs & shows the
    68  // error in Alfred.
    69  //
    70  // # Script Filter
    71  //
    72  // To generate feedback for a Script Filter, use Workflow.NewItem() to create
    73  // new Items and Workflow.SendFeedback() to send the results to Alfred.
    74  //
    75  // # Run Script
    76  //
    77  // Use the TextErrors option, so any rescued panics are printed as text,
    78  // not as JSON.
    79  //
    80  // Use ArgVars to set workflow variables, not Workflow/Feedback.
    81  //
    82  // See the _examples/ subdirectory for some full examples of workflows.
    83  type Workflow struct {
    84  	sync.WaitGroup
    85  	// Interface to workflow's settings.
    86  	// Reads workflow variables by type and saves new values to info.plist.
    87  	Config *Config
    88  
    89  	// Call Alfred's AppleScript functions.
    90  	Alfred *Alfred
    91  
    92  	// Cache is a Cache pointing to the workflow's cache directory.
    93  	Cache *Cache
    94  	// Data is a Cache pointing to the workflow's data directory.
    95  	Data *Cache
    96  	// Session is a cache that stores session-scoped data. These data
    97  	// persist until the user closes Alfred or runs a different workflow.
    98  	Session *Session
    99  
   100  	// Access macOS Keychain. Passwords are saved using the workflow's
   101  	// bundle ID as the service name. Passwords are synced between
   102  	// devices if you have iCloud Keychain turned on.
   103  	Keychain *keychain.Keychain
   104  
   105  	// The response that will be sent to Alfred. Workflow provides
   106  	// convenience wrapper methods, so you don't normally have to
   107  	// interact with this directly.
   108  	Feedback *Feedback
   109  
   110  	// Updater fetches updates for the workflow.
   111  	Updater Updater
   112  
   113  	// magicActions contains the magic actions registered for this workflow.
   114  	// Several built-in actions are registered by default. See the docs for
   115  	// MagicAction for details.
   116  	magicActions *magicActions
   117  
   118  	logPrefix   string         // Written to debugger to force a newline
   119  	maxLogSize  int            // Maximum size of log file in bytes
   120  	magicPrefix string         // Overrides DefaultMagicPrefix for magic actions.
   121  	maxResults  int            // max. results to send to Alfred. 0 means send all.
   122  	sortOptions []fuzzy.Option // Options for fuzzy filtering
   123  	textErrors  bool           // Show errors as plaintext, not Alfred JSON
   124  	helpURL     string         // URL to help page (shown if there's an error)
   125  	dir         string         // Directory workflow is in
   126  	cacheDir    string         // Workflow's cache directory
   127  	dataDir     string         // Workflow's data directory
   128  	sessionName string         // Name of the variable sessionID is stored in
   129  	sessionID   string         // Random session ID
   130  
   131  	execFunc commandRunner // Run external commands
   132  }
   133  
   134  // New creates and initialises a new Workflow, passing any Options to
   135  // Workflow.Configure().
   136  //
   137  // For available options, see the documentation for the Option type and the
   138  // following functions.
   139  //
   140  // IMPORTANT: In order to be able to initialise the Workflow correctly,
   141  // New must be run within a valid Alfred environment; specifically
   142  // *at least* the following environment variables must be set:
   143  //
   144  //	alfred_workflow_bundleid
   145  //	alfred_workflow_cache
   146  //	alfred_workflow_data
   147  //
   148  // If you aren't running from Alfred, or would like to specify a
   149  // custom environment, use NewFromEnv().
   150  func New(opts ...Option) *Workflow { return NewFromEnv(nil, opts...) }
   151  
   152  // NewFromEnv creates a new Workflows from the specified Env.
   153  // If env is nil, the system environment is used.
   154  func NewFromEnv(env Env, opts ...Option) *Workflow {
   155  	if env == nil {
   156  		env = sysEnv{}
   157  	}
   158  
   159  	if err := validateEnv(env); err != nil {
   160  		panic(err)
   161  	}
   162  
   163  	wf := &Workflow{
   164  		Config:      NewConfig(env),
   165  		Alfred:      NewAlfred(env),
   166  		Feedback:    &Feedback{},
   167  		logPrefix:   DefaultLogPrefix,
   168  		maxLogSize:  DefaultMaxLogSize,
   169  		maxResults:  DefaultMaxResults,
   170  		sessionName: DefaultSessionName,
   171  		sortOptions: []fuzzy.Option{},
   172  		execFunc:    runCommand,
   173  	}
   174  
   175  	wf.magicActions = &magicActions{
   176  		actions: map[string]MagicAction{},
   177  		wf:      wf,
   178  	}
   179  
   180  	// default magic actions
   181  	wf.Configure(AddMagic(
   182  		logMA{wf},
   183  		cacheMA{wf},
   184  		clearCacheMA{wf},
   185  		dataMA{wf},
   186  		clearDataMA{wf},
   187  		resetMA{wf},
   188  	))
   189  
   190  	wf.Configure(opts...)
   191  
   192  	wf.Cache = NewCache(wf.CacheDir())
   193  	wf.Data = NewCache(wf.DataDir())
   194  	wf.Session = NewSession(wf.CacheDir(), wf.SessionID())
   195  	wf.Keychain = keychain.New(wf.BundleID())
   196  	wf.initializeLogging()
   197  	return wf
   198  }
   199  
   200  // --------------------------------------------------------------------
   201  // Initialisation methods
   202  
   203  // Configure applies one or more Options to Workflow. The returned Option reverts
   204  // all Options passed to Configure.
   205  func (wf *Workflow) Configure(opts ...Option) (previous Option) {
   206  	prev := make(options, len(opts))
   207  	for i, opt := range opts {
   208  		prev[i] = opt(wf)
   209  	}
   210  	return prev.apply
   211  }
   212  
   213  // initializeLogging ensures future log messages are written to
   214  // workflow's log file.
   215  func (wf *Workflow) initializeLogging() {
   216  	if logInitialized { // All Workflows use the same global logger
   217  		return
   218  	}
   219  
   220  	// Rotate log file if larger than MaxLogSize
   221  	fi, err := os.Stat(wf.LogFile())
   222  	if err == nil {
   223  		if fi.Size() >= int64(wf.maxLogSize) {
   224  			newlog := wf.LogFile() + ".1"
   225  			if err := os.Rename(wf.LogFile(), newlog); err != nil {
   226  				fmt.Fprintf(os.Stderr, "Error rotating log: %v\n", err)
   227  			}
   228  
   229  			fmt.Fprintln(os.Stderr, "Rotated log")
   230  		}
   231  	}
   232  
   233  	// Open log file
   234  	file, err := os.OpenFile(wf.LogFile(), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
   235  	if err != nil {
   236  		wf.Fatal(fmt.Sprintf("Couldn't open log file %s : %v",
   237  			wf.LogFile(), err))
   238  	}
   239  
   240  	// Attach logger to file
   241  	multi := io.MultiWriter(file, os.Stderr)
   242  	log.SetOutput(multi)
   243  
   244  	// Show filenames and line numbers if Alfred's debugger is open
   245  	if wf.Debug() {
   246  		log.SetFlags(log.Ltime | log.Lshortfile)
   247  	} else {
   248  		log.SetFlags(log.Ltime)
   249  	}
   250  
   251  	logInitialized = true
   252  }
   253  
   254  // --------------------------------------------------------------------
   255  // API methods
   256  
   257  // BundleID returns the workflow's bundle ID. This library will not
   258  // work without a bundle ID, which is set in the workflow's main
   259  // setup sheet in Alfred Preferences.
   260  func (wf *Workflow) BundleID() string {
   261  	s := wf.Config.Get(EnvVarBundleID)
   262  	if s == "" {
   263  		wf.Fatal("No bundle ID set. You *must* set a bundle ID to use AwGo.")
   264  	}
   265  	return s
   266  }
   267  
   268  // Name returns the workflow's name as specified in the workflow's main
   269  // setup sheet in Alfred Preferences.
   270  func (wf *Workflow) Name() string { return wf.Config.Get(EnvVarName) }
   271  
   272  // Version returns the workflow's version set in the workflow's configuration
   273  // sheet in Alfred Preferences.
   274  func (wf *Workflow) Version() string { return wf.Config.Get(EnvVarVersion) }
   275  
   276  // SessionID returns the session ID for this run of the workflow.
   277  // This is used internally for session-scoped caching.
   278  //
   279  // The session ID is persisted as a workflow variable. It and the session
   280  // persist as long as the user is using the workflow in Alfred. That
   281  // means that the session expires as soon as Alfred closes or the user
   282  // runs a different workflow.
   283  func (wf *Workflow) SessionID() string {
   284  	if wf.sessionID == "" {
   285  		ev := os.Getenv(wf.sessionName)
   286  
   287  		if ev != "" {
   288  			wf.sessionID = ev
   289  		} else {
   290  			wf.sessionID = NewSessionID()
   291  		}
   292  	}
   293  
   294  	return wf.sessionID
   295  }
   296  
   297  // Debug returns true if Alfred's debugger is open.
   298  func (wf *Workflow) Debug() bool { return wf.Config.GetBool(EnvVarDebug) }
   299  
   300  // Args returns command-line arguments passed to the program.
   301  // It intercepts "magic args" and runs the corresponding actions, terminating
   302  // the workflow. See MagicAction for full documentation.
   303  func (wf *Workflow) Args() []string {
   304  	prefix := DefaultMagicPrefix
   305  	if wf.magicPrefix != "" {
   306  		prefix = wf.magicPrefix
   307  	}
   308  	return wf.magicActions.args(os.Args[1:], prefix)
   309  }
   310  
   311  // Run runs your workflow function, catching any errors.
   312  // If the workflow panics, Run rescues and displays an error message in Alfred.
   313  func (wf *Workflow) Run(fn func()) {
   314  	vstr := wf.Name()
   315  
   316  	if wf.Version() != "" {
   317  		vstr += "/" + wf.Version()
   318  	}
   319  
   320  	vstr = fmt.Sprintf(" %s (AwGo/%v) ", vstr, AwGoVersion)
   321  
   322  	// Print right after Alfred's introductory blurb in the debugger.
   323  	// Alfred strips whitespace.
   324  	if wf.logPrefix != "" {
   325  		fmt.Fprintln(os.Stderr, wf.logPrefix)
   326  	}
   327  
   328  	log.Println(util.Pad(vstr, "-", 50))
   329  
   330  	// Clear expired session data
   331  	wf.Add(1)
   332  	go func() {
   333  		defer wf.Done()
   334  		if err := wf.Session.Clear(false); err != nil {
   335  			log.Printf("[ERROR] clear session: %v", err)
   336  		}
   337  	}()
   338  
   339  	// Catch any `panic` and display an error in Alfred.
   340  	// Fatal(msg) will terminate the process (via log.Fatal).
   341  	defer func() {
   342  		if r := recover(); r != nil {
   343  			log.Println(util.Pad(" FATAL ERROR ", "-", 50))
   344  			log.Printf("%s : %s", r, debug.Stack())
   345  			log.Println(util.Pad(" END STACK TRACE ", "-", 50))
   346  
   347  			// log.Printf("Recovered : %x", r)
   348  			err, ok := r.(error)
   349  			if ok {
   350  				wf.outputErrorMsg(err.Error())
   351  			}
   352  
   353  			wf.outputErrorMsg(fmt.Sprintf("%v", r))
   354  		}
   355  	}()
   356  
   357  	// Call the workflow's main function.
   358  	fn()
   359  
   360  	wf.Wait()
   361  	finishLog(false)
   362  }
   363  
   364  // --------------------------------------------------------------------
   365  // Helper methods
   366  
   367  // outputErrorMsg prints and logs error, then exits process.
   368  func (wf *Workflow) outputErrorMsg(msg string) {
   369  	if wf.textErrors {
   370  		fmt.Print(msg)
   371  	} else {
   372  		wf.Feedback.Clear()
   373  		wf.NewItem(msg).Icon(IconError)
   374  		wf.SendFeedback()
   375  	}
   376  	log.Printf("[ERROR] %s", msg)
   377  	// Show help URL or website URL
   378  	if wf.helpURL != "" {
   379  		log.Printf("Get help at %s", wf.helpURL)
   380  	}
   381  	finishLog(true)
   382  }
   383  
   384  // awDataDir is the directory for AwGo's own data.
   385  func (wf *Workflow) awDataDir() string {
   386  	return util.MustExist(filepath.Join(wf.DataDir(), "_aw"))
   387  }
   388  
   389  // awCacheDir is the directory for AwGo's own cache.
   390  func (wf *Workflow) awCacheDir() string {
   391  	return util.MustExist(filepath.Join(wf.CacheDir(), "_aw"))
   392  }
   393  
   394  // --------------------------------------------------------------------
   395  // Package-level only
   396  
   397  // finishLog outputs the workflow duration
   398  func finishLog(fatal bool) {
   399  	s := util.Pad(fmt.Sprintf(" %v ", time.Since(startTime)), "-", 50)
   400  
   401  	if fatal {
   402  		log.Println(s)
   403  		exitFunc(1)
   404  	} else {
   405  		log.Println(s)
   406  	}
   407  }