aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--package-lock.json14
-rw-r--r--package.json2
-rw-r--r--src/app/css/grid.css18
-rw-r--r--src/app/css/launcher.css11
-rw-r--r--src/app/css/selection.css23
-rw-r--r--src/app/css/theming.css2
-rw-r--r--src/app/icons/sort.pngbin0 -> 1225 bytes
-rw-r--r--src/app/index.html23
-rw-r--r--src/app/js/browser.js51
-rw-r--r--src/app/js/dom_events.js6
-rw-r--r--src/app/js/gamepad.js333
-rw-r--r--src/app/js/launcher.js24
-rw-r--r--src/app/js/mods.js56
-rw-r--r--src/app/js/navigate.js903
-rw-r--r--src/app/js/settings.js9
-rw-r--r--src/app/js/toasts.js6
-rw-r--r--src/app/js/tooltip.js2
-rw-r--r--src/app/js/update.js32
-rw-r--r--src/app/main.css1
-rw-r--r--src/app/main.js3
-rw-r--r--src/cli.js6
-rw-r--r--src/index.js26
-rw-r--r--src/lang/de.json6
-rw-r--r--src/lang/en.json12
-rw-r--r--src/lang/fr.json4
-rw-r--r--src/lang/zh.json4
-rw-r--r--src/modules/gamepath.js16
-rw-r--r--src/modules/mods.js8
-rw-r--r--src/modules/packages.js1
-rw-r--r--src/modules/protocol.js39
-rw-r--r--src/modules/update.js25
31 files changed, 1616 insertions, 50 deletions
diff --git a/package-lock.json b/package-lock.json
index 19a4bee..a25334d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -20,7 +20,7 @@
"minimist": "^1.2.8",
"recursive-copy": "^2.0.13",
"simple-vdf": "^1.1.1",
- "unzip-stream": "^0.3.1"
+ "unzip-stream": "^0.3.2"
},
"devDependencies": {
"electron": "^28.2.0",
@@ -3566,9 +3566,9 @@
}
},
"node_modules/unzip-stream": {
- "version": "0.3.1",
- "resolved": "https://registry.npmjs.org/unzip-stream/-/unzip-stream-0.3.1.tgz",
- "integrity": "sha512-RzaGXLNt+CW+T41h1zl6pGz3EaeVhYlK+rdAap+7DxW5kqsqePO8kRtWPaCiVqdhZc86EctSPVYNix30YOMzmw==",
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/unzip-stream/-/unzip-stream-0.3.2.tgz",
+ "integrity": "sha512-oWhfqwjx36ULFG+krfkbtbrc/BeEzaYrlqdEWa5EPNd6x6RerzuNW8aSTM0TtNtrOfUKYdO0TwrlkzrXAE6Olg==",
"dependencies": {
"binary": "^0.3.0",
"mkdirp": "^0.5.1"
@@ -6485,9 +6485,9 @@
"integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ=="
},
"unzip-stream": {
- "version": "0.3.1",
- "resolved": "https://registry.npmjs.org/unzip-stream/-/unzip-stream-0.3.1.tgz",
- "integrity": "sha512-RzaGXLNt+CW+T41h1zl6pGz3EaeVhYlK+rdAap+7DxW5kqsqePO8kRtWPaCiVqdhZc86EctSPVYNix30YOMzmw==",
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/unzip-stream/-/unzip-stream-0.3.2.tgz",
+ "integrity": "sha512-oWhfqwjx36ULFG+krfkbtbrc/BeEzaYrlqdEWa5EPNd6x6RerzuNW8aSTM0TtNtrOfUKYdO0TwrlkzrXAE6Olg==",
"requires": {
"binary": "^0.3.0",
"mkdirp": "^0.5.1"
diff --git a/package.json b/package.json
index 1cde783..95d8119 100644
--- a/package.json
+++ b/package.json
@@ -61,7 +61,7 @@
"minimist": "^1.2.8",
"recursive-copy": "^2.0.13",
"simple-vdf": "^1.1.1",
- "unzip-stream": "^0.3.1"
+ "unzip-stream": "^0.3.2"
},
"devDependencies": {
"electron": "^28.2.0",
diff --git a/src/app/css/grid.css b/src/app/css/grid.css
index 188f32a..be405e0 100644
--- a/src/app/css/grid.css
+++ b/src/app/css/grid.css
@@ -77,6 +77,24 @@
width: var(--height) !important;
}
+.popup .misc button.long {
+ width: max-content !important;
+ padding-right: var(--spacing) !important;
+}
+
+.popup .misc button select {
+ color: white;
+ border: none;
+ opacity: 0.6;
+ outline: none;
+ background: transparent;
+}
+
+.popup .misc button select option {
+ color: white;
+ background: var(--selbg);
+}
+
.popup .misc button img {
margin: 0px;
opacity: 0.6;
diff --git a/src/app/css/launcher.css b/src/app/css/launcher.css
index c43bce1..47ecb25 100644
--- a/src/app/css/launcher.css
+++ b/src/app/css/launcher.css
@@ -34,9 +34,9 @@
transition: 0.3s ease-in-out;
background-color: transparent;
-
margin: 20px;
position: relative;
+ border-radius: 0px;
box-sizing: border-box;
flex-basis: calc(100% - 10px);
}
@@ -111,7 +111,8 @@
.contentMenu li:last-child {margin-right: 0px}
.contentMenu li:first-child {margin-left: 0px}
-.contentMenu li:hover {opacity: 0.7}
+.contentMenu li:hover,
+.contentMenu li.active-selection {opacity: 0.7}
.contentMenu li[active] {
opacity: 1.0;
@@ -305,7 +306,8 @@ button:has(img):has(span) img {
transition: opacity 0.2s ease-in-out;
}
-button:has(img):has(span):hover img {
+button:has(img):has(span):hover img,
+button:has(img):has(span).active-selection img {
opacity: 1.0;
}
@@ -315,7 +317,8 @@ button:has(img):has(span) span {
transition: right 0.2s ease-in-out;
}
-button:has(img):has(span):hover span {
+button:has(img):has(span):hover span,
+button:has(img):has(span).active-selection span {
right: 0.1em;
}
diff --git a/src/app/css/selection.css b/src/app/css/selection.css
new file mode 100644
index 0000000..5b7d647
--- /dev/null
+++ b/src/app/css/selection.css
@@ -0,0 +1,23 @@
+#selection {
+ z-index: 99999999999999;
+
+ transition: 0.15s ease-in-out;
+ transition-property:
+ opacity, border-radius, background,
+ transform, width, height, top, left;
+
+ position: fixed;
+ pointer-events: none;
+ transform: scale(1.0);
+ background: rgba(255, 255, 255, 0.3);
+ border-radius: calc(var(--padding) / 2);
+}
+
+:has(.active-selection.scroll-selection) #selection {
+ background: rgba(255, 255, 255, 0.2);
+}
+
+#selection.keyboard-selecting,
+#selection.controller-selecting {
+ transform: scale(1.1);
+}
diff --git a/src/app/css/theming.css b/src/app/css/theming.css
index fb8fbf7..c0b6497 100644
--- a/src/app/css/theming.css
+++ b/src/app/css/theming.css
@@ -42,7 +42,7 @@ a.disabled:not("[onclick='kill('game')']") {
pointer-events: none;
}
-a:hover {
+a:hover, a.active-selection {
filter: brightness(80%) !important;
}
diff --git a/src/app/icons/sort.png b/src/app/icons/sort.png
new file mode 100644
index 0000000..3dd80d6
--- /dev/null
+++ b/src/app/icons/sort.png
Binary files differ
diff --git a/src/app/index.html b/src/app/index.html
index ca80c86..98c59d4 100644
--- a/src/app/index.html
+++ b/src/app/index.html
@@ -6,6 +6,8 @@
<meta charset="utf-8">
</head>
<body>
+ <div id="selection"></div>
+
<div id="tooltip">Test</div>
<div id="bgHolder"></div>
@@ -33,7 +35,7 @@
<div id="overlay" onclick="popups.set_all(false)"></div>
<div class="popup" id="options">
<div class="misc">
- <input class="search" placeholder="%%gui.search%%">
+ <input class="search default-selection" placeholder="%%gui.search%%">
<button id="apply" onclick="settings.popup.apply();settings.popup.toggle(false)">
<img src="icons/apply.png">
%%gui.settings.save%%
@@ -251,7 +253,16 @@
</div>
<div class="misc">
- <input class="search" placeholder="%%gui.search%%">
+ <input class="search default-selection" placeholder="%%gui.search%%">
+ <button class="long" id="sort">
+ <img src="icons/sort.png" value="unix_created">
+ <select>
+ <option value="unix_created">%%gui.browser.sort.newest%%</option>
+ <option value="unix_updated">%%gui.browser.sort.last_updated%%</option>
+ <option value="rating_score">%%gui.browser.sort.highest_rating%%</option>
+ <option value="downloads">%%gui.browser.sort.most_downloads%%</option>
+ </select>
+ </button>
<button id="filter" onclick="browser.filters.toggle()">
<img src="icons/filter.png">
</button>
@@ -264,7 +275,7 @@
</div>
</div>
<div class="popup small blur" id="preview">
- <div class="misc fixed vertical">
+ <div class="misc fixed vertical default-selection">
<button id="close" onclick="browser.preview.hide()">
<img src="icons/close.png">
</button>
@@ -273,7 +284,7 @@
</button>
</div>
- <webview></webview>
+ <webview class="scroll-selection"></webview>
</div>
<nav class="gamesContainer">
@@ -297,7 +308,7 @@
<a id="setpath" href="#" onclick="gamepath.set()" class="disable-when-installing">%%gui.setpath%%</a>
</div>
</div>
- <div id="vpReleaseNotes" class="hidden section"></div>
+ <div id="vpReleaseNotes" class="scroll-selection hidden section"></div>
<div id="vpInfo" class="hidden section">
<h2>%%viper.info.links%%</h2>
<ul>
@@ -357,7 +368,7 @@
</div>
</div>
</div>
- <div id="nsRelease" class="hidden section"></div>
+ <div id="nsRelease" class="scroll-selection hidden section"></div>
</div>
</div>
diff --git a/src/app/js/browser.js b/src/app/js/browser.js
index c559031..d30ad9f 100644
--- a/src/app/js/browser.js
+++ b/src/app/js/browser.js
@@ -153,10 +153,19 @@ var browser = {
return browser.install({...properties});
}
+ packages[i].unix_created = new Date(packages[i].date_created).getTime();
+ packages[i].unix_updated = new Date(packages[i].date_updated).getTime();
+
packages[i].install = install;
packages[i].has_update = has_update;
packages[i].local_version = local_version;
+ packages[i].downloads = 0;
+
+ for (let version of packages[i].versions) {
+ packages[i].downloads += version.downloads || 0;
+ }
+
if (local_version) {
browser.mod_versions[normalized] = {
install: install,
@@ -169,6 +178,28 @@ var browser = {
}
}
},
+ // sorts `pkgs` based on `property` in package object
+ sort: (pkgs, property) => {
+ // get property from sort selector, if not specified
+ if (! property) {
+ property = sort.querySelector("select").value;
+
+ // if we somehow still don't have a property, just return
+ if (! property) {
+ return pkgs;
+ }
+ }
+
+ // if `property` doesn't even exist, just return
+ if (typeof pkgs[0][property] == "undefined") {
+ return pkgs;
+ }
+
+ // sort in descending order
+ return pkgs.sort((a, b) => {
+ return b[property] - a[property];
+ })
+ },
loadfront: async () => {
browser.loading();
@@ -198,7 +229,8 @@ var browser = {
})
}
- let pkgs = browser.filters.getpkgs();
+ let pkgs = browser.sort(browser.filters.getpkgs());
+
for (let i in pkgs) {
if (packagecount >= browser.maxentries) {
browser.endoflist();
@@ -349,6 +381,10 @@ var browser = {
}
}
+sort.querySelector("select").addEventListener("change", () => {
+ browser.loadfront();
+})
+
setInterval(browser.add_pkg_properties, 1500);
if (navigator.onLine) {
@@ -414,8 +450,10 @@ browser.mod_el = (properties) => {
let installicon = "downloads";
let installstr = lang("gui.browser.install");
- let installcallback = () => {};
let normalized_title = mods.normalize(properties.title)
+ let installcallback = () => {
+ browser.install(properties);
+ }
let nondefault_install = {
"vanillaplus": "https://github.com/Zayveeo5e/NP.VanillaPlus/blob/main/README.md"
@@ -437,10 +475,6 @@ browser.mod_el = (properties) => {
installicon = "downloads";
installstr = lang("gui.browser.update");
}
-
- installcallback = () => {
- browser.install(properties);
- }
}
let entry = document.createElement("div");
@@ -477,6 +511,11 @@ browser.mod_el = (properties) => {
browserEntries.appendChild(entry);
}
+browser.packages = async () => {
+ await browser.loadfront();
+ return packages;
+}
+
let recent_toasts = {};
function add_recent_toast(name, timeout = 3000) {
if (recent_toasts[name]) {return}
diff --git a/src/app/js/dom_events.js b/src/app/js/dom_events.js
index ab79c57..490d2ec 100644
--- a/src/app/js/dom_events.js
+++ b/src/app/js/dom_events.js
@@ -26,12 +26,6 @@ document.addEventListener("drop", (e) => {
mods.install_from_path(e.dataTransfer.files[0].path);
})
-document.body.addEventListener("keyup", (e) => {
- if (e.key == "Escape") {
- popups.hide_last();
- }
-})
-
document.body.addEventListener("click", (e) => {
if (e.target.tagName.toLowerCase() === "a"
&& e.target.protocol != "file:") {
diff --git a/src/app/js/gamepad.js b/src/app/js/gamepad.js
new file mode 100644
index 0000000..69ca13f
--- /dev/null
+++ b/src/app/js/gamepad.js
@@ -0,0 +1,333 @@
+const popups = require("./popups");
+const settings = require("./settings");
+const launcher = require("./launcher");
+const navigate = require("./navigate");
+
+window.addEventListener("gamepadconnected", (e) => {
+ console.log("Gamepad connected:", e.gamepad.id);
+}, false)
+
+window.addEventListener("gamepaddisconnected", (e) => {
+ console.log("Gamepad disconnected:", e.gamepad.id);
+}, false)
+
+// this contains the names/directions of axes and IDs that have
+// previously been pressed, if it is found that these were recently
+// pressed in the next iteration of the `setInterval()` below than the
+// iteration is skipped
+//
+// the value of each item is equivalent to the amount of iterations to
+// wait, so `up: 3` will cause it to wait 3 iterations, before `up` can
+// be pressed again
+let delay_press = {};
+
+let held_buttons = {};
+
+setInterval(() => {
+ let gamepads = navigator.getGamepads();
+
+ // this has a list of all the directions that the `.axes[]` are
+ // pointing in, letting us navigate in that direction
+ let directions = {}
+
+ // keeps track of which buttons `delay_press` that have already been
+ // lowered, that way we can lower the ones that haven't been lowered
+ // through a button press
+ let lowered_delay = [];
+
+ // is the select/accept button being held
+ let selecting = false;
+
+ for (let i in gamepads) {
+ if (! gamepads[i]) {continue}
+ // every other `.axes[]` element is a different coordinate, each
+ // analog stick has 2 elements in `.axes[]`, the first one is
+ // the x coordinate, second is the y coordinate
+ //
+ // so we use this to keep track of which coordinate we're
+ // currently on, and thereby the direction of the float inside
+ // `.axes[i]`
+ let coord = "x";
+ let deadzone = 0.5;
+
+ for (let ii = 0; ii < gamepads[i].axes.length; ii++) {
+ let value = gamepads[i].axes[ii];
+
+ // check if we're beyond the deadzone in both the negative
+ // and positive direction, and then using `coord` add a
+ // direction to `directions`
+ if (value < -deadzone) {
+ if (coord == "y") {
+ directions.up = true;
+ } else {
+ directions.left = true;
+ }
+ } else if (value > deadzone) {
+ if (coord == "y") {
+ directions.down = true;
+ } else {
+ directions.right = true;
+ }
+ }
+
+ // flip `coord`
+ if (coord == "x") {
+ coord = "y";
+ } else {
+ coord = "x";
+ }
+ }
+
+ // only support "standard" button layouts/mappings
+ //
+ // TODO: for anybody reading this in the future, the support
+ // for other mappings is something that's on the table,
+ // however, due to not having all the hardware in the world,
+ // this will have to be up to someone else
+ if (gamepads[i].mapping != "standard") {
+ continue;
+ }
+
+ for (let ii = 0; ii < gamepads[i].buttons.length; ii++) {
+ if (! gamepads[i].buttons[ii].pressed) {
+ held_buttons[ii] = false;
+ continue;
+ }
+
+ // a list of known combinations of buttons for the most
+ // common brands out there, more should possibly be added
+ let brands = {
+ "Xbox": {
+ accept: 0,
+ cancel: 1
+ },
+ "Nintendo": {
+ accept: 1,
+ cancel: 0
+ },
+ "PlayStation": {
+ accept: 0,
+ cancel: 1
+ }
+ }
+
+ // this is the most common setup, to my understanding, with
+ // the exception of third party Nintendo controller, may
+ // need to be adjusted in the future
+ let buttons = {
+ accept: 0,
+ cancel: 1
+ }
+
+ // set `cancel` and `accept` accordingly to the ID of the
+ // gamepad, if its a known brand
+ for (let brand in brands) {
+ // unknown brand
+ if (! gamepads[i].id.includes(brand)) {
+ continue;
+ }
+
+ // set buttons according to brand
+ buttons = brands[brand];
+ break;
+ }
+
+ // if the button that's being pressed is the "accept"
+ // button, then we set `selecting` to `true`, this is done
+ // before we check for the button delay so that holding the
+ // button keeps the selection in place, until the button is
+ // no longer pressed
+ if (ii == buttons.accept) {
+ selecting = true;
+ }
+
+ // if this button is still delayed, we lower the delay and
+ // then go to the next button
+ if (delay_press[ii]) {
+ delay_press[ii]--;
+ lowered_delay.push(ii);
+ continue;
+ }
+
+ // add delay to this button, so it doesn't get clicked
+ // immediately again after this
+ delay_press[ii] = 3;
+
+ if (held_buttons[ii]) {
+ continue;
+ }
+
+ held_buttons[ii] = true;
+
+ // interpret `ii` as a specific button/action, using the
+ // standard IDs: https://w3c.github.io/gamepad/#remapping
+ switch(ii) {
+ // settings popup (center cluster buttons)
+ case 8: settings.popup.toggle(); break;
+ case 9: settings.popup.toggle(); break;
+
+ // change active section (top bumpers)
+ case 4: launcher.relative_section("left"); break;
+ case 5: launcher.relative_section("right"); break;
+
+ // navigate selection (dpad)
+ case 12: navigate.move("up"); break;
+ case 13: navigate.move("down"); break;
+ case 14: navigate.move("left"); break;
+ case 15: navigate.move("right"); break;
+
+ // click selected element
+ case buttons.accept: navigate.select(); break;
+
+ // close last opened popup
+ case buttons.cancel: popups.hide_last(); break;
+ }
+ }
+ }
+
+ for (let i in directions) {
+ if (directions[i] === true) {
+ // if this direction is still delayed, we lower the delay,
+ // and then go to the next direction
+ if (delay_press[i]) {
+ delay_press[i]--;
+ lowered_delay.push(i);
+ continue;
+ }
+
+ // move in the direction
+ navigate.move(i);
+
+ // add delay to this direction, to prevent it from being
+ // triggered immediately again
+ delay_press[i] = 5;
+ }
+ }
+
+ // run through buttons that have or have had a delay
+ for (let i in delay_press) {
+ // if a button has a delay, and it hasn't already been lowered,
+ // then we lower it
+ if (delay_press[i] && ! lowered_delay.includes(i)) {
+ delay_press[i]--;
+ }
+ }
+
+ let selection_el = document.getElementById("selection");
+
+ // add `.selecting` to `#selection` depending on whether
+ // `selecting`, is set or not
+ if (selecting) {
+ selection_el.classList.add("controller-selecting");
+ } else {
+ selection_el.classList.remove("controller-selecting");
+ }
+}, 50)
+
+
+let can_keyboard_navigate = (e) => {
+ // quite empty right now, might add more in the future, these are
+ // just element selectors where movement with the keyboard is off
+ let ignore_on_focus = [
+ "input",
+ "select"
+ ]
+
+ // check for whether the active element is one that matches
+ // something in `ignore_on_focus`
+ for (let i = 0; i < ignore_on_focus.length; i++) {
+ if (! document.activeElement.matches(ignore_on_focus)) {
+ // active element does not match to `ignore_on_focus[i]`
+ continue;
+ }
+
+ // if the key that's being pressed is "Escape" then we unfocus
+ // to the currently focused active element, this lets you go
+ // into an input, and then exit it as well
+ if (e.key == "Escape") {
+ document.activeElement.blur();
+ }
+
+ return false;
+ }
+
+ // check if there's already an active selection
+ if (document.querySelector(".active-selection")) {
+ // this is a list of keys where this keyboard event will be
+ // cancelled on, this prevents key events from being sent to
+ // element, but still lets you type
+ let cancel_keys = [
+ "Space", "Enter",
+ "ArrowUp", "ArrowDown",
+ "ArrowLeft", "ArrowRight"
+ ]
+
+ // cancel this keyboard event if `e.key` is inside `cancel_keys`
+ if (cancel_keys.includes(e.code)) {
+ e.preventDefault();
+ }
+ }
+
+ return true;
+}
+
+window.addEventListener("keydown", (e) => {
+ // do nothing if we cant navigate
+ if (! can_keyboard_navigate(e)) {
+ return;
+ }
+
+ let select = () => {
+ // do nothing if this is a repeat key press
+ if (e.repeat) {return}
+
+ // select `.active-selection`
+ navigate.select();
+
+ // add `.keyboard-selecting` to `#selection`
+ document.getElementById("selection")
+ .classList.add("keyboard-selecting");
+ }
+
+ // perform the relevant action for the key that was pressed
+ switch(e.code) {
+ // select
+ case "Space": return select();
+ case "Enter": return select();
+
+ // close popup
+ case "Escape": return popups.hide_last();
+
+ // move selection
+ case "KeyK": case "ArrowUp": return navigate.move("up")
+ case "KeyJ": case "ArrowDown": return navigate.move("down")
+ case "KeyH": case "ArrowLeft": return navigate.move("left")
+ case "KeyL": case "ArrowRight": return navigate.move("right")
+ }
+})
+
+window.addEventListener("keyup", (e) => {
+ if (! can_keyboard_navigate(e)) {
+ return;
+ }
+
+ let selection_el = document.getElementById("selection");
+
+ // perform the relevant action for the key that was pressed
+ switch(e.code) {
+ // the second and third cases here are for SteamDeck bumper
+ // button support whilst inside the desktop layout
+ case "KeyQ":
+ case "ControlLeft": case "ControlRight":
+ launcher.relative_section("left"); break;
+ case "KeyE":
+ case "AltLeft": case "AltRight":
+ launcher.relative_section("right"); break;
+
+ case "Space": return selection_el
+ .classList.remove("keyboard-selecting");
+
+ case "Enter": return selection_el
+ .classList.remove("keyboard-selecting");
+ }
+})
diff --git a/src/app/js/launcher.js b/src/app/js/launcher.js
index dc99792..d249d34 100644
--- a/src/app/js/launcher.js
+++ b/src/app/js/launcher.js
@@ -1,3 +1,4 @@
+const popups = require("./popups");
const markdown = require("marked").parse;
let launcher = {};
@@ -168,6 +169,11 @@ launcher.show_ns = (section) => {
//
// `direction` can be: left or right
launcher.relative_section = (direction) => {
+ // prevent switching section if a popup is open
+ if (popups.open_list().length) {
+ return;
+ }
+
// the `.contentMenu` in the currently active tab
let active_menu = document.querySelector(
".contentContainer:not(.hidden) .contentMenu"
@@ -203,18 +209,30 @@ launcher.relative_section = (direction) => {
}
}
+ let new_section;
+
// if we're going left, and a previous section was found, click it
if (direction == "left" && prev_section) {
- prev_section.click();
+ new_section = prev_section;
} else if (direction == "right") {
// click the next section, if one was found, otherwise just
// assume that the first section is the next section, as the
// active section is likely just the last section, so we wrap
// around instead
if (next_section) {
- next_section.click();
+ new_section = next_section;
} else if (sections[0]) {
- sections[0].click();
+ new_section = sections[0];
+ }
+ }
+
+ if (new_section) {
+ new_section.click();
+
+ // if there's an active selection, we select the new section, as
+ // that selection may be in a section that's now hidden
+ if (document.querySelector(".active-selection")) {
+ navigate.selection(new_section);
}
}
}
diff --git a/src/app/js/mods.js b/src/app/js/mods.js
index f463ddb..755aad1 100644
--- a/src/app/js/mods.js
+++ b/src/app/js/mods.js
@@ -1,8 +1,10 @@
+const util = require('util');
const ipcRenderer = require("electron").ipcRenderer;
const lang = require("../../lang");
const version = require("./version");
+const toasts = require("./toasts");
const set_buttons = require("./set_buttons");
let mods = {};
@@ -216,8 +218,22 @@ mods.remove = (mod) => {
}
mods.toggle = (mod) => {
+ // is this a core mod?
if (mod.toLowerCase().match(/^northstar\./)) {
- if (! confirm(lang("gui.mods.required_confirm"))) {
+ // keep track of whether this mod is disabled
+ let is_disabled = false;
+
+ // run through disabled mods
+ for (let mod_obj of mods_list.disabled) {
+ // if `mod` is `mod_obj`, update `is_disabled`
+ if (mod_obj.name.toLowerCase() == mod.toLowerCase()) {
+ is_disabled = true;
+ break;
+ }
+ }
+
+ // show prompt if the mod is enabled
+ if (! is_disabled && ! confirm(lang("gui.mods.required_confirm"))) {
return;
}
} else if (mod == "allmods") {
@@ -328,4 +344,42 @@ ipcRenderer.on("mods", (event, mods_obj) => {
mods.load(mods_obj);
})
+ipcRenderer.on("protocol-install-mod", async (event, data) => {
+ const domain = data[0];
+ const author = data[1];
+ const package_name = data[2];
+ const version = data[3];
+
+ const packages = await browser.packages();
+
+ const package = packages.find((package) => { return package.owner == author && package.name == package_name; })
+ if (!package) {
+ alert(util.format(lang("gui.mods.cant_find_specific"), author, package_name));
+ return;
+ }
+
+ const package_obj = package.versions.find((package_version) => { return package_version.version_number == version; })
+ if (!package_obj) {
+ alert(util.format(lang("gui.mods.cant_find_version"), version, author, package_name))
+ return;
+ }
+
+ toasts.show({
+ timeout: 3000,
+ scheme: "info",
+ title: lang("gui.mods.installing"),
+ description: package_obj.full_name
+ })
+
+ mods.install_from_url(
+ package_obj.download_url,
+ package_obj.dependencies,
+ false,
+
+ author,
+ package_name,
+ version
+ );
+})
+
module.exports = mods;
diff --git a/src/app/js/navigate.js b/src/app/js/navigate.js
new file mode 100644
index 0000000..5665687
--- /dev/null
+++ b/src/app/js/navigate.js
@@ -0,0 +1,903 @@
+const events = require("./events");
+const popups = require("./popups");
+const settings = require("./settings");
+
+let navigate = {};
+
+// sets `#selection` to the correct position, size and border radius,
+// according to what is currently the `.active-selection`, if none is
+// found, it'll instead be hidden
+navigate.selection = (new_selection) => {
+ if (new_selection) {
+ let selected = document.querySelectorAll(".active-selection");
+
+ // make sure just `new_selection` has `.active-selection`
+ for (let i = 0; i < selected.length; i++) {
+ if (selected[i] != new_selection) {
+ selected[i].classList.remove("active-selection");
+ }
+ }
+
+ new_selection.classList.add("active-selection");
+ }
+
+ // shorthands
+ let selection_el = document.getElementById("selection");
+ let active_el = document.querySelector(".active-selection");
+
+ // make sure there's an `active_el`, and hide the `selection_el` if
+ // that isn't the case
+ if (! active_el) {
+ selection_el.style.opacity = "0.0";
+ return;
+ }
+
+ // this adds space between the `selection_el` and ``
+ let padding = 8;
+
+ // attempt to get the border radius of `active_el`
+ let radius = getComputedStyle(active_el).borderRadius;
+
+ // if there's no radius set, we default to the default of
+ // `selection_el` through using `null`
+ if (! radius || radius == "0px") {
+ radius = null;
+ }
+
+ // set visibility and radius
+ selection_el.style.opacity = "1.0";
+ selection_el.style.borderRadius = radius;
+
+ // get bounds for position and size calculations of `selection_el`
+ let active_bounds = active_el.getBoundingClientRect();
+
+ // set top and left side coordinate subtracting the padding
+ selection_el.style.top = active_bounds.top - padding + "px";
+ selection_el.style.left = active_bounds.left - padding + "px";
+
+ // set width of `selection_el` with the padding
+ selection_el.style.width =
+ active_bounds.width + (padding * 2) + "px";
+
+ // set height of `selection_el` with the padding
+ selection_el.style.height =
+ active_bounds.height + (padding * 2) + "px";
+}
+
+// data from the last iterations of the interval below
+let last_sel = {
+ el: false,
+ bounds: false
+}
+
+// auto update `#selection` if `.active-selection` changes bounds, but
+// not element by itself
+setInterval(() => {
+ // get active selection
+ let selected = document.querySelector(".active-selection");
+
+ // if there's no active selection, reset `last_sel`
+ if (! selected) {
+ last_sel.el = false;
+ last_sel.bounds = false;
+
+ return;
+ }
+
+ // get stringified bounds
+ let bounds = JSON.stringify(selected.getBoundingClientRect());
+
+ // if `last_sel.el` is not `selected` the selected element was
+ // changed, so we just set `last_el` and nothing more
+ if (last_sel.el != selected) {
+ last_sel.el = selected;
+ last_sel.bounds = bounds;
+
+ return;
+ }
+
+ // if stringified bounds changed we update `#selection`
+ if (bounds != last_sel.bounds) {
+ navigate.selection();
+ last_sel.el = selected;
+ last_sel.bounds = bounds;
+ }
+}, 50)
+
+// these events cause the `#selection` element to reposition itself
+window.addEventListener("resize", () => {navigate.selection()}, true);
+window.addEventListener("scroll", () => {navigate.selection()}, true);
+
+// listen for click events, and hide the `#selection` element, when
+// emitting a mouse event we will want to hide, as it then isn't needed
+window.addEventListener("click", (e) => {
+ // make sure its a trusted click event, and therefore actually a
+ // mouse, and not anything else
+ if (! e.isTrusted) {
+ return;
+ }
+
+ // get the `.active-selection`
+ let active_el = document.querySelector(".active-selection");
+
+ // if there's an `active_el` then we unselect it, and update the
+ // `#selection` element, hiding it
+ if (active_el) {
+ active_el.classList.remove("active-selection");
+ navigate.selection();
+ }
+})
+
+// returns a list of valid elements that should be possible to navigate
+// to/select with the `#selection` element
+//
+// setting `div` makes it limit itself to elements inside that, without
+// it, it'll use `document.body` or the active popup, if one is found
+navigate.get_els = (div) => {
+ let els = [];
+
+ // is `div` not set, and is there a popup shown
+ if (! div && document.body.querySelector(".popup.shown")) {
+ // the spread operator is to convert from a `NodeList` to an
+ // `Array`, and then we need to reverse this to get the ones
+ // that are layered on top first.
+ let popups_list = [...popups.list()].reverse();
+
+ // run through the list of popups
+ for (let i = 0; i < popups_list.length; i++) {
+ // if this popup is shown, we make it the current `div`
+ if (popups_list[i].classList.contains("shown")) {
+ div = popups_list[i];
+ break;
+ }
+ }
+
+ // get buttons inside `#winbtns`
+ els = [...document.body.querySelectorAll("#winbtns [onclick]")];
+ } if (! div) { // default
+ div = document.body;
+ }
+
+ // this gets the list of all the elements we should be able to
+ // select inside `div`, on top of anything that's already in `els`
+ els = [...els, ...div.querySelectorAll([
+ "a",
+ "input",
+ "button",
+ "select",
+ "textarea",
+ "[onclick]",
+ ".scroll-selection"
+ ])]
+
+ // this'll contain a filtered list of `els`
+ let new_els = [];
+
+ // filter out elements we don't care about
+ filter: for (let i = 0; i < els.length; i++) {
+ // elements that match on `els.closest()` with any of these will
+ // be stripped away, as we dont want them
+ let ignore_closest = [
+ "#overlay",
+ ".no-navigate",
+ "button.visual",
+ ".scroll-selection",
+ ".popup:not(.shown)"
+ ]
+
+ // ignore, even if `.closest()` matches, if its just matching on
+ // itself instead of a different element
+ let ignore_closest_self = [
+ ".scroll-selection"
+ ]
+
+ // check if `els[i].closest()` matches on any of the elements
+ // inside of `ignore_closest`
+ for (let ii = 0; ii < ignore_closest.length; ii++) {
+ let closest = els[i].closest(ignore_closest[ii]);
+
+ // check if `.closest()` matches, but not on itself
+ if (closest) {
+ // ignore if `closest` is just `els[i]` and the selector
+ // is inside `ignore_closest_self`
+ if (closest == els[i] &&
+ ignore_closest_self.includes(ignore_closest[ii])) {
+
+ continue;
+ }
+
+ // it matches
+ continue filter;
+ }
+ }
+
+ // make sure `els[i]` is visible on screen
+ let visible = els[i].checkVisibility({
+ checkOpacity: true,
+ visibilityProperty: true,
+ checkVisibilityCSS: true,
+ contentVisibilityAuto: true
+ })
+
+ // filter out if not visible
+ if (! visible) {continue}
+
+ // add to filtered list
+ new_els.push(els[i])
+ }
+
+ // return the filtered list of elements
+ return new_els;
+}
+
+// attempts to select the currently default selection, if inside a popup
+// we'll look for a `.default-selection`, if it doesn't exist we'll
+// simply use the first selectable element in it
+//
+// if not inside a popup we'll just use the currently selected tab in
+// the `.gamesContainer` sidebar
+navigate.default_selection = () => {
+ // the spread operator is to convert from a `NodeList` to an
+ // `Array`, and then we need to reverse this to get the ones
+ // that are layered on top first.
+ let popups_list = [...popups.list()].reverse();
+
+ let active_popup;
+
+ // run through the list of popups
+ for (let i = 0; i < popups_list.length; i++) {
+ // if this popup is shown, set set `active_popup` to it
+ if (popups_list[i].classList.contains("shown")) {
+ active_popup = popups_list[i];
+ break;
+ }
+ }
+
+ // is there no active popup?
+ if (! active_popup) {
+ // select the currently selected page in `.gamesContainer`
+ document.querySelector(
+ ".gamesContainer :not(.inactive)"
+ ).classList.add("active-selection");
+
+ // update the `#selection` element
+ navigate.selection();
+
+ return;
+ }
+
+ // get the default element inside the active popup
+ let popup_default = active_popup.querySelector("default-selection");
+
+ // did we not find a default selection element?
+ if (! popup_default) {
+ // select the first selectable element in the popup
+ navigate.get_els(active_popup)[0].classList.add(
+ "active-selection"
+ )
+
+ // update the `#selection` element
+ navigate.selection();
+
+ return;
+ }
+
+ // select the default selection
+ popup_default.classList.add("active-selection");
+
+ // update the `#selection` element
+ navigate.selection();
+}
+
+// this navigates `#selection` in the direction of `direction`
+// this can be: up, down, left and right
+navigate.move = async (direction) => {
+ // get the `.active-selection` if there is one
+ let active = document.querySelector(".active-selection");
+
+ // if there is no active selection, then attempt to select the
+ // default selection
+ if (! active) {
+ navigate.default_selection()
+
+ active = document.querySelector(".active-selection");
+
+ // if there is somehow still no active selection we stop here
+ if (! active) {
+ return;
+ }
+ }
+
+ // is the active selection one that should be scrollable?
+ if (active.classList.contains("scroll-selection")) {
+ // scroll the respective `direction` if `active` has any more
+ // scroll left in that direction
+
+ // short hand to easily scroll in `direction` by `amount` with
+ // smooth scrolling enabled
+ let scroll = (direction, amount) => {
+ // update the `#selection` element
+ navigate.selection();
+
+ // scroll inside `<webview>` if the active selection is one
+ if (active.tagName == "WEBVIEW") {
+ active.executeJavaScript(`
+ document.scrollingElement.scrollBy({
+ behavior: "smooth",
+ ${direction}: ${amount}
+ })
+ `)
+
+ return;
+ }
+
+ active.scrollBy({
+ behavior: "smooth",
+ [direction]: amount
+ })
+ }
+
+ // get values needed for determining if we should scroll the
+ // active selection, and by how much
+ let scroll_el = {
+ top: active.scrollTop,
+ left: active.scrollLeft,
+ width: active.scrollWidth,
+ height: active.scrollHeight,
+ bounds: {
+ width: active.clientWidth,
+ height: active.clientWidth
+ }
+ }
+
+ // get `scroll_el` from inside a `<webview>` if the active
+ // selection is one
+ if (active.tagName == "WEBVIEW") {
+ scroll_el = await active.executeJavaScript(`(() => {
+ return {
+ top: document.scrollingElement.scrollTop,
+ left: document.scrollingElement.scrollLeft,
+ width: document.scrollingElement.scrollWidth,
+ height: document.scrollingElement.scrollHeight,
+ bounds: {
+ width: document.scrollingElement.clientWidth,
+ height: document.scrollingElement.clientHeight
+ }
+ }
+ })()`)
+ }
+
+ // decrease to increase scroll length, and in reverse
+ let scroll_scale = 2;
+
+ if (direction == "up" && scroll_el.top > 0) {
+ return scroll("top", -scroll_el.bounds.height / scroll_scale);
+ }
+
+ if (direction == "down" &&
+ scroll_el.top <= scroll_el.height &&
+ scroll_el.height != scroll_el.bounds.height) {
+
+ return scroll("top", scroll_el.bounds.height / scroll_scale);
+ }
+
+ if (direction == "left" && scroll_el.left > 0) {
+ return scroll("left", -width / scroll_scale);
+ }
+
+ if (direction == "right" &&
+ scroll_el.left <= scroll_el.width &&
+ scroll_el.width != scroll_el.bounds.width) {
+
+ return scroll("left", scroll_el.bounds.width / scroll_scale);
+ }
+ }
+
+ // attempt to get the element in the `direction` requested
+ let move_to_el = navigate.get_relative_el(active, direction);
+
+ // if no element is found, do nothing
+ if (! move_to_el) {return}
+
+ // switch `.active-selection` from `active` to `move_to_el`
+ active.classList.remove("active-selection");
+ move_to_el.classList.add("active-selection");
+
+ // update the `#selection` element
+ navigate.selection();
+
+ // make sure the selecting classes are removed, and thereby the
+ // scale/pressed effect with it
+ document.getElementById("selection").classList.remove(
+ "keyboard-selecting",
+ "controller-selecting"
+ )
+
+ // stop here if `move_to_el` is a child to `document.body`
+ if (move_to_el.parentElement == document.body) {
+ return;
+ }
+
+ // this element will be scrolled in view later
+ let scroll_el = move_to_el;
+
+ // these elements cant be scrolled
+ let no_scroll_parents = [
+ ".el .text",
+ ".gamesContainer",
+ ]
+
+ // run through unscrollable parent elements
+ for (let i = 0; i < no_scroll_parents.length; i++) {
+ // check if `move_to_el.closest()` matches on anything in
+ // `no_scroll_parents`
+ let no_scroll_parent = move_to_el.closest(
+ no_scroll_parents[i]
+ )
+
+ if (! no_scroll_parent) {
+ // it does not match
+ continue;
+ }
+
+ // it matches, so we make the new `scroll_el` the parent
+ scroll_el = no_scroll_parent;
+ }
+
+ // refuse to scroll to begin with, if any of these are a parent
+ let ignore_parents = [
+ ".contentMenu",
+ ".gamesContainer",
+ ]
+
+ // check if `ignore_parents` match on `move_to_el`, and if so, stop
+ for (let i = 0; i < ignore_parents.length; i++) {
+ if (move_to_el.closest(ignore_parents[i])) {
+ return;
+ }
+ }
+
+ // scroll `scroll_el` smoothly into view, centered
+ scroll_el.scrollIntoView({
+ block: "center",
+ inline: "center",
+ behavior: "smooth",
+ })
+}
+
+// selects the currently selected element, by clicking or focusing it
+navigate.select = () => {
+ // get the current selection
+ let active = document.querySelector(".active-selection");
+
+ // make sure there is a selection
+ if (! active) {return}
+
+ // slight delay to prevent some timing issues
+ setTimeout(() => {
+ // if `active` is a switch, use `settings.popup.switch()` on it,
+ // to be able to toggle it
+ if (active.closest(".switch")) {
+ active.closest(".switch").click();
+ settings.popup.switch(active.closest(".switch"));
+ return;
+ }
+
+ // click and focus `active`
+ active.click();
+ active.focus();
+ }, 150)
+}
+
+// selects the closest and hopefully most correct element to select next
+// to `relative_el` in the direction of `direction`
+//
+// the direction can be: up, down, left and right
+navigate.get_relative_el = (relative_el, direction) => {
+ // get selectable elements
+ let els = navigate.get_els();
+
+ // get bounds of `relative_el`
+ let bounds = relative_el.getBoundingClientRect();
+
+ // get the centered coordinates of `relative_el`
+ let relative = {
+ x: bounds.left + (bounds.width / 2),
+ y: bounds.top + (bounds.height / 2)
+ }
+
+ // update the coordinates on the element itself
+ relative_el.coords = relative;
+
+ // attempt to return the element in the correct direction
+ // if `x` or `y` is a number that's greater or less than 0 then
+ // we'll go in the direction of the coordinate that is as such
+ //
+ // meaning `get_el(1, 0)` will go to the right
+ let get_el = (x = 0, y = 0) => {
+ // `coord` is the coordinate that we're trying to get an element
+ // on, and `rev_coord` is just the opposite coord
+ let coord, rev_coord;
+
+ // set `coord` and `rev_coord` according to `x` and `y`
+ if (x > 0 || x < 0) {
+ coord = "x";
+ rev_coord = "y";
+ } else if (y > 0 || y < 0) {
+ coord = "y";
+ rev_coord = "x";
+ } else { // something unexpected was given
+ return false;
+ }
+
+ // this is the distance between each point which we check for
+ // selectable elements, increasing this improves performance,
+ // but lowers accuracy, and likewise in reverse
+ let jump_distance = 5;
+
+ // this is the coordinates to check in the direct coord check
+ let check = {
+ x: relative.x,
+ y: relative.y
+ }
+
+ // this will contain the element that directly next to
+ // `relative_el` from checking every point from `relative_el`
+ // into `direction`
+ let direct_el;
+
+ // this is the amount of pixels inbetween `relative_el` and
+ // `direct_el`, this means it doesn't have the distance from the
+ // center of `direct_el` or anything included, just the raw
+ // distance between them, this number could vary in accuracy
+ // depending on how big or small `jump_distance` is
+ let direct_distance = 0;
+
+ // attempt to find an element from a straight line from
+ // `relative_el`, by checking whether there's an element at each
+ // point in the `direction` specified
+ while (! direct_el) {
+ // add `jump_distance` to the coordinates we're checking
+ check.x += x * jump_distance;
+ check.y += y * jump_distance;
+
+ // get the elements at the coordinates we're checking
+ let els_at = document.elementsFromPoint(check.x, check.y);
+
+ // run through all the elements we found
+ for (let i = 0; i < els_at.length; i++) {
+ // make sure `els_at[i]` isn't `relative_el` and that
+ // its a selectable element
+ if (els_at[i] == relative_el
+ || ! els.includes(els_at[i])) {
+
+ // not selectable or is `relative_el`
+ continue;
+ }
+
+ // set `direct_el`
+ direct_el = els_at[i];
+
+ // get the bounds of `direct_el`
+ let direct_bounds = direct_el.getBoundingClientRect();
+
+ // get the centered coordinates for `direct_el`
+ let direct_coords = {
+ x: direct_bounds.left + (direct_bounds.width / 2),
+ y: direct_bounds.top + (direct_bounds.height / 2)
+ }
+
+ // update the coordinates on the element itself
+ direct_el.coords = direct_coords;
+
+ // get the difference between `relative_el` and
+ // `direct_el`'s coordinates, effectively their distance
+ let diff_x = direct_coords.x - relative.x;
+ let diff_y = direct_coords.y - relative.y;
+
+ // make sure this element is marked as the element that
+ // was found directly
+ direct_el.is_direct_el = true;
+
+ // update the distance on the element itself
+ direct_el.distance = Math.sqrt(
+ diff_x*diff_x + diff_y*diff_y
+ )
+
+ // set the distance on the coord we're checking on the
+ // element itself
+ direct_el.coord_distance = direct_distance;
+
+ break; // we found the `direct_el` we can stop now
+ }
+
+ // if `els_at` has `relative_el` then we reset
+ // `direct_distance`
+ if (els_at.includes(relative_el)) {
+ direct_distance = 0;
+ } else {
+ // add `jump_distance` to `direct_distance`, because
+ // we're no longer on the `relative_el` nor the
+ // `direct_el`
+ direct_distance += jump_distance;
+ }
+
+ // are we beyond the edges of the window
+ if (check.x < 0 || check.y < 0 ||
+ check.x > innerWidth || check.y > innerHeight) {
+
+ // did we find no elements?
+ if (! els_at.length) {
+ break; // stop searching
+ }
+ }
+ }
+
+ // this contains elements in the respective directions
+ let positions = {
+ up: [], down: [],
+ left: [], right: []
+ }
+
+ // gets the nearest elements from the selectable elements
+ for (let i = 0; i < els.length; i++) {
+ // get bounds
+ let el_bounds = els[i].getBoundingClientRect();
+
+ // get centered coordinates
+ let el_coords = {
+ x: el_bounds.left + (el_bounds.width / 2),
+ y: el_bounds.top + (el_bounds.height / 2)
+ }
+
+ // get the difference between `el_coords` and `direct_el`'s
+ // coordinates, effectively their distance
+ let diff_x = el_coords.x - relative.x;
+ let diff_y = el_coords.y - relative.y;
+
+ // is this element not an element that was previously a
+ // `direct_el`?
+ if (! els[i].is_direct_el) {
+ // update centerd coordinates on the element itself
+ els[i].coords = el_coords;
+
+ // set the distance on the element itself
+ els[i].distance = Math.sqrt(
+ diff_x*diff_x + diff_y*diff_y
+ )
+
+ // get and set the distance on the coord we're checking
+ // on the element itself
+ els[i].coord_distance = Math.abs(
+ relative[coord] - el_coords[coord]
+ )
+ } else {
+ els[i].is_direct_el = false;
+ continue;
+ }
+
+ // put `els[i]` in the correct place in `positions`
+ if (el_coords.x < relative.x) {
+ positions.left.push(els[i]);
+ } if (el_coords.x > relative.x) {
+ positions.right.push(els[i]);
+ } if (el_coords.y < relative.y) {
+ positions.up.push(els[i]);
+ } if (el_coords.y > relative.y) {
+ positions.down.push(els[i]);
+ }
+ }
+
+ // this will contain the element closest to `relative_el` in the
+ // correct direction, but not necessarily at the same
+ // coordinates
+ let closest_el;
+
+ // set `closest_el` to the elements closest element in the
+ // respective position using `direction`
+ for (let i = 0; i < positions[direction].length; i++) {
+ // get the element
+ let el = positions[direction][i];
+
+ // is this the first check, or is it closer than the
+ // previous `closest_el`, then update `closest_el`
+ if (! closest_el || closest_el.distance > el.distance) {
+ closest_el = el;
+ }
+ }
+
+ // was there found a `closest_el` and `direct_el`
+ if (closest_el && direct_el) {
+ // simply return `direct_el` if its the same as `closest_el`
+ if (closest_el == direct_el) {
+ return direct_el;
+ }
+
+ // if the parent element of `closest_el` and `direct_el` is
+ // the same, then we prefer the `direct_el`
+ //
+ // unless the parent is `document.body`
+ if (closest_el.parentElement == direct_el.parentElement &&
+ direct_el.parentElement !== document.body) {
+ return direct_el;
+ }
+
+ // get the difference between `relative_el` and `direct_el`
+ // on the coordinate of `direction`
+ let same_coord_diff = Math.abs(
+ direct_el.coords[rev_coord] -
+ relative_el.coords[rev_coord]
+ )
+
+ // if the difference is less than 3 then we just return the
+ // `direct_el` as its only a couple pixels off being on the
+ // same coordinate as `relative_el`
+ if (same_coord_diff < 3) {
+ return direct_el;
+ }
+
+ // get the difference is distance on `direct_el` and
+ // `closest_el`
+ let difference = Math.abs(
+ direct_el.distance - closest_el.distance
+ )
+
+ // is the distance les than 50?
+ if (difference < 50) {
+ // get the difference between `direct_el` and
+ // `relative_el`
+ let direct_diff = Math.abs(
+ direct_el.coords[rev_coord] -
+ relative_el.coords[rev_coord]
+ )
+
+ // get the difference between `closest_el` and
+ // `relative_el`
+ let closest_diff = Math.abs(
+ closest_el.coords[rev_coord] -
+ relative_el.coords[rev_coord]
+ )
+
+ // if the `direct_el` is closer to `relative_el`, return
+ // that, otherwise return `closet_el`
+ if (direct_diff < closest_diff) {
+ return direct_el;
+ } else if (closest_diff < direct_diff) {
+ return closest_el;
+ }
+ }
+
+ // is `direct_el` closer than `closest_el` in either
+ // `.coord_distance` or `.distance`
+ //
+ // if not just return `closest_el`
+ if (direct_el.coord_distance <= closest_el.coord_distance ||
+ direct_el.distance <= closest_el.distance) {
+
+ // if `direct_el` is closer in `.coord_distance` then
+ // return `direct_el`
+ if (direct_el.coord_distance <=
+ closest_el.coord_distance) {
+
+ return direct_el;
+ }
+
+ // if the difference in `.distance` is less than 50,
+ // then return `direct_el`
+ if (difference < 50) {
+ return direct_el;
+ }
+
+ // if the `.distance` is overall closer on `direct_el`
+ // than `closest_el` then we return `direct_el`,
+ // otherwise we return `closest_el`
+ if (direct_el.distance < closest_el.distance) {
+ return direct_el;
+ } else {
+ return closest_el;
+ }
+ } else { // `direct_el` is unarguably too far away
+ return closest_el;
+ }
+ } else if (! direct_el && ! closest_el) {
+ // do nothing if no element at all was found
+ return false;
+ }
+
+ // return whichever element we did find
+ return direct_el || closest_el;
+ }
+
+ // translate `direction` into `get_el()` args
+ switch(direction) {
+ case "up": return get_el(0, -1);
+ case "down": return get_el(0, 1);
+ case "left": return get_el(-1, 0);
+ case "right": return get_el(1, 0);
+ }
+}
+
+// contains a list of the last selections we had before a popup was
+// opened, letting us go back to those selections when they're closed
+let last_popup_selections = [];
+
+// attempt to reselect the default selection when a popup is either
+// closed or opened
+events.on("popup-changed", (e) => {
+ // get the active selection
+ let active_el = document.querySelector(".active-selection");
+
+ // make sure there is a selection
+ if (! active_el) {
+ return;
+ }
+
+ // add `active_el` to `last_popup_selections` if we opened a popup
+ if (e.new_state) {
+ last_popup_selections.push({
+ el: active_el,
+ popup: e.popup
+ })
+ } else { // we're closing a popup
+ // this may contain the element we had opened before the popup
+ // we're closing was opened
+ let last_selection;
+
+ // remove selections that are for this popup
+ last_popup_selections = last_popup_selections.filter((item) => {
+ // is this selection for this popup?
+ let is_popup = item.popup == e.popup;
+
+ // set `last_selection` to `.el` if its this popup we're
+ // closing, thereby getting the last selection made before
+ // we opened this popup
+ if (is_popup) {
+ last_selection = item.el;
+ }
+
+ return ! is_popup;
+ })
+
+ // select `last_selection` if one was found
+ if (last_selection) {
+ setTimeout(() => {
+ navigate.selection(last_selection);
+ }, 150) // needed due to popup animation
+
+ return;
+ }
+ }
+
+ // remove the currently active selection
+ active_el.classList.remove("active-selection");
+
+ // update the `#selection` element
+ navigate.selection();
+
+ // wait a moment to allow the popup to open or close completely
+ setTimeout(() => {
+ // select the default selection
+ navigate.default_selection();
+ }, 300)
+})
+
+// automatically deselect a selection if its no longer visible
+setInterval(() => {
+ // get the active selection
+ let active_el = document.querySelector(".active-selection");
+
+ if (! active_el) {return}
+
+ let visible = active_el.checkVisibility({
+ checkOpacity: true,
+ visibilityProperty: true,
+ checkVisibilityCSS: true,
+ contentVisibilityAuto: true
+ })
+
+ if (! visible) {
+ navigate.default_selection();
+ }
+}, 500)
+
+module.exports = navigate;
diff --git a/src/app/js/settings.js b/src/app/js/settings.js
index 3aa9c43..b5ef773 100644
--- a/src/app/js/settings.js
+++ b/src/app/js/settings.js
@@ -286,6 +286,15 @@ settings.popup.load = () => {
}
settings.popup.switch = (el, state) => {
+ if (! el) {return}
+
+ // prevent switches from being switched when disabled
+ if (el.getAttribute("disabled") != null
+ || el.classList.contains("disabled")) {
+
+ return;
+ }
+
if (state) {
return el.classList.add("on");
} else if (state === false) {
diff --git a/src/app/js/toasts.js b/src/app/js/toasts.js
index 83ddf6a..c5cdd40 100644
--- a/src/app/js/toasts.js
+++ b/src/app/js/toasts.js
@@ -24,6 +24,10 @@ toasts.show = (properties) => {
toast.fg = "#FFFFFF";
toast.bg = "#FF9B85";
break
+ case "info":
+ toast.fg = "#FFFFFF";
+ toast.bg = "rgb(var(--blue))";
+ break
}
@@ -75,7 +79,7 @@ toasts.dismiss = (id) => {
}
ipcRenderer.on("toast", (_, properties) => {
- Toast(properties);
+ toasts.show(properties);
})
module.exports = toasts;
diff --git a/src/app/js/tooltip.js b/src/app/js/tooltip.js
index f2d97a0..13dcd67 100644
--- a/src/app/js/tooltip.js
+++ b/src/app/js/tooltip.js
@@ -128,5 +128,3 @@ document.addEventListener("mousedown", tooltip_event);
document.addEventListener("mousemove", tooltip_event);
document.addEventListener("mouseenter", tooltip_event);
document.addEventListener("mouseleave", tooltip_event);
-
-module.exports = tooltip;
diff --git a/src/app/js/update.js b/src/app/js/update.js
index 6aa1b6d..d03db02 100644
--- a/src/app/js/update.js
+++ b/src/app/js/update.js
@@ -41,14 +41,16 @@ ipcRenderer.on("ns-update-event", (event, options) => {
.innerText = `(${lang(key)})`;
}
+ let delay, now;
+
switch(key) {
case "cli.update.uptodate_short":
case "cli.update.no_internet":
// initial value
- let delay = 0;
+ delay = 0;
// get current time
- let now = new Date().getTime();
+ now = new Date().getTime();
// check if `update.ns.last_checked` was less than 500ms
// since now, this variable is set when `update.ns()` is
@@ -73,6 +75,32 @@ ipcRenderer.on("ns-update-event", (event, options) => {
}, delay)
break;
+ case "cli.update.failed":
+ // initial value
+ delay = 0;
+
+ // get current time
+ now = new Date().getTime();
+
+ // check if `update.ns.last_checked` was less than 500ms
+ // since now, this variable is set when `update.ns()` is
+ // called
+ if (now - update.ns.last_checked < 500) {
+ // if less than 500ms has passed, set `delay` to the
+ // amount of milliseconds missing until we've hit that
+ // 500ms threshold
+ delay = 500 - (now - update.ns.last_checked);
+ }
+
+ // Request version number
+ // this will also handle the play button label for us
+ ipcRenderer.send("get-version");
+
+ setTimeout(() => {
+ update_btn();
+ set_buttons(true);
+ update.ns.progress(false);
+ }, delay)
default:
update_btn();
diff --git a/src/app/main.css b/src/app/main.css
index 3f77ac4..9ade423 100644
--- a/src/app/main.css
+++ b/src/app/main.css
@@ -5,6 +5,7 @@
@import "css/tooltip.css";
@import "css/theming.css";
@import "css/launcher.css";
+@import "css/selection.css";
body {
margin: 0;
diff --git a/src/app/main.js b/src/app/main.js
index 3adb96d..0a84e66 100644
--- a/src/app/main.js
+++ b/src/app/main.js
@@ -44,5 +44,8 @@ const launcher = require("./js/launcher");
const is_running = require("./js/is_running");
const set_buttons = require("./js/set_buttons");
+const navigate = require("./js/navigate");
+const gamepad = require("./js/gamepad");
+
require("./js/dom_events");
require("./js/localize")();
diff --git a/src/cli.js b/src/cli.js
index c3d957e..c9fe05a 100644
--- a/src/cli.js
+++ b/src/cli.js
@@ -115,9 +115,9 @@ async function init() {
}
// Mod related args, --installmod, --removemod, --togglemod
- if (cli.hasSwitch("installmod") && gamepathExists()) {ipcMain.emit("installmod")}
- if (cli.hasSwitch("removemod") && gamepathExists()) {ipcMain.emit("removemod", "", cli.getSwitchValue("removemod"))}
- if (cli.hasSwitch("togglemod") && gamepathExists()) {ipcMain.emit("togglemod", "", cli.getSwitchValue("togglemod"))}
+ if (cli.hasSwitch("installmod") && gamepathExists()) {ipcMain.emit("install-mod")}
+ if (cli.hasSwitch("removemod") && gamepathExists()) {ipcMain.emit("remove-mod", "", cli.getSwitchValue("removemod"))}
+ if (cli.hasSwitch("togglemod") && gamepathExists()) {ipcMain.emit("toggle-mod", "", cli.getSwitchValue("togglemod"))}
// Prints out the list of mods
if (cli.hasSwitch("mods") && gamepathExists()) {ipcMain.emit("getmods")}
diff --git a/src/index.js b/src/index.js
index 9321598..c6a7ba8 100644
--- a/src/index.js
+++ b/src/index.js
@@ -1,5 +1,5 @@
const path = require("path");
-const { app, BrowserWindow } = require("electron");
+const { app, BrowserWindow, dialog } = require("electron");
// makes it so Electron cache doesn't get stored in your system's config
// folder, and instead changing it over to using the system's cache
@@ -10,11 +10,13 @@ app.setPath("userData", path.join(app.getPath("cache"), app.name));
process.chdir(app.getPath("appData"));
const cli = require("./cli");
+const lang = require("./lang");
const mods = require("./modules/mods");
const update = require("./modules/update");
const version = require("./modules/version");
const settings = require("./modules/settings");
+const protocol = require("./modules/protocol");
// loads `ipcMain` events that dont fit in any of the modules directly
require("./modules/ipc");
@@ -88,6 +90,7 @@ function start() {
// load list of mods on initial load
win.webContents.on("dom-ready", () => {
+ protocol();
send("mods", mods.list());
})
@@ -103,6 +106,7 @@ function start() {
}
}
+
// starts the GUI or CLI
if (cli.hasArgs()) {
if (cli.hasParam("update-viper")) {
@@ -112,9 +116,29 @@ if (cli.hasArgs()) {
cli.init();
}
} else {
+ app.setAsDefaultProtocolClient("ror2mm");
+
+ const app_lock = app.requestSingleInstanceLock()
+
// start the window/GUI
app.on("ready", () => {
+ if (!app_lock) {
+ // Viper is already running
+ if (process.argv.length <= (app.isPackaged ? 1 : 2))
+ {
+ dialog.showMessageBoxSync({
+ title: lang("viper.menu.main"),
+ message: lang("viper.already_running")
+ });
+ }
+ app.quit();
+ }
start();
})
+
+ app.on('second-instance', (event, commandLine, workingDirectory, additionalData) => {
+ protocol(commandLine);
+ })
+
}
diff --git a/src/lang/de.json b/src/lang/de.json
index 0eb17dc..0a22a68 100644
--- a/src/lang/de.json
+++ b/src/lang/de.json
@@ -43,7 +43,8 @@
"current": "Jetzige Version:",
"download_done": "Herunterladen abgeschlossen! Extrahiere...",
"downloading": "Wird heruntergeladen...",
- "finished": "Installation/Aktualisierung abeschlossen!",
+ "failed": "Installation/Aktualisierung fehlgeschlagen!",
+ "finished": "Installation/Aktualisierung abgeschlossen!",
"no_internet": "Keine Internetverbindung",
"uptodate": "Installation ist bereits auf dem neusten Stand (%s), aktualisieren wird übersprungen.",
"uptodate_short": "Auf dem neusten stand"
@@ -95,6 +96,8 @@
"install": "Installieren",
"launch": "Starten",
"mods": {
+ "cant_find_specific": "Mod %s-%s kann nicht gefunden werden!",
+ "cant_find_version": "Version %s von Mod %s-%s kann nicht gefunden werden!",
"confirm_dependencies": "Dieser Mod benötigt weitere Mods, diese werden unter dieser Nachricht angezeigt. Beim drücken auf \"Ok\" stimmst du zu da diese Installiert werden.\n\n",
"confirm_plugins": {
"description": "Native plugins haben sehr viel mehr Rechte als reguläre Mods, da durch ist das nutzen dieser um einiges unsicherer denn es ist einfacher ihnen zuschaden! Bitte installieren sie nur native plugins von vertrauten Entwicklern oder ähnliches, falls dir bewusst ist was du machst kannst du diese Nachricht ignorieren.",
@@ -283,6 +286,7 @@
"settings": "Einstellungen"
},
"viper": {
+ "already_running": "Viper läuft bereits",
"info": {
"credits": "Credits",
"discord": "Tritt dem Discord bei:",
diff --git a/src/lang/en.json b/src/lang/en.json
index f968f73..5d622e2 100644
--- a/src/lang/en.json
+++ b/src/lang/en.json
@@ -28,6 +28,7 @@
"checking": "Checking for updates...",
"download_done": "Download done! Extracting...",
"finished": "Installation/Update finished!",
+ "failed": "Installation/Update failed!",
"uptodate": "Latest version (%s) is already installed, skipping update.",
"uptodate_short": "Up-to-date",
"no_internet": "No Internet connection"
@@ -101,6 +102,8 @@
"installed_mod": "Installed mod!",
"drag_n_drop": "Drag and drop a mod to install",
"confirm_dependencies": "This package has dependencies, shown below, clicking \"Ok\" will install the package and the dependencies.\n\n",
+ "cant_find_specific": "Can't find mod %s-%s!",
+ "cant_find_version": "Can't find version %s of mod %s-%s!",
"confirm_plugins": {
"title": "The following package has native plugins:",
@@ -121,6 +124,13 @@
"no_results": "No results...",
"guide": "Guide",
+ "sort": {
+ "newest": "Newest",
+ "last_updated": "Last Updated",
+ "highest_rating": "Highest Rated",
+ "most_downloads": "Most Downloaded"
+ },
+
"filter": {
"mods": "Mods",
"skins": "Skins",
@@ -281,6 +291,8 @@
},
"viper": {
+ "already_running": "Viper is already running",
+
"menu": {
"main": "Viper",
"release": "Release Notes",
diff --git a/src/lang/fr.json b/src/lang/fr.json
index 492ba76..491e400 100644
--- a/src/lang/fr.json
+++ b/src/lang/fr.json
@@ -43,6 +43,7 @@
"current": "Version actuelle :",
"download_done": "Téléchargement terminé ! Extraction des fichiers...",
"downloading": "Téléchargement en cours...",
+ "failed": "Échec de la mise à jour !",
"finished": "Mise à jour terminée !",
"no_internet": "Pas de connexion Internet",
"uptodate": "La dernière version (%s) est déjà installée.",
@@ -95,6 +96,8 @@
"install": "Installer",
"launch": "Jouer",
"mods": {
+ "cant_find_specific": "Incapable de trouver le mod %s-%s!",
+ "cant_find_version": "Incapable de trouver la version %s du mod %s-%s!",
"confirm_dependencies": "Ce mod a des dépendances (affichées ci-dessous), cliquer \"Ok\" les installera en même temps que le mod.\n\n",
"confirm_plugins": {
"description": "Les plugins ont des accès à votre système, comparés aux mods classiques, et sont de fait plus dangereux à l'installation, comme pourrait l'être un plugin contenant un malware. Si ce plugin provient d'un tiers de confiance ou si vous savez ce que vous faites, ne tenez pas compte de ce message.",
@@ -283,6 +286,7 @@
"settings": "Paramètres"
},
"viper": {
+ "already_running": "Viper est déjà en cours d'éxecution",
"info": {
"credits": "Remerciements",
"discord": "Rejoingnez le serveur Discord :",
diff --git a/src/lang/zh.json b/src/lang/zh.json
index 2976eed..43df3b0 100644
--- a/src/lang/zh.json
+++ b/src/lang/zh.json
@@ -43,6 +43,7 @@
"current": "当前版本:",
"download_done": "下载完毕!解压中...",
"downloading": "下载中...",
+ "failed": "安装/更新 失败!",
"finished": "安装/更新 完成!",
"no_internet": "无互联网连接",
"uptodate": "最新版本 (%s) 已安装, 跳过更新.",
@@ -95,6 +96,8 @@
"install": "安装",
"launch": "启动",
"mods": {
+ "cant_find_specific": "无法找到模组%s-%s!",
+ "cant_find_version": "无法找到%s版的模组%s-%s!",
"confirm_dependencies": "此包含有依赖项, 以下展示, 点击 \"Ok\" 将会安装此包和它的依赖项.\n\n",
"confirm_plugins": {
"description": "Native插件比普通的模组拥有更多对系统的访问权限, 如果安装了恶意插件可能会导致您的计算机受到损害. 如果这个插件是来自于一位信任的开发者或您知道自己在干什么, 那么请忽略这条信息.",
@@ -283,6 +286,7 @@
"settings": "设置"
},
"viper": {
+ "already_running": "Viper已在运行中",
"info": {
"credits": "鸣谢",
"discord": "加入Discord:",
diff --git a/src/modules/gamepath.js b/src/modules/gamepath.js
index 5a5f922..65676a4 100644
--- a/src/modules/gamepath.js
+++ b/src/modules/gamepath.js
@@ -45,18 +45,20 @@ ipcMain.on("wrong-path", () => {
})
ipcMain.on("found-missing-perms", async (e, selected_gamepath) => {
+ gamepath.setting = true;
await win().alert(lang("gui.gamepath.found_missing_perms") + selected_gamepath);
ipcMain.emit("setpath", null, false, true);
})
ipcMain.on("missing-perms", async (e, selected_gamepath) => {
+ gamepath.setting = true;
await win().alert(lang("gui.gamepath.missing_perms") + selected_gamepath);
ipcMain.emit("setpath");
})
ipcMain.on("gamepath-lost-perms", async (e, selected_gamepath) => {
- if (! gamepath.setting) {
- gamepath.setting = true;
+ if (! gamepath.setting && gamepath.lost_perms != selected_gamepath) {
+ gamepath.lost_perms = selected_gamepath;
await win().alert(lang("gui.gamepath.lost_perms") + selected_gamepath);
ipcMain.emit("setpath");
}
@@ -80,17 +82,21 @@ gamepath.exists = (folder) => {
// returns false if the user doesn't have read/write permissions to the
// selected gamepath, if no gamepath is set, then this will always
// return `false`, handle that correctly!
-gamepath.has_perms = (folder) => {
+gamepath.has_perms = (folder = settings().gamepath) => {
if (! gamepath.exists(folder)) {
return false;
}
try {
fs.accessSync(
- folder || settings().gamepath,
+ folder,
fs.constants.R_OK | fs.constants.W_OK
)
+ let test_file_path = path.join(folder, ".viper_test");
+ fs.writeFileSync(test_file_path, "");
+ fs.unlinkSync(test_file_path);
+
return true;
} catch (err) {
return false;
@@ -163,6 +169,8 @@ gamepath.set = async (win, force_dialog) => {
return gamepath.setting = false;
}
+ delete gamepath.lost_perms;
+
if (! fs.existsSync(path.join(res.filePaths[0], "Titanfall2.exe"))) {
ipcMain.emit("wrong-path");
return gamepath.setting = false;
diff --git a/src/modules/mods.js b/src/modules/mods.js
index 6214098..aecb5e3 100644
--- a/src/modules/mods.js
+++ b/src/modules/mods.js
@@ -729,9 +729,17 @@ mods.toggle = (mod, fork) => {
}
// toggles all mods, thereby inverting the current enabled states
+ //
+ // this skips core mods, as there's generally little use to have
+ // this affect them
if (mod == "allmods") {
let modlist = mods.list().all; // get list of all mods
for (let i = 0; i < modlist.length; i++) { // run through list
+ // skip core mods
+ if (modlist[i].name.toLowerCase().match(/^northstar\./)) {
+ continue;
+ }
+
mods.toggle(modlist[i].name, true); // enable mod
}
diff --git a/src/modules/packages.js b/src/modules/packages.js
index 8df6665..125705a 100644
--- a/src/modules/packages.js
+++ b/src/modules/packages.js
@@ -194,6 +194,7 @@ packages.install = async (url, author, package_name, version) => {
return false;
}
+
let name = packages.format_name(author, package_name, version);
// removes zip's and folders
diff --git a/src/modules/protocol.js b/src/modules/protocol.js
new file mode 100644
index 0000000..1aeb7f9
--- /dev/null
+++ b/src/modules/protocol.js
@@ -0,0 +1,39 @@
+const { app } = require("electron");
+
+const win = require("../win");
+const version = require("./version");
+
+module.exports = async (argv) => {
+ if (version.northstar() == "unknown")
+ return;
+
+ const args = argv || process.argv;
+
+ for (const key of args) {
+ if (key.startsWith("ror2mm://")) {
+ let fragments = key.slice(9).split("/");
+
+ if (fragments.length < 6)
+ return;
+
+ const ver = fragments[0];
+ const term = fragments[1];
+ const domain = fragments[2];
+ const author = fragments[3];
+ const package_name = fragments[4];
+ const version = fragments[5];
+
+ // There is only v1
+ if (ver != "v1")
+ continue;
+
+ // No support for custom thunderstore instances
+ if (domain != "thunderstore.io")
+ continue;
+
+ if (term == "install") {
+ win().send("protocol-install-mod", [domain, author, package_name, version]);
+ }
+ }
+ }
+}
diff --git a/src/modules/update.js b/src/modules/update.js
index c001ba8..7469e02 100644
--- a/src/modules/update.js
+++ b/src/modules/update.js
@@ -401,9 +401,32 @@ update.northstar = async (force_install) => {
console.ok(lang("cli.update.download_done"));
+ let destination = unzip.Extract({path: settings().gamepath});
+
+ // If we receive multiple errors of the same type we ignore them
+ let received_errors = [];
+ destination.on("error", (err) => {
+ if (received_errors.indexOf(err.code) >= 0)
+ return;
+
+ received_errors.push(err.code);
+ extract.close();
+ update.northstar.updating = false;
+
+ let description = lang("gui.toast.desc.unknown_error") + " (" + err.code + ")";
+
+ win().toast({
+ scheme: "error",
+ title: lang("gui.toast.title.failed"),
+ description: description
+ })
+
+ win().send("ns-update-event", "cli.update.failed");
+ })
+
// extracts the zip, this is the part where we're actually
// installing Northstar.
- extract.pipe(unzip.Extract({path: settings().gamepath}))
+ extract.pipe(destination)
let extracted = 0;
let size = received;