diff --git a/.github/workflows/linters.yaml b/.github/workflows/linters.yaml index c6a72ac949..9f98fb818e 100644 --- a/.github/workflows/linters.yaml +++ b/.github/workflows/linters.yaml @@ -61,13 +61,9 @@ jobs: export PATH=/opt/$QTVERSION/gcc_64/bin:$PATH python scripts/ci/check_qrc.py - - name: Check for guide JSON syntax + - name: Check for addons JSON syntax run: | - python scripts/ci/check_jsonschema.py guide.json src/ui/guides - - - name: Check for tutorial JSON syntax - run: | - python scripts/ci/check_jsonschema.py tutorial.json src/ui/tutorials + python scripts/ci/check_jsonschema.py addon.json addons/*/manifest.json - name: Check for issues with clang-format uses: DoozyX/clang-format-lint-action@v0.11 diff --git a/.github/workflows/translations.yaml b/.github/workflows/translations.yaml index 640c220c01..282c77010b 100644 --- a/.github/workflows/translations.yaml +++ b/.github/workflows/translations.yaml @@ -43,7 +43,9 @@ jobs: ./scripts/utils/generate_ts.sh - name: Uploading - uses: actions/upload-artifact@v1 + uses: actions/upload-artifact@v3 with: - name: Translation file - path: translations.ts + name: Translation files + path: | + translations.ts + addon_ts/*.ts diff --git a/.github/workflows/wasm.yaml b/.github/workflows/wasm.yaml index 76b01b33e3..65b629d299 100644 --- a/.github/workflows/wasm.yaml +++ b/.github/workflows/wasm.yaml @@ -93,9 +93,48 @@ jobs: name: Inspector Build path: tools/inspector/dist + addons: + runs-on: ubuntu-20.04 + env: + QTVERSION: 6.2.4 + + steps: + - name: Clone repository + uses: actions/checkout@v2 + + - name: Checkout submodules + shell: bash + run: | + auth_header="$(git config --local --get http.https://github.com/.extraheader)" + git submodule sync --recursive + git -c "http.extraheader=$auth_header" -c protocol.version=2 submodule update --init --force --recursive --depth=1 + + - name: Install Qt + shell: bash + run: | + python3 -m pip install aqtinstall + python3 -m aqt install-qt -O /opt linux desktop $QTVERSION + + - name: Install python dependencies + shell: bash + run: | + pip3 install -r requirements.txt + + - name: Generating addons + shell: bash + run: | + export PATH=/opt/$QTVERSION/gcc_64/bin:$PATH + python3 scripts/addon/generate_all.py + + - name: Uploading + uses: actions/upload-artifact@v1 + with: + name: Inspector Build + path: addons/generated/addons + ghPages: runs-on: ubuntu-20.04 - needs: [wasmQt6, inspector] + needs: [wasmQt6, inspector, addons] name: Publish Wasm on Github Pages env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 237a4cbda6..34a2470d2a 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,9 @@ linux/netfilter/vendor/ __pycache__ lottie.mjs +# Translation pipeline +addon_ts/ + # CMake build artifacts cmake_install.cmake CMakeCache.txt @@ -47,7 +50,7 @@ src/mozillavpn_autogen/ node_modules #Github Codespaces -.venv +.venv asset_catalog_compiler.Info.plist @@ -60,7 +63,7 @@ android/local.properties android/**/*.cxx android/.cxx/* !android/res/debug/**/* -!android/*/**/Makefile +!android/*/**/Makefile xcode.xconfig .gradle/ .gradle_cache/ @@ -130,7 +133,7 @@ windows/split-tunnel/*.sys windows/split-tunnel/*.dll windows/split-tunnel/.status -#Rust +# Rust .cargo_home/ @@ -164,6 +167,9 @@ translations/*/locversion.plist translations/generated/ macos/pkg/Resources/*.lproj +# Addons +addons/generated/ + # Adjust SDK Files android/src/com/adjust diff --git a/src/ui/guides/01_how_to_vpn.svg b/addons/guide_01_how_to_vpn/image.svg similarity index 100% rename from src/ui/guides/01_how_to_vpn.svg rename to addons/guide_01_how_to_vpn/image.svg diff --git a/addons/guide_01_how_to_vpn/manifest.json b/addons/guide_01_how_to_vpn/manifest.json new file mode 100644 index 0000000000..a40ebe97b0 --- /dev/null +++ b/addons/guide_01_how_to_vpn/manifest.json @@ -0,0 +1,50 @@ +{ + "api_version": "0.1", + "id": "guide_01_how_to_vpn", + "name": "Guide: how to vpn", + "type": "guide", + "guide": { + "id": "how_to_vpn", + "title": "The top uses and benefits of using Mozilla VPN", + "subtitle": "Mozilla VPN secures the connection between your device and the web. Here’s some of the most useful features your VPN offers:", + "image": "qrc:/addons/guide_01_how_to_vpn/image.svg", + "blocks": [ + { "id": "c_1", + "type": "title", + "content": "Safeguard your personal data and identity" }, + { "id": "c_2", + "type": "ulist", + "content": [ + { "id": "l_1", + "content": "Surf the web and use your favorite apps freely without being tracked" }, + { "id": "l_2", + "content": "Keep confidential materials safe via our premium data encryption" } + ] + }, + { "id": "c_3", + "type": "title", + "content": "Defense on the go" }, + { "id": "c_4", + "type": "ulist", + "content": [ + { "id": "l_1", + "content": "Blocks unknown entities from seeing your private data on risky public Wi-Fi and hotspots" }, + { "id": "l_2", + "content": "Protection wherever you are, from restaurants to vacation rentals" } + ] + }, + { "id": "c_5", + "type": "title", + "content": "Connect to 30+ countries" }, + { "id": "c_6", + "type": "ulist", + "content": [ + { "id": "l_1", + "content": "Browse and access content from all over the world" }, + { "id": "l_2", + "content": "Shop on a regional server to get localized deals like cheaper airfare" } + ] + } + ] + } +} diff --git a/src/ui/guides/02_is_my_vpn_working_correctly.svg b/addons/guide_02_is_my_vpn_working_correctly/image.svg similarity index 100% rename from src/ui/guides/02_is_my_vpn_working_correctly.svg rename to addons/guide_02_is_my_vpn_working_correctly/image.svg diff --git a/addons/guide_02_is_my_vpn_working_correctly/manifest.json b/addons/guide_02_is_my_vpn_working_correctly/manifest.json new file mode 100644 index 0000000000..cea99b168d --- /dev/null +++ b/addons/guide_02_is_my_vpn_working_correctly/manifest.json @@ -0,0 +1,37 @@ +{ + "api_version": "0.1", + "id": "guide_02_is_my_vpn_working_correctly", + "name": "Guide: is my VPN working correctly", + "type": "guide", + "guide": { + "id": "is_my_vpn_working_correctly", + "title": "Is my VPN connected to the right server location?", + "subtitle": "Test your geolocation on one of our 400+ worldwide servers in two super simple ways:", + "image": "qrc:/addons/guide_02_is_my_vpn_working_correctly/image.svg", + "blocks": [ + { "id": "c_1", + "type": "title", + "content": "Search in your browser" }, + { "id": "c_2", + "type": "text", + "content": "Enter a search term like “local weather” in your browser and see a real-time weather update for your current VPN location." }, + { "id": "c_3", + "type": "title", + "content": "Sign in to your favorite app or streaming site" }, + { "id": "c_4", + "type": "text", + "content": "Traveling abroad and need to access content from back home?" }, + { "id": "c_5", + "type": "olist", + "content": [ + { + "id": "l_1", + "content": "Connect to the country server of your choice" }, + { + "id": "l_2", + "content": "Sign on to your favorite app or streaming site" } + ] + } + ] + } +} diff --git a/src/ui/guides/03_adding_and_removing_devices.svg b/addons/guide_03_adding_and_removing_devices/image.svg similarity index 100% rename from src/ui/guides/03_adding_and_removing_devices.svg rename to addons/guide_03_adding_and_removing_devices/image.svg diff --git a/addons/guide_03_adding_and_removing_devices/manifest.json b/addons/guide_03_adding_and_removing_devices/manifest.json new file mode 100644 index 0000000000..f427e8808d --- /dev/null +++ b/addons/guide_03_adding_and_removing_devices/manifest.json @@ -0,0 +1,43 @@ +{ + "api_version": "0.1", + "id": "guide_03_adding_and_removing_devices", + "name": "Guide: adding and removing devices", + "type": "guide", + "guide": { + "id": "adding_and_removing_devices", + "title": "How do I add and remove devices?", + "subtitle": "Follow this quick and simple 3 step guide to add and remove all of your devices easily.", + "image": "image.svg", + "image": "qrc:/addons/guide_03_adding_and_removing_devices/image.svg", + "blocks": [ + { "id": "c_1", + "type": "title", + "content": "How to add a device" }, + { "id": "c_2", + "type": "olist", + "content": [ + { "id": "l_1", + "content": "Open the Mozilla VPN app on your preferred device" }, + { "id": "l_2", + "content": "Sign in to your account" }, + { "id": "l_3", + "content": "Voila! Your device has been automatically added to your VPN list of connected devices" } + ] + }, + { "id": "c_3", + "type": "title", + "content": "How to remove a device" }, + { "id": "c_4", + "type": "olist", + "content": [ + { "id": "l_1", + "content": "Open Mozilla VPN in your chosen desktop or mobile device" }, + { "id": "l_2", + "content": "Tap on “My Devices” in your VPN homescreen" }, + { "id": "l_3", + "content": "Select the “Trash” icon next to the device you want to remove from your list of connected devices" } + ] + } + ] + } +} diff --git a/src/ui/guides/04_connecting_external_devices.svg b/addons/guide_04_connecting_external_devices/image.svg similarity index 100% rename from src/ui/guides/04_connecting_external_devices.svg rename to addons/guide_04_connecting_external_devices/image.svg diff --git a/addons/guide_04_connecting_external_devices/manifest.json b/addons/guide_04_connecting_external_devices/manifest.json new file mode 100644 index 0000000000..a2155cc5ac --- /dev/null +++ b/addons/guide_04_connecting_external_devices/manifest.json @@ -0,0 +1,43 @@ +{ + "api_version": "0.1", + "id": "guide_04_connecting_external_devices", + "name": "Guide: connecting external devices", + "type": "guide", + "guide": { + "id": "connecting_external_devices", + "title": "Connecting to other devices while using VPN", + "subtitle": "Enabling local network access allows you to connect to other devices without sacrificing your VPN protection.", + "image": "qrc:/addons/guide_04_connecting_external_devices/image.svg", + "blocks": [ + { "id": "c_1", + "type": "title", + "content": "How do I connect to my printer or fax?" }, + { "id": "c_2", + "type": "olist", + "content": [ + { "id": "l_1", + "content": "Enable “Local network access” in your network settings" }, + { "id": "l_2", + "content": "Connect the printer or fax cable to your device’s port (wired) or via wireless" }, + { "id": "l_3", + "content": "Print or fax your documents as normal Desktop computers / laptops / mobile phones" } + ] + }, + { "id": "c_3", + "type": "title", + "content": "What other types of devices can I connect to?" }, + { "id": "c_4", + "type": "text", + "content": "With “Local network access” turned on, you can also connect to devices like:" }, + { "id": "c_5", + "type": "ulist", + "content": [ + { "id": "l_1", + "content": "Other computers and mobile phones" }, + { "id": "l_2", + "content": "Streaming sticks (Chromecast / Roku / Apple AirPlay)" } + ] + } + ] + } +} diff --git a/addons/language/language.qrc b/addons/language/language.qrc index 73980ab9c5..cd3ac5d910 100644 --- a/addons/language/language.qrc +++ b/addons/language/language.qrc @@ -1,5 +1,5 @@ - + manifest.json i18n/locale_en.qm diff --git a/addons/language/manifest.json b/addons/language/manifest.json index a87d8e807e..3bd3f262f0 100644 --- a/addons/language/manifest.json +++ b/addons/language/manifest.json @@ -1,5 +1,6 @@ { - "version": "0.1", - "name": "Tic-Tac-Toe", + "id": "language-addon", + "api_version": "0.1", + "name": "language package", "type": "i18n" } diff --git a/addons/tic-tac-toe/manifest.json b/addons/tic-tac-toe/manifest.json index a161dca706..13a94631ce 100644 --- a/addons/tic-tac-toe/manifest.json +++ b/addons/tic-tac-toe/manifest.json @@ -1,5 +1,6 @@ { - "version": "0.1", + "id": "tic-tac-toe", + "api_version": "0.1", "name": "Tic-Tac-Toe", "qml": "tic-tac-toe.qml", "type": "demo" diff --git a/addons/tic-tac-toe/tic-tac-toe.qrc b/addons/tic-tac-toe/tic-tac-toe.qrc index 42a63db899..a419da4df9 100644 --- a/addons/tic-tac-toe/tic-tac-toe.qrc +++ b/addons/tic-tac-toe/tic-tac-toe.qrc @@ -1,5 +1,5 @@ - + manifest.json script.mjs tic-tac-toe.qml diff --git a/src/ui/tutorials/01_get_started.svg b/addons/tutorial_01_get_started/image.svg similarity index 100% rename from src/ui/tutorials/01_get_started.svg rename to addons/tutorial_01_get_started/image.svg diff --git a/addons/tutorial_01_get_started/manifest.json b/addons/tutorial_01_get_started/manifest.json new file mode 100644 index 0000000000..c287ae4bd7 --- /dev/null +++ b/addons/tutorial_01_get_started/manifest.json @@ -0,0 +1,69 @@ +{ + "api_version": "0.1", + "id": "tutorial_01_get_started", + "name": "Tutorial: get started", + "type": "tutorial", + "tutorial": { + "id": "01_get_started", + "highlighted": true, + "image": "qrc:/addons/tutorial_01_get_started/image.svg", + "title": "Getting started with VPN", + "subtitle": "Follow this walkthrough to learn how to get started with using your VPN.", + "completion_message": "You’ve successfully changed your location, turned the VPN on and off. Would you like to learn more tips and tricks?", + "steps": [ + { + "id": "mainScreen", + "element": "serverListButton", + "tooltip": "Select your location", + "before": [{ + "op": "vpn_location_set", + "exitCountryCode": "at", + "exitCity": "Vienna", + "entryCountryCode": "", + "entryCity": "" + }], + "next": { + "op": "signal", + "qml_emitter": "serverListButton", + "signal": "visibleChanged" + } + }, + { + "id": "countryAU", + "element": "serverCountryList/serverCountry-au", + "tooltip": "Select a different country", + "before": [{ + "op": "property_set", + "element": "serverCountryView", + "property": "contentY", + "value": 0 + }], + "next": { + "op": "signal", + "qml_emitter": "serverCountryList/serverCountry-au", + "signal": "cityListVisibleChanged" + } + }, + { + "id": "cityAU", + "element": "serverCountryList/serverCountry-au/serverCityList/serverCity-Melbourne", + "tooltip": "Select a different server location", + "next": { + "op": "signal", + "qml_emitter": "serverCountryList/serverCountry-au/serverCityList/serverCity-Melbourne", + "signal": "visibleChanged" + } + }, + { + "id": "toggle", + "element": "controllerToggle", + "tooltip": "Toggle this switch to activate or deactivate the VPN", + "next": { + "op": "signal", + "vpn_emitter": "controller", + "signal": "stateChanged" + } + } + ] + } +} diff --git a/src/ui/tutorials/02_connect_on_startup.svg b/addons/tutorial_02_connect_on_startup/image.svg similarity index 100% rename from src/ui/tutorials/02_connect_on_startup.svg rename to addons/tutorial_02_connect_on_startup/image.svg diff --git a/addons/tutorial_02_connect_on_startup/manifest.json b/addons/tutorial_02_connect_on_startup/manifest.json new file mode 100644 index 0000000000..e91d202608 --- /dev/null +++ b/addons/tutorial_02_connect_on_startup/manifest.json @@ -0,0 +1,48 @@ +{ + "api_version": "0.1", + "id": "tutorial_02_connect_on_startup", + "name": "Tutorial: Connect on startup", + "type": "tutorial", + "tutorial": { + "id": "02_connect_on_startup", + "image": "qrc:/addons/tutorial_02_connect_on_startup/image.svg", + "title": "Connect VPN on startup", + "subtitle": "Follow this walkthrough to learn how to activate your VPN when you start your device.", + "completion_message": "You’ve successfully learned how to set “Connect VPN on startup”. Would you like to learn more tips and tricks?", + "conditions": { + "enabledFeatures": ["startOnBoot"] + }, + "steps": [ + { + "id": "s1", + "element": "settingsButton", + "tooltip": "Select your account settings", + "next": { + "op": "signal", + "qml_emitter": "settingsButton", + "signal": "visibleChanged" + } + }, + { + "id": "s2", + "element": "settingsPreferences", + "tooltip": "Select your system preferences", + "next": { + "op": "signal", + "qml_emitter": "settingsPreferences", + "signal": "visibleChanged" + } + }, + { + "id": "s3", + "element": "settingStartAtBoot", + "tooltip": "Select or deselect this checkbox", + "next": { + "op": "signal", + "vpn_emitter": "settingsHolder", + "signal": "startAtBootChanged" + } + } + ] + } +} diff --git a/linux/debian/control.beineri b/linux/debian/control.beineri index 32fbf4ebc3..16d8d38336 100644 --- a/linux/debian/control.beineri +++ b/linux/debian/control.beineri @@ -9,6 +9,7 @@ Build-Depends: debhelper (>= 9.20160709), golang (>=2:1.13~) | golang-1.13, cargo, python3-yaml, + python3-lxml, qt515base (>=5.15.2-1basyskom4), qt515declarative (>= 5.15.2-1basyskom1), qt515graphicaleffects (>= 5.15.2-1basyskom1), diff --git a/linux/debian/control.focal b/linux/debian/control.focal index 99dd7b129f..379ac7ae8f 100644 --- a/linux/debian/control.focal +++ b/linux/debian/control.focal @@ -9,6 +9,7 @@ Build-Depends: debhelper (>= 9.20160709), golang (>=1.13), cargo, python3-yaml, + python3-lxml, qt515base (>=5.15.2-1basyskom4), qt515declarative (>= 5.15.2-1basyskom1), qt515graphicaleffects (>= 5.15.2-1basyskom1), diff --git a/linux/debian/control.qt5 b/linux/debian/control.qt5 index eb4e4e7f54..13c8a13877 100644 --- a/linux/debian/control.qt5 +++ b/linux/debian/control.qt5 @@ -9,6 +9,7 @@ Build-Depends: debhelper (>= 9.20160709), golang (>=2:1.13~) | golang-1.13, cargo, python3-yaml, + python3-lxml, libqt5networkauth5-dev (>=5.15.2), libqt5websockets5-dev (>=5.15.2), qtbase5-dev (>=5.15.2), diff --git a/linux/debian/control.qt6 b/linux/debian/control.qt6 index 1eee88d582..da838a0950 100644 --- a/linux/debian/control.qt6 +++ b/linux/debian/control.qt6 @@ -10,6 +10,7 @@ Build-Depends: debhelper (>= 9.20160709), golang (>=2:1.13~) | golang-1.13, cargo, python3-yaml, + python3-lxml, libgl-dev, libopengl-dev (>= 1.3.0~) | libglvnd-dev (<< 1.3.0~), libqt6core5compat6-dev (>=6.2.0~), diff --git a/scripts/README.md b/scripts/README.md index b84e5a3989..f37d282f32 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -68,6 +68,11 @@ TODO: - ./apply-format - apply clang-format rules - ./git-pre-commit-format - configure the pre-commit git hook for clang-formatting +# Addons + +- ./addon/build.py - generate a single addon +- ./addon/generate_all.py generate all the addons + # Others - ./tooltool.py - utility used in taskcluster diff --git a/scripts/addon/build.py b/scripts/addon/build.py new file mode 100755 index 0000000000..fff13cd5bb --- /dev/null +++ b/scripts/addon/build.py @@ -0,0 +1,362 @@ +#! /usr/bin/env python3 +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import argparse +import json +import os +from lxml import etree as ET +import tempfile +import shutil +import sys + +comment_types = { + "text": "Standard text in a guide block", + "title": "Title in a guide block", + "ulist": "Bullet unordered list item in a guide block", + "olist": "Bullet ordered list item in a guide block", +} + + +def retrieve_strings_tutorial(manifest, filename): + tutorial_strings = {} + + tutorial_json = manifest["tutorial"] + if "id" not in tutorial_json: + exit(f"Tutorial {filename} does not have an id") + if "title" not in tutorial_json: + exit(f"Tutorial {filename} does not have a title") + if "subtitle" not in tutorial_json: + exit(f"Tutorial {filename} does not have a subtitle") + if "completion_message" not in tutorial_json: + exit(f"Tutorial {filename} does not have a completion message") + if "steps" not in tutorial_json: + exit(f"Tutorial {filename} does not have steps") + + tutorial_id = tutorial_json["id"] + title_id = f"tutorial.{tutorial_id}.title" + tutorial_strings[title_id] = { + "value": tutorial_json["title"], + "comments": tutorial_json.get("title_comment", "Title for a tutorial view"), + } + + subtitle_id = f"tutorial.{tutorial_id}.subtitle" + tutorial_strings[subtitle_id] = { + "value": tutorial_json["subtitle"], + "comments": tutorial_json.get( + "subtitle_comment", "Subtitle for a tutorial view" + ), + } + + completion_id = f"tutorial.{tutorial_id}.completion_message" + tutorial_strings[completion_id] = { + "value": tutorial_json["completion_message"], + "comments": tutorial_json.get( + "completion_message_comment", "Completion message for a tutorial view" + ), + } + + for step in tutorial_json["steps"]: + if "id" not in step: + exit(f"Tutorial {filename} does not have an id for one of the steps") + if "tooltip" not in step: + exit( + f"Tutorial {filename} does not have a tooltip for step id {step['id']}" + ) + + step_id = f"tutorial.{tutorial_id}.step.{step['id']}" + if step_id in tutorial_strings: + exit(f"Duplicate step id {step_id} when parsing {filename}") + + tutorial_strings[step_id] = { + "value": step["tooltip"], + "comments": step.get("comment", "A tutorial step tooltip"), + } + + return tutorial_strings + + +def retrieve_strings_guide(manifest, filename): + guide_strings = {} + + guide_json = manifest["guide"] + if "id" not in guide_json: + exit(f"Guide {filename} does not have an id") + if "title" not in guide_json: + exit(f"Guide {filename} does not have a title") + if "subtitle" not in guide_json: + exit(f"Guide {filename} does not have a subtitle") + if "blocks" not in guide_json: + exit(f"Guide {filename} does not have a blocks") + + guide_id = guide_json["id"] + title_id = f"guide.{guide_id}.title" + guide_strings[title_id] = { + "value": guide_json["title"], + "comments": guide_json.get("title_comment", "Title for a guide view"), + } + subtitle_id = f"guide.{guide_id}.subtitle" + guide_strings[subtitle_id] = { + "value": guide_json["subtitle"], + "comments": guide_json.get("subtitle_comment", "Subtitle for a guide view"), + } + + for block in guide_json["blocks"]: + if "id" not in block: + exit(f"Guide {filename} does not have an id for one of the blocks") + if "type" not in block: + exit(f"Guide {filename} does not have a type for block id {block['id']}") + if "content" not in block: + exit(f"Guide {filename} does not have a content for block id {block['id']}") + + block_id = block["id"] + block_string_id = f"guide.{guide_id}.block.{block_id}" + block_default_comment = comment_types.get(block["type"], "") + if block_string_id in guide_strings: + exit(f"Duplicate block enum {block_string_id} when parsing {filename}") + + if not isinstance(block["content"], list): + guide_strings[block_string_id] = { + "value": block["content"], + "comments": block.get("comment", block_default_comment), + } + continue + + for subblock in block["content"]: + if "id" not in subblock: + exit( + f"Guide {filename} does not have an id for one of the subblocks of block {block_id}" + ) + if "content" not in subblock: + exit( + f"Guide file {filename} does not have a content for subblock id {subblock['id']}" + ) + + subblock_id = subblock["id"] + subblock_string_id = f"guide.{guide_id}.block.{block_id}.{subblock_id}" + if subblock_string_id in guide_strings: + exit( + f"Duplicate sub-block enum {subblock_string_id} when parsing {filename}" + ) + + guide_strings[subblock_string_id] = { + "value": subblock["content"], + "comments": subblock.get("comment", block_default_comment), + } + + return guide_strings + + +def write_en_language(filename, strings): + ts = ET.Element("TS") + ts.set("version", "2.1") + ts.set("language", "en") + + context = ET.SubElement(ts, "context") + ET.SubElement(context, "name") + + for key, value in strings.items(): + message = ET.SubElement(context, "message") + message.set("id", key) + + location = ET.SubElement(message, "location") + location.set("filename", "addon.qml") + + source = ET.SubElement(message, "source") + source.text = value["value"] + + translation = ET.SubElement(message, "translation") + translation.set("type", "unfinished") + + if len(value["comments"]) > 0: + extracomment = ET.SubElement(message, "extracomment") + extracomment.text = value["comments"] + + with open(filename, "w", encoding="utf-8") as f: + f.write(ET.tostring(ts, encoding="unicode", pretty_print=True)) + + +def copy_files(path, dest_path): + for file in os.listdir(path): + if file.startswith("."): + continue + + file_path = os.path.join(path, file) + if os.path.isfile(file_path): + if file_path.endswith((".ts", ".qrc", ".rcc")): + exit(f"Unexpected extension file found: {os.path.join(path, file)}") + + shutil.copyfile(file_path, os.path.join(dest_path, file)) + continue + + if os.path.isdir(file_path): + dir_path = os.path.join(dest_path, file) + os.mkdir(dir_path) + copy_files(file_path, dir_path) + + +def get_file_list(path, prefix): + file_list = [] + for file in os.listdir(path): + file_path = os.path.join(path, file) + if ( + os.path.isfile(file_path) + and not file_path.endswith(".ts") + and not file_path.endswith(".qrc") + and not file_path.endswith(".rcc") + ): + file_list.append(f"{prefix}{file}") + continue + + if os.path.isdir(file_path): + file_list += get_file_list(file_path, f"{prefix}{file}/") + + return file_list + + +parser = argparse.ArgumentParser(description="Generate an addon package") +parser.add_argument( + "source", + metavar="MANIFEST", + type=str, + action="store", + help="The addon manifest", +) +parser.add_argument( + "dest", + metavar="DEST", + type=str, + action="store", + help="The destination folder", +) +parser.add_argument( + "-q", + "--qt_path", + default=None, + dest="qtpath", + help="The QT binary path. If not set, we try to guess.", +) +args = parser.parse_args() + + +def qtquery(qmake, propname): + try: + qtquery = os.popen(f"{qmake} -query {propname}") + qtpath = qtquery.read().strip() + if len(qtpath) > 0: + return qtpath + finally: + pass + return None + + +qtbinpath = args.qtpath +if qtbinpath is None: + qtbinpath = qtquery("qmake", "QT_INSTALL_BINS") +if qtbinpath is None: + qtbinpath = qtquery("qmake6", "QT_INSTALL_BINS") +if qtbinpath is None: + print("Unable to locate qmake tool.") + sys.exit(1) + +if not os.path.isdir(qtbinpath): + print(f"QT path is not a diretory: {qtbinpath}") + sys.exit(1) + +lconvert = os.path.join(qtbinpath, "lconvert") +lrelease = os.path.join(qtbinpath, "lrelease") + +rcc_bin = "rcc" +if os.name == "nt": + rcc_bin = "rcc.exe" + +rcc = os.path.join(qtbinpath, rcc_bin) +if not os.path.isfile(rcc): + qtlibexecpath = qtquery(os.path.join(qtbinpath, "qmake"), "QT_INSTALL_LIBEXECS") + if qtlibexecpath is None: + qtlibexecpath = qtquery( + os.path.join(qtbinpath, "qmake6"), "QT_INSTALL_LIBEXECS" + ) + if qtlibexecpath is None: + print("Unable to locate qmake libexec path.") + sys.exit(1) + rcc = os.path.join(qtlibexecpath, rcc_bin) + if not os.path.isfile(rcc): + print("Unable to locate rcc path.") + sys.exit(1) + +if not os.path.isfile(args.source): + exit(f"`{args.source}` is not a file") + +if not os.path.isdir(args.dest): + exit(f"`{args.dest}` is not a directory") + +script_path = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) + +jsonSchema = os.path.join(script_path, "ci", "jsonSchemas", "addon.json") +if not os.path.isfile(jsonSchema): + exit(f"The JSONSchema {jsonSchema} does not exist") + +with open(args.source, "r", encoding="utf-8") as file: + manifest = json.load(file) + + print("Copying files in a temporary folder...") + tmp_path = tempfile.mkdtemp() + copy_files(os.path.dirname(args.source), tmp_path) + + print("Retrieving strings...") + strings = {} + if manifest["type"] == "tutorial": + strings = retrieve_strings_tutorial(manifest, args.source) + elif manifest["type"] == "guide": + strings = retrieve_strings_guide(manifest, args.source) + else: + exit(f"Unupported manifest type `{manifest['type']}`") + + print("Create localization file...") + os.mkdir(os.path.join(tmp_path, "i18n")) + template_ts_file = os.path.join(args.dest, f"{manifest['id']}.ts") + write_en_language(template_ts_file, strings) + + # This will be probably replaced by the en locale if it exists + en_ts_file = os.path.join(tmp_path, "i18n", "locale_en.ts") + shutil.copyfile(template_ts_file, en_ts_file) + os.system(f"{lrelease} -idbased {en_ts_file}") + + # Fallback + ts_file = os.path.join(tmp_path, "i18n", "locale.ts") + shutil.copyfile(template_ts_file, ts_file) + os.system(f"{lrelease} -idbased {ts_file}") + + i18n_path = os.path.join(os.path.dirname(script_path), "i18n") + for locale in os.listdir(i18n_path): + if not os.path.isdir(os.path.join(i18n_path, locale)) or locale.startswith("."): + continue + + xliff_path = os.path.join( + i18n_path, locale, "addons", manifest["id"], "locale.xliff" + ) + + if os.path.isfile(xliff_path): + locale_file = os.path.join(tmp_path, "i18n", f"locale_{locale}.ts") + os.system(f"{lconvert} -if xlf -i {xliff_path} -o {locale_file}") + os.system(f"{lrelease} -idbased {locale_file}") + + print("Generate the RCC file...") + files = get_file_list(tmp_path, "") + + qrc_file = os.path.join(tmp_path, f"{manifest['id']}.qrc") + with open(qrc_file, "w", encoding="utf-8") as f: + rcc_elm = ET.Element("RCC") + qresource = ET.SubElement(rcc_elm, "qresource") + qresource.set("prefix", "/") + for file in files: + elm = ET.SubElement(qresource, "file") + elm.text = file + f.write(ET.tostring(rcc_elm, encoding="unicode")) + + print("Creating the final addon...") + rcc_file = os.path.join(args.dest, f"{manifest['id']}.rcc") + os.system(f"{rcc} {qrc_file} -o {rcc_file} -binary") + print(f"Done: {rcc_file}") diff --git a/scripts/addon/generate_all.py b/scripts/addon/generate_all.py new file mode 100755 index 0000000000..4f65f7ed09 --- /dev/null +++ b/scripts/addon/generate_all.py @@ -0,0 +1,62 @@ +#! /usr/bin/env python3 +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import argparse +import hashlib +import json +import os +import subprocess +import sys + +parser = argparse.ArgumentParser(description="Generate an addon package") +parser.add_argument( + "-q", + "--qt_path", + default=None, + dest="qtpath", + help="The QT binary path. If not set, we try to guess.", +) +args = parser.parse_args() + +build_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "build.py") +addons_path = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(os.path.realpath(__file__)))), + "addons", +) + +generated_path = os.path.join(addons_path, "generated") +if not os.path.isdir(generated_path): + os.mkdir(generated_path) +generated_path = os.path.join(generated_path, "addons") +if not os.path.isdir(generated_path): + os.mkdir(generated_path) + +addons = [] +for file in os.listdir(addons_path): + if not file.startswith("tutorial_") and not file.startswith("guide_"): + continue + addon_path = os.path.join(addons_path, file, "manifest.json") + + build_cmd = [sys.executable, build_path, addon_path, generated_path] + if args.qtpath: + build_cmd.append("-q") + build_cmd.append(args.qtpath) + subprocess.call(build_cmd) + + generated_addon_path = os.path.join(generated_path, file + ".rcc") + if not os.path.exists(generated_addon_path): + exit(f"Expected addon file {generated_addon_path}") + + with open(generated_addon_path,"rb") as f: + sha256 = hashlib.sha256(f.read()).hexdigest(); + addons.append({ 'id': file, 'sha256': sha256 }) + +index = { + 'api_version': '0.1', + 'addons': addons, +} + +with open(os.path.join(generated_path, "manifest.json"), "w") as f: + f.write(json.dumps(index, indent=2)) diff --git a/scripts/ci/check_jsonschema.py b/scripts/ci/check_jsonschema.py index 1e71220d0a..1e11f8744c 100755 --- a/scripts/ci/check_jsonschema.py +++ b/scripts/ci/check_jsonschema.py @@ -12,7 +12,7 @@ parser = argparse.ArgumentParser() parser.add_argument('jsonSchema', metavar='JSONSchema', type=str, nargs=1, help='the JSON Schema file to be used. The file must be stored in `scripts/ci/jsonSchemas`') -parser.add_argument('path', metavar='path', type=str, nargs=1, +parser.add_argument('path', metavar='path', type=str, nargs='+', help='the path containing the JSON files to be validated.') args = parser.parse_args() @@ -26,14 +26,9 @@ def validateFile(file, schema, resolver): if not os.path.isfile(jsonSchema): exit(f"The JSONSchema {jsonSchema} does not exist") -if not os.path.isdir(args.path[0]): - exit(f"`{args.path[0]}` is not a directory") - with open(jsonSchema, "r", encoding="utf-8") as schema: schema = json.load(schema) resolver = RefResolver(Path(jsonSchema).as_uri(), "") - for root,d_names,f_names in os.walk(args.path[0]): - for f in f_names: - if f.endswith(".json"): - validateFile(os.path.join(args.path[0], f), schema, resolver) + for path in args.path: + validateFile(path, schema, resolver) diff --git a/scripts/ci/jsonSchemas/addon.json b/scripts/ci/jsonSchemas/addon.json new file mode 100644 index 0000000000..d142ecb541 --- /dev/null +++ b/scripts/ci/jsonSchemas/addon.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "description": "The id of this addon" + }, + "name": { + "type": "string", + "description": "The name of this addon" + }, + "api_version": { + "type": "string", + "description": "The addon version framework" + }, + "type": { + "type": "string", + "description": "The type of the addon: tutorial, guide, i18n" + }, + "tutorial": { + "$ref": "tutorial.json#" + }, + "guide": { + "$ref": "guide.json#" + }, + "qml": { + "type": "string", + "description": "The QML entry point" + } + }, + "required": [ "id", "name", "api_version", "type" ] +} + diff --git a/scripts/utils/generate_strings.py b/scripts/utils/generate_strings.py index 936451da12..ffde41b469 100755 --- a/scripts/utils/generate_strings.py +++ b/scripts/utils/generate_strings.py @@ -3,19 +3,10 @@ # License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at http://mozilla.org/MPL/2.0/. -import re import os import yaml -import json import argparse -comment_types = { - "text": f"Standard text in a guide block", - "title": f"Title in a guide block", - "ulist": f"Bullet unordered list item in a guide block", - "olist": f"Bullet ordered list item in a guide block", -} - def stop(string_id): exit( @@ -44,6 +35,7 @@ def construct_mapping(self, node, deep=False): mapping.append(key) return super().construct_mapping(node, deep) + def parseTranslationStrings(yamlfile): if not os.path.isfile(yamlfile): exit(f"Unable to find {yamlfile}") @@ -118,172 +110,11 @@ def parseTranslationStrings(yamlfile): "value": value, "comments": comments, } - + return yaml_strings -## Parse the strings from a guide block and append them to the output dictionary. -def parseGuideBlock(block, guide_id, output, filename): - if not "id" in block: - exit(f"Guide {filename} does not have an id for one of the blocks") - if not "type" in block: - exit(f"Guide {filename} does not have a type for block id {block['id']}") - if not "content" in block: - exit(f"Guide {filename} does not have a content for block id {block['id']}") - - block_id = block['id'] - block_enum = pascalize(f"guide_{guide_id}_block_{block_id}") - block_default_comment = comment_types.get(block['type'], "") - if block_enum in output: - exit(f"Duplicate block enum {block_enum} when parsing {filename}") - - if not isinstance(block["content"], list): - output[block_enum] = { - "string_id": f"guide.{guide_id}.block.{block_id}", - "value": [block["content"]], - "comments": [block.get("comment", block_default_comment)], - } - return - - for subblock in block["content"]: - if not "id" in subblock: - exit(f"Guide {filename} does not have an id for one of the subblocks of block {block_id}") - if not "content" in subblock: - exit(f"Guide file {filename} does not have a content for subblock id {subblock['id']}") - - subblock_id = subblock['id'] - subblock_enum = pascalize(f"guide_{guide_id}_block_{block_id}_{subblock_id}") - if subblock_enum in output: - exit(f"Duplicate sub-block enum {subblock_enum} when parsing {filename}") - - output[subblock_enum] = { - "string_id": f"guide.{guide_id}.block.{block_id}.{subblock_id}", - "value": [subblock["content"]], - "comments": [subblock.get("comment", block_default_comment)], - } - -## Parse the strings from a JSON guide and return them as a dictionary. -def parseGuideJson(guide_json, filename): - guide_strings = {} - - if not "id" in guide_json: - exit(f"Guide {filename} does not have an id") - if not "title" in guide_json: - exit(f"Guide {filename} does not have a title") - if not "subtitle" in guide_json: - exit(f"Guide {filename} does not have a subtitle") - if not "blocks" in guide_json: - exit(f"Guide {filename} does not have a blocks") - - guide_id = guide_json['id'] - title_enum = pascalize(f"guide_{guide_id}_title") - guide_strings[title_enum] = { - "string_id": f"guide.{guide_id}.title", - "value": [guide_json["title"]], - "comments": [guide_json.get("title_comment", "Title for a guide view")], - } - subtitle_enum = pascalize(f"guide_{guide_id}_subtitle") - guide_strings[subtitle_enum] = { - "string_id": f"guide.{guide_id}.subtitle", - "value": [guide_json["subtitle"]], - "comments": [guide_json.get("subtitle_comment", "Subtitle for a guide view")], - } - - for block in guide_json["blocks"]: - parseGuideBlock(block, guide_id, guide_strings, filename) - - return guide_strings - -## Parse a directory of JSON guides, returning their combined strings as a dictionary -def parseGuideStrings(guidepath): - guide_strings = {} - for filename in os.listdir(guidepath): - if not filename.endswith(".json"): - continue - - with open(os.path.join(guidepath, filename), "r", encoding="utf-8") as fp: - guide_json = json.load(fp) - - for key, value in parseGuideJson(guide_json, filename).items(): - if key in guide_strings: - exit(f"Duplicate enum {key} when parsing {filename}") - guide_strings[key] = value - - return guide_strings - -## Parse the strings from a JSON tutorial and return them as a dictionary. -def parseTutorialJson(tutorial_json, filename): - tutorial_strings = {} - - if not "id" in tutorial_json: - exit(f"Tutorial {filename} does not have an id") - if not "title" in tutorial_json: - exit(f"Tutorial {filename} does not have a title") - if not "subtitle" in tutorial_json: - exit(f"Tutorial {filename} does not have a subtitle") - if not "completion_message" in tutorial_json: - exit(f"Tutorial {filename} does not have a completion message") - if not "steps" in tutorial_json: - exit(f"Tutorial {filename} does not have steps") - - tutorial_id = tutorial_json['id'] - title_enum = pascalize(f"tutorial_{tutorial_id}_title") - tutorial_strings[title_enum] = { - "string_id": f"tutorial.{tutorial_id}.title", - "value": [tutorial_json["title"]], - "comments": [tutorial_json.get("title_comment", "Title for a tutorial view")], - } - - subtitle_enum = pascalize(f"tutorial_{tutorial_id}_subtitle") - tutorial_strings[subtitle_enum] = { - "string_id": f"tutorial.{tutorial_id}.subtitle", - "value": [tutorial_json["subtitle"]], - "comments": [tutorial_json.get("subtitle_comment", "Subtitle for a tutorial view")], - } - - completion_enum = pascalize(f"tutorial_{tutorial_id}_completion_message") - tutorial_strings[completion_enum] = { - "string_id": f"tutorial.{tutorial_id}.completion_message", - "value": [tutorial_json["completion_message"]], - "comments": [tutorial_json.get("completion_message_comment", "Completion message for a tutorial view")], - } - - for step in tutorial_json["steps"]: - if not "id" in step: - exit(f"Tutorial {filename} does not have an id for one of the steps") - if not "tooltip" in step: - exit(f"Tutorial {filename} does not have a tooltip for step id {step['id']}") - - step_id = step['id'] - step_enum = pascalize(f"tutorial_{tutorial_id}_step_{step_id}") - if step_enum in tutorial_strings: - exit(f"Duplicate step enum {step_enum} when parsing {filename}") - - tutorial_strings[step_enum] = { - "string_id": f"tutorial.{tutorial_id}.step.{step_id}", - "value": [step["tooltip"]], - "comments": [step.get("comment", "A tutorial step tooltip")], - } - - return tutorial_strings - -## Parse a directory of JSON tutorials, returning their combined strings as a dictionary -def parseTutorialStrings(tutorialpath): - tutorial_strings = {} - for filename in os.listdir(tutorialpath): - if not filename.endswith(".json"): - continue - - with open(os.path.join(tutorialpath, filename), "r", encoding="utf-8") as fp: - tutorial_json = json.load(fp) - - for key, value in parseTutorialJson(tutorial_json, filename).items(): - if key in tutorial_strings: - exit(f"Duplicate enum {key} when parsing {filename}") - tutorial_strings[key] = value - - return tutorial_strings - -## Render a dictionary of strings into the l18nstrings module. + +# Render a dictionary of strings into the l18nstrings module. def generateStrings(strings, outdir): os.makedirs(outdir, exist_ok=True) with open(os.path.join(outdir, "l18nstrings.h"), "w", encoding="utf-8") as output: @@ -334,7 +165,9 @@ class L18nStrings final : public QQmlPropertyMap { """ ) - with open(os.path.join(outdir, "l18nstrings_p.cpp"), "w", encoding="utf-8") as output: + with open( + os.path.join(outdir, "l18nstrings_p.cpp"), "w", encoding="utf-8" + ) as output: output.write( """/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this @@ -376,39 +209,46 @@ def serialize(string): # Generate the retranslate() method. output.write("void L18nStrings::retranslate() {\n") for key in strings: - output.write(f" insert(\"{key}\", qtTrId(_ids[{key}]));\n") + output.write(f' insert("{key}", qtTrId(_ids[{key}]));\n') output.write("}") if __name__ == "__main__": - ## Parse arguments to locate the input and output files. + # Parse arguments to locate the input and output files. parser = argparse.ArgumentParser( - description='Generate internationaliation strings database from a YAML source') - parser.add_argument('source', metavar='SOURCE', type=str, action='store', nargs='?', - help='YAML strings file to process') - parser.add_argument('-o', '--output', metavar='DIR', type=str, action='store', - help='Output directory for generated files') - parser.add_argument('-g', '--guides', metavar='DIR', type=str, action='store', - help='Parse JSON guides from DIR') - parser.add_argument('-t', '--tutorials', metavar='DIR', type=str, action='store', - help='Parse JSON tutorials from DIR') + description="Generate internationaliation strings database from a YAML source" + ) + parser.add_argument( + "source", + metavar="SOURCE", + type=str, + action="store", + nargs="?", + help="YAML strings file to process", + ) + parser.add_argument( + "-o", + "--output", + metavar="DIR", + type=str, + action="store", + help="Output directory for generated files", + ) args = parser.parse_args() - ## If no source was provided, find it relative to this script file. + # If no source was provided, find it relative to this script file. if args.source is None: - rootpath = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir, os.pardir)) - args.source = os.path.join('translations', 'strings.yaml') - - ## If no output directory was provided, use the current directory. + rootpath = os.path.abspath( + os.path.join(os.path.dirname(__file__), os.pardir, os.pardir) + ) + args.source = os.path.join(rootpath, "translations", "strings.yaml") + + # If no output directory was provided, use the current directory. if args.output is None: args.output = os.getcwd() - - ## Parse the inputs for their sweet juicy strings. + + # Parse the inputs for their sweet juicy strings. strings = parseTranslationStrings(args.source) - if args.guides: - strings.update(parseGuideStrings(args.guides)) - if args.tutorials: - strings.update(parseTutorialStrings(args.tutorials)) - - ## Render the strings into generated content. + + # Render the strings into generated content. generateStrings(strings, args.output) diff --git a/scripts/utils/generate_ts.sh b/scripts/utils/generate_ts.sh index 1fefbd1f33..9691bb6222 100755 --- a/scripts/utils/generate_ts.sh +++ b/scripts/utils/generate_ts.sh @@ -22,10 +22,11 @@ cp scripts/utils/generate_strings.py cache || die print G "done." printn Y "Generating strings... " -python cache/generate_strings.py -o translations/generated -g src/ui/guides -t src/ui/tutorials +python cache/generate_strings.py -o translations/generated print G "done." printn Y "Generating a dummy PRO file... " +mkdir -p translations/generated || die cat > translations/generated/dummy.pro << EOF HEADERS += l18nstrings.h SOURCES += l18nstrings_p.cpp @@ -43,18 +44,40 @@ print G "done" print Y "Generating the main translation file... " lupdate translations/generated/dummy.pro -ts translations.ts || die -for branch in $(git branch -r | grep origin/releases); do - printn Y "Importing strings from $branch..." +printn Y "Generating strings for addons... " +python scripts/addon/generate_all.py +mkdir -p addon_ts || die +cp addons/generated/addons/*.ts addon_ts +print G "done." +for branch in $(git branch -r | grep origin/releases); do git checkout $branch &>/dev/null || die - PARAMS= - [ -d src/ui/guides ] && PARAMS="$PARAMS -g src/ui/guides" - [ -d src/ui/tutorials ] && PARAMS="$PARAMS -t src/ui/tutorials" - python cache/generate_strings.py -o translations/generated $PARAMS || die + + printn Y "Importing main strings from $branch..." + python cache/generate_strings.py -o translations/generated translations/strings.yaml || die lupdate translations/generated/dummy.pro -ts branch.ts || die lconvert -i translations.ts branch.ts -o tmp.ts || die mv tmp.ts translations.ts || die rm branch.ts || die + + if [ -f "scripts/addon/generate_all.py" ]; then + printn Y "Importing addon strings from $branch..." + python scripts/addon/generate_all.py + ts_files="addons/generated/addons/*.ts" + for f in $ts_files + do + ts_name=$(basename "$f") + if [ -f "addon_ts/${ts_name}"]; then + printn Y "File ${ts_name} exists, updating with branch strings..." + lconvert -i "cache_rs/${ts_name}" "addons/generated/addons/${ts_name}" -o tmp.ts || die + mv tmp.ts "addon_ts/${ts_name}" + else + printn Y "File ${ts_name} does not exist, copying over..." + cp "addons/generated/addons/${ts_name}" addon_ts/ + fi + done + rm addons/generated/addons/*.ts || die + fi done printn Y "Remove cache... " diff --git a/scripts/utils/import_languages.py b/scripts/utils/import_languages.py index 695d9fcfa8..4a6b2e5b7c 100755 --- a/scripts/utils/import_languages.py +++ b/scripts/utils/import_languages.py @@ -171,8 +171,6 @@ def qtquery(qmake, propname): title("Generate the Js/C++ string definitions...") try: subprocess.call([sys.executable, os.path.join('scripts', 'utils', 'generate_strings.py'), - '-g', os.path.join('src', 'ui', 'guides'), - '-t', os.path.join('src', 'ui', 'tutorials'), '-o', os.path.join('translations', 'generated'), os.path.join('translations', 'strings.yaml')]) except Exception as e: diff --git a/src/addon.cpp b/src/addon.cpp deleted file mode 100644 index 432fc11b45..0000000000 --- a/src/addon.cpp +++ /dev/null @@ -1,50 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -#include "addon.h" -#include "leakdetector.h" -#include "logger.h" -#include "settingsholder.h" - -#include - -namespace { -Logger logger(LOG_MAIN, "Addon"); -} - -Addon::Addon(QObject* parent, AddonType addonType, const QString& fileName, - const QString& id, const QString& name, const QString& qml) - : QObject(parent), - m_addonType(addonType), - m_fileName(fileName), - m_id(id), - m_name(name), - m_qml(qml) { - MVPN_COUNT_CTOR(Addon); - - QCoreApplication::installTranslator(&m_translator); - retranslate(); -} - -Addon::~Addon() { - MVPN_COUNT_DTOR(Addon); - QCoreApplication::removeTranslator(&m_translator); -} - -void Addon::retranslate() { - SettingsHolder* settingsHolder = SettingsHolder::instance(); - Q_ASSERT(settingsHolder); - - QString code = settingsHolder->languageCode(); - - QLocale locale = QLocale(code); - if (code.isEmpty()) { - locale = QLocale(QLocale::system().bcp47Name()); - } - - if (!m_translator.load(locale, "locale", "_", - QString(":/addons/%1/i18n").arg(m_id))) { - logger.error() << "Loading the locale failed. - code:" << code; - } -} diff --git a/src/addonmanager.cpp b/src/addonmanager.cpp index 67a8d74b4c..a1d6905df3 100644 --- a/src/addonmanager.cpp +++ b/src/addonmanager.cpp @@ -3,17 +3,25 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ #include "addonmanager.h" +#include "constants.h" #include "leakdetector.h" -#include "localizer.h" #include "logger.h" #include "models/feature.h" +#include "taskscheduler.h" +#include "tasks/addon/taskaddon.h" #include -#include +#include +#include +#include #include #include +#include #include -#include +#include + +constexpr const char* ADDON_FOLDER = "addons"; +constexpr const char* ADDON_INDEX_FILENAME = "manifest.json"; namespace { @@ -27,6 +35,7 @@ AddonManager* s_instance = nullptr; AddonManager* AddonManager::instance() { if (!s_instance) { s_instance = new AddonManager(qApp); + s_instance->initialize(); } return s_instance; } @@ -37,140 +46,310 @@ AddonManager::AddonManager(QObject* parent) : QObject(parent) { AddonManager::~AddonManager() { MVPN_COUNT_DTOR(AddonManager); } -bool AddonManager::load(const QString& fileName) { +void AddonManager::initialize() { if (!Feature::get(Feature::Feature_addon)->isSupported()) { logger.warning() << "Addons disabled by feature flag"; - return false; + return; } - QString addonId = QFileInfo(fileName).baseName(); - if (m_addons.contains(addonId)) { - logger.warning() << "Addon" << addonId << "already loaded"; - return false; + // Initialization of the addon folder. + { + QDir addonDir( + QStandardPaths::writableLocation(QStandardPaths::AppDataLocation)); + if (!addonDir.exists(ADDON_FOLDER) && !addonDir.mkdir(ADDON_FOLDER)) { + logger.info() << "Unable to create the addon folder"; + return; + } } - if (!QResource::registerResource(fileName, "/addons")) { - logger.warning() << "Unable to load resource from file" << fileName; - return false; + if (!validateIndex(readIndex())) { + logger.debug() << "Unable to validate the index"; } +} - auto guard = - qScopeGuard([&] { QResource::unregisterResource(fileName, "/addons"); }); +void AddonManager::updateIndex(const QByteArray& index) { + QByteArray currentIndex = readIndex(); - QFile file(QString(":/addons/%1/manifest.json").arg(addonId)); - if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { - logger.warning() << "Unable to read the addon manifest of" << addonId; - return false; + if (currentIndex == index) { + logger.debug() << "The index has not changed"; + return; } - QJsonDocument json = QJsonDocument::fromJson(file.readAll()); - if (!json.isObject()) { - logger.warning() << "The manifest must be a JSON document" << addonId; - return false; + if (!validateIndex(index)) { + logger.debug() << "Unable to validate the index"; + return; } - QJsonObject obj = json.object(); + writeIndex(index); +} - QString version = obj["version"].toString(); - if (version.isEmpty()) { - logger.warning() << "No version in the manifest" << addonId; - return false; - } +bool AddonManager::validateIndex(const QByteArray& index) { + // TODO: signature validation - if (version != "0.1") { - logger.warning() << "Unsupported version" << version << addonId; + QJsonDocument doc = QJsonDocument::fromJson(index); + if (!doc.isObject()) { + logger.debug() << "The index must be an object"; return false; } - QString name = obj["name"].toString(); - if (name.isEmpty()) { - logger.warning() << "No name in the manifest" << addonId; + QJsonObject obj = doc.object(); + if (obj["api_version"].toString() != "0.1") { + logger.debug() << "Invalid index file - api_version does not match"; return false; } - QString type = obj["type"].toString(); - if (type.isEmpty()) { - logger.warning() << "No type in the manifest" << addonId; - return false; - } + QList addons; + for (const QJsonValue item : obj["addons"].toArray()) { + QJsonObject addonObj = item.toObject(); - Addon::AddonType addonType; - QString qmlFileName; + QString sha256hex = addonObj["sha256"].toString(); + if (sha256hex.isEmpty()) { + logger.warning() << "Incomplete index - sha256"; + return false; + } - if (type == "demo") { - addonType = Addon::AddonTypeDemo; - QString qml = obj["qml"].toString(); - if (qml.isEmpty()) { - logger.warning() << "No qml in the manifest" << addonId; + if (sha256hex.length() != 64) { + logger.warning() << "Invalid sha256 hash"; return false; } - qmlFileName = QString(":/addons/%1/%2").arg(addonId).arg(qml); - if (!QFile::exists(qmlFileName)) { - logger.warning() << "Unable to load the qml entry" << qmlFileName << qml - << addonId; + QString addonId = addonObj["id"].toString(); + if (addonId.isEmpty()) { + logger.warning() << "Incomplete index - addonId"; return false; } - } else if (type == "i18n") { - addonType = Addon::AddonTypeI18n; - } else { - logger.warning() << "Unsupported type" << type << addonId; - return false; + + addons.append( + {QByteArray::fromHex(sha256hex.toLocal8Bit()), addonId, nullptr}); } - guard.dismiss(); + // Remove unknown addons + QStringList addonsToBeRemoved; + for (QHash::const_iterator i(m_addons.constBegin()); + i != m_addons.constEnd(); ++i) { + bool found = false; + for (const AddonData& addonData : addons) { + if (addonData.m_addonId == i.key()) { + found = true; + break; + } + } + + if (found) continue; + addonsToBeRemoved.append(i.key()); + } + + for (const QString& addonId : addonsToBeRemoved) { + unload(addonId); + removeAddon(addonId); + } + + // Fetch new addons + for (const AddonData& addonData : addons) { + if (!m_addons.contains(addonData.m_addonId) && + validateAndLoad(addonData.m_addonId, addonData.m_sha256)) { + Q_ASSERT(m_addons.contains(addonData.m_addonId)); + Q_ASSERT(m_addons[addonData.m_addonId].m_sha256 == addonData.m_sha256); + continue; + } - Addon* addon = - new Addon(this, addonType, fileName, addonId, name, qmlFileName); - m_addons.insert(addonId, addon); + if (!m_addons.contains(addonData.m_addonId) || + m_addons[addonData.m_addonId].m_sha256 != addonData.m_sha256) { + TaskScheduler::scheduleTask( + new TaskAddon(addonData.m_addonId, addonData.m_sha256)); + } + } + + return true; +} + +bool AddonManager::loadManifest(const QString& manifestFileName, + const QByteArray& sha256) { + Addon* addon = Addon::create(this, manifestFileName); + if (!addon) { + logger.warning() << "Unable to create an addon from manifest" + << manifestFileName; + return false; + } + + m_addons.insert(addon->id(), {sha256, addon->id(), addon}); return true; } void AddonManager::unload(const QString& addonId) { + if (!Feature::get(Feature::Feature_addon)->isSupported()) { + logger.warning() << "Addons disabled by feature flag"; + return; + } + if (!m_addons.contains(addonId)) { logger.warning() << "No addon with id" << addonId; return; } - Addon* addon = m_addons[addonId]; + Addon* addon = m_addons[addonId].m_addon; Q_ASSERT(addon); - QResource::unregisterResource(addon->fileName(), "/addons"); - - emit unloadAddon(addonId); m_addons.remove(addonId); - addon->deleteLater(); } -void AddonManager::run(const QString& addonId) { - if (!Feature::get(Feature::Feature_addon)->isSupported()) { - logger.warning() << "Addons disabled by feature flag"; +void AddonManager::retranslate() { + foreach (const AddonData& addonData, m_addons) { + // This comment is here to make the linter happy. + addonData.m_addon->retranslate(); + } +} + +// static +bool AddonManager::addonDir(QDir* dir) { + Q_ASSERT(dir); + + QDir addonDir( + QStandardPaths::writableLocation(QStandardPaths::AppDataLocation)); + if (!addonDir.exists(ADDON_FOLDER)) { + return false; + } + + if (!addonDir.cd(ADDON_FOLDER)) { + logger.warning() << "Unable to open the addons folder"; + return false; + } + + *dir = addonDir; + return true; +} + +// static +QByteArray AddonManager::readIndex() { + QDir dir; + if (!addonDir(&dir)) { + return ""; + } + + QFile indexFile(dir.filePath(ADDON_INDEX_FILENAME)); + if (!indexFile.open(QIODevice::ReadOnly | QIODevice::Text)) { + logger.warning() << "Unable to open the addon index"; + return ""; + } + + return indexFile.readAll(); +} + +// static +void AddonManager::writeIndex(const QByteArray& index) { + QDir dir; + if (!addonDir(&dir)) { return; } - if (!m_addons.contains(addonId)) { - logger.warning() << "No addon with id" << addonId; + QFile indexFile(dir.filePath(ADDON_INDEX_FILENAME)); + if (!indexFile.open(QIODevice::WriteOnly | QIODevice::Text)) { + logger.warning() << "Unable to open the addon index"; return; } - Addon* addon = m_addons[addonId]; - Q_ASSERT(addon); + if (!indexFile.write(index)) { + logger.warning() << "Unable to write the addon file"; + } +} + +// static +void AddonManager::removeAddon(const QString& addonId) { + QDir dir; + if (!addonDir(&dir)) { + return; + } - switch (addon->type()) { - case Addon::AddonTypeDemo: - emit runAddon(addon); - break; + QString addonFileName(QString("%1.rcc").arg(addonId)); + if (!dir.exists(addonFileName)) { + logger.warning() << "Addon does not exist" << addonFileName; + return; + } - case Addon::AddonTypeI18n: - emit Localizer::instance()->codeChanged(); - break; + if (!dir.remove(addonFileName)) { + logger.warning() << "Unable to remove the addon file name"; } } -void AddonManager::retranslate() { - foreach (Addon* addon, m_addons) { - // This comment is here to make the linter happy. - addon->retranslate(); +bool AddonManager::validateAndLoad(const QString& addonId, + const QByteArray& sha256, bool checkSha256) { + logger.debug() << "Load addon" << addonId; + + if (m_addons.contains(addonId)) { + logger.warning() << "Addon" << addonId << "already loaded"; + return false; + } + + // Hash validation + QDir dir; + if (!addonDir(&dir)) { + return false; + } + + QString addonFileName(dir.filePath(QString("%1.rcc").arg(addonId))); + if (checkSha256) { + QFile addonFile(addonFileName); + if (!addonFile.open(QIODevice::ReadOnly)) { + logger.warning() << "Unable to open the addon file" << addonFileName; + return false; + } + + if (QCryptographicHash::hash(addonFile.readAll(), + QCryptographicHash::Sha256) != sha256) { + logger.warning() << "Addon hash does not match" << addonFileName; + return false; + } + } + + if (!QResource::registerResource(addonFileName, + QString("/addons/%1").arg(addonId))) { + logger.warning() << "Unable to load resource from file" << addonFileName; + return false; + } + + if (!loadManifest(QString(":/addons/%1/manifest.json").arg(addonId), + sha256)) { + QResource::unregisterResource(addonFileName, "/addons"); + return false; + } + + return true; +} + +void AddonManager::storeAndLoadAddon(const QByteArray& addonData, + const QString& addonId, + const QByteArray& sha256) { + // Maybe we have to replace an existing addon. Let's start removing it. + if (m_addons.contains(addonId)) { + unload(addonId); + removeAddon(addonId); + } + + if (QCryptographicHash::hash(addonData, QCryptographicHash::Sha256) != + sha256) { + logger.warning() << "Invalid addon hash"; + return; + } + + QDir dir; + if (!addonDir(&dir)) { + return; + } + + QString addonFileName(dir.filePath(QString("%1.rcc").arg(addonId))); + QFile addonFile(addonFileName); + if (!addonFile.open(QIODevice::WriteOnly)) { + logger.warning() << "Unable to open the addon file" << addonFileName; + return; + } + + if (!addonFile.write(addonData)) { + logger.warning() << "Unable to write the addon file"; + return; + } + + if (!validateAndLoad(addonId, sha256, false)) { + logger.warning() << "Unable to load the addon"; } } diff --git a/src/addonmanager.h b/src/addonmanager.h index b304bfbb36..c06e9c45a1 100644 --- a/src/addonmanager.h +++ b/src/addonmanager.h @@ -5,11 +5,13 @@ #ifndef ADDONMANAGER_H #define ADDONMANAGER_H -#include "addon.h" +#include "addons/addon.h" // required for the signal #include #include +class QDir; + class AddonManager final : public QObject { Q_OBJECT Q_DISABLE_COPY_MOVE(AddonManager) @@ -19,21 +21,44 @@ class AddonManager final : public QObject { ~AddonManager(); - bool load(const QString& addonFileName); - void unload(const QString& addonName); - void run(const QString& addonName); + void updateIndex(const QByteArray& index); + + void storeAndLoadAddon(const QByteArray& addonData, const QString& addonId, + const QByteArray& sha256); + + bool loadManifest(const QString& addonManifestFileName, + const QByteArray& sha256); + + void unload(const QString& addonId); void retranslate(); private: explicit AddonManager(QObject* parent); + void initialize(); + + bool validateIndex(const QByteArray& index); + bool validateAndLoad(const QString& addonId, const QByteArray& sha256, + bool checkSha256 = true); + + static bool addonDir(QDir* dir); + static QByteArray readIndex(); + static void writeIndex(const QByteArray& index); + + static void removeAddon(const QString& addonId); + signals: void runAddon(Addon* addon); - void unloadAddon(const QString& addonId); private: - QHash m_addons; + struct AddonData { + QByteArray m_sha256; + QString m_addonId; + Addon* m_addon; + }; + + QHash m_addons; }; #endif // ADDONMANAGER_H diff --git a/src/addons/addon.cpp b/src/addons/addon.cpp new file mode 100644 index 0000000000..0266a0afbf --- /dev/null +++ b/src/addons/addon.cpp @@ -0,0 +1,126 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "addon.h" +#include "addondemo.h" +#include "addonguide.h" +#include "addoni18n.h" +#include "addontutorial.h" +#include "leakdetector.h" +#include "logger.h" +#include "models/guidemodel.h" +#include "settingsholder.h" + +#include +#include +#include +#include +#include + +namespace { +Logger logger(LOG_MAIN, "Addon"); +} + +// static +Addon* Addon::create(QObject* parent, const QString& manifestFileName) { + QFile file(manifestFileName); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + logger.warning() << "Unable to read the addon manifest of" + << manifestFileName; + return nullptr; + } + + QJsonDocument json = QJsonDocument::fromJson(file.readAll()); + if (!json.isObject()) { + logger.warning() << "The manifest must be a JSON document" + << manifestFileName; + return nullptr; + } + + QJsonObject obj = json.object(); + + QString version = obj["api_version"].toString(); + if (version.isEmpty()) { + logger.warning() << "No API version in the manifest" << manifestFileName; + return nullptr; + } + + if (version != "0.1") { + logger.warning() << "Unsupported API version" << version + << manifestFileName; + return nullptr; + } + + QString id = obj["id"].toString(); + if (id.isEmpty()) { + logger.warning() << "No id in the manifest" << manifestFileName; + return nullptr; + } + + QString name = obj["name"].toString(); + if (name.isEmpty()) { + logger.warning() << "No name in the manifest" << manifestFileName; + return nullptr; + } + + QString type = obj["type"].toString(); + if (type.isEmpty()) { + logger.warning() << "No type in the manifest" << manifestFileName; + return nullptr; + } + + if (type == "demo") { + return AddonDemo::create(parent, manifestFileName, id, name, obj); + } + + if (type == "i18n") { + return new AddonI18n(parent, manifestFileName, id, name); + } + + if (type == "tutorial") { + return AddonTutorial::create(parent, manifestFileName, id, name, obj); + } + + if (type == "guide") { + return AddonGuide::create(parent, manifestFileName, id, name, obj); + } + + logger.warning() << "Unsupported type" << type << manifestFileName; + return nullptr; +} + +Addon::Addon(QObject* parent, const QString& manifestFileName, + const QString& id, const QString& name) + : QObject(parent), + m_manifestFileName(manifestFileName), + m_id(id), + m_name(name) { + MVPN_COUNT_CTOR(Addon); + + QCoreApplication::installTranslator(&m_translator); + retranslate(); +} + +Addon::~Addon() { + MVPN_COUNT_DTOR(Addon); + QCoreApplication::removeTranslator(&m_translator); +} + +void Addon::retranslate() { + SettingsHolder* settingsHolder = SettingsHolder::instance(); + Q_ASSERT(settingsHolder); + + QString code = settingsHolder->languageCode(); + + QLocale locale = QLocale(code); + if (code.isEmpty()) { + locale = QLocale(QLocale::system().bcp47Name()); + } + + if (!m_translator.load( + locale, "locale", "_", + QFileInfo(m_manifestFileName).dir().filePath("i18n"))) { + logger.error() << "Loading the locale failed. - code:" << code; + } +} diff --git a/src/addon.h b/src/addons/addon.h similarity index 50% rename from src/addon.h rename to src/addons/addon.h index b67538e00e..876a5f4f16 100644 --- a/src/addon.h +++ b/src/addons/addon.h @@ -8,36 +8,30 @@ #include #include -class Addon final : public QObject { +class Addon : public QObject { Q_OBJECT Q_DISABLE_COPY_MOVE(Addon) - Q_PROPERTY(QString id MEMBER m_id CONSTANT) + Q_PROPERTY(QString id READ id CONSTANT) Q_PROPERTY(QString name MEMBER m_name CONSTANT) - Q_PROPERTY(QString qml MEMBER m_qml CONSTANT) public: - enum AddonType { - AddonTypeDemo, - AddonTypeI18n, - }; + static Addon* create(QObject* parent, const QString& manifestFileName); - Addon(QObject* parent, AddonType addonType, const QString& fileName, - const QString& id, const QString& name, const QString& qml); ~Addon(); - const QString& fileName() const { return m_fileName; } - - AddonType type() const { return m_addonType; } + const QString& id() const { return m_id; } void retranslate(); + protected: + Addon(QObject* parent, const QString& manifestFileName, const QString& id, + const QString& name); + private: - const AddonType m_addonType; - const QString m_fileName; + const QString m_manifestFileName; const QString m_id; const QString m_name; - const QString m_qml; QTranslator m_translator; }; diff --git a/src/addons/addondemo.cpp b/src/addons/addondemo.cpp new file mode 100644 index 0000000000..69fd8cdc8a --- /dev/null +++ b/src/addons/addondemo.cpp @@ -0,0 +1,56 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "addondemo.h" +#include "addonmanager.h" +#include "leakdetector.h" +#include "logger.h" + +#include +#include +#include + +namespace { +Logger logger(LOG_MAIN, "AddonDemo"); +} + +// static +Addon* AddonDemo::create(QObject* parent, const QString& manifestFileName, + const QString& id, const QString& name, + const QJsonObject& obj) { + QString qml = obj["qml"].toString(); + if (qml.isEmpty()) { + logger.warning() << "No qml in the manifest" << manifestFileName; + return nullptr; + } + + QString qmlFileName = QFileInfo(manifestFileName).dir().filePath(qml); + if (!QFile::exists(qmlFileName)) { + logger.warning() << "Unable to load the qml entry" << qmlFileName << qml + << manifestFileName; + return nullptr; + } + + Addon* addon = new AddonDemo(parent, manifestFileName, id, name, qmlFileName); + emit AddonManager::instance()->runAddon(addon); + + return addon; +} + +AddonDemo::AddonDemo(QObject* parent, const QString& manifestFileName, + const QString& id, const QString& name, + const QString& qmlFileName) + : Addon(parent, manifestFileName, id, name), m_qmlFileName(qmlFileName) { + MVPN_COUNT_CTOR(AddonDemo); +} + +AddonDemo::~AddonDemo() { MVPN_COUNT_DTOR(AddonDemo); } + +QString AddonDemo::qml() const { + if (m_qmlFileName.at(0) == ':') { + return QString("qrc%1").arg(m_qmlFileName); + } + + return QString("file:%1").arg(m_qmlFileName); +} diff --git a/src/addons/addondemo.h b/src/addons/addondemo.h new file mode 100644 index 0000000000..3b10497d7d --- /dev/null +++ b/src/addons/addondemo.h @@ -0,0 +1,35 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef ADDONDEMO_H +#define ADDONDEMO_H + +#include "addon.h" + +class QJsonObject; + +class AddonDemo final : public Addon { + Q_OBJECT + Q_DISABLE_COPY_MOVE(AddonDemo) + + Q_PROPERTY(QString qml READ qml CONSTANT) + + public: + static Addon* create(QObject* parent, const QString& manifestFileName, + const QString& id, const QString& name, + const QJsonObject& obj); + + ~AddonDemo(); + + QString qml() const; + + private: + AddonDemo(QObject* parent, const QString& manifestFileName, const QString& id, + const QString& name, const QString& qmlFileName); + + private: + QString m_qmlFileName; +}; + +#endif // ADDONDEMO_H diff --git a/src/addons/addonguide.cpp b/src/addons/addonguide.cpp new file mode 100644 index 0000000000..6b54794780 --- /dev/null +++ b/src/addons/addonguide.cpp @@ -0,0 +1,38 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "addonguide.h" +#include "leakdetector.h" +#include "logger.h" +#include "models/guidemodel.h" + +#include + +namespace { +Logger logger(LOG_MAIN, "AddonGuide"); +} + +// static +Addon* AddonGuide::create(QObject* parent, const QString& manifestFileName, + const QString& id, const QString& name, + const QJsonObject& obj) { + if (!GuideModel::instance()->createFromJson(id, obj["guide"].toObject())) { + logger.warning() << "Unable to add the guide"; + return nullptr; + } + + return new AddonGuide(parent, manifestFileName, id, name); +} + +AddonGuide::AddonGuide(QObject* parent, const QString& manifestFileName, + const QString& id, const QString& name) + : Addon(parent, manifestFileName, id, name) { + MVPN_COUNT_CTOR(AddonGuide); +} + +AddonGuide::~AddonGuide() { + MVPN_COUNT_DTOR(AddonGuide); + + GuideModel::instance()->remove(id()); +} diff --git a/src/addons/addonguide.h b/src/addons/addonguide.h new file mode 100644 index 0000000000..74ee6559b9 --- /dev/null +++ b/src/addons/addonguide.h @@ -0,0 +1,28 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef ADDONGUIDE_H +#define ADDONGUIDE_H + +#include "addon.h" + +class QJsonObject; + +class AddonGuide final : public Addon { + Q_OBJECT + Q_DISABLE_COPY_MOVE(AddonGuide) + + public: + static Addon* create(QObject* parent, const QString& manifestFileName, + const QString& id, const QString& name, + const QJsonObject& obj); + + ~AddonGuide(); + + private: + AddonGuide(QObject* parent, const QString& manifestFileName, + const QString& id, const QString& name); +}; + +#endif // ADDONGUIDE_H diff --git a/src/addons/addoni18n.cpp b/src/addons/addoni18n.cpp new file mode 100644 index 0000000000..c529da2e1f --- /dev/null +++ b/src/addons/addoni18n.cpp @@ -0,0 +1,17 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "addoni18n.h" +#include "leakdetector.h" +#include "localizer.h" + +AddonI18n::AddonI18n(QObject* parent, const QString& manifestFileName, + const QString& id, const QString& name) + : Addon(parent, manifestFileName, id, name) { + MVPN_COUNT_CTOR(AddonI18n); + + emit Localizer::instance()->codeChanged(); +} + +AddonI18n::~AddonI18n() { MVPN_COUNT_DTOR(AddonI18n); } diff --git a/src/addons/addoni18n.h b/src/addons/addoni18n.h new file mode 100644 index 0000000000..7e7e779ec6 --- /dev/null +++ b/src/addons/addoni18n.h @@ -0,0 +1,21 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef ADDONI18N_H +#define ADDONI18N_H + +#include "addon.h" + +class AddonI18n final : public Addon { + Q_OBJECT + Q_DISABLE_COPY_MOVE(AddonI18n) + + public: + AddonI18n(QObject* parent, const QString& manifestFileName, const QString& id, + const QString& name); + + ~AddonI18n(); +}; + +#endif // ADDONI18N_H diff --git a/src/addons/addontutorial.cpp b/src/addons/addontutorial.cpp new file mode 100644 index 0000000000..abdcc5bfc7 --- /dev/null +++ b/src/addons/addontutorial.cpp @@ -0,0 +1,39 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "addontutorial.h" +#include "leakdetector.h" +#include "logger.h" +#include "models/tutorialmodel.h" + +#include + +namespace { +Logger logger(LOG_MAIN, "AddonTutorial"); +} + +// static +Addon* AddonTutorial::create(QObject* parent, const QString& manifestFileName, + const QString& id, const QString& name, + const QJsonObject& obj) { + if (!TutorialModel::instance()->createFromJson(id, + obj["tutorial"].toObject())) { + logger.warning() << "Unable to add the tutorial"; + return nullptr; + } + + return new AddonTutorial(parent, manifestFileName, id, name); +} + +AddonTutorial::AddonTutorial(QObject* parent, const QString& manifestFileName, + const QString& id, const QString& name) + : Addon(parent, manifestFileName, id, name) { + MVPN_COUNT_CTOR(AddonTutorial); +} + +AddonTutorial::~AddonTutorial() { + MVPN_COUNT_DTOR(AddonTutorial); + + TutorialModel::instance()->remove(id()); +} diff --git a/src/addons/addontutorial.h b/src/addons/addontutorial.h new file mode 100644 index 0000000000..1b45f529e8 --- /dev/null +++ b/src/addons/addontutorial.h @@ -0,0 +1,28 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef ADDONTUTORIAL_H +#define ADDONTUTORIAL_H + +#include "addon.h" + +class QJsonObject; + +class AddonTutorial final : public Addon { + Q_OBJECT + Q_DISABLE_COPY_MOVE(AddonTutorial) + + public: + static Addon* create(QObject* parent, const QString& manifestFileName, + const QString& id, const QString& name, + const QJsonObject& obj); + + ~AddonTutorial(); + + private: + AddonTutorial(QObject* parent, const QString& manifestFileName, + const QString& id, const QString& name); +}; + +#endif // ADDONTUTORIAL_H diff --git a/src/cmake/linux.cmake b/src/cmake/linux.cmake index 2af9c4d5e3..0e44005fb9 100644 --- a/src/cmake/linux.cmake +++ b/src/cmake/linux.cmake @@ -96,7 +96,7 @@ install(FILES ../linux/extra/icons/32x32/mozillavpn.png install(FILES ../linux/extra/icons/48x48/mozillavpn.png DESTINATION ${CMAKE_INSTALL_DATADIR}/icons/hicolor/48x48/apps) -add_definitions(-DMVPN_ICON_PATH=\"${CMAKE_INSTALL_DATADIR}/icons/hicolor/64x64/apps/mozillavpn.png\") +add_definitions(-DMVPN_ICON_PATH=\"${CMAKE_INSTALL_PREFIX}/${CMAKE_INSTALL_DATADIR}/icons/hicolor/64x64/apps/mozillavpn.png\") install(FILES ../linux/extra/icons/64x64/mozillavpn.png DESTINATION ${CMAKE_INSTALL_DATADIR}/icons/hicolor/64x64/apps) diff --git a/src/cmake/sources.cmake b/src/cmake/sources.cmake index cd9819c4cd..8dd6b7e5cf 100644 --- a/src/cmake/sources.cmake +++ b/src/cmake/sources.cmake @@ -8,10 +8,18 @@ target_sources(mozillavpn PRIVATE ${CMAKE_CURRENT_BINARY_DIR}/version.h) # VPN Client source files target_sources(mozillavpn PRIVATE - addon.cpp - addon.h addonmanager.cpp addonmanager.h + addons/addon.cpp + addons/addon.h + addons/addondemo.cpp + addons/addondemo.h + addons/addonguide.cpp + addons/addonguide.h + addons/addoni18n.cpp + addons/addoni18n.h + addons/addontutorial.cpp + addons/addontutorial.h appimageprovider.h applistprovider.h apppermission.cpp @@ -241,6 +249,10 @@ target_sources(mozillavpn PRIVATE tasks/account/taskaccount.h tasks/adddevice/taskadddevice.cpp tasks/adddevice/taskadddevice.h + tasks/addon/taskaddon.cpp + tasks/addon/taskaddon.h + tasks/addonindex/taskaddonindex.cpp + tasks/addonindex/taskaddonindex.h tasks/authenticate/taskauthenticate.cpp tasks/authenticate/taskauthenticate.h tasks/captiveportallookup/taskcaptiveportallookup.cpp @@ -291,10 +303,8 @@ target_sources(mozillavpn PRIVATE # VPN Client UI resources target_sources(mozillavpn PRIVATE - ui/guides.qrc ui/license.qrc ui/resources.qrc - ui/tutorials.qrc ui/ui.qrc resources/certs/certs.qrc ) diff --git a/src/constants.h b/src/constants.h index ebdfd96bd0..e25a9c8f31 100644 --- a/src/constants.h +++ b/src/constants.h @@ -103,6 +103,8 @@ PRODBETAEXPR( const char*, balrogRootCertFingerprint, "97e8ba9cf12fb3de53cc42a4e6577ed64df493c247b414fea036818d3823560e", "3c01446abe9036cea9a09acaa3a520ac628f20a7ae32ce861cb2efb70fa0c745"); +PRODBETAEXPR(const char*, addonSourceUrl, "TODO", + "https://mozilla-mobile.github.io/mozilla-vpn-client/addons/") #undef PRODBETAEXPR diff --git a/src/featureslist.h b/src/featureslist.h index 12eea99ded..9ebea308ca 100644 --- a/src/featureslist.h +++ b/src/featureslist.h @@ -30,9 +30,9 @@ FEATURE_SIMPLE(addon, // Feature ID "Addon support", // Feature name "2.9.0", // released true, // Can be flipped on - false, // Can be flipped off + true, // Can be flipped off QStringList(), // feature dependencies - FeatureCallback_false) + FeatureCallback_true) FEATURE_SIMPLE(appReview, // Feature ID "App Review", // Feature name diff --git a/src/inspector/inspectorhandler.cpp b/src/inspector/inspectorhandler.cpp index 4457cc9067..b28d2e3169 100644 --- a/src/inspector/inspectorhandler.cpp +++ b/src/inspector/inspectorhandler.cpp @@ -889,25 +889,22 @@ static QList s_commands{ return QJsonObject(); }}, - InspectorCommand{"load_addon", "Load an addon", 1, + InspectorCommand{"load_addon_manifest", "Load an add-on", 1, [](InspectorHandler*, const QList& arguments) { QJsonObject obj; - obj["value"] = - AddonManager::instance()->load(arguments[1]); + // This is a debugging method. We don't need to compute + // the hash of the addon because we will not be able to + // find it in the addon index. + obj["value"] = AddonManager::instance()->loadManifest( + arguments[1], "INVALID SHA256"); return obj; }}, - InspectorCommand{"unload_addon", "Load an addon", 1, + InspectorCommand{"unload_addon", "Unload an add-on", 1, [](InspectorHandler*, const QList& arguments) { AddonManager::instance()->unload(arguments[1]); return QJsonObject(); }}, - - InspectorCommand{"run_addon", "Load an addon", 1, - [](InspectorHandler*, const QList& arguments) { - AddonManager::instance()->run(arguments[1]); - return QJsonObject(); - }}, }; // static diff --git a/src/models/guide.cpp b/src/models/guide.cpp index 87b37ae666..28e0539f05 100644 --- a/src/models/guide.cpp +++ b/src/models/guide.cpp @@ -4,7 +4,6 @@ #include "guide.h" #include "guideblock.h" -#include "l18nstrings.h" #include "leakdetector.h" #include "logger.h" #include "models/feature.h" @@ -136,8 +135,11 @@ Guide* Guide::create(QObject* parent, const QString& fileName) { return nullptr; } - QJsonObject obj = json.object(); + return create(parent, json.object()); +} +// static +Guide* Guide::create(QObject* parent, const QJsonObject& obj) { QJsonObject conditions = obj["conditions"].toObject(); if (!evaluateConditions(conditions)) { logger.info() << "Exclude the guide because conditions do not match"; @@ -146,52 +148,37 @@ Guide* Guide::create(QObject* parent, const QString& fileName) { QString guideId = obj["id"].toString(); if (guideId.isEmpty()) { - logger.warning() << "Empty ID for guide file" << fileName; + logger.warning() << "Empty ID for guide"; return nullptr; } - L18nStrings* l18nStrings = L18nStrings::instance(); - Q_ASSERT(l18nStrings); - Guide* guide = new Guide(parent); auto guard = qScopeGuard([&] { guide->deleteLater(); }); - guide->m_titleId = pascalize(QString("guide_%1_title").arg(guideId)); - if (!l18nStrings->contains(guide->m_titleId)) { - logger.warning() << "No string ID found for the title of guide file" - << fileName << "ID:" << guide->m_titleId; - return nullptr; - } - - guide->m_subtitleId = pascalize(QString("guide_%1_subtitle").arg(guideId)); - if (!l18nStrings->contains(guide->m_subtitleId)) { - logger.warning() << "No string ID found for the subtitle of guide file" - << fileName << "ID:" << guide->m_subtitleId; - return nullptr; - } + guide->m_titleId = QString("guide.%1.title").arg(guideId); + guide->m_subtitleId = QString("guide.%1.subtitle").arg(guideId); guide->m_image = obj["image"].toString(); if (guide->m_image.isEmpty()) { - logger.warning() << "Empty image for guide file" << fileName; + logger.warning() << "Empty image for guide"; return nullptr; } QJsonValue blocksArray = obj["blocks"]; if (!blocksArray.isArray()) { - logger.warning() << "No blocks for guide file" << fileName; + logger.warning() << "No blocks for guide"; return nullptr; } for (QJsonValue blockValue : blocksArray.toArray()) { if (!blockValue.isObject()) { - logger.warning() << "Expected JSON objects as blocks for guide file" - << fileName; + logger.warning() << "Expected JSON objects as blocks for guide"; return nullptr; } QJsonObject blockObj = blockValue.toObject(); - GuideBlock* block = GuideBlock::create(guide, guideId, fileName, blockObj); + GuideBlock* block = GuideBlock::create(guide, guideId, blockObj); if (!block) { return nullptr; } @@ -203,20 +190,6 @@ Guide* Guide::create(QObject* parent, const QString& fileName) { return guide; } -// static -QString Guide::pascalize(const QString& input) { - QString output; - - for (QString chunk : input.split("_")) { - if (chunk.isEmpty()) continue; - - chunk[0] = chunk[0].toUpper(); - output.append(chunk); - } - - return output; -} - // static bool Guide::evaluateConditions(const QJsonObject& conditions) { if (!evaluateConditionsEnabledFeatures( diff --git a/src/models/guide.h b/src/models/guide.h index 0662e4e71e..35539d2937 100644 --- a/src/models/guide.h +++ b/src/models/guide.h @@ -20,8 +20,7 @@ class Guide final : public QObject { public: static Guide* create(QObject* parent, const QString& fileName); - - static QString pascalize(const QString& input); + static Guide* create(QObject* parent, const QJsonObject& obj); static bool evaluateConditions(const QJsonObject& conditions); diff --git a/src/models/guideblock.cpp b/src/models/guideblock.cpp index a054120c1b..2c326363c7 100644 --- a/src/models/guideblock.cpp +++ b/src/models/guideblock.cpp @@ -4,7 +4,6 @@ #include "guideblock.h" #include "guide.h" -#include "l18nstrings.h" #include "leakdetector.h" #include "logger.h" @@ -23,23 +22,18 @@ GuideBlock::~GuideBlock() { MVPN_COUNT_DTOR(GuideBlock); } // static GuideBlock* GuideBlock::create(QObject* parent, const QString& guideId, - const QString& fileName, const QJsonObject& blockObj) { Q_ASSERT(parent); - L18nStrings* l18nStrings = L18nStrings::instance(); - Q_ASSERT(l18nStrings); - GuideBlock* block = new GuideBlock(parent); QString blockId = blockObj["id"].toString(); if (blockId.isEmpty()) { - logger.error() << "Empty block ID for guide file" << fileName; + logger.error() << "Empty block ID for guide"; return nullptr; } - block->m_id = - Guide::pascalize(QString("guide_%1_block_%2").arg(guideId).arg(blockId)); + block->m_id = QString("guide.%1.block.%2").arg(guideId).arg(blockId); QString type = blockObj["type"].toString(); if (type == "title") { @@ -51,7 +45,7 @@ GuideBlock* GuideBlock::create(QObject* parent, const QString& guideId, } else if (type == "ulist") { block->m_type = GuideModel::GuideBlockTypeUnorderedList; } else { - logger.error() << "Invalid type for block for guide file" << fileName; + logger.error() << "Invalid type for block for guide"; return nullptr; } @@ -59,44 +53,30 @@ GuideBlock* GuideBlock::create(QObject* parent, const QString& guideId, block->m_type == GuideModel::GuideBlockTypeUnorderedList) { QJsonValue subBlockArray = blockObj["content"]; if (!subBlockArray.isArray()) { - logger.error() << "No content for block type list in guide file" - << fileName; + logger.error() << "No content for block type list in guide"; return nullptr; } for (QJsonValue subBlockValue : subBlockArray.toArray()) { if (!subBlockValue.isObject()) { logger.error() - << "Expected JSON object for block content list in guide file" - << fileName; + << "Expected JSON object for block content list in guide"; return nullptr; } QJsonObject subBlockObj = subBlockValue.toObject(); QString subBlockId = subBlockObj["id"].toString(); if (subBlockId.isEmpty()) { - logger.error() << "Empty sub block ID for guide file" << fileName; - return nullptr; - } - - subBlockId = Guide::pascalize(QString("guide_%1_block_%2_%3") - .arg(guideId) - .arg(blockId) - .arg(subBlockId)); - if (!l18nStrings->contains(subBlockId)) { - logger.error() << "No string ID found for the block of guide file" - << fileName << "ID:" << subBlockId; + logger.error() << "Empty sub block ID for guide"; return nullptr; } + subBlockId = QString("guide.%1.block.%2.%3") + .arg(guideId) + .arg(blockId) + .arg(subBlockId); block->m_subBlockIds.append(subBlockId); } - } else { - if (!l18nStrings->contains(block->m_id)) { - logger.error() << "No string ID found for the block of guide file" - << fileName << "ID:" << block->m_id; - return nullptr; - } } return block; diff --git a/src/models/guideblock.h b/src/models/guideblock.h index e9857adbad..e363681c3d 100644 --- a/src/models/guideblock.h +++ b/src/models/guideblock.h @@ -20,7 +20,7 @@ class GuideBlock final : public QObject { Q_PROPERTY(QStringList subBlockIds MEMBER m_subBlockIds CONSTANT) static GuideBlock* create(QObject* parent, const QString& guideId, - const QString& fileName, const QJsonObject& json); + const QJsonObject& json); ~GuideBlock(); private: diff --git a/src/models/guidemodel.cpp b/src/models/guidemodel.cpp index 1f037d1643..cb9c9e19ea 100644 --- a/src/models/guidemodel.cpp +++ b/src/models/guidemodel.cpp @@ -26,30 +26,39 @@ GuideModel* GuideModel::instance() { GuideModel::GuideModel(QObject* parent) : QAbstractListModel(parent) { MVPN_COUNT_CTOR(GuideModel); - initialize(); } GuideModel::~GuideModel() { MVPN_COUNT_DTOR(GuideModel); } QStringList GuideModel::guideTitleIds() const { QStringList guides; - for (const Guide* guide : m_guides) { - guides.append(guide->titleId()); + for (const GuideData& guideData : m_guides) { + guides.append(guideData.m_guide->titleId()); } return guides; } -void GuideModel::initialize() { - QDir dir(":/guides"); - QStringList files = dir.entryList(); - files.sort(); - for (const QString& file : files) { - if (file.endsWith(".json")) { - Guide* guide = Guide::create(this, dir.filePath(file)); - if (guide) { - m_guides.append(guide); - } +bool GuideModel::createFromJson(const QString& addonId, + const QJsonObject& obj) { + Guide* guide = Guide::create(this, obj); + if (guide) { + beginResetModel(); + m_guides.append({addonId, guide}); + endResetModel(); + return true; + } + + return false; +} + +void GuideModel::remove(const QString& addonId) { + for (auto i = m_guides.begin(); i != m_guides.end(); ++i) { + if (i->m_addonId == addonId) { + beginResetModel(); + m_guides.erase(i); + endResetModel(); + break; } } } @@ -69,7 +78,7 @@ QVariant GuideModel::data(const QModelIndex& index, int role) const { switch (role) { case GuideRole: - return QVariant::fromValue(m_guides.at(index.row())); + return QVariant::fromValue(m_guides.at(index.row()).m_guide); default: return QVariant(); diff --git a/src/models/guidemodel.h b/src/models/guidemodel.h index c7cd88ec98..a88e314dcc 100644 --- a/src/models/guidemodel.h +++ b/src/models/guidemodel.h @@ -9,6 +9,7 @@ #include class Guide; +class QJsonObject; class GuideModel final : public QAbstractListModel { Q_OBJECT @@ -33,6 +34,10 @@ class GuideModel final : public QAbstractListModel { QStringList guideTitleIds() const; + bool createFromJson(const QString& addonId, const QJsonObject& obj); + + void remove(const QString& addonId); + // QAbstractListModel methods QHash roleNames() const override; @@ -44,9 +49,11 @@ class GuideModel final : public QAbstractListModel { private: explicit GuideModel(QObject* parent); - void initialize(); - - QList m_guides; + struct GuideData { + QString m_addonId; + Guide* m_guide; + }; + QList m_guides; }; #endif // GUIDEMODEL_H diff --git a/src/models/tutorial.cpp b/src/models/tutorial.cpp index 49618ed96a..c1d011b121 100644 --- a/src/models/tutorial.cpp +++ b/src/models/tutorial.cpp @@ -4,7 +4,6 @@ #include "tutorial.h" #include "guide.h" -#include "l18nstrings.h" #include "leakdetector.h" #include "logger.h" #include "qmlengineholder.h" @@ -30,6 +29,8 @@ Tutorial::~Tutorial() { MVPN_COUNT_DTOR(Tutorial); } // static Tutorial* Tutorial::create(QObject* parent, const QString& fileName) { + logger.debug() << "Tutorial create" << fileName; + QFile file(fileName); if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { logger.warning() << "Unable to read the tutorial file" << fileName; @@ -43,8 +44,11 @@ Tutorial* Tutorial::create(QObject* parent, const QString& fileName) { return nullptr; } - QJsonObject obj = json.object(); + return create(parent, json.object()); +} +// static +Tutorial* Tutorial::create(QObject* parent, const QJsonObject& obj) { QJsonObject conditions = obj["conditions"].toObject(); if (!Guide::evaluateConditions(conditions)) { logger.info() << "Exclude the tutorial because conditions do not match"; @@ -53,66 +57,41 @@ Tutorial* Tutorial::create(QObject* parent, const QString& fileName) { QString tutorialId = obj["id"].toString(); if (tutorialId.isEmpty()) { - logger.warning() << "Empty ID for tutorial file" << fileName; + logger.warning() << "Empty ID for tutorial"; return nullptr; } - L18nStrings* l18nStrings = L18nStrings::instance(); - Q_ASSERT(l18nStrings); - Tutorial* tutorial = new Tutorial(parent); auto guard = qScopeGuard([&] { tutorial->deleteLater(); }); tutorial->m_highlighted = obj["highlighted"].toBool(); - tutorial->m_titleId = - Guide::pascalize(QString("tutorial_%1_title").arg(tutorialId)); - if (!l18nStrings->contains(tutorial->m_titleId)) { - logger.warning() << "No string ID found for the title of tutorial file" - << fileName << "ID:" << tutorial->m_titleId; - return nullptr; - } - - tutorial->m_subtitleId = - Guide::pascalize(QString("tutorial_%1_subtitle").arg(tutorialId)); - if (!l18nStrings->contains(tutorial->m_subtitleId)) { - logger.warning() << "No string ID found for the subtitle of tutorial file" - << fileName << "ID:" << tutorial->m_subtitleId; - return nullptr; - } - - tutorial->m_completionMessageId = Guide::pascalize( - QString("tutorial_%1_completion_message").arg(tutorialId)); - if (!l18nStrings->contains(tutorial->m_completionMessageId)) { - logger.warning() - << "No string ID found for the completion message of tutorial file" - << fileName << "ID:" << tutorial->m_completionMessageId; - return nullptr; - } + tutorial->m_titleId = QString("tutorial.%1.title").arg(tutorialId); + tutorial->m_subtitleId = QString("tutorial.%1.subtitle").arg(tutorialId); + tutorial->m_completionMessageId = + QString("tutorial.%1.completion_message").arg(tutorialId); tutorial->m_image = obj["image"].toString(); if (tutorial->m_image.isEmpty()) { - logger.warning() << "Empty image for tutorial file" << fileName; + logger.warning() << "Empty image for tutorial"; return nullptr; } QJsonValue stepsArray = obj["steps"]; if (!stepsArray.isArray()) { - logger.warning() << "No steps for tutorial file" << fileName; + logger.warning() << "No steps for tutorial"; return nullptr; } for (QJsonValue stepValue : stepsArray.toArray()) { if (!stepValue.isObject()) { - logger.warning() << "Expected JSON objects as steps for tutorial file" - << fileName; + logger.warning() << "Expected JSON objects as steps for tutorial"; return nullptr; } TutorialStep* ts = TutorialStep::create(tutorial, tutorialId, stepValue); if (!ts) { - logger.warning() << "Unable to create a tutorial step for tutorial file" - << fileName; + logger.warning() << "Unable to create a tutorial step for tutorial"; return nullptr; } @@ -159,8 +138,7 @@ bool Tutorial::maybeStop(bool completed) { TutorialModel* tutorialModel = TutorialModel::instance(); Q_ASSERT(tutorialModel); - tutorialModel->requireTutorialCompleted( - this, L18nStrings::instance()->value(m_completionMessageId).toString()); + tutorialModel->requireTutorialCompleted(this, m_completionMessageId); } TutorialModel::instance()->stop(); diff --git a/src/models/tutorial.h b/src/models/tutorial.h index 8097414e38..b6bc5464dc 100644 --- a/src/models/tutorial.h +++ b/src/models/tutorial.h @@ -7,6 +7,7 @@ #include "itempicker.h" +class QJsonObject; class TutorialStep; class Tutorial final : public ItemPicker { @@ -21,6 +22,7 @@ class Tutorial final : public ItemPicker { ~Tutorial(); static Tutorial* create(QObject* parent, const QString& fileName); + static Tutorial* create(QObject* parent, const QJsonObject& obj); void play(const QStringList& allowedItems); void stop(); diff --git a/src/models/tutorialmodel.cpp b/src/models/tutorialmodel.cpp index 1d3a91ee4a..75fee50959 100644 --- a/src/models/tutorialmodel.cpp +++ b/src/models/tutorialmodel.cpp @@ -25,22 +25,44 @@ TutorialModel* TutorialModel::instance() { } TutorialModel::TutorialModel(QObject* parent) : QAbstractListModel(parent) { + logger.debug() << "create"; MVPN_COUNT_CTOR(TutorialModel); - initialize(); } TutorialModel::~TutorialModel() { MVPN_COUNT_DTOR(TutorialModel); } -void TutorialModel::initialize() { - QDir dir(":/tutorials"); - QStringList files = dir.entryList(); - files.sort(); - for (const QString& file : files) { - if (file.endsWith(".json")) { - Tutorial* tutorial = Tutorial::create(this, dir.filePath(file)); - if (tutorial) { - m_tutorials.append(tutorial); +bool TutorialModel::createFromJson(const QString& addonId, + const QJsonObject& obj) { + logger.debug() << "Creation from json"; + + Tutorial* tutorial = Tutorial::create(this, obj); + if (tutorial) { + beginResetModel(); + m_tutorials.append({addonId, tutorial}); + endResetModel(); + + if (tutorial->highlighted()) { + emit highlightedTutorialChanged(); + } + return true; + } + + return false; +} + +void TutorialModel::remove(const QString& addonId) { + for (auto i = m_tutorials.begin(); i != m_tutorials.end(); ++i) { + if (i->m_addonId == addonId) { + bool wasHighlighted = i->m_tutorial->highlighted(); + + beginResetModel(); + m_tutorials.erase(i); + endResetModel(); + + if (wasHighlighted) { + emit highlightedTutorialChanged(); } + break; } } } @@ -62,7 +84,7 @@ QVariant TutorialModel::data(const QModelIndex& index, int role) const { switch (role) { case TutorialRole: - return QVariant::fromValue(m_tutorials.at(index.row())); + return QVariant::fromValue(m_tutorials.at(index.row()).m_tutorial); default: return QVariant(); @@ -120,9 +142,9 @@ void TutorialModel::requireTooltipShown(Tutorial* tutorial, bool shown) { } Tutorial* TutorialModel::highlightedTutorial() const { - for (Tutorial* tutorial : m_tutorials) { - if (tutorial->highlighted()) { - return tutorial; + for (const TutorialData& tutorialData : m_tutorials) { + if (tutorialData.m_tutorial->highlighted()) { + return tutorialData.m_tutorial; } } return nullptr; diff --git a/src/models/tutorialmodel.h b/src/models/tutorialmodel.h index 57216fed83..965498c7cf 100644 --- a/src/models/tutorialmodel.h +++ b/src/models/tutorialmodel.h @@ -10,6 +10,7 @@ #include #include +class QJsonObject; class Tutorial; class TutorialModel final : public QAbstractListModel { @@ -17,7 +18,8 @@ class TutorialModel final : public QAbstractListModel { Q_DISABLE_COPY_MOVE(TutorialModel) Q_PROPERTY(bool tooltipShown MEMBER m_tooltipShown NOTIFY tooltipShownChanged) Q_PROPERTY(bool playing READ isPlaying NOTIFY playingChanged) - Q_PROPERTY(Tutorial* highlightedTutorial READ highlightedTutorial CONSTANT) + Q_PROPERTY(Tutorial* highlightedTutorial READ highlightedTutorial NOTIFY + highlightedTutorialChanged) public: enum ModelRoles { @@ -42,6 +44,9 @@ class TutorialModel final : public QAbstractListModel { Tutorial* highlightedTutorial() const; + bool createFromJson(const QString& addonId, const QJsonObject& obj); + void remove(const QString& addonId); + // QAbstractListModel methods QHash roleNames() const override; @@ -57,12 +62,18 @@ class TutorialModel final : public QAbstractListModel { void tooltipShownChanged(); void tutorialCompleted(const QString& completionMessageText); + signals: + void highlightedTutorialChanged(); + private: explicit TutorialModel(QObject* parent); - void initialize(); + struct TutorialData { + QString m_addonId; + Tutorial* m_tutorial; + }; - QList m_tutorials; + QList m_tutorials; Tutorial* m_currentTutorial = nullptr; QStringList m_allowedItems; diff --git a/src/models/tutorialstep.cpp b/src/models/tutorialstep.cpp index fed7653916..fc2268ffae 100644 --- a/src/models/tutorialstep.cpp +++ b/src/models/tutorialstep.cpp @@ -5,7 +5,6 @@ #include "tutorialstep.h" #include "guide.h" #include "inspector/inspectorutils.h" -#include "l18nstrings.h" #include "leakdetector.h" #include "logger.h" #include "tutorial.h" @@ -27,9 +26,6 @@ constexpr int TIMEOUT_ITEM_TIMER_MSEC = 300; // static TutorialStep* TutorialStep::create(Tutorial* parent, const QString& tutorialId, const QJsonValue& json) { - L18nStrings* l18nStrings = L18nStrings::instance(); - Q_ASSERT(l18nStrings); - QJsonObject obj = json.toObject(); QString stepId = obj["id"].toString(); @@ -38,12 +34,7 @@ TutorialStep* TutorialStep::create(Tutorial* parent, const QString& tutorialId, return nullptr; } - stepId = Guide::pascalize( - QString("tutorial_%1_step_%2").arg(tutorialId).arg(stepId)); - if (!l18nStrings->contains(stepId)) { - logger.warning() << "No string ID found for the tutorial step" << stepId; - return nullptr; - } + stepId = QString("tutorial.%1.step.%2").arg(tutorialId).arg(stepId); QString element = obj["element"].toString(); if (element.isEmpty()) { @@ -156,8 +147,8 @@ void TutorialStep::startInternal() { tutorialModel->requireTooltipShown(m_parent, true); tutorialModel->requireTooltipNeeded( - m_parent, L18nStrings::instance()->value(m_stringId).toString(), - QRectF(x, y, item->width(), item->height()), m_element); + m_parent, m_stringId, QRectF(x, y, item->width(), item->height()), + m_element); connect(m_next, &TutorialStepNext::completed, this, &TutorialStep::completed); m_next->start(); diff --git a/src/mozillavpn.cpp b/src/mozillavpn.cpp index 0b8b95fdda..f3dbaafd87 100644 --- a/src/mozillavpn.cpp +++ b/src/mozillavpn.cpp @@ -3,6 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ #include "mozillavpn.h" +#include "addonmanager.h" #include "authenticationinapp/authenticationinapp.h" #include "constants.h" #include "dnshelper.h" @@ -18,11 +19,13 @@ #include "settingsholder.h" #include "tasks/account/taskaccount.h" #include "tasks/adddevice/taskadddevice.h" +#include "tasks/addonindex/taskaddonindex.h" #include "tasks/authenticate/taskauthenticate.h" #include "tasks/captiveportallookup/taskcaptiveportallookup.h" #include "tasks/controlleraction/taskcontrolleraction.h" #include "tasks/deleteaccount/taskdeleteaccount.h" #include "tasks/function/taskfunction.h" +#include "tasks/getfeaturelist/taskgetfeaturelist.h" #include "tasks/group/taskgroup.h" #include "tasks/heartbeat/taskheartbeat.h" #include "tasks/products/taskproducts.h" @@ -31,7 +34,6 @@ #include "tasks/surveydata/tasksurveydata.h" #include "tasks/sendfeedback/tasksendfeedback.h" #include "tasks/createsupportticket/taskcreatesupportticket.h" -#include "tasks/getfeaturelist/taskgetfeaturelist.h" #include "taskscheduler.h" #include "telemetry/gleansample.h" #include "update/versionapi.h" @@ -220,6 +222,9 @@ void MozillaVPN::initialize() { m_private->m_webSocketHandler.initialize(); } + AddonManager::instance(); + TaskScheduler::scheduleTask(new TaskAddonIndex()); + TaskScheduler::scheduleTask(new TaskGetFeatureList()); #ifdef MVPN_ADJUST diff --git a/src/qmake/sources.pri b/src/qmake/sources.pri index d3174325a9..4bc4cd0ad2 100644 --- a/src/qmake/sources.pri +++ b/src/qmake/sources.pri @@ -3,8 +3,12 @@ # file, You can obtain one at http://mozilla.org/MPL/2.0/. SOURCES += \ - addon.cpp \ addonmanager.cpp \ + addons/addon.cpp \ + addons/addondemo.cpp \ + addons/addonguide.cpp \ + addons/addoni18n.cpp \ + addons/addontutorial.cpp \ apppermission.cpp \ authenticationlistener.cpp \ authenticationinapp/authenticationinapp.cpp \ @@ -118,6 +122,8 @@ SOURCES += \ statusicon.cpp \ tasks/account/taskaccount.cpp \ tasks/adddevice/taskadddevice.cpp \ + tasks/addon/taskaddon.cpp \ + tasks/addonindex/taskaddonindex.cpp \ tasks/authenticate/taskauthenticate.cpp \ tasks/captiveportallookup/taskcaptiveportallookup.cpp \ tasks/deleteaccount/taskdeleteaccount.cpp \ @@ -144,8 +150,12 @@ SOURCES += \ websockethandler.cpp HEADERS += \ - addon.h \ addonmanager.h \ + addons/addon.h \ + addons/addondemo.h \ + addons/addonguide.h \ + addons/addoni18n.h \ + addons/addontutorial.h \ appimageprovider.h \ apppermission.h \ applistprovider.h \ @@ -260,6 +270,8 @@ HEADERS += \ task.h \ tasks/account/taskaccount.h \ tasks/adddevice/taskadddevice.h \ + tasks/addon/taskaddon.h \ + tasks/addonindex/taskaddonindex.h \ tasks/authenticate/taskauthenticate.h \ tasks/captiveportallookup/taskcaptiveportallookup.h \ tasks/deleteaccount/taskdeleteaccount.h \ @@ -295,5 +307,3 @@ RESOURCES += ui/resources.qrc RESOURCES += ui/license.qrc RESOURCES += ui/ui.qrc RESOURCES += resources/certs/certs.qrc -RESOURCES += ui/guides.qrc -RESOURCES += ui/tutorials.qrc diff --git a/src/tasks/addon/taskaddon.cpp b/src/tasks/addon/taskaddon.cpp new file mode 100644 index 0000000000..66d6ef9feb --- /dev/null +++ b/src/tasks/addon/taskaddon.cpp @@ -0,0 +1,41 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "taskaddon.h" +#include "addonmanager.h" +#include "constants.h" +#include "leakdetector.h" +#include "logger.h" +#include "networkrequest.h" + +namespace { +Logger logger(LOG_MAIN, "TaskAddon"); +} + +TaskAddon::TaskAddon(const QString& addonId, const QByteArray& sha256) + : Task("TaskAddon"), m_addonId(addonId), m_sha256(sha256) { + MVPN_COUNT_CTOR(TaskAddon); +} + +TaskAddon::~TaskAddon() { MVPN_COUNT_DTOR(TaskAddon); } + +void TaskAddon::run() { + NetworkRequest* request = NetworkRequest::createForGetUrl( + this, QString("%1%2.rcc").arg(Constants::addonSourceUrl()).arg(m_addonId), + 200); + + connect(request, &NetworkRequest::requestFailed, this, + [this](QNetworkReply::NetworkError error, const QByteArray&) { + logger.error() << "Get addon failed" << error; + emit completed(); + }); + + connect(request, &NetworkRequest::requestCompleted, this, + [this](const QByteArray& data) { + logger.debug() << "Get addon completed"; + AddonManager::instance()->storeAndLoadAddon(data, m_addonId, + m_sha256); + emit completed(); + }); +} diff --git a/src/tasks/addon/taskaddon.h b/src/tasks/addon/taskaddon.h new file mode 100644 index 0000000000..99cf375166 --- /dev/null +++ b/src/tasks/addon/taskaddon.h @@ -0,0 +1,26 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef TASKADDON_H +#define TASKADDON_H + +#include "task.h" + +#include + +class TaskAddon final : public Task { + Q_DISABLE_COPY_MOVE(TaskAddon) + + public: + TaskAddon(const QString& addonId, const QByteArray& sha256); + ~TaskAddon(); + + void run() override; + + private: + const QString m_addonId; + const QByteArray m_sha256; +}; + +#endif // TASKADDON_H diff --git a/src/tasks/addonindex/taskaddonindex.cpp b/src/tasks/addonindex/taskaddonindex.cpp new file mode 100644 index 0000000000..8804b51c43 --- /dev/null +++ b/src/tasks/addonindex/taskaddonindex.cpp @@ -0,0 +1,38 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "taskaddonindex.h" +#include "addonmanager.h" +#include "constants.h" +#include "leakdetector.h" +#include "logger.h" +#include "networkrequest.h" + +namespace { +Logger logger(LOG_MAIN, "TaskAddonIndex"); +} + +TaskAddonIndex::TaskAddonIndex() : Task("TaskAddonIndex") { + MVPN_COUNT_CTOR(TaskAddonIndex); +} + +TaskAddonIndex::~TaskAddonIndex() { MVPN_COUNT_DTOR(TaskAddonIndex); } + +void TaskAddonIndex::run() { + NetworkRequest* request = NetworkRequest::createForGetUrl( + this, QString("%1manifest.json").arg(Constants::addonSourceUrl()), 200); + + connect(request, &NetworkRequest::requestFailed, this, + [this](QNetworkReply::NetworkError error, const QByteArray&) { + logger.error() << "Get addon index failed" << error; + emit completed(); + }); + + connect(request, &NetworkRequest::requestCompleted, this, + [this](const QByteArray& data) { + logger.debug() << "Get addon index completed"; + AddonManager::instance()->updateIndex(data); + emit completed(); + }); +} diff --git a/src/tasks/addonindex/taskaddonindex.h b/src/tasks/addonindex/taskaddonindex.h new file mode 100644 index 0000000000..adcac5a447 --- /dev/null +++ b/src/tasks/addonindex/taskaddonindex.h @@ -0,0 +1,22 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef TASKADDONINDEX_H +#define TASKADDONINDEX_H + +#include "task.h" + +#include + +class TaskAddonIndex final : public Task { + Q_DISABLE_COPY_MOVE(TaskAddonIndex) + + public: + TaskAddonIndex(); + ~TaskAddonIndex(); + + void run() override; +}; + +#endif // TASKADDONINDEX_H diff --git a/src/ui/developerMenu/ViewTutorials.qml b/src/ui/developerMenu/ViewTutorials.qml index bc3203b37b..c8ae0fdbe4 100644 --- a/src/ui/developerMenu/ViewTutorials.qml +++ b/src/ui/developerMenu/ViewTutorials.qml @@ -42,8 +42,8 @@ Item { // I'm too lazy to create a proper view. function showTutorialContent(tutorial) { const list = []; - list.push("Translate title: " + VPNl18n[tutorial.titleId]); - list.push("Translate subtitle: " + VPNl18n[tutorial.subtitleId]); + list.push("Translate title: " + qsTrId(tutorial.titleId)); + list.push("Translate subtitle: " + qsTrId(tutorial.subtitleId)); list.push("Image: " + tutorial.image); list.push("Highlighted: " + tutorial.highlighted + "(" + (tutorial === VPNTutorial.highlightedTutorial ? "true" : "false") + ")"); diff --git a/src/ui/guides/.keepme b/src/ui/guides/.keepme deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/ui/guides/01_how_to_vpn.json b/src/ui/guides/01_how_to_vpn.json deleted file mode 100644 index 58be2d5fe8..0000000000 --- a/src/ui/guides/01_how_to_vpn.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "id": "how_to_vpn", - "title": "The top uses and benefits of using Mozilla VPN", - "subtitle": "Mozilla VPN secures the connection between your device and the web. Here’s some of the most useful features your VPN offers:", - "image": "qrc:/guides/01_how_to_vpn.svg", - "blocks": [ - { "id": "c_1", - "type": "title", - "content": "Safeguard your personal data and identity" }, - { "id": "c_2", - "type": "ulist", - "content": [ - { "id": "l_1", - "content": "Surf the web and use your favorite apps freely without being tracked" }, - { "id": "l_2", - "content": "Keep confidential materials safe via our premium data encryption" } - ] - }, - { "id": "c_3", - "type": "title", - "content": "Defense on the go" }, - { "id": "c_4", - "type": "ulist", - "content": [ - { "id": "l_1", - "content": "Blocks unknown entities from seeing your private data on risky public Wi-Fi and hotspots" }, - { "id": "l_2", - "content": "Protection wherever you are, from restaurants to vacation rentals" } - ] - }, - { "id": "c_5", - "type": "title", - "content": "Connect to 30+ countries" }, - { "id": "c_6", - "type": "ulist", - "content": [ - { "id": "l_1", - "content": "Browse and access content from all over the world" }, - { "id": "l_2", - "content": "Shop on a regional server to get localized deals like cheaper airfare" } - ] - } - ] -} diff --git a/src/ui/guides/02_is_my_vpn_working_correctly.json b/src/ui/guides/02_is_my_vpn_working_correctly.json deleted file mode 100644 index 1da459ba4b..0000000000 --- a/src/ui/guides/02_is_my_vpn_working_correctly.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "id": "is_my_vpn_working_correctly", - "title": "Is my VPN connected to the right server location?", - "subtitle": "Test your geolocation on one of our 400+ worldwide servers in two super simple ways:", - "image": "qrc:/guides/02_is_my_vpn_working_correctly.svg", - "blocks": [ - { "id": "c_1", - "type": "title", - "content": "Search in your browser" }, - { "id": "c_2", - "type": "text", - "content": "Enter a search term like “local weather” in your browser and see a real-time weather update for your current VPN location." }, - { "id": "c_3", - "type": "title", - "content": "Sign in to your favorite app or streaming site" }, - { "id": "c_4", - "type": "text", - "content": "Traveling abroad and need to access content from back home?" }, - { "id": "c_5", - "type": "olist", - "content": [ - { - "id": "l_1", - "content": "Connect to the country server of your choice" }, - { - "id": "l_2", - "content": "Sign on to your favorite app or streaming site" } - ] - } - ] -} diff --git a/src/ui/guides/03_adding_and_removing_devices.json b/src/ui/guides/03_adding_and_removing_devices.json deleted file mode 100644 index 28e3e3a905..0000000000 --- a/src/ui/guides/03_adding_and_removing_devices.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "id": "adding_and_removing_devices", - "title": "How do I add and remove devices?", - "subtitle": "Follow this quick and simple 3 step guide to add and remove all of your devices easily.", - "image": "qrc:/guides/03_adding_and_removing_devices.svg", - "blocks": [ - { "id": "c_1", - "type": "title", - "content": "How to add a device" }, - { "id": "c_2", - "type": "olist", - "content": [ - { "id": "l_1", - "content": "Open the Mozilla VPN app on your preferred device" }, - { "id": "l_2", - "content": "Sign in to your account" }, - { "id": "l_3", - "content": "Voila! Your device has been automatically added to your VPN list of connected devices" } - ] - }, - { "id": "c_3", - "type": "title", - "content": "How to remove a device" }, - { "id": "c_4", - "type": "olist", - "content": [ - { "id": "l_1", - "content": "Open Mozilla VPN in your chosen desktop or mobile device" }, - { "id": "l_2", - "content": "Tap on “My Devices” in your VPN homescreen" }, - { "id": "l_3", - "content": "Select the “Trash” icon next to the device you want to remove from your list of connected devices" } - ] - } - ] -} diff --git a/src/ui/guides/04_connecting_external_devices.json b/src/ui/guides/04_connecting_external_devices.json deleted file mode 100644 index 772fd42c55..0000000000 --- a/src/ui/guides/04_connecting_external_devices.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "id": "connecting_external_devices", - "title": "Connecting to other devices while using VPN", - "subtitle": "Enabling local network access allows you to connect to other devices without sacrificing your VPN protection.", - "image": "qrc:/guides/04_connecting_external_devices.svg", - "blocks": [ - { "id": "c_1", - "type": "title", - "content": "How do I connect to my printer or fax?" }, - { "id": "c_2", - "type": "olist", - "content": [ - { "id": "l_1", - "content": "Enable “Local network access” in your network settings" }, - { "id": "l_2", - "content": "Connect the printer or fax cable to your device’s port (wired) or via wireless" }, - { "id": "l_3", - "content": "Print or fax your documents as normal Desktop computers / laptops / mobile phones" } - ] - }, - { "id": "c_3", - "type": "title", - "content": "What other types of devices can I connect to?" }, - { "id": "c_4", - "type": "text", - "content": "With “Local network access” turned on, you can also connect to devices like:" }, - { "id": "c_5", - "type": "ulist", - "content": [ - { "id": "l_1", - "content": "Other computers and mobile phones" }, - { "id": "l_2", - "content": "Streaming sticks (Chromecast / Roku / Apple AirPlay)" } - ] - } - ] -} diff --git a/src/ui/main.qml b/src/ui/main.qml index 190a7afca2..5bcd6e3bdd 100644 --- a/src/ui/main.qml +++ b/src/ui/main.qml @@ -504,19 +504,19 @@ Window { Connections { target: VPNTutorial - function onTooltipNeeded(text, rect, objectName) { + function onTooltipNeeded(textId, rect, objectName) { console.log("OBJECT NAME:" + objectName); if (tooltip.height + rect.y + rect.height <= window.height - VPNTheme.theme.windowMargin) { tooltip.y = rect.y + rect.height; } else { tooltip.y = rect.y - tooltip.height; } - tooltip.text = text + tooltip.text = qsTrId(textId); tooltip.open(); } function onTutorialCompleted(text) { - console.log("TODO", text); + console.log("TODO", qsTrId(text)); } } diff --git a/src/ui/settings/ViewGuide.qml b/src/ui/settings/ViewGuide.qml index 8dd4dc7d15..42f0b13174 100644 --- a/src/ui/settings/ViewGuide.qml +++ b/src/ui/settings/ViewGuide.qml @@ -127,7 +127,7 @@ Item { Layout.topMargin: VPNTheme.theme.vSpacingSmall Layout.fillWidth: true - text: VPNl18n[guide.titleId] + text: qsTrId(guide.titleId) lineHeightMode: Text.FixedHeight lineHeight: VPNTheme.theme.vSpacing wrapMode: Text.Wrap @@ -138,7 +138,7 @@ Item { Layout.topMargin: VPNTheme.theme.listSpacing Layout.fillWidth: true - text: VPNl18n[guide.subtitleId] + text: qsTrId(guide.subtitleId) font.pixelSize: VPNTheme.theme.fontSizeSmall color: VPNTheme.theme.fontColor horizontalAlignment: Text.AlignLeft @@ -162,7 +162,7 @@ Item { VPNBoldInterLabel { property var guideBlock - text: VPNl18n[guideBlock.id] + text: qstrId(guideBlock.id) font.pixelSize: VPNTheme.theme.fontSize lineHeight: VPNTheme.theme.labelLineHeight verticalAlignment: Text.AlignVCenter @@ -175,7 +175,7 @@ Item { VPNInterLabel { property var guideBlock - text: VPNl18n[guideBlock.id] + text: qsTrId(guideBlock.id) font.pixelSize: VPNTheme.theme.fontSizeSmall color: VPNTheme.theme.fontColor horizontalAlignment: Text.AlignLeft @@ -188,7 +188,7 @@ Item { VPNInterLabel { property var guideBlock property string listType - property var tagsList: guideBlock.subBlockIds.map(subBlockId => `
  • ${VPNl18n[subBlockId]}
  • `) + property var tagsList: guideBlock.subBlockIds.map(subBlockId => `
  • ${qsTrId(subBlockId)}
  • `) text: `<${listType} style='margin-left: -24px;-qt-list-indent:1;'>%1`.arg(tagsList.join("")) textFormat: Text.RichText diff --git a/src/ui/settings/ViewTipsAndTricks.qml b/src/ui/settings/ViewTipsAndTricks.qml index 5dd81a7fba..a2968ab4e7 100644 --- a/src/ui/settings/ViewTipsAndTricks.qml +++ b/src/ui/settings/ViewTipsAndTricks.qml @@ -57,8 +57,8 @@ VPNFlickable { height: parent.height imageSrc: highlightedTutorial.image - title: VPNl18n[highlightedTutorial.titleId] - description: VPNl18n[highlightedTutorial.subtitleId] + title: qsTrId(highlightedTutorial.titleId) + description: qsTrId(highlightedTutorial.subtitleId) } } @@ -124,7 +124,7 @@ VPNFlickable { width: vpnFlickable.width < VPNTheme.theme.tabletMinimumWidth ? (parent.width - parent.spacing) / 2 : (parent.width - (parent.spacing * 2)) / 3 imageSrc: guide.image - title: VPNl18n[guide.titleId] + title: qsTrId(guide.titleId) onClicked: mainStackView.push("qrc:/ui/settings/ViewGuide.qml", {"guide": guide, "imageBgColor": imageBgColor}) } @@ -151,8 +151,8 @@ VPNFlickable { height: VPNTheme.theme.tutorialCardHeight imageSrc: tutorial.image - title: VPNl18n[tutorial.titleId] - description: VPNl18n[tutorial.subtitleId] + title: qsTrId(tutorial.titleId) + description: qsTrId(tutorial.subtitleId) } } } diff --git a/src/ui/tutorials/.keepme b/src/ui/tutorials/.keepme deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/ui/tutorials/01_get_started.json b/src/ui/tutorials/01_get_started.json deleted file mode 100644 index 2e01e485fe..0000000000 --- a/src/ui/tutorials/01_get_started.json +++ /dev/null @@ -1,63 +0,0 @@ -{ - "id": "01_get_started", - "highlighted": true, - "image": "qrc:/tutorials/01_get_started.svg", - "title": "Getting started with VPN", - "subtitle": "Follow this walkthrough to learn how to get started with using your VPN.", - "completion_message": "You’ve successfully changed your location, turned the VPN on and off. Would you like to learn more tips and tricks?", - "steps": [ - { - "id": "mainScreen", - "element": "serverListButton", - "tooltip": "Select your location", - "before": [{ - "op": "vpn_location_set", - "exitCountryCode": "at", - "exitCity": "Vienna", - "entryCountryCode": "", - "entryCity": "" - }], - "next": { - "op": "signal", - "qml_emitter": "serverListButton", - "signal": "visibleChanged" - } - }, - { - "id": "countryAU", - "element": "serverCountryList/serverCountry-au", - "tooltip": "Select a different country", - "before": [{ - "op": "property_set", - "element": "serverCountryView", - "property": "contentY", - "value": 0 - }], - "next": { - "op": "signal", - "qml_emitter": "serverCountryList/serverCountry-au", - "signal": "cityListVisibleChanged" - } - }, - { - "id": "cityAU", - "element": "serverCountryList/serverCountry-au/serverCityList/serverCity-Melbourne", - "tooltip": "Select a different server location", - "next": { - "op": "signal", - "qml_emitter": "serverCountryList/serverCountry-au/serverCityList/serverCity-Melbourne", - "signal": "visibleChanged" - } - }, - { - "id": "toggle", - "element": "controllerToggle", - "tooltip": "Toggle this switch to activate or deactivate the VPN", - "next": { - "op": "signal", - "vpn_emitter": "controller", - "signal": "stateChanged" - } - } - ] -} diff --git a/src/ui/tutorials/02_connect_on_startup.json b/src/ui/tutorials/02_connect_on_startup.json deleted file mode 100644 index c77d9d3816..0000000000 --- a/src/ui/tutorials/02_connect_on_startup.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "id": "02_connect_on_startup", - "image": "qrc:/tutorials/02_connect_on_startup.svg", - "title": "Connect VPN on startup", - "subtitle": "Follow this walkthrough to learn how to activate your VPN when you start your device.", - "completion_message": "You’ve successfully learned how to set “Connect VPN on startup”. Would you like to learn more tips and tricks?", - "conditions": { - "enabledFeatures": ["startOnBoot"] - }, - "steps": [ - { - "id": "s1", - "element": "settingsButton", - "tooltip": "Select your account settings", - "next": { - "op": "signal", - "qml_emitter": "settingsButton", - "signal": "visibleChanged" - } - }, - { - "id": "s2", - "element": "settingsPreferences", - "tooltip": "Select your system preferences", - "next": { - "op": "signal", - "qml_emitter": "settingsPreferences", - "signal": "visibleChanged" - } - }, - { - "id": "s3", - "element": "settingStartAtBoot", - "tooltip": "Select or deselect this checkbox", - "next": { - "op": "signal", - "vpn_emitter": "settingsHolder", - "signal": "startAtBootChanged" - } - } - ] -} diff --git a/src/ui/views/ViewAddon.qml b/src/ui/views/ViewAddon.qml index 50071b1c75..df84a1a0f7 100644 --- a/src/ui/views/ViewAddon.qml +++ b/src/ui/views/ViewAddon.qml @@ -35,15 +35,6 @@ Item { anchors.topMargin: VPNTheme.theme.windowMargin anchors.top: menu.bottom - source: "qrc" + parent.addon.qml - } - - Connections { - target: VPNAddonManager - function onUnloadAddon(addonId) { - if (addonId === addon.id) { - return mainStackView.pop(addonWrapper, StackView.Immediate); - } - } + source: parent.addon.qml } } diff --git a/tests/unit/CMakeLists.txt b/tests/unit/CMakeLists.txt index 59be7c038f..80cdb0e29e 100644 --- a/tests/unit/CMakeLists.txt +++ b/tests/unit/CMakeLists.txt @@ -36,6 +36,18 @@ target_link_libraries(unit_tests PRIVATE glean lottie nebula translations) # VPN Client source files target_sources(unit_tests PRIVATE + ${MVPN_SOURCE_DIR}/addonmanager.cpp + ${MVPN_SOURCE_DIR}/addonmanager.h + ${MVPN_SOURCE_DIR}/addons/addon.cpp + ${MVPN_SOURCE_DIR}/addons/addon.h + ${MVPN_SOURCE_DIR}/addons/addondemo.cpp + ${MVPN_SOURCE_DIR}/addons/addondemo.h + ${MVPN_SOURCE_DIR}/addons/addonguide.cpp + ${MVPN_SOURCE_DIR}/addons/addonguide.h + ${MVPN_SOURCE_DIR}/addons/addoni18n.cpp + ${MVPN_SOURCE_DIR}/addons/addoni18n.h + ${MVPN_SOURCE_DIR}/addons/addontutorial.cpp + ${MVPN_SOURCE_DIR}/addons/addontutorial.h ${MVPN_SOURCE_DIR}/adjust/adjustfiltering.cpp ${MVPN_SOURCE_DIR}/adjust/adjustfiltering.h ${MVPN_SOURCE_DIR}/adjust/adjustproxypackagehandler.cpp @@ -175,6 +187,8 @@ target_sources(unit_tests PRIVATE ${MVPN_SOURCE_DIR}/tasks/account/taskaccount.h ${MVPN_SOURCE_DIR}/tasks/adddevice/taskadddevice.cpp ${MVPN_SOURCE_DIR}/tasks/adddevice/taskadddevice.h + ${MVPN_SOURCE_DIR}/tasks/addon/taskaddon.cpp + ${MVPN_SOURCE_DIR}/tasks/addon/taskaddon.h ${MVPN_SOURCE_DIR}/tasks/ipfinder/taskipfinder.cpp ${MVPN_SOURCE_DIR}/tasks/ipfinder/taskipfinder.h ${MVPN_SOURCE_DIR}/tasks/function/taskfunction.cpp diff --git a/tests/unit/testguide.cpp b/tests/unit/testguide.cpp index eded6a9509..f254b68ab1 100644 --- a/tests/unit/testguide.cpp +++ b/tests/unit/testguide.cpp @@ -6,114 +6,88 @@ #include "../../src/models/guide.h" #include "../../src/models/guidemodel.h" #include "../../src/settingsholder.h" -#include "../../translations/generated/l18nstrings.h" #include "helper.h" -void TestGuide::pascalize() { - QCOMPARE(Guide::pascalize(""), ""); - QCOMPARE(Guide::pascalize("a"), "A"); - QCOMPARE(Guide::pascalize("ab"), "Ab"); - QCOMPARE(Guide::pascalize("ab_c"), "AbC"); - QCOMPARE(Guide::pascalize("ab_cd"), "AbCd"); -} - void TestGuide::create_data() { - QTest::addColumn("l18n"); + QTest::addColumn("id"); QTest::addColumn("content"); QTest::addColumn("created"); - QTest::addRow("empty") << QStringList() << QByteArray("") << false; - QTest::addRow("non-object") << QStringList() << QByteArray("[]") << false; - QTest::addRow("object-without-id") - << QStringList() << QByteArray("{}") << false; + QTest::addRow("empty") << "" << QByteArray("") << false; + QTest::addRow("non-object") << "" << QByteArray("[]") << false; + QTest::addRow("object-without-id") << "" << QByteArray("{}") << false; QJsonObject obj; obj["id"] = "foo"; - QTest::addRow("invalid-id") - << QStringList() << QJsonDocument(obj).toJson() << false; - QTest::addRow("no-image") << QStringList{"GuideFooTitle", "GuideFooSubtitle"} - << QJsonDocument(obj).toJson() << false; + QTest::addRow("no-image") << "foo" << QJsonDocument(obj).toJson() << false; obj["image"] = "foo.png"; - QTest::addRow("no-blocks") << QStringList{"GuideFooTitle", "GuideFooSubtitle"} - << QJsonDocument(obj).toJson() << false; + QTest::addRow("no-blocks") << "foo" << QJsonDocument(obj).toJson() << false; QJsonArray blocks; obj["blocks"] = blocks; - QTest::addRow("with-blocks") - << QStringList{"GuideFooTitle", "GuideFooSubtitle"} - << QJsonDocument(obj).toJson() << true; + QTest::addRow("with-blocks") << "foo" << QJsonDocument(obj).toJson() << true; blocks.append(""); obj["blocks"] = blocks; QTest::addRow("with-invalid-block") - << QStringList{"GuideFooTitle", "GuideFooSubtitle"} - << QJsonDocument(obj).toJson() << false; + << "foo" << QJsonDocument(obj).toJson() << false; QJsonObject block; blocks.replace(0, block); obj["blocks"] = blocks; QTest::addRow("with-block-without-id") - << QStringList{"GuideFooTitle", "GuideFooSubtitle"} - << QJsonDocument(obj).toJson() << false; + << "foo" << QJsonDocument(obj).toJson() << false; block["id"] = "A"; blocks.replace(0, block); obj["blocks"] = blocks; QTest::addRow("with-block-without-type") - << QStringList{"GuideFooTitle", "GuideFooSubtitle"} - << QJsonDocument(obj).toJson() << false; + << "foo" << QJsonDocument(obj).toJson() << false; block["type"] = "wow"; blocks.replace(0, block); obj["blocks"] = blocks; QTest::addRow("with-block-with-invalid-type") - << QStringList{"GuideFooTitle", "GuideFooSubtitle"} - << QJsonDocument(obj).toJson() << false; + << "foo" << QJsonDocument(obj).toJson() << false; block["type"] = "title"; blocks.replace(0, block); obj["blocks"] = blocks; QTest::addRow("with-block-type-title") - << QStringList{"GuideFooTitle", "GuideFooSubtitle", "GuideFooBlockA"} - << QJsonDocument(obj).toJson() << true; + << "foo" << QJsonDocument(obj).toJson() << true; block["type"] = "text"; blocks.replace(0, block); obj["blocks"] = blocks; QTest::addRow("with-block-type-text") - << QStringList{"GuideFooTitle", "GuideFooSubtitle", "GuideFooBlockA"} - << QJsonDocument(obj).toJson() << true; + << "foo" << QJsonDocument(obj).toJson() << true; block["type"] = "olist"; blocks.replace(0, block); obj["blocks"] = blocks; QTest::addRow("with-block-type-olist-without-content") - << QStringList{"GuideFooTitle", "GuideFooSubtitle", "GuideFooBlockA"} - << QJsonDocument(obj).toJson() << false; + << "foo" << QJsonDocument(obj).toJson() << false; block["content"] = "foo"; blocks.replace(0, block); obj["blocks"] = blocks; QTest::addRow("with-block-type-olist-with-invalid-content") - << QStringList{"GuideFooTitle", "GuideFooSubtitle", "GuideFooBlockA"} - << QJsonDocument(obj).toJson() << false; + << "foo" << QJsonDocument(obj).toJson() << false; QJsonArray content; block["content"] = content; blocks.replace(0, block); obj["blocks"] = blocks; QTest::addRow("with-block-type-olist-with-empty-content") - << QStringList{"GuideFooTitle", "GuideFooSubtitle", "GuideFooBlockA"} - << QJsonDocument(obj).toJson() << true; + << "foo" << QJsonDocument(obj).toJson() << true; content.append("foo"); block["content"] = content; blocks.replace(0, block); obj["blocks"] = blocks; QTest::addRow("with-block-type-olist-with-invalid-content") - << QStringList{"GuideFooTitle", "GuideFooSubtitle", "GuideFooBlockA"} - << QJsonDocument(obj).toJson() << false; + << "foo" << QJsonDocument(obj).toJson() << false; QJsonObject subBlock; content.replace(0, subBlock); @@ -121,8 +95,7 @@ void TestGuide::create_data() { blocks.replace(0, block); obj["blocks"] = blocks; QTest::addRow("with-block-type-olist-without-id-subblock") - << QStringList{"GuideFooTitle", "GuideFooSubtitle", "GuideFooBlockA"} - << QJsonDocument(obj).toJson() << false; + << "foo" << QJsonDocument(obj).toJson() << false; subBlock["id"] = "sub"; content.replace(0, subBlock); @@ -130,36 +103,24 @@ void TestGuide::create_data() { blocks.replace(0, block); obj["blocks"] = blocks; QTest::addRow("with-block-type-olist-with-subblock") - << QStringList{"GuideFooTitle", "GuideFooSubtitle", "GuideFooBlockA", - "GuideFooBlockASub"} - << QJsonDocument(obj).toJson() << true; + << "foo" << QJsonDocument(obj).toJson() << true; obj["conditions"] = QJsonObject(); QTest::addRow("with-block-type-olist-with-subblock and conditions") - << QStringList{"GuideFooTitle", "GuideFooSubtitle", "GuideFooBlockA", - "GuideFooBlockASub"} - << QJsonDocument(obj).toJson() << true; + << "foo" << QJsonDocument(obj).toJson() << true; block["type"] = "ulist"; blocks.replace(0, block); obj["conditions"] = QJsonObject(); QTest::addRow("with-block-type-ulist-with-subblock and conditions") - << QStringList{"GuideFooTitle", "GuideFooSubtitle", "GuideFooBlockA", - "GuideFooBlockASub"} - << QJsonDocument(obj).toJson() << true; + << "foo" << QJsonDocument(obj).toJson() << true; } void TestGuide::create() { - QFETCH(QStringList, l18n); + QFETCH(QString, id); QFETCH(QByteArray, content); QFETCH(bool, created); - L18nStrings* l18nStrings = L18nStrings::instance(); - QVERIFY(!!l18nStrings); - for (const QString& s : l18n) { - l18nStrings->insert(s, "WOW!"); - } - QTemporaryFile file; QVERIFY(file.open()); QCOMPARE(file.write(content.data(), content.length()), content.length()); @@ -173,9 +134,9 @@ void TestGuide::create() { } QString guideTitleId = guide->property("titleId").toString(); - QVERIFY(l18nStrings->contains(guideTitleId)); + QCOMPARE(guideTitleId, QString("guide.%1.title").arg(id)); QString guideSubTitleId = guide->property("subtitleId").toString(); - QVERIFY(l18nStrings->contains(guideSubTitleId)); + QCOMPARE(guideSubTitleId, QString("guide.%1.subtitle").arg(id)); QCOMPARE(guide->property("image").toString(), "foo.png"); @@ -189,15 +150,7 @@ void TestGuide::createNotExisting() { } void TestGuide::model() { - L18nStrings* l18nStrings = L18nStrings::instance(); - QVERIFY(!!l18nStrings); - for (const QString& s : QStringList{ - "GuideDemoTitle", "GuideDemoSubtitle", "GuideDemoBlockC1", - "GuideDemoBlockC2", "GuideDemoBlockC3L1", "GuideDemoBlockC3L2", - "GuideDemoBlockC3L3", "GuideDemoBlockC4L1", "GuideDemoBlockC4L2", - "GuideDemoBlockC4L3"}) { - l18nStrings->insert(s, "WOW!"); - } + SettingsHolder settingsHolder; GuideModel* mg = GuideModel::instance(); QVERIFY(!!mg); @@ -206,6 +159,14 @@ void TestGuide::model() { QCOMPARE(rn.count(), 1); QCOMPARE(rn[GuideModel::GuideRole], "guide"); + QCOMPARE(mg->rowCount(QModelIndex()), 0); + + QFile guideFile(":/guides/01_demo.json"); + QVERIFY(guideFile.open(QIODevice::ReadOnly | QIODevice::Text)); + QJsonDocument json = QJsonDocument::fromJson(guideFile.readAll()); + QVERIFY(json.isObject()); + mg->createFromJson("test", json.object()); + QCOMPARE(mg->rowCount(QModelIndex()), 1); QCOMPARE(mg->data(QModelIndex(), GuideModel::GuideRole), QVariant()); diff --git a/tests/unit/testguide.h b/tests/unit/testguide.h index 6dd94ed9c1..d08edb08bc 100644 --- a/tests/unit/testguide.h +++ b/tests/unit/testguide.h @@ -8,8 +8,6 @@ class TestGuide final : public TestHelper { Q_OBJECT private slots: - void pascalize(); - void create_data(); void create(); void createNotExisting(); diff --git a/tests/unit/testtutorial.cpp b/tests/unit/testtutorial.cpp index a8ecec15c6..e15861c069 100644 --- a/tests/unit/testtutorial.cpp +++ b/tests/unit/testtutorial.cpp @@ -7,15 +7,9 @@ #include "../../src/models/tutorialmodel.h" #include "../../src/qmlengineholder.h" #include "../../src/settingsholder.h" -#include "../../translations/generated/l18nstrings.h" void TestTutorial::model() { - L18nStrings* l18nStrings = L18nStrings::instance(); - l18nStrings->insert("TutorialDemoTitle", "title"); - l18nStrings->insert("TutorialDemoSubtitle", "subtitle"); - l18nStrings->insert("TutorialDemoCompletionMessage", "completion_message"); - l18nStrings->insert("TutorialDemoStepS1", "wow1"); - l18nStrings->insert("TutorialDemoStepS2", "wow2"); + SettingsHolder settingsHolder; TutorialModel* mg = TutorialModel::instance(); QVERIFY(!!mg); @@ -24,6 +18,14 @@ void TestTutorial::model() { QCOMPARE(rn.count(), 1); QCOMPARE(rn[TutorialModel::TutorialRole], "tutorial"); + QCOMPARE(mg->rowCount(QModelIndex()), 0); + + QFile tutorialFile(":/tutorials/01_demo.json"); + QVERIFY(tutorialFile.open(QIODevice::ReadOnly | QIODevice::Text)); + QJsonDocument json = QJsonDocument::fromJson(tutorialFile.readAll()); + QVERIFY(json.isObject()); + mg->createFromJson("test", json.object()); + QCOMPARE(mg->rowCount(QModelIndex()), 1); QCOMPARE(mg->data(QModelIndex(), TutorialModel::TutorialRole), QVariant()); @@ -33,75 +35,54 @@ void TestTutorial::model() { } void TestTutorial::create_data() { - QTest::addColumn("l18n"); + QTest::addColumn("id"); QTest::addColumn("content"); QTest::addColumn("created"); - QTest::addRow("empty") << QStringList() << QByteArray("") << false; - QTest::addRow("non-object") << QStringList() << QByteArray("[]") << false; - QTest::addRow("object-without-id") - << QStringList() << QByteArray("{}") << false; + QTest::addRow("empty") << "" << QByteArray("") << false; + QTest::addRow("non-object") << "" << QByteArray("[]") << false; + QTest::addRow("object-without-id") << "" << QByteArray("{}") << false; QJsonObject obj; obj["id"] = "foo"; - QTest::addRow("invalid-id") - << QStringList() << QJsonDocument(obj).toJson() << false; - QTest::addRow("no-image") - << QStringList{"TutorialFooTitle", "TutorialFooSubtitle", - "TutorialFooCompletionMessage"} - << QJsonDocument(obj).toJson() << false; + QTest::addRow("invalid-id") << "foo" << QJsonDocument(obj).toJson() << false; + QTest::addRow("no-image") << "foo" << QJsonDocument(obj).toJson() << false; obj["image"] = "foo.png"; - QTest::addRow("no-steps") - << QStringList{"TutorialFooTitle", "TutorialFooSubtitle", - "TutorialFooCompletionMessage"} - << QJsonDocument(obj).toJson() << false; + QTest::addRow("no-steps") << "foo" << QJsonDocument(obj).toJson() << false; QJsonArray steps; obj["steps"] = steps; - QTest::addRow("with-steps") - << QStringList{"TutorialFooTitle", "TutorialFooSubtitle", - "TutorialFooCompletionMessage"} - << QJsonDocument(obj).toJson() << false; + QTest::addRow("with-steps") << "foo" << QJsonDocument(obj).toJson() << false; steps.append(""); obj["steps"] = steps; QTest::addRow("with-invalid-step") - << QStringList{"TutorialFooTitle", "TutorialFooSubtitle", - "TutorialFooCompletionMessage"} - << QJsonDocument(obj).toJson() << false; + << "foo" << QJsonDocument(obj).toJson() << false; QJsonObject step; steps.replace(0, step); obj["steps"] = steps; QTest::addRow("with-step-without-id") - << QStringList{"TutorialFooTitle", "TutorialFooSubtitle", - "TutorialFooCompletionMessage"} - << QJsonDocument(obj).toJson() << false; + << "foo" << QJsonDocument(obj).toJson() << false; step["id"] = "s1"; steps.replace(0, step); obj["steps"] = steps; QTest::addRow("with-step-without-element") - << QStringList{"TutorialFooTitle", "TutorialFooSubtitle", - "TutorialFooCompletionMessage"} - << QJsonDocument(obj).toJson() << false; + << "foo" << QJsonDocument(obj).toJson() << false; step["element"] = "wow"; steps.replace(0, step); obj["steps"] = steps; QTest::addRow("with-step-without-next") - << QStringList{"TutorialFooTitle", "TutorialFooSubtitle", - "TutorialFooCompletionMessage", "TutorialFooStepS1"} - << QJsonDocument(obj).toJson() << false; + << "foo" << QJsonDocument(obj).toJson() << false; step["next"] = "wow"; steps.replace(0, step); obj["steps"] = steps; QTest::addRow("with-step-with-invalid-next") - << QStringList{"TutorialFooTitle", "TutorialFooSubtitle", - "TutorialFooCompletionMessage", "TutorialFooStepS1"} - << QJsonDocument(obj).toJson() << false; + << "foo" << QJsonDocument(obj).toJson() << false; QJsonObject nextObj; @@ -109,92 +90,69 @@ void TestTutorial::create_data() { steps.replace(0, step); obj["steps"] = steps; QTest::addRow("with-step-with-invalid-next-1") - << QStringList{"TutorialFooTitle", "TutorialFooSubtitle", - "TutorialFooCompletionMessage", "TutorialFooStepS1"} - << QJsonDocument(obj).toJson() << false; + << "foo" << QJsonDocument(obj).toJson() << false; nextObj["op"] = "wow"; step["next"] = nextObj; steps.replace(0, step); obj["steps"] = steps; QTest::addRow("with-step-with-invalid-next-2") - << QStringList{"TutorialFooTitle", "TutorialFooSubtitle", - "TutorialFooCompletionMessage", "TutorialFooStepS1"} - << QJsonDocument(obj).toJson() << false; + << "foo" << QJsonDocument(obj).toJson() << false; nextObj["op"] = "signal"; step["next"] = nextObj; steps.replace(0, step); obj["steps"] = steps; QTest::addRow("with-step-with-invalid-next-3") - << QStringList{"TutorialFooTitle", "TutorialFooSubtitle", - "TutorialFooCompletionMessage", "TutorialFooStepS1"} - << QJsonDocument(obj).toJson() << false; + << "foo" << QJsonDocument(obj).toJson() << false; nextObj["signal"] = "a"; step["next"] = nextObj; steps.replace(0, step); obj["steps"] = steps; QTest::addRow("with-step-with-invalid-next-4") - << QStringList{"TutorialFooTitle", "TutorialFooSubtitle", - "TutorialFooCompletionMessage", "TutorialFooStepS1"} - << QJsonDocument(obj).toJson() << false; + << "foo" << QJsonDocument(obj).toJson() << false; nextObj["qml_emitter"] = "a"; step["next"] = nextObj; steps.replace(0, step); obj["steps"] = steps; QTest::addRow("with-step-with-invalid-next-5") - << QStringList{"TutorialFooTitle", "TutorialFooSubtitle", - "TutorialFooCompletionMessage", "TutorialFooStepS1"} - << QJsonDocument(obj).toJson() << true; + << "foo" << QJsonDocument(obj).toJson() << true; nextObj["vpn_emitter"] = "a"; step["next"] = nextObj; steps.replace(0, step); obj["steps"] = steps; QTest::addRow("with-step-with-invalid-next-6") - << QStringList{"TutorialFooTitle", "TutorialFooSubtitle", - "TutorialFooCompletionMessage", "TutorialFooStepS1"} - << QJsonDocument(obj).toJson() << false; + << "foo" << QJsonDocument(obj).toJson() << false; nextObj.remove("qml_emitter"); step["next"] = nextObj; steps.replace(0, step); obj["steps"] = steps; QTest::addRow("with-step-with-invalid-next-7") - << QStringList{"TutorialFooTitle", "TutorialFooSubtitle", - "TutorialFooCompletionMessage", "TutorialFooStepS1"} - << QJsonDocument(obj).toJson() << false; + << "foo" << QJsonDocument(obj).toJson() << false; nextObj["vpn_emitter"] = "settingsHolder"; step["next"] = nextObj; steps.replace(0, step); obj["steps"] = steps; QTest::addRow("with-step-with-invalid-next-8") - << QStringList{"TutorialFooTitle", "TutorialFooSubtitle", - "TutorialFooCompletionMessage", "TutorialFooStepS1"} - << QJsonDocument(obj).toJson() << true; + << "foo" << QJsonDocument(obj).toJson() << true; obj["conditions"] = QJsonObject(); QTest::addRow("with-step-element and conditions") - << QStringList{"GuideFooTitle", "GuideFooBlockA", "GuideFooBlockASub"} - << QJsonDocument(obj).toJson() << true; + << "foo" << QJsonDocument(obj).toJson() << true; } void TestTutorial::create() { - QFETCH(QStringList, l18n); + QFETCH(QString, id); QFETCH(QByteArray, content); QFETCH(bool, created); SettingsHolder settingsHolder; - L18nStrings* l18nStrings = L18nStrings::instance(); - QVERIFY(!!l18nStrings); - for (const QString& s : l18n) { - l18nStrings->insert(s, "WOW!"); - } - QTemporaryFile file; QVERIFY(file.open()); QCOMPARE(file.write(content.data(), content.length()), content.length()); @@ -212,14 +170,15 @@ void TestTutorial::create() { QVERIFY(!tm->isPlaying()); QString tutorialTitleId = tutorial->property("titleId").toString(); - QVERIFY(l18nStrings->contains(tutorialTitleId)); + QCOMPARE(tutorialTitleId, QString("tutorial.%1.title").arg(id)); QString tutorialSubtitleId = tutorial->property("subtitleId").toString(); - QVERIFY(l18nStrings->contains(tutorialSubtitleId)); + QCOMPARE(tutorialSubtitleId, QString("tutorial.%1.subtitle").arg(id)); QString tutorialCompletionMessageId = tutorial->property("completionMessageId").toString(); - QVERIFY(l18nStrings->contains(tutorialCompletionMessageId)); + QCOMPARE(tutorialCompletionMessageId, + QString("tutorial.%1.completion_message").arg(id)); QCOMPARE(tutorial->property("image").toString(), "foo.png"); diff --git a/tests/unit/unit.pro b/tests/unit/unit.pro index d54d8b7484..2bf06680af 100644 --- a/tests/unit/unit.pro +++ b/tests/unit/unit.pro @@ -42,6 +42,12 @@ include($$PWD/../../translations/translations.pri) RESOURCES ~= 's/.*servers.qrc//g' HEADERS += \ + ../../src/addonmanager.h \ + ../../src/addons/addon.h \ + ../../src/addons/addondemo.h \ + ../../src/addons/addonguide.h \ + ../../src/addons/addoni18n.h \ + ../../src/addons/addontutorial.h \ ../../src/adjust/adjustfiltering.h \ ../../src/adjust/adjustproxypackagehandler.h \ ../../src/captiveportal/captiveportal.h \ @@ -113,6 +119,7 @@ HEADERS += \ ../../src/task.h \ ../../src/tasks/account/taskaccount.h \ ../../src/tasks/adddevice/taskadddevice.h \ + ../../src/tasks/addon/taskaddon.h \ ../../src/tasks/ipfinder/taskipfinder.h \ ../../src/tasks/function/taskfunction.h \ ../../src/tasks/release/taskrelease.h \ @@ -149,6 +156,12 @@ HEADERS += \ testwebsockethandler.h SOURCES += \ + ../../src/addonmanager.cpp \ + ../../src/addons/addon.cpp \ + ../../src/addons/addondemo.cpp \ + ../../src/addons/addonguide.cpp \ + ../../src/addons/addoni18n.cpp \ + ../../src/addons/addontutorial.cpp \ ../../src/adjust/adjustfiltering.cpp \ ../../src/adjust/adjustproxypackagehandler.cpp \ ../../src/captiveportal/captiveportal.cpp \ @@ -217,6 +230,7 @@ SOURCES += \ ../../src/statusicon.cpp \ ../../src/tasks/account/taskaccount.cpp \ ../../src/tasks/adddevice/taskadddevice.cpp \ + ../../src/tasks/addon/taskaddon.cpp \ ../../src/tasks/ipfinder/taskipfinder.cpp \ ../../src/tasks/function/taskfunction.cpp \ ../../src/tasks/release/taskrelease.cpp \ diff --git a/translations/CMakeLists.txt b/translations/CMakeLists.txt index fb349ebf72..61f8a82e60 100644 --- a/translations/CMakeLists.txt +++ b/translations/CMakeLists.txt @@ -25,8 +25,6 @@ add_custom_command( OUTPUT ${GENERATED_DIR}/l18nstrings_p.cpp ${GENERATED_DIR}/l18nstrings.h MAIN_DEPENDENCY ${CMAKE_CURRENT_SOURCE_DIR}/strings.yaml COMMAND python3 ${MVPN_SCRIPT_DIR}/utils/generate_strings.py -o ${GENERATED_DIR} - -g ${CMAKE_SOURCE_DIR}/src/ui/guides - -t ${CMAKE_SOURCE_DIR}/src/ui/tutorials ${CMAKE_CURRENT_SOURCE_DIR}/strings.yaml ) diff --git a/translations/translations.pri b/translations/translations.pri index 55efcc9644..f8e28a3e27 100644 --- a/translations/translations.pri +++ b/translations/translations.pri @@ -23,14 +23,10 @@ STRING_SOURCES = $$PWD/strings.yaml HEADERS += $$PWD/generated/l18nstrings.h ## Perform string generation -## FIXME: This also depends vaguely on the contents of the guide and tutorials. -## TODO: Make translations for the guides and tutorials loaded dynamically. stringgen.input = STRING_SOURCES stringgen.output = $$PWD/generated/l18nstrings.h stringgen.commands = @echo Generating strings from ${QMAKE_FILE_IN} \ && python3 $$PWD/../scripts/utils/generate_strings.py \ - -g ${QMAKE_FILE_IN_PATH}/../src/ui/guides \ - -t ${QMAKE_FILE_IN_PATH}/../src/ui/tutorials \ -o ${QMAKE_FILE_OUT_PATH} ${QMAKE_FILE_IN} stringgen.depends += ${QMAKE_FILE_IN} stringgen.variable_out = HEADERS diff --git a/windows/installer/MozillaVPN.wxs b/windows/installer/MozillaVPN.wxs index f385025dcd..5ae08fc588 100644 --- a/windows/installer/MozillaVPN.wxs +++ b/windows/installer/MozillaVPN.wxs @@ -76,8 +76,8 @@ - - + + diff --git a/windows/installer/build.cmd b/windows/installer/build.cmd index 285ba54ac0..e91f1e8e8a 100644 --- a/windows/installer/build.cmd +++ b/windows/installer/build.cmd @@ -43,9 +43,9 @@ if exist .deps\prepared goto :build :msi if not exist "%~1" mkdir "%~1" echo [+] Compiling %1 - "%WIX%bin\candle" %WIX_CANDLE_FLAGS% -dPlatform=%1 -out "%~1\MozillaVPN.wixobj" -arch %1 MozillaVPN.wxs || exit /b %errorlevel% + "%WIX%bin\candle" %WIX_CANDLE_FLAGS% -dPlatform=%1 -out "%~1\MozillaVPN.wixobj" -arch %1 MozillaVPN.wxs || exit /b %errorlevel% echo [+] Linking %1 - "%WIX%bin\light" %WIX_LIGHT_FLAGS% -out "%~1/MozillaVPN.msi" "%~1\MozillaVPN.wixobj" || exit /b %errorlevel% + "%WIX%bin\light" %WIX_LIGHT_FLAGS% -out "%~1/MozillaVPN.msi" "%~1\MozillaVPN.wixobj" || exit /b %errorlevel% goto :eof :error diff --git a/windows/installer/build_prod.cmd b/windows/installer/build_prod.cmd index d0443d4222..0e3ed3c1d2 100644 --- a/windows/installer/build_prod.cmd +++ b/windows/installer/build_prod.cmd @@ -43,9 +43,9 @@ if exist .deps\prepared goto :build :msi if not exist "%~1" mkdir "%~1" echo [+] Compiling %1 - "%WIX%bin\candle" %WIX_CANDLE_FLAGS% -dPlatform=%1 -out "%~1\MozillaVPN.wixobj" -arch %1 MozillaVPN_prod.wxs || exit /b %errorlevel% + "%WIX%bin\candle" %WIX_CANDLE_FLAGS% -dPlatform=%1 -out "%~1\MozillaVPN.wixobj" -arch %1 MozillaVPN_prod.wxs || exit /b %errorlevel% echo [+] Linking %1 - "%WIX%bin\light" %WIX_LIGHT_FLAGS% -out "%~1/MozillaVPN.msi" "%~1\MozillaVPN.wixobj" || exit /b %errorlevel% + "%WIX%bin\light" %WIX_LIGHT_FLAGS% -out "%~1/MozillaVPN.msi" "%~1\MozillaVPN.wixobj" || exit /b %errorlevel% goto :eof :error