diff --git a/applications/metadata.js b/applications/metadata.js index 6507bad..4ad5b5e 100644 --- a/applications/metadata.js +++ b/applications/metadata.js @@ -333,6 +333,15 @@ var metadata = { "applications": "icon": "domainMapper/icon_inactive_white.png", "caption": "DOMAP" }, + { + "isActive": true, + "directory": "more", + "name": "MoreNG", + "description": "More app rewrite in QML. Please see documentation https://github.com/overte-org/community-apps/tree/master/applications/more for more information", + "jsfile": "more/more.js", + "icon": "more/img/icon_white.png", + "caption": "MORE-NG" + }, { "isActive": true, "directory": "hmd3rdPerson", diff --git a/applications/more/img/icon_black.png b/applications/more/img/icon_black.png new file mode 100644 index 0000000..b62d597 Binary files /dev/null and b/applications/more/img/icon_black.png differ diff --git a/applications/more/img/icon_white.png b/applications/more/img/icon_white.png new file mode 100644 index 0000000..e765410 Binary files /dev/null and b/applications/more/img/icon_white.png differ diff --git a/applications/more/menu.svg b/applications/more/menu.svg new file mode 100644 index 0000000..8d3273d --- /dev/null +++ b/applications/more/menu.svg @@ -0,0 +1,38 @@ + + + + + + diff --git a/applications/more/more.js b/applications/more/more.js new file mode 100644 index 0000000..250fa8e --- /dev/null +++ b/applications/more/more.js @@ -0,0 +1,231 @@ +// +// more.js +// +// Easily install additional functionality from repositories online +// +// Created by Armored Dragon, 2024. +// Copyright 2024 Overte e.V. +// +// Distributed under the Apache License, Version 2.0. +// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html + +(() => { + "use strict"; + + // TODO: Preinstall Overte community apps by default + var installed_scripts = Settings.getValue("ArmoredMore-InstalledScripts", []) || []; // All scripts installed though more.js + var installed_repositories = Settings.getValue("ArmoredMore-InstalledRepositories", []) || []; // All repositories installed though more.js + + // Global vars + var tablet; + var app_button; + var active = false; + + tablet = Tablet.getTablet("com.highfidelity.interface.tablet.system"); + + app_button = tablet.addButton({ + icon: Script.resolvePath("./img/icon_white.png"), + activeIcon: Script.resolvePath("./img/icon_black.png"), + text: "MORE", + isActive: active, + }); + // When script ends, remove itself from tablet + Script.scriptEnding.connect(function () { + console.log("Shutting Down"); + tablet.removeButton(app_button); + }); + + // Overlay button toggle + app_button.clicked.connect(toolbarButtonClicked); + + tablet.fromQml.connect(fromQML); + + function toolbarButtonClicked() { + if (active) { + tablet.gotoHomeScreen(); + active = !active; + app_button.editProperties({ + isActive: active, + }); + } else { + getLists(); + tablet.loadQMLSource(Script.resolvePath("./more.qml")); + active = !active; + app_button.editProperties({ + isActive: active, + }); + } + } + + function installApp({ title, repository, url, icon, description }) { + // Add script to saved list + installed_scripts.push({ + title: title, + repository: repository, + url: url, + icon: icon, + description: description, + }); + + // Save new list as setting + Settings.setValue("ArmoredMore-InstalledScripts", installed_scripts); + + // Install the script + ScriptDiscoveryService.loadScript(url, true); // Force reload the script, do not use cache. + + // Send updated app list + getLists(); + } + + function uninstallApp(url) { + // Find app in saved list + var entry = installed_scripts.filter((app) => app.url == url); + const index = installed_scripts.indexOf(entry); + + // Remove it from list + installed_scripts.splice(index, 1); + + // Save new list as setting + Settings.setValue("ArmoredMore-InstalledScripts", installed_scripts); + + // Uninstall the script + ScriptDiscoveryService.stopScript(url, false); + + // Send updated app list + getLists(); + } + + // TODO: Duplication check + async function installRepo(url) { + // Test repository + const repo = await request(url); + if (!repo) return; // Failure + + // Add repo to saved list + installed_repositories.push({ + title: repo.title || "Unnamed repository", + url: url, + }); + + // Save new list as setting + Settings.setValue("ArmoredMore-InstalledRepositories", installed_repositories); + + // Send updated repository list + getLists(); + } + function uninstallRepo(url) { + // Find app in saved list + var entry = installed_repositories.filter((repo) => repo.url == url); + const index = installed_repositories.indexOf(entry); + + // Remove it from list + installed_repositories.splice(index, 1); + + // Save new list as setting + Settings.setValue("ArmoredMore-InstalledRepositories", installed_repositories); + + // Send updated app list + getLists(); + } + + // Startup populate lists + async function getLists() { + let application_list = []; + let installed_apps_by_url = installed_scripts.map((app) => app.url); + + for (let i = 0; installed_repositories.length > i; i++) { + let repo = installed_repositories[i]; + let apps = await request(repo.url); + if (!apps) continue; // Failure + + apps = apps.application_list || []; + + // Filter to non-installed ones + apps = apps.filter((app) => { + let app_root = repo.url.replace(/\/metadata.json/g, "") + `/${app.directory}`; + + let script_url = app_root + `/${app.script}`; + + return installed_apps_by_url.indexOf(script_url) == -1; + }); + + apps = apps.map((app) => { + let app_root = repo.url.replace(/\/metadata.json/g, "") + `/${app.directory}`; + + let script_url = app_root + `/${app.script}`; + let script_icon = app_root + `/${app.icon}`; + + return { + title: app.name, + description: app.description, + icon: script_icon, + repository: repo.title, + url: script_url, + }; + }); + + // Add all apps from repo to list + application_list.push(...apps); + } + + _emitEvent({ + type: "installed_apps", + app_list: [ + ...installed_scripts.map((app) => { + return { ...app, installed: true }; + }), + ...application_list, + ], + }); + + _emitEvent({ + type: "installed_repositories", + repository_list: installed_repositories, + }); + } + + async function request(url) { + var xmlHttp = new XMLHttpRequest(); + xmlHttp.open("GET", url, false); + xmlHttp.send(null); + + // Any request we make is intended to be a JSON response. + // If it can not be parsed into JSON then fail. + try { + return JSON.parse(xmlHttp.responseText); + } catch { + return false; + } + } + + function fromQML(event) { + console.log(`New QML event:\n${JSON.stringify(event)}`); + + switch (event.type) { + case "initialized": + getLists(); + break; + case "install_application": + installApp(event); + break; + case "remove_application": + uninstallApp(event.url); + break; + case "install_repo": + installRepo(event.url); + break; + case "remove_repo": + uninstallRepo(event.url); + break; + } + } + + /** + * Emit a packet to the HTML front end. Easy communication! + * @param {Object} packet - The Object packet to emit to the HTML + * @param {("show_message"|"clear_messages"|"notification"|"initial_settings")} packet.type - The type of packet it is + */ + function _emitEvent(packet = { type: "" }) { + tablet.sendToQml(packet); + } +})(); diff --git a/applications/more/more.qml b/applications/more/more.qml new file mode 100644 index 0000000..6b98b42 --- /dev/null +++ b/applications/more/more.qml @@ -0,0 +1,713 @@ +import QtQuick 2.7 +import QtQuick.Controls 2.0 +import QtQuick.Layouts 1.3 +import controlsUit 1.0 as HifiControlsUit + +Rectangle { + color: Qt.rgba(0.1,0.1,0.1,1) + signal sendToScript(var message); + width: 200 + height: 700 + id: root + + property string current_page: "app_list" + property string last_message_user: "" + property date last_message_time: new Date() + + Timer { + interval: 10 + running: true + repeat: false + onTriggered: { + toScript({type: "initialized"}); + } + } + + // User view + Item { + anchors.fill: parent + + // Navigation Bar + Rectangle { + id: navigation_bar + width: parent.width + height: 60 + color: Qt.rgba(0,0,0,1) + visible: ["app_list", "repos"].includes(current_page) ? true : false + + Item { + anchors.centerIn: parent + width: parent.width - 10 + height: parent.height - 25 + + Rectangle { + color: "white" + width: parent.width - 100 + anchors.verticalCenter: parent.verticalCenter + height: parent.height + radius: 5 + + TextInput { + width: parent.width - 10 + color: "black" + font.pointSize: 12 + anchors.centerIn: parent + id: search_query + onAccepted: { + if (current_page == "app_list"){ + searchList(search_query.text, installed_apps); + return; + } + if (current_page == "repos"){ + searchList(search_query.text, repo_list); + return; + } + } + } + + Text { + color: "Gray" + font.pointSize: 10 + anchors.verticalCenter: parent.verticalCenter + x: 5 + text: "Search..." + font.italic: true + visible: parent.children[0].text == "" + } + } + + Rectangle { + color: "#296992" + width: parent.width - parent.children[0].width - 10 + anchors.verticalCenter: parent.verticalCenter + height: parent.height + radius: 5 + anchors.right: parent.right + + Image { + source: "menu.svg" + anchors.centerIn: parent + sourceSize.width: 20 + sourceSize.height: 20 + } + + MouseArea { + anchors.fill: parent + onClicked: { + if (root.current_page == "app_list") { + root.current_page = "repos" + return; + } + + if (root.current_page == "repos") { + root.current_page = "app_list" + return; + } + } + } + } + } + } + + // Pages ---- + + // Installed Apps + Item { + width: parent.width + height: parent.height - 40 + anchors.top: navigation_bar.bottom + visible: current_page == "app_list" + + // Installed Apps + ListView { + property int index_selected: -1 + width: parent.width + height: parent.height - 60 + clip: true + interactive: true + spacing: 5 + id: installed_apps_list + model: installed_apps + + delegate: Loader { + property int delegateIndex: index + property string delegateTitle: model.title + property string delegateRepository: model.repository + property string delegateDescription: model.description + property string delegateIcon: model.icon + property string delegateURL: model.url + property bool delegateInstalled: model.installed + property bool delegateIsVisible: model.is_visible + width: installed_apps_list.width + + sourceComponent: app_listing + } + } + + ListModel { + id: installed_apps + } + } + + // Repository Manager + Item { + width: parent.width + height: parent.height - 40 + anchors.top: navigation_bar.bottom + visible: current_page == "repos" + + Rectangle { + height: 70 + width: parent.width + color: "#111111" + + Item { + width: parent.width - 10 + height: parent.height + anchors.horizontalCenter: parent.horizontalCenter + + + Text{ + text: "Add a new repository" + color: "White" + font.pointSize: 12 + wrapMode: Text.WordWrap + height: 30 + } + + Rectangle{ + width: parent.width - 70 + height: 30 + radius: 5 + anchors.top: parent.children[0].bottom + + TextInput { + width: parent.width - 10 + color: "black" + font.pointSize: 12 + anchors.centerIn: parent + id: repo_url + } + + Text { + color: "Gray" + font.pointSize: 10 + anchors.verticalCenter: parent.verticalCenter + x: 5 + text: "Add a manifest.json url" + font.italic: true + visible: parent.children[0].text == "" + } + } + + Rectangle { + anchors.top: parent.children[0].bottom + width: parent.width - parent.children[1].width - 10 + anchors.right: parent.right + height: 30 + color: "green" + radius: 5 + + Text { + text: "+" + color: "White" + font.pointSize: 14 + anchors.centerIn: parent + } + + MouseArea { + anchors.fill: parent + + onClicked: { + installNewRepository(repo_url.text); + repo_url.text = ""; + } + } + } + } + + } + + ListView { + property int index_selected: -1 + width: parent.width + height: parent.height - 60 + clip: true + interactive: true + spacing: 5 + id: registered_repo_list + model: repo_list + anchors.top: parent.children[0].bottom + delegate: Loader { + property int delegateIndex: index + property string delegateTitle: model.title + property string delegateURL: model.url + property bool selected: false + property bool delegateIsVisible: model.is_visible + + width: registered_repo_list.width + + sourceComponent: repo_listing + } + } + ListModel { + id: repo_list + } + } + + + // Go back button from app details + Rectangle { + id: go_back_button + width: parent.width + height: 60 + color: Qt.rgba(0,0,0,1) + visible: current_page == "details" + + Rectangle { + width: parent.width - 20 + anchors.verticalCenter: parent.verticalCenter + anchors.horizontalCenter: parent.horizontalCenter + height: 35 + radius: 5 + color: "#771d1d" + + Text { + color: "white" + font.pointSize: 12 + anchors.centerIn: parent + text: "Back" + } + + MouseArea { + anchors.fill: parent + + onClicked: { + current_page = "app_list" + } + } + } + } + + // App Details + Item { + width: parent.width - 20 + height: parent.height - 40 + anchors.top: navigation_bar.bottom + visible: current_page == "details" + anchors.horizontalCenter: parent.horizontalCenter + + Item { + width: parent.width + height: 100 + y: 10 + + + Rectangle{ + width: 100 + height: 100 + radius: 5 + + Rectangle { + color: "black" + width: 96 + height: 96 + radius: 5 + anchors.centerIn: parent + + Image { + id: details_icon + width: 90 + height: 90 + anchors.centerIn: parent + } + } + } + + Text { + x: parent.children[0].width + 10 + text: "" + color:"white"; + font.pointSize: 14 + id: details_title + } + + Text { + x: parent.children[0].width + 10 + y: parent.children[1].height + 5 + text: "" + color: "gray"; + font.pointSize: 10 + id: details_repo_url + } + } + + Item { + width: parent.width + anchors.top: parent.children[0].bottom + + Text{ + text: "" + color: "white"; + font.pointSize: 12 + y: 20 + id: details_description + } + } + + } + } + + // Templates + Component { + id: app_listing + + Rectangle { + property int index: delegateIndex + property string title: delegateTitle + property string repo: delegateRepository + property string description: delegateDescription + property string icon: delegateIcon + property string url: delegateURL + property bool installed: delegateInstalled + property bool is_visible: delegateIsVisible + + property bool selected: (installed_apps_list.index_selected == index) + + visible: is_visible + height: is_visible ? selected ? 100 : 60 : 0 + width: parent.width + + color: index % 2 === 0 ? "transparent" : Qt.rgba(0.15,0.15,0.15,1) + + Behavior on height { + NumberAnimation { + duration: 100 + } + } + + Item { + width: parent.width - 10 + anchors.horizontalCenter: parent.horizontalCenter + height: parent.height + clip: true + + // Icon + Rectangle { + width: 50 + height: 50 + radius: 5 + color: installed ? "#505186" : "white" + y: 5 + + Rectangle{ + anchors.centerIn: parent + width: 46 + height: 46 + radius: 5 + color: "black" + + Image { + source: icon + anchors.centerIn: parent + sourceSize.width: 40 + sourceSize.height: 40 + } + } + } + + // App info + Item { + width: parent.width - parent.children[0].width - 50 + x: parent.children[0].width + 10 + height: 20 + + Text { + width: parent.width + height: 20 + text: title + color: "white" + font.pointSize: 12 + wrapMode: Text.NoWrap + elide: Text.ElideRight + } + Text { + width: parent.width + height: 20 + text: repo + color: "gray" + font.pointSize: 10 + anchors.top: parent.children[0].bottom + } + } + + // Action Buttons + Item { + width: parent.width + height: 30 + + y: 65 + visible: selected ? true : false + + + Rectangle { + width: 120 + height: parent.height + radius: 5 + color: "#771d1d" + visible: installed + + Text{ + text: "Uninstall" + anchors.centerIn: parent + color:"white" + } + + MouseArea { + anchors.fill: parent + + onClicked: { + removeApp(url); + } + } + + } + + Rectangle { + width: 120 + height: parent.height + radius: 5 + color: "#00930f" + visible: !installed + + Text{ + text: "Install" + anchors.centerIn: parent + color:"white" + } + + MouseArea { + anchors.fill: parent + + onClicked: { + installNewApp(title, url, repo, description, icon); + } + } + } + + Rectangle { + width: 120 + height: parent.height + radius: 5 + color: "#505186" + x: parent.children[0].width + 5 + + Text { + text: "Details" + anchors.centerIn: parent + color:"white" + } + + MouseArea { + anchors.fill: parent + + onClicked: { + openAppDetails(title, url, repo, description, icon); + } + } + } + } + + MouseArea { + width: parent.width + height: 60 + + onClicked: { + if (installed_apps_list.index_selected == index){ + installed_apps_list.index_selected = -1; + return; + } + + installed_apps_list.index_selected = index + } + } + + } + } + } + + Component { + id: repo_listing + + Rectangle { + property int index: delegateIndex + property string title: delegateTitle + property string url: delegateURL + property bool is_visible: delegateIsVisible + + property bool selected: (registered_repo_list.index_selected == index) + + height: selected ? 70 : 40 + width: parent.width + visible: is_visible + color: index % 2 === 0 ? "transparent" : Qt.rgba(0.15,0.15,0.15,1) + clip: true + + Behavior on height { + NumberAnimation { + duration: 100 + easing.type: Easing.OutQuad + } + } + + Item { + width: parent.width - 10 + anchors.horizontalCenter: parent.horizontalCenter + height: parent.height + + // Repo Info + Text { + width: parent.width + height: 20 + text: title + color: "white" + font.pointSize: 12 + wrapMode: Text.noWrap + elide: Text.ElideRight + } + Text { + width: parent.width + height: 20 + anchors.top: parent.children[0].bottom + text: url + color: "gray" + font.pointSize: 10 + wrapMode: Text.noWrap + elide: Text.ElideRight + } + + // Action Buttons + Item { + height: selected ? 30 : 0 + width: parent.width + anchors.top: parent.children[1].bottom + visible: selected ? true : false + + Rectangle { + width: 120 + height: parent.height + radius: 5 + color: "#771d1d" + + Text{ + text: "Remove" + anchors.centerIn: parent + color:"white" + } + + MouseArea { + anchors.fill: parent + + onClicked: { + removeRepository(url); + } + } + } + } + + } + + MouseArea { + width: parent.width + height: 40 + onClicked: { + if (registered_repo_list.index_selected == index){ + registered_repo_list.index_selected = -1; + return; + } + + registered_repo_list.index_selected = index + } + } + + } + } + + // List population and management + function addApplicationToList(name, repo_name, description, installed, url){ + + } + function clearApplicationList(){ + installed_apps.clear() + } + function addRepositoryToList(repo_name, url){ + + } + function clearRepositoryList(){ + repo_list.clear() + } + + // Funcionality + function installNewRepository(url){ + toScript({type: "install_repo", url: url}); + } + function removeRepository(url){ + toScript({type: "remove_repo", url: url}); + } + function installNewApp(title, url, repository, description, icon){ + toScript({type: "install_application", title: title, url: url, repository: repository, description: description, icon: icon}); + } + function removeApp(url){ + toScript({type: "remove_application", url: url}); + } + + // Searching + function searchList(text, element){ + + for (var i = 0; i < element.count; i++) { + var app = element.get(i); + + var is_found = app.title.toLowerCase().includes(text.toLowerCase()) || app.description.toLowerCase().includes(text.toLowerCase()) || app.url.toLowerCase().includes(text.toLowerCase()) + + if (!app.title.toLowerCase().includes(text.toLowerCase())){ + app.is_visible = false; + } + else { + app.is_visible = true + } + + } + } + + // App Details page + function openAppDetails(title, url, repo, description, icon){ + current_page = "details"; + details_title.text = title; + details_repo_url.text = repo; + details_description.text = description; + details_icon.source = icon; + } + + // Messages from script + function fromScript(message) { + switch (message.type){ + case "installed_apps": + clearApplicationList(); + message.app_list.forEach((app) => installed_apps.append({title: app.title, repository: app.repository, description: app.description, icon: app.icon, url: app.url, installed: app.installed || false, is_visible: true })) + break; + case "installed_repositories": + clearRepositoryList(); + message.repository_list.forEach((repo) => repo_list.append({ title: repo.title, url: repo.url, is_visible: true })) + break; + case "clear_messages": + break; + case "initial_settings": + break; + } + } + + // Send message to script + function toScript(packet){ + sendToScript(packet) + } +}