extern crate log;
extern crate log4rs;
-use std::env;
use std::ptr;
use std::thread;
use std::time::Duration;
use std::cell::RefCell;
extern crate time;
+extern crate open;
#[macro_use]
extern crate serde_derive;
// 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 {
Redraw, // instantly, with stale data
}
-#[derive(Serialize, Deserialize, Debug, Clone, Copy)]
+#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq)]
enum CallbackAction {
SelectDevice,
PlayPause,
Volume,
Preset,
Redraw,
+ Reconfigure,
}
#[derive(Serialize, Deserialize, Debug)]
}
}
+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,
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 {
CallbackAction::Redraw => {
refresh = RefreshTime::Redraw;
}
+ CallbackAction::Reconfigure => {}
}
refresh
}
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)>>>,
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![]));
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;
{
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() {
_ => refresh_time_utc,
};
if refresh_strategy == RefreshTime::Redraw {
- let _ = tx.send(String::new());
+ let _ = tx.send(SpotifyThreadCommand::Update);
}
}
}
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);
}
}
});
}
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));
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.");
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);
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,
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 {
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() {
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)
}
};
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;
}
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();
use std::fmt;
use std::iter;
-use std::process;
use std::cell::Cell;
use std::collections::BTreeMap;
use std::sync::mpsc::{channel, Receiver};
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)]
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> {