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 }