1: <?php
2:
3: /**
4: * PHP implementation of the Alfred Bundler
5: *
6: * Main PHP interface for the Alfred Dependency Bundler. This file should be
7: * the only one from the bundler that is distributed with your workflow.
8: * You can use this file and the class contained within it to lazy load a
9: * variety of assets that can be used in your workflows.
10: *
11: * This file is part of the Alfred Bundler, released under the MIT licence.
12: * Copyright (c) 2014 The Alfred Bundler Team
13: * See https://shawnrice.github.io/alfred-bundler for more information.
14: *
15: * @copyright The Alfred Bundler Team 2014
16: * @license http://opensource.org/licenses/MIT MIT
17: * @version Taurus 1
18: * @link http://shawnrice.github.io/alfred-bundler
19: * @package AlfredBundler
20: * @since File available since Aries 1
21: */
22:
23:
24: /**
25: * The PHP interface for the Alfred Bundler
26: *
27: * This class is the only one that you should interact with. The rest of the
28: * magic that the bundler performs happens under the hood. Also, the backend
29: * of the bundler (here the 'AlfredBundlerInternalClass') may change; however,
30: * this bundlet will continue to work with the bundler API for the remainder of
31: * this major version (Taurus). Limited documentation is below, for more
32: * detailed documentation, see http://shawnrice.github.io/alfred-bundler.
33: *
34: * Example usage:
35: * <code>
36: * require_once( 'alfred.bundler.php' );
37: * $b = new AlfredBundler;
38: *
39: * // Downloads and requires David Ferguson's PHP Workflows library
40: * $b->library( 'Workflows' );
41: *
42: * // Download icons
43: * $icon1 = $b->icon( 'elusive', 'dashboard', 'ab332c', TRUE );
44: * $icon2 = $b->icon( 'fontawesome', 'adjust', 'aabbcc', '998877');
45: * $icon3 = $b->icon( 'fontawesome, 'bug' );
46: * $icon4 = $b->icon( 'system', 'Accounts' );
47: *
48: * // Send a message to the console (shows up in Alfred's debugger)
49: * $b->log( 'Loaded icons', 'INFO', 'console' );
50: * // Log a message to a logfile found in the workflow's data directory
51: * $b->log( 'Initial bootstrap complete, check the log file', 'DEBUG', 'log' );
52: * // Send the same message to the console and the log file
53: * $b->log( 'Bootstrap completed.', 'INFO', 'both' );
54: *
55: *
56: * // Get Pashua to use later
57: * $pashua = $b->utility( 'Pashua' );
58: *
59: * // Get an asset not included in the "defaults"
60: * $myAsset = $b->load( 'utility', 'myAsset', 'latest', '/path/to/json' );
61: *
62: * // Load 'cocoadialog' with the bundler's wrappers
63: * $cocoadialog = $b->wrapper( 'cocoadialog' );
64: *
65: * // Load/install composer packages
66: * $bundler->composer( array(
67: * "monolog/monolog" => "1.10.*@dev"
68: * ));
69: *
70: * </code>
71: *
72: * @see AlfredBundlerInternalClass
73: * @since Class available since Taurus 1
74: *
75: */
76: class AlfredBundler {
77:
78: /**
79: * An internal object that interfaces with the PHP Bundler API
80: *
81: * @access protected
82: * @var object
83: */
84: protected $bundler;
85:
86: /**
87: * A filepath to the bundler directory
88: *
89: * We're using this name so it doesn't clash with the internal $data
90: * variable.
91: *
92: * @access public
93: * @var string
94: */
95: private $_data;
96:
97: /**
98: * A filepath to the bundler cache directory
99: *
100: * We're using this name so it doesn't clash with the internal $data
101: * variable.
102: *
103: * @access private
104: * @var string
105: */
106: private $_cache;
107:
108: /**
109: * The MAJOR version of the bundler (which API to use)
110: *
111: * @access private
112: * @var string
113: */
114: private $_major_version;
115:
116: /**
117: * The class constructor
118: *
119: * @return bool Returns successful/failed instantiation
120: */
121: public function __construct() {
122:
123: if ( ! file_exists( 'info.plist' ) ) {
124: throw new Exception('The Alfred Bundler cannot be used without an `info.plist` file present.');
125: return FALSE;
126: }
127:
128: if ( isset( $_ENV[ 'AB_BRANCH' ] ) && ! empty( $_ENV[ 'AB_BRANCH' ] ) ) {
129: $this->_major_version = $_ENV[ 'AB_BRANCH' ];
130: } else {
131: $this->_major_version = 'devel';
132: }
133:
134: // Set date/time to avoid warnings/errors.
135: if ( ! ini_get( 'date.timezone' ) ) {
136: $tz = exec( 'tz=`ls -l /etc/localtime` && echo ${tz#*/zoneinfo/}' );
137: ini_set( 'date.timezone', $tz );
138: }
139:
140: $this->_data = "{$_SERVER['HOME']}/Library/Application Support/Alfred 2/" .
141: "Workflow Data/alfred.bundler-{$this->_major_version}";
142: $this->_cache = "{$_SERVER['HOME']}/Library/Caches/" .
143: "com.runningwithcrayons.Alfred-2/Workflow Data/" .
144: "alfred.bundler-{$this->_major_version}";
145:
146: if ( file_exists( "{$this->_data}/bundler/AlfredBundler.php" ) ) {
147: require_once ( "{$this->_data}/bundler/AlfredBundler.php" );
148: $this->bundler = new AlfredBundlerInternalClass();
149: } else {
150: if ( $this->installBundler() === FALSE ) {
151: // The bundler could not install itself, so throw an exception.
152: throw new Exception('The Alfred Bundler could not be installed.');
153: return FALSE;
154: } else {
155: chmod( "{$this->_data}/bundler/includes/LightOrDark", 0755 );
156: // The bundler is now in place, so require the actual PHP Bundler file
157: require_once "{$this->_data}/bundler/AlfredBundler.php";
158: // Create the internal class object
159: $this->bundler = new AlfredBundlerInternalClass();
160: $this->bundler->notify(
161: 'Alfred Bundler',
162: 'Installation successful. Thank you for waiting.',
163: "{$this->_data}/bundler/meta/icons/bundle.png"
164: );
165: }
166: }
167:
168: // Call the wrapper to update itself, processed is forked for speed
169: exec( "bash '{$this->_data}/bundler/meta/update-wrapper.sh'" );
170:
171: return TRUE;
172: }
173:
174: /**
175: * Logs output to the console
176: *
177: * This method provides very limited console logging functionality. It is
178: * employed only by this bundlet only when it is installing the PHP
179: * implementation of the Alfred Bundler. A much more robust logging
180: * functionality is the the backend, so those methods will be used at all
181: * other times.
182: *
183: * @see AlfredBundlerInternalClass::log
184: * @see AlfredBundlerLogger::log
185: * @see AlfredBundlerLogger::logFile
186: * @see AlfredBundlerLogger::logConsole
187: *
188: * @param string $message message to be logged
189: * @param mixed $level log level to be recorded
190: *
191: * @since Taurus 1
192: *
193: */
194: private function report( $message, $level ) {
195:
196: // These are the appropriate log levels
197: $logLevels = array( 0 => 'DEBUG',
198: 1 => 'INFO',
199: 2 => 'WARNING',
200: 3 => 'ERROR',
201: 4 => 'CRITICAL',
202: );
203:
204: $date = date( 'H:i:s', time() );
205: file_put_contents( 'php://stderr', "[{$date}] [{$level}] {$message}" . PHP_EOL );
206:
207: }
208:
209: /**
210: * Passes calls to the internal object
211: *
212: * Wrapper function that passes all other calls to the internal object when
213: * the method has not been found in this class
214: *
215: * @param string $method Name of method
216: * @param array $args An array of args passed
217: * @return mixed Whatever the internal function sends back
218: *
219: * @since Taurus 1
220: */
221: public function __call( $method, $args ) {
222:
223: // Make sure that the bundler installation was not refused
224: if ( isset( $_ENV[ 'ALFRED_BUNDLER_INSTALL_REFUSED'] ) ) {
225: $bt = array_unshift( debug_backtrace() );
226: $date = date( 'H:i:s' );
227: $trace = basename( $bt[ 'file' ] ) . ':' . $bt[ 'line' ];
228: $message = "Trying to call an Alfred Bundler method ({$method}), but user refused to install the bundler.";
229: $this->report( "[{$date}] [{$trace}] {$message}", 'CRITICAL' );
230: return FALSE;
231: }
232:
233:
234: // Check to make sure that the method exists in the
235: // 'AlfredBundlerInternalClass' class
236: if ( ! method_exists( $this->bundler, $method ) ) {
237: // Whoops. We called a non-existent method
238: $this->bundler->log( "Could not find method [$method] in class 'AlfredBundler'.",
239: 'ERROR', 'console' );
240: return FALSE;
241: }
242:
243: // The method exists, so call it and return the output
244: return call_user_func_array( array( $this->bundler, $method ), $args );
245: }
246:
247: /**
248: * Gets variables from the AlfredBundlerInternalClass
249: *
250: * @param string $name name of variable to get
251: * @return mixed the variable from the internal class object
252: *
253: * @access public
254: * @since Taurus 1
255: */
256: public function &__get( $name ) {
257: if ( isset( $this->bundler ) && is_object( $this->bundler ) ) {
258: if ( isset( $this->bundler->$name ) )
259: return $this->bundler->$name;
260: }
261: }
262:
263: /******************************************************************************
264: *** Begin Installation Functions
265: *****************************************************************************/
266:
267: /**
268: * Validates and checks variables for installation
269: *
270: * @return bool returns false on failure
271: * @since Taurus 1
272: */
273: private function prepareInstallation() {
274: if ( ! file_exists( 'info.plist' ) ) {
275: $this->report( "You need a valid `info.plist` to use the Alfred Bundler.", 'CRITICAL' );
276: return FALSE;
277: }
278: if ( isset( $_ENV[ 'alfred_version' ] ) )
279: $this->prepareModern();
280: else
281: $this->prepareDeprecated();
282:
283: }
284:
285: /**
286: * Sets the name of the workflow
287: * Method used for Alfredv2.4:277+
288: *
289: * @since Taurus 1
290: */
291: private function prepareModern() {
292: $this->name = $_ENV[ 'alfred_workflow_name' ];
293: }
294:
295: /**
296: * Sets the name of the workflow
297: * This method is used only for version < Alfredv2.4:277
298: *
299: * @since Taurus 1
300: */
301: private function prepareDeprecated() {
302: $this->name = exec( "/usr/libexec/PlistBuddy -c 'Print :name' 'info.plist'" );
303: }
304:
305: /**
306: * Prepares the text for the installation confirmation AS dialog
307: *
308: * @since Taurus 1
309: */
310: private function prepareASDialog() {
311:
312: if ( file_exists( 'icon.png' ) ) {
313: $icon = realpath( 'icon.png' );
314: $icon = str_replace('/', ':', 'icon');
315: $icon = substr( $icon, 1, strlen( $icon ) - 1 );
316: } else {
317: $icon = "System:Library:CoreServices:CoreTypes.bundle:Contents:Resources:SideBarDownloadsFolder.icns";
318: }
319: // Text for the dialog message.
320: $text = "{$this->name} needs to install additional components, which will be placed in the Alfred storage directory and will not interfere with your system.
321:
322: You may be asked to allow some components to run, depending on your security settings.
323:
324: You can decline this installation, but {$this->name} may not work without them. There will be a slight delay after accepting.";
325:
326: $this->script = "display dialog \"$text\" " .
327: "buttons {\"More Info\",\"Cancel\",\"Proceed\"} default button 3 " .
328: "with title \"{$this->name} Setup\" with icon file \"$icon\"";
329:
330: }
331:
332: /**
333: * Executes AppleScript dialog confirmation and handles return value
334: *
335: * @todo decide and implement proper exit behavior for failure
336: * @return bool confirmation / refusal to install the bundler
337: *
338: * @since Taurus 1
339: */
340: private function processASDialog() {
341: $info = "https://github.com/shawnrice/alfred-bundler/wiki/What-is-the-Alfred-Bundler";
342: $confirm = str_replace( 'button returned:', '', exec( "osascript -e '{$this->script}'" ) );
343: if ( $confirm == 'More Info' ) {
344: exec( "open {$info}" );
345: die(); // Stop the workflow. If it's a script filter, then this will happen anyway.
346: } else if ( $confirm == 'Proceed' ) {
347: return TRUE;
348: } else {
349: $this->report( "User canceled installation of Alfred Bundler. Unknown " .
350: "and possibly catastrophic effects to follow.",
351: 'CRITICAL' );
352: $_ENV[ 'ALFRED_BUNDLER_INSTALL_REFUSED' ] = TRUE;
353: return FALSE;
354: }
355: return TRUE;
356: }
357:
358: /**
359: * Makes the data and cache directories for the Bundler
360: *
361: * @TODO: add in error handling for failed permissions (should be _very_ rare)
362: * @since Taurus 1
363: */
364: private function prepareDirectories() {
365: // Make the bundler cache directory
366: if ( ! file_exists( $this->_cache ) )
367: mkdir( $this->_cache, 0755, TRUE );
368: // Make the bundler data directory
369: if ( ! file_exists( $this->_data ) )
370: mkdir( $this->_data, 0755, TRUE );
371: }
372:
373: private function userCanceledInstallation() {
374: throw new Exception('The user canceled the installation of the Alfred Bundler.');
375: }
376:
377: /**
378: * Installs the Alfred Bundler
379: *
380: * @return bool Success or failure of installation
381: *
382: * @since Taurus 1
383: */
384: private function installBundler() {
385:
386: $this->prepareInstallation();
387: $this->prepareASDialog();
388:
389: if ( ! $this->processASDialog() ) {
390: $this->userCanceledInstallation(); // Throw an exception.
391: return FALSE;
392: }
393: $this->prepareDirectories();
394:
395: // This is a list of mirrors that host the bundler. A current list is in
396: // bundler/meta/bundler_servers, but that file should not exist on the
397: // machine -- yet -- because this is the function that installs that file.
398: // The 'latest' tag is the current release.
399: $suffix = "-latest.zip";
400: if ( isset( $_ENV[ 'AB_BRANCH' ] ) &&
401: ( ! empty( $_ENV[ 'AB_BRANCH' ] ) ) ) {
402: $suffix = ".zip";
403: }
404: $bundler_servers = array(
405: "https://github.com/shawnrice/alfred-bundler/archive/{$this->_major_version}{$suffix}",
406: "https://bitbucket.org/shawnrice/alfred-bundler/get/{$this->_major_version}{$suffix}"
407: );
408:
409: // Cycle through the servers until we find one that is up.
410: foreach ( $bundler_servers as $server ) :
411: $success = $this->dl( $server, "{$this->_cache}/bundler.zip" );
412: if ( $success === TRUE ) {
413: $this->report( "Downloaded Bundler Installation from... {$server}", 'INFO' );
414: break; // We found one, so break
415: }
416: endforeach;
417:
418: // If success is true, then we downloaded a copy of the bundler
419: if ( $success !== TRUE ) {
420: $this->report( "Could not reach server to install Alfred Bundler.", 'CRITICAL' );
421: unlink( "{$this->_cache}/bundler.zip" );
422: return FALSE;
423: }
424:
425: // Unzip the bundler archive
426: $zip = new ZipArchive;
427: $resource = $zip->open( "{$this->_cache}/bundler.zip" );
428: if ( $resource !== TRUE ) {
429: $this->report( "Bundler install zip file corrupt.", 'CRITICAL' );
430: if ( file_exists( "{$this->_cache}/bundler.zip" ) ) {
431: unlink( "{$this->_cache}/bundler.zip" );
432: }
433: return FALSE;
434: } else {
435: $zip->extractTo( "{$this->_cache}" );
436: $zip->close();
437: }
438:
439: if ( file_exists( "{$this->_data}/bundler" ) ) {
440: $this->report( "Bundler already installed. Exiting install script.", 'WARNING' );
441: return FALSE; // Add in error reporting
442: }
443:
444: // Move the bundler into place
445: $directoryHandle = opendir( $this->_cache );
446: while ( FALSE !== ( $file = readdir( $directoryHandle ) ) ) {
447: if ( is_dir( "{$this->_cache}/{$file}" ) && (strpos( $file, "alfred-bundler-" ) === 0 ) ) {
448: $bundlerFolder = "{$this->_cache}/{$file}";
449: closedir( $directoryHandle );
450: break;
451: }
452: }
453:
454: if ( ( ! isset( $bundlerFolder ) ) || ( empty( $bundlerFolder ) ) ) {
455: $this->report( "Could not find Alfred Bundler folder in installation zip.", 'CRITICAL' );
456: return FALSE;
457: }
458:
459: rename( "{$bundlerFolder}/bundler", "{$this->_data}/bundler" );
460:
461: $this->report( 'Alfred Bundler successfully installed, cleaning up...', 'INFO');
462: unlink( "{$this->_cache}/bundler.zip" );
463: // We'll do a cheat here to remove the leftover installation files
464: exec( "rm -fR '{$bundlerFolder}'" );
465: return TRUE; // The bundler should be in place now
466: }
467:
468: /**
469: * Wraps a cURL function to download files
470: *
471: * This method should be used only by the bundlet to download the
472: * bundler from the server
473: *
474: * @param string $url A URL to the file
475: * @param string $file The destination file
476: * @param int $timeout = '5' A timeout variable (in seconds)
477: * @return bool True on success and error code / false on failure
478: *
479: * @since Taurus 1
480: */
481: private function dl( $url, $file, $timeout = '5' ) {
482: // Check the URL here
483:
484: // Make sure that the download directory exists
485: if ( ! file_exists( realpath( dirname( $file ) ) ) ) {
486: $this->report( "Bundler install directory could not be created.", 'CRITICAL' );
487: return FALSE;
488: }
489:
490: // Create the cURL object
491: $ch = curl_init( $url );
492: // Open the file that we'll write to
493: $fp = fopen( $file , "w" );
494:
495: // Set standard cURL options
496: curl_setopt_array( $ch, array(
497: CURLOPT_FILE => $fp,
498: CURLOPT_HEADER => FALSE,
499: CURLOPT_FOLLOWLOCATION => TRUE,
500: CURLOPT_CONNECTTIMEOUT => $timeout
501: ) );
502:
503:
504: // Execute the cURL request and check for errors
505: if ( curl_exec( $ch ) === FALSE ) {
506: // We've run into an error, so, let's handle the error as best as possible
507: // Close the connection
508: curl_close( $ch );
509: // Close the file
510: fclose( $fp );
511:
512: // If the file has been written (partially), then delete it
513: if ( file_exists( $file ) )
514: unlink( $file );
515:
516: // Return the cURL error
517: // Curl error codes: http://curl.haxx.se/libcurl/c/libcurl-errors.html
518: return curl_error( $ch ) ;
519: }
520:
521: // File downloaded without error, so close the connection
522: curl_close( $ch );
523: // Close the file
524: fclose( $fp );
525:
526: // Return success
527: return TRUE;
528: }
529:
530: /******************************************************************************
531: *** End Installation Functions
532: *****************************************************************************/
533:
534: }
535:
536:
537: // Update logic for the bundler
538: // ----------------------------
539: // Unfortunately, Apple doesn't let us have the pcntl functions natively, so
540: // we'll take a different route and spoof this sort of thing. Here is a not
541: // very well developed implementation of forking the update process.
542: //
543: // if ( ! function_exists('pcntl_fork') )
544: // die('PCNTL functions not available on this PHP installation');
545: // else {
546: // $pid = pcntl_fork();
547: //
548: // switch($pid) {
549: // case -1:
550: // print "Could not fork!\n";
551: // exit;
552: // case 0:
553: // // Check for bundler minor update
554: // $cmd = "sh '$__data/bundler/meta/update.sh' > /dev/null 2>&1";
555: // exec( $cmd );
556: // break;
557: // default:
558: // print "In parent!\n";
559: // }
560: // }