summary history branches tags files
commit:6ce033f166d674e5ec20b4fba8ca8aabfc644655
author:Trevor Bentley
committer:Trevor Bentley
date:Wed Aug 2 10:42:39 2017 +0200
parents:bbb3d0e88759321f1fd7d04cb55afc431a6b0bee
Support configuring Connectr via local web server
diff --git a/Cargo.toml b/Cargo.toml
line changes: +4/-2
index da27fc3..76efc91
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -9,7 +9,6 @@ homepage = "https://github.com/mrmekon/connectr"
 repository = "https://github.com/mrmekon/connectr"
 documentation = "https://mrmekon.github.io/connectr/connectr/"
 license = "Apache-2.0"
-build = "build.rs"
 
 [lib]
 name = "connectr"
@@ -47,7 +46,10 @@ chrono = "0.3.0"
 log = "0.3.7"
 log4rs = "0.6.3"
 ctrlc = "3.0.1"
-fruitbasket = "0.4"
+
+[dependencies.fruitbasket]
+version = "0.4"
+features = ["logging"]
 
 [target."cfg(windows)".dependencies]
 #systray = "0.1.1"

diff --git a/README.md b/README.md
line changes: +20/-18
index 65749eb..754280a
--- a/README.md
+++ b/README.md
@@ -2,17 +2,19 @@
 
 connectr is a Rust library and systray/menubar application for controlling and monitoring [Spotify Connect](https://www.spotify.com/se/connect/) devices.
 
-As a library, connectr exposes the official [Spotify 'Player' Web API](https://developer.spotify.com/web-api/web-api-connect-endpoint-reference/) for controlling Spotify Connect devices.
+**Note:** Spotify Premium is required to create an application.  You must have Premium to use connectr.
 
 As an application, connectr provides a minimal "systray" application to observe the currently playing track, control Spotify playback, switch playback devices, and start preset playlists.  The goal is very low memory usage, so the basic Spotify functionality can always be available without keeping the massive Spotify desktop application resident in memory.
 
+As a library, connectr exposes the official [Spotify 'Player' Web API](https://developer.spotify.com/web-api/web-api-connect-endpoint-reference/) for controlling Spotify Connect devices.
+
 *Note:* connectr is not an audio playback tool; it's just a remote control.  Spotify has not publicly released a library for implementing audio playback with Spotify Connect support.  There's a reverse engineering effort, coincidentally also in Rust, at [librespot](https://github.com/plietar/librespot).
 
 ### Development Status
 [![OSX/Linux Build Status](https://travis-ci.org/mrmekon/connectr.svg?branch=master)](https://travis-ci.org/mrmekon/connectr)
 [![Windows Build Status](https://ci.appveyor.com/api/projects/status/4afwy0yj2477f84h/branch/master?svg=true)](https://ci.appveyor.com/project/mrmekon/connectr/branch/master)
 
-Alpha / Hobby / Experimental
+Beta / Hobby / Experimental
 
 ### Code Status
 Sloppy; this is my first Rust.
@@ -22,7 +24,7 @@ Sloppy; this is my first Rust.
 
 ### Platform Status
 
-The underlying library should be fully cross-platform, though I'm only testing x86_64 Windows and OS X.  Let me know if you run it on an ARM; I'd like to know if that works.
+The underlying library should be fully cross-platform, though I'm only testing x86_64 Windows and OS X.
 
 *Web API Library*:
 Fully functional and pretty stable for the requirements of the connectr menu bar app.  Error handling isn't extremely robust, and it doesn't implement retries or exponential backoff, which it should.  The Spotify API can, of course, do plenty more than connectr exposes.
@@ -36,30 +38,30 @@ Fully functional and pretty stable for the requirements of the connectr menu bar
 <img src="https://github.com/mrmekon/connectr/blob/master/docs/screenshot.png" width="300">
 <img src="https://github.com/mrmekon/connectr/blob/master/docs/screenshot_windows.png" width="300">
 
-### Instructions
-
-No binaries are provided.  You must build from source with Cargo.
+### Build Instructions
 
-Create a Spotify application here: https://developer.spotify.com/my-applications
-**Note:** Spotify Premium is required to create an application.  You must have Premium to use connectr.
-You must add 'http://127.0.0.1:5432' as a Redirect URI for your application.
-Copy the Client ID and Client Secret to connectr's configuration file (see below).
-
-Something like this:
 ```
 $ git clone https://github.com/mrmekon/connectr.git
 $ cd connectr
-$ cp connectr.ini.in ~/.connectr.ini
-$ ./clientid_prompt.sh
 $ cargo run
 ```
 
-You must provide your Spotify application's client ID and secret in connectr's configuration file.  This is handled by the `clientid_prompt.sh` script, or can be done manually.
+On first launch, Connectr will open your web browser to a self-configuration page, and save its configuration to your system home directory.  The configuration page will walk you through creating the necessary Spotify developer application.
+
+### Spotify Developer Application configuration
 
-**Note:** connectr uses `~/.connectr.ini` if it exists.  If it does _not_ exist, connectr will fallback to trying `connectr.ini` in the directory it is run from.  If built as an OS X application, connectr will create `~/.connectr.ini` on first launch, but will fail to run until you add your Client ID and Secret.  The included script `clientid_prompt.sh` can optionally be used to generate `~/.connectr.ini`; it will prompt for your Client ID and Secret when run.  A template is provided in `connectr.ini.in`.
+On the first launch, connectr will guide you through setting up a Spotify developer application.  If you want to do it manually instead, or if something goes wrong, here are the instructions:
+
+* Go to your [Spotify Applications](https://developer.spotify.com/my-applications/#!/applications/create) page (login with your Spotify credentials)
+* Click "CREATE AN APP" in the upper-right corner
+* Enter a name (perhaps "Connectr") and description ("Use Connectr app with my account.")
+* Add a Redirect URI: <em>http://127.0.0.1:5432</em>
+* Copy your <em>Client ID</em> and <em>Client Secret</em> to `connectr.ini` (see below).
 
 ### Configuration file (connectr.ini) format
 
+**Note:** connectr uses `~/.connectr.ini` if it exists.  If it does _not_ exist, connectr will fallback to trying `connectr.ini` in the directory it is run from.  A template is provided in `connectr.ini.in`.
+
 connectr's configuration is read from a regular INI file with these sections:
 
 #### [connectr]
@@ -70,9 +72,9 @@ connectr's configuration is read from a regular INI file with these sections:
 * secret - Spotify web application's Client Secret (string)
 
 #### [presets]
-* [name] - Key name is the display name of a playable preset, the value must be a Spotify URI to play. (string)
+* <preset name< - Key name is the display name of a playable preset, the value must be a Spotify URI to play. (string)
 
-_ex: `Bakesale=spotify:album:70XjdLKH7HHsFVWoQipP0T` will show as 'Bakesale' in the menu, and will play the specified Sebadoh album when clicked._
+_ex: `Bakesale = spotify:album:70XjdLKH7HHsFVWoQipP0T` will show as 'Bakesale' in the menu, and will play the specified Sebadoh album when clicked._
 
 #### [tokens]
 * access - Spotify Web API access token

diff --git a/build.rs b/build.rs
line changes: +0/-16
index 15c907a..0000000
--- a/build.rs
+++ /dev/null
@@ -1,16 +0,0 @@
-use std::fs;
-use std::path::Path;
-fn main() {
-    // Copy connectr.ini.in to connectr.ini if connectr.ini does not exist.
-    //
-    // The local changes in connectr.ini are always preserved, so you can
-    // set private keys without worrying about git.
-    let ini_file = Path::new("connectr.ini");
-    if !ini_file.exists() {
-        let _ = fs::copy("connectr.ini.in", "connectr.ini");
-    }
-
-    // Try again on re-build if either INI file has changed.
-    println!("cargo:rerun-if-changed=connectr.ini");
-    println!("cargo:rerun-if-changed=connectr.ini.in");
-}

diff --git a/src/http/mod.rs b/src/http/mod.rs
line changes: +127/-11
index 4b522c1..e7846f1
--- a/src/http/mod.rs
+++ b/src/http/mod.rs
@@ -3,6 +3,10 @@ use std::error::Error;
 use std::str;
 use std::io::{Read, Write, BufReader, BufRead};
 use std::net::{TcpListener};
+use std::thread;
+use std::sync::mpsc::channel;
+use std::time::Duration;
+use std::collections::BTreeMap;
 
 extern crate regex;
 use self::regex::Regex;
@@ -10,6 +14,7 @@ use self::regex::Regex;
 extern crate curl;
 use self::curl::easy::{Easy, List};
 
+extern crate time;
 extern crate open;
 extern crate url;
 use self::url::percent_encoding;
@@ -167,20 +172,131 @@ fn oauth_request_with_local_webserver(port: u32, url: &str, reply: &str) -> Vec<
     if !open::that(url).is_ok() {
         return Vec::<String>::new()
     }
+    let start = time::now_utc().to_timespec().sec as i64;
     let host = format!("127.0.0.1:{}", port);
-    let listener = TcpListener::bind(host).unwrap();
-    let stream = listener.accept().unwrap().0;
-    let mut reader = BufReader::new(stream);
-    let mut response = Vec::<String>::new();
-    for line in reader.by_ref().lines() {
-        let line_str = line.unwrap();
-        response.push(line_str.clone());
-        if line_str == "" {
-            break;
+    let listener = TcpListener::bind(host);
+    if listener.is_err() {
+        return Vec::<String>::new();
+    }
+    let timeout_sec = 20;
+    let listener = listener.unwrap();
+    let _ = listener.set_nonblocking(true);
+    loop {
+        let conn = listener.accept();
+        if conn.is_err() {
+            let now = time::now_utc().to_timespec().sec as i64;
+            if now >= start + timeout_sec {
+                warn!("Spotify OAuth request timed out.");
+                break;
+            }
+            thread::sleep(Duration::from_millis(100));
+            continue;
+        }
+        let stream = conn.unwrap().0;
+        let mut reader = BufReader::new(stream);
+        let mut response = Vec::<String>::new();
+        for line in reader.by_ref().lines() {
+            let line_str = line.unwrap();
+            response.push(line_str.clone());
+            if line_str == "" {
+                break;
+            }
+        }
+        let _ = reader.into_inner().write(reply.as_bytes());
+        return response;
+    }
+    Vec::<String>::new()
+}
+
+pub fn config_request_local_webserver(port: u32, form: String, reply: String) -> BTreeMap<String,String> {
+    let mut config = BTreeMap::<String,String>::new();
+    let (tx,rx) = channel::<Option<(String,String)>>();
+    let (tx_kill,rx_kill) = channel::<()>();
+    thread::spawn(move || {
+        // Implement a custom HTTP server, because YOU'RE NOT MY MOM.
+        let host = format!("127.0.0.1:{}", port);
+        let listener = TcpListener::bind(host);
+        if listener.is_err() {
+            let _ = tx.send(None); // Start data transfer
+            let _ = tx.send(None); // End data transfer
+            return;
+        }
+        let listener = listener.unwrap();
+        let _ = listener.set_nonblocking(true);
+        loop {
+            let conn = listener.accept();
+            if conn.is_err() {
+                match rx_kill.try_recv() {
+                    Ok(_) => { break;},
+                    Err(_) => {
+                        thread::sleep(Duration::from_millis(100));
+                        continue;
+                    },
+                }
+            }
+            let stream = conn.unwrap().0;
+            let mut reader = BufReader::new(stream);
+            let mut response = Vec::<String>::new();
+            let mut post_bytes: u32 = 0;
+            let re = Regex::new(r"Content-Length: ([0-9 ]+)").unwrap();
+            for line in reader.by_ref().lines() {
+                let line_str = line.unwrap();
+                if re.is_match(line_str.as_str()) {
+                    post_bytes = re.captures(line_str.as_str()).unwrap()[1].parse::<u32>().unwrap();
+                }
+                response.push(line_str.clone());
+                if line_str == "" {
+                    break;
+                }
+            }
+            match post_bytes {
+                x if x > 0  => {
+                    // Tell parent thread data is coming.  Cancels timeout mechanism.
+                    let _ = tx.send(None);
+                    {
+                        let mut post_reader = reader.by_ref().take(post_bytes as u64);
+                        let mut post_data = Vec::<u8>::new();
+                        let _ = post_reader.read_to_end(&mut post_data);
+                        let post_data = String::from_utf8(post_data).unwrap();
+                        for post_pair in post_data.split("&") {
+                        let mut key_value = post_pair.split("=");
+                            let key = key_value.next().unwrap();
+                            let value = key_value.next().unwrap();
+                            let _ = tx.send(Some((key.to_string(),value.to_string())));
+                        }
+                    }
+                    let _ = reader.into_inner().write(reply.as_bytes());
+                    // Tell parent thread that data is finished.
+                    let _ = tx.send(None);
+                    break;
+                },
+                _ => {
+                    let _ = reader.into_inner().write(form.as_bytes());
+                }
+            }
+        }
+    });
+    if !open::that(format!("http://127.0.0.1:{}", port)).is_ok() {
+        return config;
+    }
+    // Run web server for an hour.
+    let timeout = Duration::from_secs(60*60);
+    match rx.recv_timeout(timeout) {
+        Ok(_) => {
+            while let Some(pair) = rx.recv().unwrap() {
+                let key = pair.0.replace("+"," ").trim().to_string();
+                let key = percent_encoding::percent_decode(key.as_bytes()).decode_utf8_lossy();
+                let value = pair.1.replace("+"," ").trim().to_string();
+                let value = percent_encoding::percent_decode(value.trim().as_bytes()).decode_utf8_lossy();
+                config.insert(key.to_string(), value.to_string());
+            }
+        }
+        _ => {
+            warn!("Web configuration timed out.");
+            let _ = tx_kill.send(());
         }
     }
-    let _ = reader.into_inner().write(reply.as_bytes());
-    response
+    config
 }
 
 fn spotify_auth_code(lines: Vec<String>) -> String {

diff --git a/src/lib.rs b/src/lib.rs
line changes: +5/-0
index 09079d5..1969713
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -111,6 +111,11 @@ impl TStatusBar for DummyStatusBar {
     fn run(&mut self, _: bool) {}
 }
 
+pub fn reconfigure() {
+    let web_config = settings::request_web_config();
+    let _ = settings::save_web_config(web_config);
+}
+
 pub fn search_paths() -> Vec<String> {
     use std::collections::BTreeSet;
     //let mut v = Vec::<String>::new();

diff --git a/src/main.rs b/src/main.rs
line changes: +90/-40
index 6ee5868..d774cfc
--- a/src/main.rs
+++ b/src/main.rs
@@ -23,7 +23,6 @@ use std::sync::RwLock;
 extern crate log;
 extern crate log4rs;
 
-use std::env;
 use std::ptr;
 use std::thread;
 use std::time::Duration;
@@ -33,6 +32,7 @@ use std::rc::Rc;
 use std::cell::RefCell;
 
 extern crate time;
+extern crate open;
 
 #[macro_use]
 extern crate serde_derive;
@@ -43,6 +43,11 @@ use std::process;
 // How often to refresh Spotify state (if nothing triggers a refresh earlier).
 pub const REFRESH_PERIOD: i64 = 30;
 
+enum SpotifyThreadCommand {
+    Update,
+    InvalidSettings,
+}
+
 #[allow(dead_code)]
 #[derive(PartialEq, Debug)]
 enum RefreshTime {
@@ -52,7 +57,7 @@ enum RefreshTime {
     Redraw, // instantly, with stale data
 }
 
-#[derive(Serialize, Deserialize, Debug, Clone, Copy)]
+#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq)]
 enum CallbackAction {
     SelectDevice,
     PlayPause,
@@ -61,6 +66,7 @@ enum CallbackAction {
     Volume,
     Preset,
     Redraw,
+    Reconfigure,
 }
 
 #[derive(Serialize, Deserialize, Debug)]
@@ -391,6 +397,32 @@ fn play_action_label(is_playing: bool) -> &'static str {
     }
 }
 
+fn loading_menu<T: TStatusBar>(status: &mut T) {
+    status.add_label("Syncing with Spotify...");
+    status.add_separator();
+    status.add_quit("Exit");
+}
+fn reconfig_menu<T: TStatusBar>(status: &mut T) {
+    status.add_label("Invalid Configuration!");
+    status.add_separator();
+    let cb: NSCallback = Box::new(move |sender, tx| {
+        let cmd = MenuCallbackCommand {
+            action: CallbackAction::Reconfigure,
+            sender: sender,
+            data: String::new(),
+        };
+        let _ = tx.send(serde_json::to_string(&cmd).unwrap());
+    });
+    let _ = status.add_item("Reconfigure Connectr", cb, false);
+    status.add_separator();
+    let cb: NSCallback = Box::new(move |_sender, _tx| {
+        let _ = open::that("https://github.com/mrmekon/connectr");
+    });
+    let _ = status.add_item("Help!", cb, false);
+    status.add_separator();
+    status.add_quit("Exit");
+}
+
 fn fill_menu<T: TStatusBar>(app: &mut ConnectrApp,
                             spotify: &SpotifyThread,
                             status: &mut T,
@@ -585,34 +617,6 @@ fn clear_menu<T: TStatusBar>(app: &mut ConnectrApp, status: &mut T) {
     status.clear_items();
 }
 
-fn create_logger() {
-    use log::LogLevelFilter;
-    use log4rs::append::console::ConsoleAppender;
-    use log4rs::append::file::FileAppender;
-    use log4rs::encode::pattern::PatternEncoder;
-    use log4rs::config::{Appender, Config, Logger, Root};
-
-    let log_path = format!("{}/{}", env::home_dir().unwrap().display(), ".connectr.log");
-    let stdout = ConsoleAppender::builder()
-        .encoder(Box::new(PatternEncoder::new("{m}{n}")))
-        .build();
-    let requests = FileAppender::builder()
-        .build(&log_path)
-        .unwrap();
-
-    let config = Config::builder()
-        .appender(Appender::builder().build("stdout", Box::new(stdout)))
-        .appender(Appender::builder().build("requests", Box::new(requests)))
-        .logger(Logger::builder().build("app::backend::db", LogLevelFilter::Info))
-        .logger(Logger::builder()
-            .appender("requests")
-            .additive(false)
-            .build("app::requests", LogLevelFilter::Info))
-        .build(Root::builder().appender("stdout").appender("requests").build(LogLevelFilter::Info))
-        .unwrap();
-    let _ = log4rs::init_config(config).unwrap();
-}
-
 fn handle_callback(player_state: Option<&connectr::PlayerState>,
                    spotify: &mut connectr::SpotifyConnectr,
                    cmd: &MenuCallbackCommand) -> RefreshTime {
@@ -646,6 +650,7 @@ fn handle_callback(player_state: Option<&connectr::PlayerState>,
         CallbackAction::Redraw => {
             refresh = RefreshTime::Redraw;
         }
+        CallbackAction::Reconfigure => {}
     }
     refresh
 }
@@ -700,7 +705,7 @@ struct SpotifyThread {
     handle: std::thread::JoinHandle<()>,
     #[allow(dead_code)]
     tx: Sender<String>,
-    rx: Receiver<String>,
+    rx: Receiver<SpotifyThreadCommand>,
     device_list: Arc<RwLock<Option<connectr::ConnectDeviceList>>>,
     player_state: Arc<RwLock<Option<connectr::PlayerState>>>,
     presets: Arc<RwLock<Vec<(String,String)>>>,
@@ -708,7 +713,7 @@ struct SpotifyThread {
 
 fn create_spotify_thread(rx_cmd: Receiver<String>) -> SpotifyThread {
     let (tx_in,rx_in) = channel::<String>();
-    let (tx_out,rx_out) = channel::<String>();
+    let (tx_out,rx_out) = channel::<SpotifyThreadCommand>();
     let device_list = Arc::new(RwLock::new(None));
     let player_state = Arc::new(RwLock::new(None));
     let presets = Arc::new(RwLock::new(vec![]));
@@ -720,7 +725,28 @@ fn create_spotify_thread(rx_cmd: Receiver<String>) -> SpotifyThread {
         let rx = rx_in;
         let rx_cmd = rx_cmd;
         let mut refresh_time_utc = 0;
-        let mut spotify = connectr::SpotifyConnectr::new();
+
+        // Continuously try to create a connection to Spotify web API.
+        // If it fails, assume that the settings file is corrupt and inform
+        // the main thread of it.  The main thread can request that the
+        // settings file be re-configured.
+        let mut spotify: Option<connectr::SpotifyConnectr>;
+        loop {
+            spotify = connectr::SpotifyConnectr::new();
+            match spotify {
+                Some(_) => { break; },
+                None => {
+                    let _ = tx.send(SpotifyThreadCommand::InvalidSettings);
+                    if let Ok(s) = rx_cmd.recv_timeout(Duration::from_secs(120)) {
+                        let cmd: MenuCallbackCommand = serde_json::from_str(&s).unwrap();
+                        if cmd.action == CallbackAction::Reconfigure {
+                            connectr::reconfigure();
+                        }
+                    }
+                },
+            }
+        }
+        let mut spotify = spotify.unwrap();
         let device_list = thread_device_list;
         let player_state = thread_player_state;
         let presets = thread_presets;
@@ -731,7 +757,7 @@ fn create_spotify_thread(rx_cmd: Receiver<String>) -> SpotifyThread {
         {
             let mut preset_writer = presets.write().unwrap();
             *preset_writer = spotify.get_presets().clone();
-            let _ = tx.send(String::new());
+            let _ = tx.send(SpotifyThreadCommand::Update);
         }
         loop {
             if rx.try_recv().is_ok() {
@@ -754,7 +780,7 @@ fn create_spotify_thread(rx_cmd: Receiver<String>) -> SpotifyThread {
                     _ => refresh_time_utc,
                 };
                 if refresh_strategy == RefreshTime::Redraw {
-                    let _ = tx.send(String::new());
+                    let _ = tx.send(SpotifyThreadCommand::Update);
                 }
             }
 
@@ -772,7 +798,7 @@ fn create_spotify_thread(rx_cmd: Receiver<String>) -> SpotifyThread {
                 }
                 refresh_time_utc = refresh_time(player_state.read().unwrap().as_ref(), now);
                 info!("Refreshed Spotify state.");
-                let _ = tx.send(String::new()); // inform main thread
+                let _ = tx.send(SpotifyThreadCommand::Update);
             }
         }
     });
@@ -787,23 +813,37 @@ fn create_spotify_thread(rx_cmd: Receiver<String>) -> SpotifyThread {
 }
 
 fn main() {
+    fruitbasket::create_logger(".connectr.log", fruitbasket::LogDir::Home, 5, 2).unwrap();
+
     // Relaunch in a Mac app bundle if running on OS X and not already bundled.
     let icon = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
         .join("icon").join("connectr.icns");
     let touchbar_icon = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
         .join("connectr_80px_300dpi.png");
+    let clientid_script = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
+        .join("clientid_prompt.sh");
+    let license = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
+        .join("LICENSE");
+    let ini = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
+        .join("connectr.ini.in");
     if let Ok(nsapp) = fruitbasket::Trampoline::new(
-        "connectr", "connectr", "com.trevorbentley.connectr")
+        "Connectr", "connectr", "com.trevorbentley.connectr")
         .icon("connectr.icns")
         .version(env!("CARGO_PKG_VERSION"))
         .plist_key("LSBackgroundOnly", "1")
         .resource(icon.to_str().unwrap())
         .resource(touchbar_icon.to_str().unwrap())
+        .resource(clientid_script.to_str().unwrap())
+        .resource(license.to_str().unwrap())
+        .resource(ini.to_str().unwrap())
         .build(fruitbasket::InstallDir::Custom("target/".to_string())) {
             nsapp.set_activation_policy(fruitbasket::ActivationPolicy::Prohibited);
         }
+    else {
+        error!("Failed to create OS X bundle!");
+        std::process::exit(1);
+    }
 
-    create_logger();
     info!("Started Connectr");
 
     let running = Arc::new(AtomicBool::new(true));
@@ -830,6 +870,7 @@ fn main() {
 
     let mut status = connectr::StatusBar::new(tx.clone());
     info!("Created status bar.");
+    loading_menu(&mut status);
     let mut touchbar = TouchbarUI::init(tx);
     info!("Created touchbar.");
 
@@ -852,8 +893,17 @@ fn main() {
 
     let mut need_redraw: bool = false;
     while running.load(Ordering::SeqCst) {
-        if spotify_thread.rx.recv_timeout(Duration::from_millis(100)).is_ok() {
-            need_redraw = true;
+        match spotify_thread.rx.recv_timeout(Duration::from_millis(100)) {
+            Ok(cmd) => {
+                match cmd {
+                    SpotifyThreadCommand::Update => { need_redraw = true; },
+                    SpotifyThreadCommand::InvalidSettings => {
+                        clear_menu(&mut app, &mut status);
+                        reconfig_menu(&mut status);
+                    }
+                }
+            },
+            Err(_) => {}
         }
         if need_redraw && status.can_redraw() {
             clear_menu(&mut app, &mut status);

diff --git a/src/settings/mod.rs b/src/settings/mod.rs
line changes: +92/-34
index ae2a3d0..a1e3f23
--- a/src/settings/mod.rs
+++ b/src/settings/mod.rs
@@ -1,15 +1,19 @@
 extern crate ini;
 use self::ini::Ini;
+use super::http;
 
 extern crate time;
 extern crate fruitbasket;
 
 use std::env;
-use std::fs;
 use std::path;
+use std::collections::BTreeMap;
 
 const INIFILE: &'static str = "connectr.ini";
+const PORT: u32 = 5432;
+const WEB_PORT: u32 = 5676;
 
+#[derive(Default)]
 pub struct Settings {
     pub port: u32,
     pub secret: String,
@@ -20,11 +24,8 @@ pub struct Settings {
     pub presets: Vec<(String,String)>,
 }
 
-fn bundled_ini() -> String {
-    match fruitbasket::FruitApp::bundled_resource_path("connectr", "ini") {
-        Some(path) => path,
-        None => String::new(),
-    }
+fn default_inifile() -> String {
+    format!("{}/.{}", env::home_dir().unwrap().display(), INIFILE)
 }
 
 fn inifile() -> String {
@@ -35,15 +36,6 @@ fn inifile() -> String {
         return path;
     }
 
-    // If it doesn't exist, try to copy the template from the app bundle, if
-    // such a thing exists.
-    let bundle_ini = bundled_ini();
-    if path::Path::new(&bundle_ini).exists() {
-        info!("Copied config: {}", bundle_ini);
-        let _ = fs::copy(bundle_ini, path.clone());
-        return path;
-    }
-
     // Default to looking in current working directory
     let path = INIFILE.to_string();
     if path::Path::new(&path).exists() {
@@ -54,22 +46,88 @@ fn inifile() -> String {
     String::new()
 }
 
+pub fn request_web_config() -> BTreeMap<String,String> {
+    let form = format!(r###"
+{}
+<!DOCTYPE HTML>
+<html>
+<head><title>Connectr Installation</title></head>
+<body>
+<h2>Connectr Installation</h2>
+Connectr requires a <em>paid</em> Spotify Premium account and a <em>free</em> Spotify developer application.</br>
+If you don't have a Premium account, perhaps try a <a href="https://www.spotify.com/us/premium/">free trial</a>.</br>
+</br>
+To create your free developer application for Connectr, follow these instructions:</br>
+<p><ul>
+<li> Go to your <a href="https://developer.spotify.com/my-applications/#!/applications/create">Spotify Applications</a> page (login with your Spotify credentials)
+<li> Click "CREATE AN APP" in the upper-right corner
+<li> Enter a name (perhaps "Connectr") and description ("Use Connectr app with my account.")
+<li> Add a Redirect URI: <em>http://127.0.0.1:{}</em>
+<li> Copy your <em>Client ID</em> and <em>Client Secret</em> to the fields below.
+<li> Press the <em>SAVE</em> button at the bottom of Spotify's webpage
+<li> Submit this configuration form
+</ul></p>
+<form method="POST" action="#"><table>
+<tr><td>Client ID:</td><td><input type="text" name="client_id"></td></tr>
+<tr><td>Client Secret:</td><td><input type="text" name="secret"></td></tr>
+<tr><td colspan=2></br></br></tr></tr>
+<tr><td>Presets:</br>(optional, one per line)</td><td><textarea rows="10" cols="80"  name="presets" placeholder="First Preset Name=spotify:user:spotify:playlist:37i9dQZEVXboyJ0IJdpcuT"></textarea></td></tr>
+<tr><td colspan=2><center><input type="submit" value="Write config file"></center></td></tr>
+</br>
+</table></form>
+</br>
+<small>Config will be saved as: <em>{}</em></br>
+If something goes wrong or changes, edit or delete that file.</small>
+</body></html>
+"###,
+                       "HTTP/1.1 200 OK\r\n\r\n",
+                       PORT,
+                       default_inifile());
+    let reply = format!("Configuration saved.  You can close this window.");
+    let mut config = BTreeMap::<String,String>::new();
+    config.insert("port".to_string(), PORT.to_string());
+    config.append(&mut http::config_request_local_webserver(WEB_PORT, form, reply));
+    config
+}
+
+pub fn save_web_config(mut config: BTreeMap<String,String>) -> Ini {
+    let mut c = Ini::new();
+    let port = config.remove("port").unwrap();
+    c.with_section(Some("connectr".to_owned()))
+        .set("port", port);
+    let secret = config.remove("secret").unwrap_or("<PLACEHOLDER>".to_string());
+    let client_id = config.remove("client_id").unwrap_or("<PLACEHOLDER>".to_string());
+    let presets = config.remove("presets").unwrap_or(String::new());
+    c.with_section(Some("application".to_owned()))
+        .set("secret", secret)
+        .set("client_id", client_id);
+    {
+        // TODO: INI uses HashMap, doesn't support maintaining order
+        for preset in presets.split("\n") {
+            let mut pair = preset.split("=");
+            if pair.clone().count() == 2 {
+                let key = pair.next().unwrap().trim();
+                let value = pair.next().unwrap().trim();
+                c.set_to(Some("presets"), key.to_string(), value.to_string());
+            }
+        }
+    }
+    c.write_to_file(&default_inifile()).unwrap();
+    c
+}
+
 pub fn read_settings() -> Option<Settings> {
     info!("Attempting to read config file.");
     let conf = match Ini::load_from_file(&inifile()) {
         Ok(c) => c,
         Err(e) => {
             info!("Load file error: {}", e);
-            // No connectr.ini found.  Generate a junk one in-memory, which
-            // will fail shortly after with the nice error message.
-            let mut c = Ini::new();
             info!("No config file found.");
-            c.with_section(Some("connectr".to_owned()))
-                .set("port", 5657.to_string());
-            c.with_section(Some("application".to_owned()))
-                .set("secret", "<PLACEHOLDER>".to_string())
-                .set("client_id", "<PLACEHOLDER>".to_string());
-            c
+            info!("Requesting settings via web form.");
+            // Launch a local web server and open a browser to it.  Returns
+            // the Spotify configuration.
+            let web_config = request_web_config();
+            save_web_config(web_config)
         }
     };
 
@@ -81,15 +139,15 @@ pub fn read_settings() -> Option<Settings> {
     let client_id = section.get("client_id").unwrap();
     if client_id.starts_with('<') || secret.starts_with('<') {
         error!("Invalid or missing configuration.  Cannot continue.");
-        println!("");
-        println!("ERROR: Spotify Client ID or Secret not set in connectr.ini!");
-        println!("");
-        println!("Create a Spotify application at https://developer.spotify.com/my-applications/ and");
-        println!("add the client ID and secret to connectr.ini.");
-        println!("");
-        println!("Be sure to add a redirect URI of http://127.0.0.1:<PORT> to your Spotify application,");
-        println!("and make sure the port matches in connectr.ini.");
-        println!("");
+        info!("");
+        info!("ERROR: Spotify Client ID or Secret not set in connectr.ini!");
+        info!("");
+        info!("Create a Spotify application at https://developer.spotify.com/my-applications/ and");
+        info!("add the client ID and secret to connectr.ini.");
+        info!("");
+        info!("Be sure to add a redirect URI of http://127.0.0.1:<PORT> to your Spotify application,");
+        info!("and make sure the port matches in connectr.ini.");
+        info!("");
         return None;
     }
 
@@ -100,7 +158,7 @@ pub fn read_settings() -> Option<Settings> {
         access = Some(section.get("access").unwrap().clone());
         refresh = Some(section.get("refresh").unwrap().clone());
         expire_utc = Some(section.get("expire").unwrap().parse().unwrap());
-        println!("Read access token from INI!");
+        info!("Read access token from INI!");
     }
 
     let mut presets = Vec::<(String,String)>::new();

diff --git a/src/webapi/mod.rs b/src/webapi/mod.rs
line changes: +39/-21
index 04319ff..7ca637e
--- a/src/webapi/mod.rs
+++ b/src/webapi/mod.rs
@@ -7,7 +7,6 @@ extern crate chrono;
 
 use std::fmt;
 use std::iter;
-use std::process;
 use std::cell::Cell;
 use std::collections::BTreeMap;
 use std::sync::mpsc::{channel, Receiver};
@@ -25,14 +24,17 @@ pub type DeviceId = String;
 pub type SpotifyResponse = HttpResponse;
 
 pub fn parse_spotify_token(json: &str) -> (String, String, u64) {
-    let json_data: Value = serde_json::from_str(json).unwrap();
-    let access_token = json_data["access_token"].as_str().unwrap();
-    let refresh_token = match json_data.get("refresh_token") {
-        Some(j) => j.as_str().unwrap(),
-        None => "",
-    };
-    let expires_in = json_data["expires_in"].as_u64().unwrap();
-    (String::from(access_token),String::from(refresh_token), expires_in)
+    if let Ok(json_data) = serde_json::from_str(json) {
+        let json_data: Value = json_data;
+        let access_token = json_data["access_token"].as_str().unwrap_or("");
+        let refresh_token = match json_data.get("refresh_token") {
+            Some(j) => j.as_str().unwrap(),
+            None => "",
+        };
+        let expires_in = json_data["expires_in"].as_u64().unwrap_or(0 as u64);
+        return (String::from(access_token),String::from(refresh_token), expires_in);
+    }
+    (String::new(), String::new(), 0)
 }
 
 #[derive(Deserialize, Debug)]
@@ -294,26 +296,42 @@ pub struct SpotifyConnectr<'a> {
     refresh_timer_guard: Option<timer::Guard>,
     refresh_timer_channel: Option<Receiver<()>>,
 }
+impl<'a> Default for SpotifyConnectr<'a> {
+    fn default() -> Self {
+        SpotifyConnectr {
+            api: Cell::new(SPOTIFY_API),
+            settings: Default::default(),
+            auth_code: Default::default(),
+            access_token: Default::default(),
+            refresh_token: Default::default(),
+            expire_utc: Default::default(),
+            device: Default::default(),
+            refresh_timer: timer::Timer::new(),
+            refresh_timer_guard: Default::default(),
+            refresh_timer_channel: Default::default(),
+        }
+    }
+}
 
 impl<'a> SpotifyConnectr<'a> {
-    pub fn new() -> SpotifyConnectr<'a> {
+    pub fn new() -> Option<SpotifyConnectr<'a>> {
         let settings = match settings::read_settings() {
             Some(s) => s,
-            None => process::exit(0),
+            None => { return None },
         };
         let expire = settings.expire_utc;
         let access = settings.access_token.clone();
         let refresh = settings.refresh_token.clone();
-        SpotifyConnectr {api:Cell::new(SPOTIFY_API),
-                         settings: settings,
-                         auth_code: String::new(),
-                         access_token: access,
-                         refresh_token: refresh,
-                         expire_utc: expire,
-                         device: None,
-                         refresh_timer: timer::Timer::new(),
-                         refresh_timer_guard: None,
-                         refresh_timer_channel: None}
+        Some(SpotifyConnectr {api:Cell::new(SPOTIFY_API),
+                              settings: settings,
+                              auth_code: String::new(),
+                              access_token: access,
+                              refresh_token: refresh,
+                              expire_utc: expire,
+                              device: None,
+                              refresh_timer: timer::Timer::new(),
+                              refresh_timer_guard: None,
+                              refresh_timer_channel: None})
     }
     #[cfg(test)]
     fn with_api(self, api: SpotifyEndpoints<'a>) -> SpotifyConnectr<'a> {