shawkinsl/mtga-tracker

View on GitHub
electron/mainRenderer.js

Summary

Maintainability
F
2 wks
Test Coverage
console.time('init')

const request = require("request")
const crypto = require("crypto")
const ReconnectingWebSocket = require('./vendor/rws.js')
const fs = require('fs')
const jwt = require('jsonwebtoken')
const Timer = require('easytimer.js');
const keytar = require('keytar')
const hideWindowManager = require("./hide-manager")

const { remote, ipcRenderer, shell, clipboard } = require('electron')
const { Menu, MenuItem } = remote
let browserWindow = remote.getCurrentWindow()
const activeWin = require("active-win")
const mousetrap = require('mousetrap')

function contextData(data) {
  // find out what context we are in.
  // return data for that context as a single string value.
  if (data.showDraftStats) {
    return appData.draftStats.map(card=>card.pretty_name).join('\n')
  } else if (data.list_selected) {
    return data.selected_list.map(
      c=>(c.count_in_deck + ' ' + c.pretty_name + ' (' + c.set + ') ' + c.set_number)
    ).join('\n')
  } else if (data.show_available_decklists) {
    return data.player_decks.map( d=>d.pool_name ).join('\n')
  } else if (data.draw_stats.length) {
    // FIXME: Figure out where the set and card numbers are stored.
    return data.draw_stats.map(
      c=>(c.count_in_deck + ' ' + c.card)
    ).join('\n')
  } else {
    console.log('Contextual copy found no data.')
  }
}

function copyData() {
  // This should copy the data on the current screen onto the clipboard.
  // The format should be context specific, comma or tab delimited seems
  // like the best choice for most stuff.
  // TODO: deck lists.

  console.log('Copying relevant data to clipboard, if any.')
  let dataString = contextData(appData)

  if (dataString) {
    clipboard.writeText(dataString)
  }
}

function copyEventHandler(e) {
  copyData()
}

var { rendererPreload } = require('electron-routes');
rendererPreload();

let addClickHandler = (selector,handler) => {
  let new_handler = (e) => {
    if (!$.contains($('.menu-div').get(0),event.target) && !$('#main-menu').hasClass('hide-me')){
      e.preventDefault()
      e.stopPropagation()
      toggleMenu()
    } else {
      if ( handler != null){
        handler(e)
      }
    }
  }
  $(selector).each( (i,elem) => {
    $(elem).off().on('click',new_handler)
  })
}

// poll for active window semi-regularly; if it's not MTGA or MTGATracker, minimize / unset alwaysontop
setInterval(() => {
  if (appData.mtgaOverlayOnly) {
    activeWin().then(win => {
      if (win.owner.name == "MTGA.exe" || win.owner.name == "MTGATracker.exe" || win.title == "MTGA Tracker") {
        if(!browserWindow.isAlwaysOnTop()) browserWindow.setAlwaysOnTop(true)
      } else {
        if(browserWindow.isAlwaysOnTop()) browserWindow.setAlwaysOnTop(false)
      }
    })
  } else {
    console.log("skipping overlay check and turning on always on top")
    if(!browserWindow.isAlwaysOnTop()) browserWindow.setAlwaysOnTop(true)
  }
}, 200)

window.addEventListener('beforeunload', function() {
    ws.send("die")
})

let rightClickPosition = null

const contextMenu = new Menu()
const contextMenuItem = new MenuItem({
  label: 'Inspect Element',
  click: () => {
    remote.getCurrentWindow().inspectElement(rightClickPosition.x, rightClickPosition.y)
  }
})
contextMenu.append(contextMenuItem)

const API_URL = remote.getGlobal('API_URL');

var lastUsedUsername = null;

var debug = remote.getGlobal('debug');
var useFrame = remote.getGlobal('useFrame');
var staticMode = remote.getGlobal('staticMode');
var showIIDs = remote.getGlobal('showIIDs');
var showErrors = remote.getGlobal('showErrors');
var appVersionStr = remote.getGlobal('version');
var runFromSource = remote.getGlobal('runFromSource');
var showTotalWinLossCounter = remote.getGlobal('showTotalWinLossCounter');
var showDeckWinLossCounter = remote.getGlobal('showDeckWinLossCounter');
var showDailyTotalWinLossCounter = remote.getGlobal('showDailyTotalWinLossCounter');
var showDailyDeckWinLossCounter = remote.getGlobal('showDailyDeckWinLossCounter');
var showVaultProgress = remote.getGlobal('showVaultProgress');
var lastCollection = remote.getGlobal('lastCollection');
var lastVaultProgress = remote.getGlobal('lastVaultProgress');
var minVaultProgress = remote.getGlobal('minVaultProgress');
var sortMethod = remote.getGlobal('sortMethod');
var showChessTimers = remote.getGlobal('showChessTimers');
var hideDelay = remote.getGlobal('hideDelay');
var invertHideMode = remote.getGlobal('invertHideMode');
var rollupMode = remote.getGlobal('rollupMode');
var minToTray = remote.getGlobal('minToTray');
var recentCardsQuantityToShow = remote.getGlobal('recentCardsQuantityToShow');
var showGameTimer = remote.getGlobal('showGameTimer');
var zoom = remote.getGlobal('zoom');
var recentCards = remote.getGlobal('recentCards');
var port = remote.getGlobal('port');
var timerRunning = false;
var uploadDelay = 0;
var hideModeManager;
var showUIButtons = remote.getGlobal('showUIButtons')
var showHideButton = remote.getGlobal('showHideButton')
var showMenu = remote.getGlobal('showMenu')
var useMinimal = remote.getGlobal('useMinimal')

setInterval(() => {
  uploadDelay -= 1
  if (uploadDelay < 0) uploadDelay = 0;
}, 200)

var lastUseTheme = remote.getGlobal('useTheme')
var lastThemeFile = remote.getGlobal('themeFile')

var token = null;
keytar.getPassword("mtgatracker", "tracker-id-token").then(savedToken => {
  token = savedToken;
})

if (debug) {
  window.addEventListener('contextmenu', (e) => {
    e.preventDefault()
    rightClickPosition = {x: e.x, y: e.y}
    contextMenu.popup(remote.getCurrentWindow())
  }, false)
}

var ws = new ReconnectingWebSocket(`ws://127.0.0.1:${port}/`, null, {constructor: WebSocket})

var gameLookup = {}
var lastGameState = null;
var resizing = false;

var overallTimer = new Timer();
var heroTimer = new Timer();
var opponentTimer = new Timer();

window.overallTimer = overallTimer
window.heroTimer = heroTimer
window.opponentTimer = opponentTimer

var winLossCounterInitial = remote.getGlobal("winLossCounter")

let getMainWindowDisplay = () => {
  let {x, y} = browserWindow.getBounds()
  let display = remote.screen.getDisplayNearestPoint({x: x, y: y})
  return display;
}

let calcMainMaxHeight = () => {
  let displayBounds = getMainWindowDisplay().bounds
  let displayY= displayBounds.y
  let displayHeight = displayBounds.height
  let windowBounds = browserWindow.getBounds()
  let windowY = windowBounds.y
  let maxHeight = displayHeight - (windowY - displayY)
  return maxHeight + 10;  // add some buffer; 10px won't hide anything
}

var appData = {
    deck_name: "loading...",
    cant_connect: false,
    showErrors: showErrors,
    last_error: "",
    error_count: 0,
    debug: debug,
    show_iids: showIIDs,
    last_connect: 0,
    last_connect_as_seconds: 0,
    lastCollection: lastCollection,
    lastVaultProgress: lastVaultProgress,
    minVaultProgress: minVaultProgress,
    game_in_progress: false,
    showDraftStats: false,
    draftStats: [],
    game_complete: false,
    game_dismissed: false,
    show_available_decklists: true,
    no_decks: false,
    no_list_selected: true,
    list_selected: false,
    selected_list_size: "0",
    selected_list: [],
    selected_list_name: "",
    player_decks: [],
    total_cards_in_deck: "0",
    draw_stats: [],
    opponent_hand: [],
    messages: [],
    version: appVersionStr,
    showTotalWinLossCounter: showTotalWinLossCounter,
    showDeckWinLossCounter: showDeckWinLossCounter,
    showDailyTotalWinLossCounter: showDailyTotalWinLossCounter,
    showDailyDeckWinLossCounter: showDailyDeckWinLossCounter,
    showDeckCounters: false,
    winLossObj: winLossCounterInitial,
    activeDeck: 'total',
    totalWinCounter: winLossCounterInitial.alltime.total.win,
    totalLossCounter: winLossCounterInitial.alltime.total.loss,
    deckWinCounter: 0,
    deckLossCounter: 0,
    dailyTotalWinCounter: winLossCounterInitial.daily.total.win,
    dailyTotalLossCounter: winLossCounterInitial.daily.total.win,
    dailyDeckWinCounter: 0,
    dailyDeckLossCounter: 0,
    showVaultProgress: showVaultProgress,
    showGameTimer: showGameTimer,
    showChessTimers: showChessTimers,
    hideDelay: hideDelay,
    invertHideMode: invertHideMode,
    rollupMode: rollupMode,
    recentCardsQuantityToShow: recentCardsQuantityToShow,
    recentCards: recentCards,
    minToTray: minToTray,
    showUIButtons: showUIButtons,
    showHideButton: showHideButton,
    showMenu: showMenu
}

var parseVersionString = (versionStr) => {
    version = {}
    version_parts = versionStr.split("-")
    if (version_parts.length > 1)
        version.suffix = version_parts[1]
    version_bits = version_parts[0].split(".")
    version.major = version_bits[0]
    version.medium = version_bits[1]
    version.minor = version_bits[2]
    return version;
}

var addMessage = (message, link, mustfollow, messageID) => {
    if (!link) link = "#"
    let existingMessage = appData.messages.filter(x => x["text"] == message && x["show"])
    if (existingMessage.length) {
      // we can just bump the count on this one
      existingMessage[0]["count"] += 1
    } else {
      // we need a new one
      let newMessage = {text: message, count: 1, show: true}
      if (messageID) {
        newMessage["messageID"] = messageID
      }
      if (mustfollow) {
        newMessage["mustfollow"] = link
      } else {
        newMessage["mayfollow"] = link
      }
      appData.messages.push(newMessage)
    }
    resizeWindow()
}

var dismissMessage = (element) => {
  let $element = $(element)
  if ($element.hasClass('no-dismiss')){
    return
  }
  if (!$element.hasClass('message-container')){
    element = $(element).parents('.message-container').get(0)
  }

  let elementIdx = element.attributes.index.value
  let messageID = false
  if (element.attributes.messageID) {
    messageID = element.attributes.messageID.value
  }
  if (messageID) {
    ipcRenderer.send('messageAcknowledged', messageID)
  }
  appData.messages[elementIdx]["show"] = false;
  resizeWindow()
}

request.get({
    url: `${API_URL}/api/tracker-notifications`,
    json: true,
    headers: {'User-Agent': 'MTGATracker-App'}
}, (err, res, data) => {
  if (data.notifications) {
    data.notifications.forEach(message => {
      let link = "#"
      let mustfollow = false;
      if (message.mayfollow) {
        link = message.mayfollow
      } else if (message.mustfollow) {
        link = message.mustfollow
        mustfollow = true
      }
      addMessage(message.text, link, mustfollow, message.messageID)
    })
  }
})

let deckFrequencySubLists = function (decklist) {
  let card_count = -1;
  let sublists = [];
  let current_sublist = null;
  let reverse_sort = function(a, b){return b.count_in_deck - a.count_in_deck}
  // Don't mutate input list
  sorted_deck = [...decklist].sort(reverse_sort)
  for ( card of sorted_deck ) {
    if (card.count_in_deck != card_count){
      if ( current_sublist != null ){
        sublists.push(current_sublist);
      }
      current_sublist = [];
      card_count = card.count_in_deck;
    }
    current_sublist.push(card);
  }
  sublists.push(current_sublist);
  return sublists;
};

let emeraldSort = function (decklist) {
   return decklist.sort(
            function (a, b) {
                // Sort by cardtype first
                return cardtypeCompare(a.card_type, b.card_type)
                        // Then sort by mana cost
                        || manaCostCompare(a, b)
                        // Then sort by name
                        || nameCompare(a, b);
            }
    );
};

let drawSort = function (decklist,subsort) {
  let sublists = deckFrequencySubLists(decklist);
  let sorted = [];
  for (sublist of sublists){
    if (subsort === 'name') {
      sorted.push(sublist.sort( (a,b) => { return nameCompare(a,b); } ));
    } else if ( subsort === 'emerald' ) {
      sorted.push(emeraldSort(sublist));
    } else if ( subsort === 'color' ) {
      sorted.push(colorSort(sublist));
    }
  }
  //flatten
  return [].concat.apply([], sorted);
}

let colorSort = function (decklist) {
    return decklist.sort(
            function (a, b) {
                // Sort by cmc first
                return manaCostCompare(a, b)
                        // then sort by color
                        || colorCompare(a,b)
                        // Then sort by name
                        || nameCompare(a, b);
            }
    );
}

let cardtypeCompare = function (a, b) {
    // Creatures -> Planeswalkers -> Enchantments -> Artifacts -> Sorceries -> Instants -> Non-Basic Lands -> Basic Lands
    if (a.includes("Creature")) {
        if (!b.includes("Creature")) {
            return -1;
        }
        return 0;
    }
    if (b.includes("Creature")) {
        return 1;
    }
    if (a.includes("Planeswalker")) {
        if (!b.includes("Planeswalker")) {
            return -1;
        }
        return 0;
    }
    if (b.includes("Planeswalker")) {
        return 1;
    }
    if (a.includes("Enchantment")) {
        if (!b.includes("Enchantment")) {
            return -1;
        }
        return 0;
    }
    if (b.includes("Enchantment")) {
        return 1;
    }
    if (a.includes("Artifact")) {
        if (!b.includes("Artifact")) {
            return -1;
        }
        return 0;
    }
    if (b.includes("Artifact")) {
        return 1;
    }
    if (a.includes("Sorcery")) {
        if (!b.includes("Sorcery")) {
            return -1;
        }
        return 0;
    }
    if (b.includes("Sorcery")) {
        return 1;
    }
    if (a.includes("Instant")) {
        if (!b.includes("Instant")) {
            return -1;
        }
        return 0;
    }
    if (b.includes("Instant")) {
        return 1;
    }
    if (a.includes("Basic")) {
        if (!b.includes("Basic")) {
            return 1;
        }
        return 0;
    }
    if (b.includes("Basic")) {
        return -1;
    }
    return 0;
};

let cmcCompute = function (card) {
  //put lands at bottom
  if ( card.card_type === 'Land'){
    return 1000;
  }

  let total = 0;
  for (let manaSymbol of card.cost) {
    // Put X spells at the end
    if (manaSymbol === "X") {
        total += 100;
    } else {
      // Generic mana amount
      let intValue = parseInt(manaSymbol);
      if (!isNaN(intValue)) {
        total += intValue;

      } else {
        // Colored mana
        total += 1;
      }
    }
  }

  return total;
};

let manaCostCompare = function (a, b) {
    let cmcA = cmcCompute(a);
    let cmcB = cmcCompute(b);

    if (cmcA < cmcB) {
        return -1;
    }
    if (cmcB < cmcA) {
        return 1;
    }
    return 0;
};

let nameCompare = function (a, b) {
    let field = null
    if (a.pretty_name != undefined){
      field = 'pretty_name'
    } else if (a.card != undefined){
      field = 'card'
    }
    if (a[field] < b[field]) {
        return -1;
    }
    if (b[field] < a[field]) {
        return 1;
    }
    return 0;
};

/**
 * Rank color combos.
 *
 * Each color is assigned value
 * W=1 U=2 B=4 R=8 G=16 C=32 L=+32
 *
 * Array holds total color values in sorted order.
 * To find sort position of particular card,
 * get index of color combo value
 */

let color_ranks = [
  1,2,4,8,16,     // W U B G R
  3,5,9,17,       // WU WB WR WG
  6,10,18,        // UB UR UG
  12,20,          // BR BG
  24,             // RG
  7,11,19,        // WUB WUR WUG
  13,21,          // WBR WBG
  25,             // WRG
  14,22,26,       // UBR UBG URG
  28,             // BRG
  15,23,27,29,30, // WUBR WUBG WURG WBRG UBRG
  31,             // WUBRG
  32,             // C
  33,34,36,40,48, // L W U B G R
  35,37,41,49,    // L WU WB WR WG
  38,42,50,       // L UB UR UG
  44,52,          // L BR BG
  56,             // L RG
  39,43,51,       // L WUB WUR WUG
  45,53,          // L WBR WBG
  57,             // L WRG
  46,54,58,       // L UBR UBG URG
  60,             // L BRG
  47,55,59,61,62, // L WUBR WUBG WURG WBRG UBRG
  63,             // L WUBRG
  64              // L C
]

let colorCompare = function (a,b) {
  let a_index = color_ranks.indexOf(getColorValue(a));
  let b_index = color_ranks.indexOf(getColorValue(b));
  if ( a_index < b_index ) {
    return -1;
  } else if ( a_index === b_index ) {
    return 0;
  } else {
    return 1;
  }
}

let getColorValue = function(card) {
  let value = card.cardtype === 'Land' ? 32 : 0
  for ( color of card.colors) {
    if ( color === 'White' ){
      value += 1
    } else if ( color === 'Blue' ) {
      value += 2
    } else if ( color === 'Black' ) {
      value += 4
    } else if ( color === 'Red') {
      value += 8
    } else if ( color === 'Green' ){
      value += 16
    } else if ( color === 'Colorless' ){
      value += 32
    }
  }
  return value;
}

let mergeDuplicates = function(decklist) {
    let field = 'pretty_name'
    if (decklist[0].card != undefined){ field = 'card'}
    let mergedDecklist = new Map();
    decklist.forEach((card) => {
        if (mergedDecklist.get(card[field])) {
            mergedDecklist.get(card[field]).count_in_deck += card.count_in_deck;
        }
        else {
            mergedDecklist.set(card[field], Object.assign({}, card));
        }
    });
    return Array.from(mergedDecklist.values());
};

rivets.formatters.decklistSort = function(decklist) {
    if (decklist.length === 0) {
        return decklist;
    }

    let sorted_decklist = mergeDuplicates(decklist)
    if (sortMethod.startsWith("draw")) {
        let subsort = sortMethod.split('-')[1];
        if ( subsort === undefined ){
          subsort = 'name';
        }
        return drawSort(sorted_decklist,subsort);
    } else if (sortMethod == "emerald") {
        return emeraldSort(sorted_decklist);
    } else if (sortMethod == "color") {
        return colorSort(sorted_decklist);
    }
};

rivets.binders.showmessage = function(el, value) {
  if (value && remote.getGlobal('messagesAcknowledged').includes(value)) {
    el.style.display = "none"
  } else {
    el.style.display = "block"
  }
}

rivets.binders.showvault = function(el, value) {
  if (value > appData.minVaultProgress && appData.showVaultProgress) {
    el.style.display = "block"
  } else {
    el.style.display = "none";
  }
}

rivets.binders.manasymbols = (el,cost) => {
  let $el = $(el);
  $el.children().remove()

  for (let symbol of cost){
    let $span = $('<span class="mana-symbol"></span>')
    let colors = []

    if (symbol.indexOf('/') == -1) {
      colors.push(symbol.toLowerCase())
    } else {
      colors = symbol.split('/').map(x => x.replace('(','').replace(')','').toLowerCase())
      $span.addClass('mi-split').addClass('mi-split-color')
    }
    for (let color of colors) {
      let $i = ($('<i class="mi mi-shadow mi-' + color + '"></i>'))
      if (colors.length == 1){
        $i.addClass('mi-mana')
      }
      $span.append($i)
    }

    $(el).append($span)
  }
}

rivets.binders.card_color = function(el, value) {
  el.classList.remove("card-b")
  el.classList.remove("card-g")
  el.classList.remove("card-r")
  el.classList.remove("card-u")
  el.classList.remove("card-w")

  el.classList.remove("card-c")  // colorless
  el.classList.remove("card-m")  // multicolor, not mountain
  let atLeastOneColor = false;

  if (value.length > 1) {
    // card-m sets the fade color
    el.classList.add("card-m")
  }

  if (value.length > 2) {
    // card-m-back sets the background image to generic 3-color background
    el.classList.add("card-m-back")
  } else {

      if (value.includes("Black")) {
        el.classList.add("card-b")
        atLeastOneColor = true
      }
      if (value.includes("White")) {
        el.classList.add("card-w")
        atLeastOneColor = true
      }
      if (value.includes("Blue")) {
        el.classList.add("card-u")
        atLeastOneColor = true
      }
       if (value.includes("Green")) {
        el.classList.add("card-g")
        atLeastOneColor = true
      }
       if (value.includes("Red")) {
        el.classList.add("card-r")
        atLeastOneColor = true
      }
      if (value.includes("Colorless") || !atLeastOneColor) {
        el.classList.add("card-c")
      }
  }
}

rivets.formatters.as_seconds = function(value) {
    return value / 100;
}

rivets.formatters.more_than_one = function(value) {
    return value > 1;
}

rivets.bind(document.getElementById('container'), appData)

var hideBackButton = function(hidden) {
  if(hidden) {
    $(".hide-on-rollup").addClass("rollup-modifier");
  } else {
    $(".hide-on-rollup").removeClass("rollup-modifier");
  }
}
function hideCallback(hidden) {
  console.log("hidden callback called")
  hideBackButton(hidden)
  resizeWindow()
}

ws.addEventListener('open', () => {
    ws.send('hello!');
    console.log("sent hello")
});

function resizeWindow() {
    let total = 0;
    $.each($(".card"), function(i, c) {
        total += c.offsetHeight;
    })

    container = document.getElementById("container")

    let totalHeight = 0;
    totalHeight += $('#tracker-header').outerHeight(true);
    $("#tracker-body").children().each(function(c, e) {
        if(e.style.display != "none" && !e.classList.contains("no-height-contribution"))
            totalHeight += $(e).outerHeight(true);
    });

    bounds = browserWindow.getBounds()
    container.style.height = "" + parseInt(totalHeight) + "px"
    if (zoom > 0.85) {
      // TODO: resize with math that is less "throwing shit at the wall"
      // tbh this is stupid but it works for now
      // (and is probably better than setting max width and height and hoping transparent window works)
      let heightPower = 5;
      if (remote.getGlobal("useFlat")) {
        heightPower = 3;
      }
      totalHeight += parseInt(100 * (zoom ** heightPower))
      bounds.width = parseInt(354 * (zoom ** 2))
      if (0.85 < zoom && zoom < 0.95) {
        // dirty, dirty hack, but 0.9 is broken so.
        bounds.width += 30;
      }
    } else {
      bounds.width = parseInt(354 * zoom);
    }

    let menuHeight = 0
    let selectors = ['#tracker-header h1', '#main-menu ul', '#main-menu ul li ul']
    for (selector of selectors) {
      menuHeight += $(selector).outerHeight(true)
    }

    if (menuHeight > totalHeight) {
      totalHeight = menuHeight
    }

    bounds.height = Math.min(parseInt(totalHeight), calcMainMaxHeight());
    if (!(debug || useFrame)) {
        browserWindow.setBounds(bounds)
    }
    setClickHandlers();
}

function populateDeck(elem) {
    deckID = elem.getAttribute('data-deckid');
    appData.activeDeck = deckID;
    deck = getDeckById(deckID);

    const types = ['daily','alltime'];
    $.each(types, (i, type) => {
      if (appData.winLossObj[type][deckID] === undefined) {
        appData.winLossObj[type][deckID] = {win: 0, loss: 0, name: deck.pool_name}
      }
      const counter_prefix = type === 'daily' ? 'dailyDeck' : 'deck';
      appData[counter_prefix + 'WinCounter'] = appData.winLossObj[type][deckID].win;
      appData[counter_prefix + 'LossCounter'] = appData.winLossObj[type][deckID].loss;
    });

    if (deck != null){
      appData.selected_list = deck.cards;
      appData.selected_list_name = deck.pool_name;
      appData.list_selected = true;
      appData.no_list_selected = false;
      appData.showDeckCounters = true;
    }

    resizeWindow()
}

function exitDraft() {
    appData.game_in_progress = false;
    appData.show_available_decklists = true;
    appData.showDraftStats = false;
    resizeWindow()
}

function unpopulateDecklist() {
    appData.list_selected = false;
    appData.no_list_selected = true;
    appData.activeDeck = 'total';
    appData.deckWinCounter = 0;
    appData.deckLossCounter = 0;
    appData.dailyDeckWinCounter = 0;
    appData.dailyDeckLossCounter = 0;
    appData.showDeckCounters = false;

    appData.game_in_progress = false;
    appData.show_available_decklists = true;
    appData.showDraftStats = false;

    resizeWindow()
}

function passThrough(endpoint, passData, playerID) {
  passData.playerId = playerID;
  return new Promise((resolve, reject) => {
    console.log(`posting ${endpoint} request...`)
    let fetchURL = `insp://${endpoint}`
    fetch(fetchURL, {method: "POST", body: JSON.stringify(passData)})
    .then(resp => resp.json())
    .then(data => {
      if (data.error) throw new Error(data.error)
      console.log(`finished posting ${endpoint} request...`)
      resolve({success: true})
    }).catch(err => {
      addMessage(`WARNING! Could not record ${endpoint}! Please contact us in Discord for support! (${err})`)
      console.log(err)
      reject({on: `post_${endpoint}`, error: err || res})
    })
  })
}

function cleanError(error, depth, maxDepth) {
  if (depth === undefined) depth = 0;
  if (maxDepth === undefined) maxDepth = 3;
  if (depth >= maxDepth) return;
  if (error && typeof error === 'object') {
    for (let key in error) {
      if (key == "token") {
        delete error[key]
      } else {
        cleanError(error[key], depth+1, maxDepth)
      }
    }
  }
}

function cleanErrors(errors) {
  errors.forEach(error => cleanError(error, 0))
}

function getDeckById(deckID){
  return appData.player_decks.find(x => x.deck_id == deckID) || false
}

function uploadGame(gameData) {
    const victory = gameData.players[0].name === gameData.winner;

    //only update per-deck win/loss for decks we know about
    const deckID = gameData.players[0].deck.deckID;
    if (appData.player_decks.map(deck=>deck.deck_id).includes(deckID)) {
      const types = ['daily','alltime'];
      $.each(types, (i, type) => {
        if(appData.winLossObj[type][deckID]) {
          if (victory) {
            appData.winLossObj[type][deckID].win++;
          } else {
            appData.winLossObj[type][deckID].loss++;
          }
        } else {
          if (victory) {
            appData.winLossObj[type][deckID] = {win: 1, loss: 0, name: gameData.players[0].deck.poolName};
          } else {
            appData.winLossObj[type][deckID] = {win: 0, loss: 1, name: gameData.players[0].deck.poolName};
          }
        }
      });

      ipcRenderer.send('updateWinLossCounters', {
        key: deckID,
        value: {alltime: appData.winLossObj['alltime'][deckID],daily:appData.winLossObj['daily'][deckID]}
      })
    }
    if (victory) {
      appData.winLossObj.alltime.total.win++;
      appData.winLossObj.daily.total.win++;
    } else {
      appData.winLossObj.alltime.total.loss++;
      appData.winLossObj.daily.total.loss++;
    }
    ipcRenderer.send('updateWinLossCounters', {
      key: "total",
      value: {alltime:appData.winLossObj.alltime.total,daily:appData.winLossObj.daily.total}
    });

  let fetchURL = `insp://insert-game`
  fetch(fetchURL, {method: "POST", body: JSON.stringify(gameData)})
    .then(resp => resp.json())
    .then(data => {
      if (data.error) {
        throw new Error(data.error)
      }
      console.log(`got ${data} from insert-game`)
      console.log(data)

      if (remote.getGlobal("showInspector")) {
        // TODO: open inspector window instead of linking to https://inspector...
        addMessage("Game saved in Inspector!", "javascript:openInspector();")
      }
    })
    .catch(err => {
      console.log(err)
      addMessage(`WARNING! Could not save game result! Please contact us in Discord for support! (${err})`)
    })

  return new Promise((resolve, reject) => {
    let anonGameData = {
      anonymousUserID: crypto.createHash('md5').update(gameData.players[0].name).digest("hex"),
      gameID: crypto.createHash('md5').update(gameData.gameID).digest("hex"),
      client_version: appData.version,
    }
    console.log("posting game request...")
    console.log(anonGameData)
    postGameUrl = `${API_URL}/api/game`
    request.post({
      url: postGameUrl,
      json: true,
      body: anonGameData,
      headers: {'User-Agent': 'MTGATracker-App', token: token}
    }, (err, res, data) => {
      console.log("finished posting game request...")
      console.log(res)
      console.log(err)
      if (err || res.statusCode != 201) {
        console.log({on: "post_game", error: err || res})
        resolve({attempt: attempt, errors: errors})
      } else {
        resolve({success: true})
      }
    })
  })
}

let gameAlreadyUploaded = (gameID) => {
  return Object.keys(gameLookup).includes(gameID)
}

let onMessage = (data) => {
    data = JSON.parse(event.data)
    if (data.data_type == "game_state") {
        if (data.match_complete) {

            timerRunning = false;
            $("#opponent-timer").removeClass("active")
            $("#hero-timer").removeClass("active")
            overallTimer.pause()
            heroTimer.pause()
            opponentTimer.pause()

            console.log("match over")
            if (data.game && gameAlreadyUploaded(data.game.gameID)) {
              console.log(`Backend sent match_complete for ${data.game.gameID}, but already know that game`)
            } else if (data.game) {
              appData.game_complete = true;
              $(".cardsleft").addClass("gamecomplete")

              gameLookup[data.game.gameID] = {count: 0, uploaded: true}
              uploadGame(data.game)
            } else if (data.gameID) {
              console.log(`match_complete and gameID ${data.gameID} but no game data`)
              if (gameAlreadyUploaded(data.gameID)) {
                if (gameLookup[data.gameID].count++ > 5) {
                  if (!gameLookup[data.gameID].uploaded) {
                    gameLookup[data.gameID].uploaded = true
                    if (lastGameState) {
                      uploadGame(lastGameState)
                    }
                  }
                }
              } else { // gameLookup doesn't know this game yet
                console.log(`haven't seen ${data.gameID} before, adding now'`)
                gameLookup[data.gameID] = {count: 0, uploaded: false}
              }
            }
        } else if (!gameAlreadyUploaded(data.game_id)) {
            lastGameState = data
            if (!timerRunning) {
              timerRunning = true;
              console.log("TIMER: resetcss")
              // this is transition into a game. reset all the timers
              overallTimer.reset()
              opponentTimer.reset()
              heroTimer.reset()
              overallTimer.start()
              // pause each player's timer. we'll unpause them soon, with a decisionPlayerChange event.
              opponentTimer.pause()
              heroTimer.pause()

              //set the stats to report for this deck, if known
              //otherwise, use total stats
              if (appData.player_decks.map(deck=>deck.deck_id).includes(data.deck_id)){
                const deck = getDeckById(data.deck_id);
                appData.activeDeck = data.deck_id;
                const types = ['daily','alltime'];
                $.each(types, (i, type) => {
                  if (!(data.deck_id in appData.winLossObj[type])){
                    appData.winLossObj[type][appData.activeDeck] = {win: 0, loss: 0, name: deck.pool_name};
                  }
                });
                appData.deckWinCounter = appData.winLossObj['alltime'][data.deck_id].win;
                appData.deckLossCounter = appData.winLossObj['alltime'][data.deck_id].loss;
                appData.dailyDeckWinCounter = appData.winLossObj['daily'][data.deck_id].win;
                appData.dailyDeckLossCounter = appData.winLossObj['daily'][data.deck_id].loss;
                appData.showDeckCounters = true;

              } else {
                appData.activeDeck = 'total';
                appData.deckWinCounter = 0;
                appData.deckLossCounter = 0;
                appData.dailyDeckWinCounter = 0;
                appData.dailyDeckLossCounter = 0;
                appData.showDeckCounters = false;
              }
            }
            appData.game_in_progress = true;
            appData.show_available_decklists = false;
            appData.showDraftStats = false;
            appData.game_complete = false;
            $(".cardsleft").removeClass("gamecomplete")

            appData.deck_name = data.draw_odds.deck_name;
            appData.opponent_hand = data.opponent_hand;

            if (staticMode) {
              appData.draw_stats = data.draw_odds.original_deck_stats;
              appData.total_cards_in_deck = data.draw_odds.original_decklist_total;
            } else {
              appData.draw_stats = data.draw_odds.stats;
              appData.total_cards_in_deck = data.draw_odds.total_cards_in_deck;
            }
        }
    } else if (data.game_history_event) {
      ipcRenderer.send('gameHistoryEvent', data.game_history_event)
    } else if (data.data_type == "error") {
        if (data.count) {
            appData.error_count = data.count;
        }
        appData.last_error = data.msg;
    } else if (data.data_type == "message") {
        if (data.right_click && !debug) {
            hideWindow()
        } else if (data.draft_collection_count) {
          console.log("handle draft stuff")
          console.log(data.draft_collection_count)

          appData.game_in_progress = false;
          appData.show_available_decklists = false;
          appData.showDraftStats = true;

          appData.draftStats = data.draft_collection_count
        } else if (data.rank_change) {
          passThrough("rankChange", data.rank_change, data.player_key).catch(e => {
            console.log("error uploading rank data: ")
            console.log(e)
          })
        } else if (data.inventory_update) {
         // passThrough("tracker-api/inventory-update", data.inventory_update, data.player_key).catch(e => {
         // // TODO: check for wildcard redemptions? or should we do that in the API?
         //   console.log("error uploading inventory-update data: ")
         //   console.log(e)
         // })
        } else if (data.inventory) {
          if (data.inventory.vaultProgress) {
            appData.lastVaultProgress = data.inventory.vaultProgress;

            ipcRenderer.send("lastVaultProgressChanged",appData.lastVaultProgress)
          }
          //console.log(data.inventory)
          ipcRenderer.send('inventoryChanged', data.inventory)
         // passThrough("tracker-api/inventory", data.inventory, data.player_key).catch(e => {
         //   console.log("error uploading inventory data: ")
         //   console.log(e)
         // })
        } else if (data.collection) {
          var cardQuantity;
          if (data.collection) {
            if(appData.lastCollection && (Object.keys(appData.lastCollection).length != 0)) {
              var objectToPush = {time:(new Date(Date.now())).toLocaleString(), cardsObtained:{}};
              for(var cardID in data.collection) {
                  if(data.collection.hasOwnProperty(cardID)) {
                      if(/^\d+$/.test(cardID)) {
                        cardQuantity = data.collection[cardID] - appData.lastCollection[cardID];
                        if(isNaN(cardQuantity)) {
                          cardQuantity = data.collection[cardID];
                        }
                        if(cardQuantity > 0) {
                          objectToPush.cardsObtained[cardID] = cardQuantity;
                        }
                      }
                  }
              }

              if(Object.keys(objectToPush.cardsObtained).length > 0) {
                appData.recentCards.unshift(objectToPush);
                console.log(appData.recentCards);
                ipcRenderer.send('recentCardsChanged', objectToPush)
              }
            }

            appData.lastCollection = data.collection
            ipcRenderer.send('settingsChanged', {
              key: "lastCollection",
              value: appData.lastCollection
            })

           // passThrough("tracker-api/collection", data.collection, data.player_key).catch(e => {
           //   console.log("error uploading collections data: ")
           //   console.log(e)
           // })
          }
        } else if (data.draftPick) {
          passThrough("draft-pick", data.draftPick, data.player_key).catch(e => {
            console.log("error uploading draftPick data: ")
            console.log(e)
          })
        } else if (data.decisionPlayerChange) {
            if (data.heroIsDeciding) {
                opponentTimer.start()
                heroTimer.pause()
                $("#opponent-timer").removeClass("active")
                $("#hero-timer").addClass("active")
            } else {
                opponentTimer.pause()
                heroTimer.start()
                $("#opponent-timer").addClass("active")
                $("#hero-timer").removeClass("active")
            }
        } else if (data.authenticateResponse) {
          console.log("handle authenticateResponse")
          // TODO save user?
        }
    } else if (data.data_type=="decklist_change") {
        if (data.decks.no_decks_defined) {
            appData.no_decks = true;
        } else {
            new_decks = []
            $.each(data.decks, (key, value) => {
                new_decks.push(value)
            })
            appData.player_decks = new_decks.sort((a,b) => {
              if (a.pool_name < b.pool_name ){
                return -1;
              } else if ( a.pool_name === b.pool_name ){
                return 0;
              } else {
                return 1;
              }
            });
            appData.no_decks = false;
        }
    }
    resizeWindow()
}

let stepZoom = (zoomIn=true) => {
  zoom += zoomIn ? 0.1 : -0.1;
  if ( zoom < 0.2 ) {
    zoom = 0.2;
  }
  applyZoom()
}

let resetZoom = () => {
  zoom = 1
  applyZoom()
}

let applyZoom = () => {
  browserWindow.webContents.setZoomFactor(zoom)
  ipcRenderer.send('settingsChanged', {key: "zoom", value: zoom})
}

let zoomIn = () => {
  stepZoom()
}

let zoomOut = () => {
  stepZoom(false)
}

//function to close because of coming feature: close to tray
let close = () => {
  browserWindow.close()
}

let openInspector = () => { ipcRenderer.send('openInspector', null); }
let openHistory = () => { ipcRenderer.send('openHistory', null); }
let openSettings = () => { ipcRenderer.send('openSettings', null); }
let openCollection = () => { ipcRenderer.send('openCollection', null); }

let menu_items = [
  {
    label: 'History',
    action: openHistory,
    keybind: 'Ctrl+H'
  },
   {
    label: 'Inspector',
    action: openInspector,
    keybind: 'Ctrl+I'
  },
  {
    label: 'Settings',
    action: openSettings,
    keybind: 'Ctrl+;',
    alt_keybind: 'Ctrl+S'
  },
  {
    label: 'Vault',
    action: openCollection,
    keybind: "Ctrl+V"
  },
  {
    label: 'Copy',
    action: copyData,
    keybind: 'Ctrl+C',
    separator: 'top'
  },
  {
    label: 'Zoom',
    submenu: [
      {
        label: 'Zoom in',
        action: zoomIn,
        keep_open: true,
        keybind: 'Ctrl+\+',
        alt_keybind: 'Ctrl+='
      },
      {
        label: 'Zoom out',
        action: zoomOut,
        keep_open: true,
        keybind: 'Ctrl+-',
        alt_keybind: 'Ctrl+Shift+-'
      },
      {
        label: 'Reset zoom',
        action: resetZoom,
        keep_open: true,
        keybind: 'Ctrl+0'
      },
    ],
    separator: 'bottom'
  },
  {
    label: 'Quit',
    action: close,
    keybind: 'Ctrl+Q'
  },
];

let buildMenu = () => {
  let menu = $('<ul></ul>')
  for (let menu_item of menu_items) {
    menu.append(buildMenuItem(menu_item))
  }
  $('#main-menu').append(menu)
};

let buildMenuItem = (menu_item) => {
  let li = $('<li></li>')
  let item = $('<a></a>')
  item.text(menu_item.label)

  if (menu_item.action != undefined) {
    let action = null
    if ( menu_item.keep_open ) {
      action = menu_item.action
    } else {
      action = () => { menu_item.action.call(); toggleMenu()}
    }
    item.click(action)
  }

  if (menu_item.keybind != undefined){
    let span = $('<span class="keybind">' + menu_item.keybind + '</span>')
    item.append(span)
    mousetrap.bind(menu_item.keybind.toLowerCase(),menu_item.action)
    if (menu_item.alt_keybind != undefined){
      mousetrap.bind(menu_item.alt_keybind.toLowerCase(),menu_item.action)
    }
  }

  li.append(item)

  if (menu_item.separator != undefined) {
    if (menu_item.separator == 'both' || menu_item.separator == 'top'){
      li.addClass('separator-top')
    }
    if (menu_item.separator == 'both' || menu_item.separator == 'bottom'){
      li.addClass('separator-bottom')
    }
  }

  if (menu_item.submenu != undefined) {
    let submenu = $('<ul></ul>')
    for (let submenu_item of menu_item.submenu){
      submenu.append(buildMenuItem(submenu_item))
    }
    li.append(submenu)
  }
  return li
}

let toggleMenu = () => {
  $('#main-menu').toggleClass('hide-me');
  $('body').toggleClass('no-drag');
  resizeWindow()
}

let updateTitleWidth = () => {
  let width = useMinimal ? 278 : 320
  if (appData.showHideButton) {
    width -= 26
  }
  if (appData.showUIButtons){
    width -= 50
  }
  if (appData.showMenu) {
    width -= 24
  }
  if (!(appData.showHideButton && appData.showMenu && appData.showUIButtons)){
    width -= 8
  }
  $('#tracker-header h1').css('width',width)
}

/**
 * These click handlers need to be reset on all resizeWindow calls because of DOM changes
 */

let setClickHandlers = () => {
  addClickHandler('.message-container',(e) => {dismissMessage(e.target)})
  addClickHandler('.back-draft',(e) => {exitDraft()})
  addClickHandler('.back-to-decklist',(e) => {unpopulateDecklist()})
  addClickHandler('.deck-container',(e) => {populateDeck($(e.target).parent().get(0))})
}

let hideWindow = () => {
    hideModeManager.toggleHidden()
    ipcRenderer.send('hideRequest')
}

document.addEventListener("DOMContentLoaded", function(event) {
    hideModeManager = hideWindowManager({
      useRollupMode: function() {return remote.getGlobal('rollupMode')},
      getHideDelay: function() {return remote.getGlobal('hideDelay')},
      getInverted: function() {return remote.getGlobal('invertHideMode')},
      windowName: "mainRenderer",
      bodyID: "#tracker-body",
      headerID: "#tracker-header",
      containerID: "#container",
      hideCallback: hideCallback,
    })

    if (invertHideMode){
      hideWindow()
    }

    setInterval(() => {
        $('#overall-timer').html(overallTimer.getTimeValues().toString());
        $('#hero-timer').html(opponentTimer.getTimeValues().toString());
        $('#opponent-timer').html(heroTimer.getTimeValues().toString());
    }, 1000)

    if (debug || useFrame) {
        $("#container").addClass("container-framed")
        $("body").css("background-color", "green")
    } else {
        $("#container").addClass("container-normal")
    }

    /**
     * These click handlers only need to be set once, so called here.
     */

    buildMenu()
    updateTitleWidth()
    addClickHandler('#menu-icon',toggleMenu)
    addClickHandler('#floating-eye',hideWindow)
    addClickHandler('#minimize-icon',() => {browserWindow.minimize()})
    addClickHandler('#close-icon',close)
    addClickHandler('body',null)

    //open links externally by default
    $(document).on('click', 'a[href^="http"]', function(event) {
        event.preventDefault();
        shell.openExternal(this.href);
    });
    // load theme on first launch without settings change
    if (lastThemeFile && lastUseTheme) {
    let currentThemeLink = $("#theme")
    if (currentThemeLink) {
      currentThemeLink.remove()
    }
    if (lastUseTheme) {
      let head  = document.getElementsByTagName('head')[0];
      let link  = document.createElement('link');
      link.id   = 'theme';
      link.rel  = 'stylesheet';
      link.type = 'text/css';
      let themePath = runFromSource ? "themes/" : "../../../themes/";
      link.href = themePath + lastThemeFile;
      head.appendChild(link)
    }
  }
  ws.onmessage = onMessage
});

ipcRenderer.on('stdout', (event, data) => {
  console.log(data.text)
})

ipcRenderer.on('updateReadyToInstall', (messageInfo) => {
  addMessage("A new tracker update will be applied on next launch!", "https://github.com/shawkinsl/mtga-tracker/releases/latest")
})

ipcRenderer.on('settingsChanged', () => {
  debug = remote.getGlobal('debug');
  appData.debug = debug

  mtgaOverlayOnly = remote.getGlobal('mtgaOverlayOnly');
  appData.mtgaOverlayOnly = mtgaOverlayOnly

  sortMethod = remote.getGlobal('sortMethod');

  useFrame = remote.getGlobal('useFrame');
  appData.useFrame = useFrame

  staticMode = remote.getGlobal('staticMode');
  appData.staticMode = staticMode

  showIIDs = remote.getGlobal('showIIDs');
  appData.showIIDs = showIIDs

  showErrors = remote.getGlobal('showErrors');
  appData.showErrors = showErrors

  appVersionStr = remote.getGlobal('version');
  appData.appVersionStr = appVersionStr

  showTotalWinLossCounter = remote.getGlobal('showTotalWinLossCounter');
  appData.showTotalWinLossCounter = showTotalWinLossCounter

  showDeckWinLossCounter = remote.getGlobal('showDeckWinLossCounter');
  appData.showDeckWinLossCounter = showDeckWinLossCounter

  showDailyTotalWinLossCounter = remote.getGlobal('showDailyTotalWinLossCounter');
  appData.showDailyTotalWinLossCounter = showDailyTotalWinLossCounter

  showDailyDeckWinLossCounter = remote.getGlobal('showDailyDeckWinLossCounter');
  appData.showDailyDeckWinLossCounter = showDailyDeckWinLossCounter

  showVaultProgress = remote.getGlobal('showVaultProgress');
  appData.showVaultProgress = showVaultProgress

  showGameTimer = remote.getGlobal('showGameTimer');
  appData.showGameTimer = showGameTimer

  showChessTimers = remote.getGlobal('showChessTimers');
  appData.showChessTimers = showChessTimers

  hideDelay = remote.getGlobal('hideDelay');
  appData.hideDelay = hideDelay

  invertHideMode = remote.getGlobal('invertHideMode');
  appData.invertHideMode = invertHideMode

  rollupMode = remote.getGlobal('rollupMode');
  appData.rollupMode = rollupMode

  recentCards = remote.getGlobal('recentCards');
  appData.recentCards = recentCards

  minToTray = remote.getGlobal('minToTray');
  appData.minToTray = minToTray

  let useTheme = remote.getGlobal("useTheme")
  let themeFile = remote.getGlobal("themeFile")
  let useFlat = remote.getGlobal("useFlat")

  let currentFlatLink = $("#flat")
  if (useFlat) {
    if(!currentFlatLink.length) {
      let head  = document.getElementsByTagName('head')[0];
      let link  = document.createElement('link');
      link.id   = 'flat';
      link.rel  = 'stylesheet';
      link.type = 'text/css';
      link.href = 'flat.css';
      head.appendChild(link)
    } else {
      console.log(currentFlatLink)
    }
  } else if (currentFlatLink) {
    currentFlatLink.remove()
  }

  useMinimal = remote.getGlobal("useMinimal")

  let currentMinimalLink = $("#minimal")
  if (useMinimal) {
    if (!currentMinimalLink.length) {
      let head  = document.getElementsByTagName('head')[0];
      let link  = document.createElement('link');
      link.id   = 'minimal';
      link.rel  = 'stylesheet';
      link.type = 'text/css';
      link.href = 'minimal.css';
      head.appendChild(link)
      updateTitleWidth()
    }
  } else if (currentMinimalLink) {
    currentMinimalLink.remove()
    updateTitleWidth()
  }

  if ((themeFile && (themeFile != lastThemeFile)) || useTheme != lastUseTheme) {
    lastThemeFile = themeFile
    lastUseTheme = useTheme
    let currentThemeLink = $("#theme")
    if (currentThemeLink) {
      currentThemeLink.remove()
    }
    if (useTheme) {
      let head  = document.getElementsByTagName('head')[0];
      let link  = document.createElement('link');
      link.id   = 'theme';
      link.rel  = 'stylesheet';
      link.type = 'text/css';
      let themePath = runFromSource ? "themes/" : "../../../themes/";
      link.href = themePath + lastThemeFile;
      head.appendChild(link)
    }
  }

  let buttonsChanged = false
  let fields = ['showUIButtons','showMenu','showHideButton']
  for (let field of fields) {
    let newVal = remote.getGlobal(field)
    if (newVal != appData[field]){
      appData[field] = newVal
      buttonsChanged = true
    }
  }
  if (buttonsChanged) {
    updateTitleWidth()
  }

  resizeWindow()
})

ipcRenderer.on('counterChanged', (e,new_wlc) => {
  appData.winLossObj = new_wlc;

  if (appData.activeDeck === 'total') {
      appData.deckWinCounter = 0;
      appData.deckLossCounter = 0;
      appData.dailyDeckWinCounter = 0;
      appData.dailyDeckLossCounter = 0;
      appData.showDeckCounters = false;
  } else {
    appData.deckWinCounter = appData.winLossObj.alltime[appData.activeDeck].win;
    appData.deckLossCounter = appData.winLossObj.alltime[appData.activeDeck].loss;
    if (appData.winLossObj.daily[appData.activeDeck] === undefined){
      appData.winLossObj.daily[appData.activeDeck] = {win:0,loss:0,name:getDeckById(appData.activeDeck).pool_name};
    }
    appData.dailyDeckWinCounter = appData.winLossObj.daily[appData.activeDeck].win;
    appData.dailyDeckLossCounter = appData.winLossObj.daily[appData.activeDeck].loss;
    appData.showDeckCounters = true;
  }

  appData.totalWinCounter = appData.winLossObj.alltime.total.win;
  appData.totalLossCounter = appData.winLossObj.alltime.total.loss;
  appData.dailyTotalWinCounter = appData.winLossObj.daily.total.win;
  appData.dailyTotalLossCounter = appData.winLossObj.daily.total.loss;
});

console.timeEnd('init')