├── .gitattributes ├── .github └── workflows │ ├── build.yml │ └── generate-nix-lock.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── default.nix ├── flake.lock ├── flake.nix ├── lucem.nimble ├── nim.cfg ├── nix └── lock.json ├── screenshots ├── demo.jpg ├── demo.webp ├── roblox_fonts.png ├── settings_gui_1.webp ├── settings_gui_2.webp ├── settings_gui_3.webp └── settings_gui_4.webp ├── shell.nix └── src ├── IBMPlexSans-Regular.ttf ├── api ├── games.nim ├── ipinfo.nim └── thumbnails.nim ├── argparser.nim ├── assets ├── lucem-title.svg ├── lucem.png └── lucem.svg ├── cache_calls.nim ├── commands ├── edit_config.nim ├── explain.nim ├── init.nim └── run.nim ├── common.nim ├── config.nim ├── desktop_files.nim ├── fflags.nim ├── flatpak.nim ├── fs.nim ├── gpu_info.nim ├── http.nim ├── internal_fonts.nim ├── log_file.nim ├── lucem.nim ├── lucem_overlay.nim ├── lucemd.nim ├── meta.nim ├── notifications.nim ├── patches ├── bring_back_oof.nim ├── patch_fonts.nim ├── sun_and_moon_textures.nim └── windowing_backend.nim ├── proto.nim ├── shell ├── core.nim └── loading_screen.nim ├── sober_config.nim ├── sober_state.nim ├── sugar.nim ├── systemd.nim └── updater.nim /.gitattributes: -------------------------------------------------------------------------------- 1 | nimble.lock linguist-generated 2 | nix/lock.json linguist-generated 3 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a Nimble project 2 | 3 | name: Build 4 | 5 | on: push 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | 11 | 12 | steps: 13 | - uses: actions/checkout@v4 14 | with: 15 | fetch-depth: 0 16 | # ⚠️ This setups the latest stable Nim version 17 | - name: Install Nim 18 | uses: iffy/install-nim@v5 19 | - name: Install dependencies 20 | run: nimble install -y 21 | - name: Build 22 | run: nimble build --define:release --out:lucem 23 | 24 | - name: Upload Build Artifacts 25 | uses: actions/upload-artifact@v4 26 | with: 27 | name: Build 28 | path: lucem 29 | -------------------------------------------------------------------------------- /.github/workflows/generate-nix-lock.yml: -------------------------------------------------------------------------------- 1 | name: Generate lock.json 2 | 3 | on: 4 | push: 5 | paths: 6 | - lucem.nimble 7 | 8 | jobs: 9 | generate: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | contents: write 13 | steps: 14 | - uses: actions/checkout@v4 15 | with: 16 | fetch-depth: 0 17 | - name: Install Nim 18 | uses: iffy/install-nim@v5 19 | - name: Install nnl 20 | run: | 21 | nimble install https://github.com/daylinmorgan/nnl 22 | - name: Generate lock.json 23 | run: | 24 | nnl . -o nix/lock.json 25 | - name: Push commit 26 | uses: stefanzweifel/git-auto-commit-action@v5 27 | with: 28 | commit_message: "chore(nix): update lock.json" 29 | file_pattern: "nix/lock.json" 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !*.* 3 | !*/ 4 | result 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Lucem 2.1.0 is here! 2 | Yay. \ 3 | This changelog contains every feature from 2.0.2 to 2.1.0 4 | 5 | ## Fixed Bugs 6 | * The Flatpak command that'd install Sober would get stuck on a confirmation (2.0.2) 7 | * `lucemd` no longer causes CPU spikes (2.0.3) 8 | * `lucem_overlay` lets your compositor blur its surface (2.0.3) 9 | * Added support for the new Sober configuration interface (2.0.3) 10 | * Fixed botched symbolic icons in the settings shell (2.0.4) 11 | * Fixed arbitrary daemon sleep time (2.0.4) 12 | * Don't emit `--opengl` flag, use configuration instead (2.0.4) 13 | * Lock FPS to 60 by default, preventing coil whine (2.1.0) 14 | 15 | ## Additions 16 | * Added autoupdater, this checks for updates every time Lucem is run. (2.1.0) 17 | * You can now update Lucem by running `lucem update`. (2.1.0) 18 | * Added update alert that shows up every time a new release is available. (2.1.0) 19 | * Overhauled Lucem shell to make it nicer to use (2.1.0) 20 | * Added new Lucem icon (2.0.4) 21 | 22 | ## Installation 23 | Run `nimble install https://github.com/xTrayambak/lucem` in your terminal. Remember, this requires a Nim toolchain with version 2.0 or higher. 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 xTrayambak 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Notice 2 | As of *8th of May, 2025*: Lucem is now **officially deprecated**. No updates will henceforth be released. 3 | 4 | We are now shifting over to [Equinox](https://github.com/equinoxhq/equinox) which is an independent, open source runtime for Roblox on Linux. 5 | 6 | Thanks for using Lucem. This was the first time a lot of people used software I wrote, and I'm quite happy for that. I learnt quite a few things along the way. 7 | 8 | We hope to see you over there as we (aim to) make a viable alternative to Sober. :^) 9 | 10 | (tunis can finally take a rest) 11 | 12 | ## 13 | Lucem is a small wrapper over [Sober](https://sober.vinegarhq.org) to provide some QoL improvements. \ 14 | Please keep in mind that while Lucem is fully open source software, Sober is proprietary for a very good reason, that being to preserve RoL from 9-year-old skiddies. 15 | 16 | ![A demo of Lucem's font patcher](screenshots/roblox_fonts.png) 17 | ![A demo of Lucem demonstrating Discord rich presence and a notification saying where the server is located](screenshots/demo.webp) 18 | ![A demo of Lucem's nice looking GTK4 based settings menu](screenshots/settings_gui_1.webp) 19 | ![A demo of Lucem's nice looking GTK4 based FFlag editor](screenshots/settings_gui_2.webp) 20 | ![A demo of Lucem's nice looking GTK4 based settings menu](screenshots/settings_gui_3.webp) 21 | ![A demo of Lucem's nice looking GTK4 based settings menu](screenshots/settings_gui_4.webp) 22 | 23 | ## Contact/Support 24 | You can open issues for bugs. \ 25 | We now have a Discord server! You can join it [here](https://discord.gg/Z5m3n9fjcU) 26 | 27 | ## Disclaimer, in big bold letters. 28 | Lucem does not allow you to bypass the (reasonable and justified) restrictions the Vinegar team has put on Sober's ability to load APKs that are modified. 29 | 30 | If you really cheat on Roblox, I'd say you should reconsider your life decisions than anything. \ 31 | **Lucem is not associated with the VinegarHQ team or Roblox, nor is it endorsed by them!** 32 | 33 | ## Features 34 | - GTK4 + Libadwaita GUI to modify settings 35 | - GTK4-based FFlag editor that is easy to use 36 | - Rich presence 37 | - Server region notifier 38 | - Game overlay 39 | - If you're not a fan of GUIs, we provide a nifty configuration file that does the same thing! (located at `~/.config/lucem/config.toml`, or you can run `lucem edit-config`!) 40 | - Managing Sober 41 | 42 | Whilst not a feature you use directly, Lucem also caches API calls whenever it can in order to save bandwidth and resources. 43 | 44 | ## Icon 45 | The Lucem icon is made by [AshtakaOOf](https://github.com/AshtakaOOf). It is located [here](src/assets/lucem.svg). \ 46 | You are free to use it wherever you want. 47 | 48 | ### Patches 49 | Lucem provides the following optional patches. All of them go under the `tweaks` section in your configuration. 50 | 51 | #### Bring back the old Oof sound 52 | ```toml 53 | oldOof = true 54 | ``` 55 | 56 | #### Use another font 57 | Both OTFs and TTFs are supported. 58 | ```toml 59 | font = "/path/to/your/font.ttf" 60 | ``` 61 | 62 | #### Replace sun and moon 63 | ```toml 64 | sun = "/path/to/sun.jpeg" 65 | moon = "/path/to/moon.jpeg" 66 | ``` 67 | 68 | #### Force X11 or Wayland to be used 69 | This one goes in the `client` section! 70 | ```toml 71 | [client] 72 | backend = "x11" # or "wayland", "wl". This is case insensitive. 73 | ``` 74 | 75 | ## Installation 76 | ### AUR 77 | If you use Arch, you can install lucem directly from the [AUR](https://aur.archlinux.org/packages/lucem-git) using 78 | ```command 79 | # yay -S lucem-git 80 | ``` 81 | 82 | ### Building from source 83 | You will need the following dependencies to compile Lucem: 84 | 85 | #### Arch 86 | ```command 87 | # pacman -S gtk4 libadwaita nim git libcurl 88 | ``` 89 | 90 | #### Fedora 91 | ```command 92 | # dnf install gtk4-devel libadwaita-devel git libcurl-devel 93 | ``` 94 | You can get nim from the [Terra](https://terra.fyralabs.com/) repository 95 | 96 | #### Debian 97 | ```command 98 | # apt install gtk4 libadwaita-1-0 libadwaita-1-dev git libcurl4-openssl-dev 99 | ``` 100 | Debian ships an old version of Nim that is not compatible with Lucem, install Nim [here](https://nim-lang.org/install_unix.html) 101 | 102 | #### NixOS 103 | There's no Nix flake yet. 104 | ```command 105 | $ git clone https://github.com/xTrayambak/lucem 106 | $ cd lucem 107 | $ nix-shell 108 | ``` 109 | 110 | The package names are similar for other distributions. 111 | 112 | Run the following command to compile Lucem. 113 | ```command 114 | $ nimble install https://github.com/xTrayambak/lucem 115 | ``` 116 | 117 | ## Submitting bug reports 118 | Please make sure to attach the logs that Lucem generates. Also, please run Lucem with the verbose flag (`lucem run -v`) and submit that log as it contains crucial debugging informationt. 119 | 120 | ## Usage 121 | ### Launching the Lucem GUI 122 | ```command 123 | $ lucem shell 124 | ``` 125 | 126 | ### Configuring Lucem 127 | ```command 128 | $ lucem edit-config 129 | ``` 130 | 131 | This will open the configuration file in your preferred editor. Your configuration will immediately be validated after you exit the editor. 132 | 133 | ### Launching Roblox 134 | ```command 135 | $ lucem run 136 | ``` 137 | 138 | ### Check build metadata 139 | ```command 140 | $ lucem meta 141 | ``` 142 | 143 | ### Clearing API caches 144 | ```command 145 | $ lucem clear-cache 146 | ``` 147 | 148 | ### Installing Desktop File 149 | ```command 150 | $ lucem install-desktop-files 151 | ``` 152 | 153 | ### Installing Systemd Service (run as user, not root!) 154 | ```command 155 | $ lucem install-systemd-service 156 | ``` 157 | 158 | ## Troubleshooting 159 | 160 | ### It says `lucem: command not found` after compiling! 161 | The nimble binary folder is not in your PATH, you can run: 162 | ```command 163 | $ export PATH=$HOME/.nimble/bin:$PATH 164 | ``` 165 | Add the export to your .bashrc to make it permanent. 166 | -------------------------------------------------------------------------------- /default.nix: -------------------------------------------------------------------------------- 1 | (import ( 2 | let 3 | lock = builtins.fromJSON (builtins.readFile ./flake.lock); 4 | nodeName = lock.nodes.root.inputs.flake-compat; 5 | in 6 | fetchTarball { 7 | url = 8 | lock.nodes.${nodeName}.locked.url 9 | or "https://github.com/edolstra/flake-compat/archive/${lock.nodes.${nodeName}.locked.rev}.tar.gz"; 10 | sha256 = lock.nodes.${nodeName}.locked.narHash; 11 | } 12 | ) { src = ./.; }).defaultNix 13 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-compat": { 4 | "locked": { 5 | "lastModified": 1733328505, 6 | "narHash": "sha256-NeCCThCEP3eCl2l/+27kNNK7QrwZB1IJCrXfrbv5oqU=", 7 | "owner": "edolstra", 8 | "repo": "flake-compat", 9 | "rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "owner": "edolstra", 14 | "repo": "flake-compat", 15 | "type": "github" 16 | } 17 | }, 18 | "flake-utils": { 19 | "inputs": { 20 | "systems": "systems" 21 | }, 22 | "locked": { 23 | "lastModified": 1731533236, 24 | "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 25 | "owner": "numtide", 26 | "repo": "flake-utils", 27 | "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "owner": "numtide", 32 | "repo": "flake-utils", 33 | "type": "github" 34 | } 35 | }, 36 | "nixpkgs": { 37 | "locked": { 38 | "lastModified": 1743315132, 39 | "narHash": "sha256-6hl6L/tRnwubHcA4pfUUtk542wn2Om+D4UnDhlDW9BE=", 40 | "owner": "nixos", 41 | "repo": "nixpkgs", 42 | "rev": "52faf482a3889b7619003c0daec593a1912fddc1", 43 | "type": "github" 44 | }, 45 | "original": { 46 | "owner": "nixos", 47 | "ref": "nixos-unstable", 48 | "repo": "nixpkgs", 49 | "type": "github" 50 | } 51 | }, 52 | "root": { 53 | "inputs": { 54 | "flake-compat": "flake-compat", 55 | "flake-utils": "flake-utils", 56 | "nixpkgs": "nixpkgs" 57 | } 58 | }, 59 | "systems": { 60 | "locked": { 61 | "lastModified": 1681028828, 62 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 63 | "owner": "nix-systems", 64 | "repo": "default", 65 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 66 | "type": "github" 67 | }, 68 | "original": { 69 | "owner": "nix-systems", 70 | "repo": "default", 71 | "type": "github" 72 | } 73 | } 74 | }, 75 | "root": "root", 76 | "version": 7 77 | } 78 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "An open-source bootstrapper for Sober, similar to Bloxstrap."; 3 | 4 | inputs = { 5 | nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; 6 | flake-utils.url = "github:numtide/flake-utils"; 7 | flake-compat.url = "github:edolstra/flake-compat"; 8 | }; 9 | 10 | outputs = 11 | { nixpkgs, flake-utils, ... }: 12 | flake-utils.lib.eachDefaultSystem ( 13 | system: 14 | let 15 | pkgs = import nixpkgs { inherit system; }; 16 | inherit (pkgs) lib; 17 | in 18 | { 19 | packages = rec { 20 | lucem = pkgs.buildNimPackage { 21 | pname = "lucem"; 22 | version = "2.1.2"; 23 | 24 | src = ./.; 25 | 26 | nativeBuildInputs = with pkgs; [ 27 | wrapGAppsHook4 28 | pkg-config 29 | ]; 30 | 31 | buildInputs = with pkgs; [ 32 | gtk4.dev 33 | libadwaita.dev 34 | openssl.dev 35 | curl.dev 36 | libGL.dev 37 | xorg.libX11 38 | xorg.libXcursor.dev 39 | xorg.libXrender 40 | xorg.libXext 41 | libxkbcommon 42 | wayland.dev 43 | wayland-protocols 44 | wayland-scanner.dev 45 | ]; 46 | 47 | nimbleFile = ./lucem.nimble; 48 | lockFile = ./nix/lock.json; 49 | 50 | nimFlags = [ 51 | "--define:ssl" 52 | "--define:adwMinor=4" 53 | "--define:nvgGLES3" 54 | "--deepCopy:on" 55 | "--panics:on" 56 | ]; 57 | }; 58 | default = lucem; 59 | }; 60 | 61 | devShells.default = pkgs.mkShell { 62 | buildInputs = with pkgs; [ 63 | nim 64 | nimble 65 | pkg-config 66 | gtk4.dev 67 | libadwaita.dev 68 | openssl.dev 69 | curl.dev 70 | libGL.dev 71 | xorg.libX11 72 | xorg.libXcursor.dev 73 | xorg.libXrender 74 | xorg.libXext 75 | libxkbcommon 76 | wayland.dev 77 | wayland-protocols 78 | wayland-scanner.dev 79 | ]; 80 | 81 | LD_LIBRARY_PATH = lib.makeLibraryPath ( 82 | with pkgs; 83 | [ 84 | gtk4.dev 85 | libadwaita.dev 86 | pkg-config 87 | curl.dev 88 | openssl.dev 89 | wayland.dev 90 | ] 91 | ); 92 | }; 93 | } 94 | ); 95 | } 96 | -------------------------------------------------------------------------------- /lucem.nimble: -------------------------------------------------------------------------------- 1 | # Package 2 | 3 | version = "2.1.2" 4 | author = "xTrayambak" 5 | description = "A small wrapper over Sober that provides quality of life improvements" 6 | license = "MIT" 7 | srcDir = "src" 8 | backend = "c" 9 | bin = @["lucem", "lucemd", "lucem_overlay"] 10 | 11 | # Dependencies 12 | 13 | requires "nim >= 2.0.0" 14 | requires "colored_logger >= 0.1.0" 15 | requires "jsony >= 1.1.5" 16 | requires "toml_serialization >= 0.2.12" 17 | requires "pretty >= 0.1.0" 18 | requires "owlkettle >= 3.0.0" 19 | requires "nimgl >= 1.3.2" 20 | requires "netty >= 0.2.1" 21 | requires "curly >= 1.1.1" 22 | requires "nanovg >= 0.4.0" 23 | requires "siwin#9ce9aa3efa84f55bbf3d29ef0517b2411d08a357" 24 | requires "opengl >= 1.2.9" 25 | 26 | after install: 27 | exec "$HOME/.nimble/bin/lucem init" 28 | 29 | echo "\e[1mPssst, hey you!\e[0m" 30 | echo "\e[1;34mYes, you!\e[0m" 31 | echo "\e[1mThanks for installing Lucem!" 32 | echo "If you run `lucem` in the terminal and no command is found, try running the command below:\e[0m" 33 | echo "\e[1:32mexport PATH=\"$HOME/.nimble/bin:$PATH\"\e[0m" 34 | 35 | requires "semver >= 1.2.3" 36 | -------------------------------------------------------------------------------- /nim.cfg: -------------------------------------------------------------------------------- 1 | --define:ssl 2 | --define:adwMinor=4 3 | --define:nvgGLES3 4 | --deepCopy:on 5 | --panics:on 6 | -------------------------------------------------------------------------------- /nix/lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "depends": [ 3 | { 4 | "method": "fetchzip", 5 | "path": "/nix/store/lk4hcmvwvliliyyidx7k3fk9yfijddc5-source", 6 | "rev": "b2e71179174e040884ebf6a16cbac711c84620b9", 7 | "sha256": "0pi6cq43ysm1wy5vva3i2dqvyh4dqppjjjl04yj9wfq7mngpqaa1", 8 | "srcDir": "src", 9 | "url": "https://github.com/treeform/chroma/archive/b2e71179174e040884ebf6a16cbac711c84620b9.tar.gz", 10 | "subDir": "", 11 | "packages": [ 12 | "chroma" 13 | ] 14 | }, 15 | { 16 | "method": "fetchzip", 17 | "path": "/nix/store/n3pckbrnpfnlcm7n0c5i75lgl47ghgha-source", 18 | "rev": "d9ee0328d5cec8fd216d3ce8676cebf1976e9272", 19 | "sha256": "02wq4wad7r517rls5n1i9gww9a138a20kiy3d2ax723s3h6s0srg", 20 | "srcDir": "src", 21 | "url": "https://github.com/4zv4l/colored_logger/archive/d9ee0328d5cec8fd216d3ce8676cebf1976e9272.tar.gz", 22 | "subDir": "", 23 | "packages": [ 24 | "colored_logger" 25 | ] 26 | }, 27 | { 28 | "method": "fetchzip", 29 | "path": "/nix/store/cbzq2fmn5582kqx3w9ima7ll4x19cmx1-source", 30 | "rev": "a0f42baacbc48f4e5924b18854c0df9dcc251466", 31 | "sha256": "0033kxrh8s3wmmh5ky6vlbjk2mq3c3vy0syvl5rwah2zmg0k6wzf", 32 | "srcDir": "src", 33 | "url": "https://github.com/guzba/curly/archive/a0f42baacbc48f4e5924b18854c0df9dcc251466.tar.gz", 34 | "subDir": "", 35 | "packages": [ 36 | "curly" 37 | ] 38 | }, 39 | { 40 | "method": "fetchzip", 41 | "path": "/nix/store/cxdwn7p4cis5hd5w4jsn8lql5vzx5civ-source", 42 | "rev": "2b08c774afaafd600cf4c6f994cf78b8aa090c0c", 43 | "sha256": "10zl9a5phdsjj811v8by0yzadfc8d3azaj878an2hr8qsfi9y1ps", 44 | "srcDir": "", 45 | "url": "https://github.com/status-im/nim-faststreams/archive/2b08c774afaafd600cf4c6f994cf78b8aa090c0c.tar.gz", 46 | "subDir": "", 47 | "packages": [ 48 | "faststreams" 49 | ] 50 | }, 51 | { 52 | "method": "fetchzip", 53 | "path": "/nix/store/vx0a8hw7hs5an0dnbrn6l16bd6is7hdr-source", 54 | "rev": "07f6ba8ab96238e5bd1264cf0cea1d1746abb00c", 55 | "sha256": "005nrldaasfl09zdsni1vi8s7dk0y85ijv6rm2wpj94435x66s36", 56 | "srcDir": "src", 57 | "url": "https://github.com/treeform/flatty/archive/07f6ba8ab96238e5bd1264cf0cea1d1746abb00c.tar.gz", 58 | "subDir": "", 59 | "packages": [ 60 | "flatty" 61 | ] 62 | }, 63 | { 64 | "method": "fetchzip", 65 | "path": "/nix/store/bzcq8q439rdsqhhihikzv3rsx4l4ybdm-source", 66 | "rev": "ea811bec7fa50f5abd3088ba94cda74285e93f18", 67 | "sha256": "1720iqsxjhqmhw1zhhs7d2ncdz25r8fqadls1p1iry1wfikjlnba", 68 | "srcDir": "src", 69 | "url": "https://github.com/treeform/jsony/archive/ea811bec7fa50f5abd3088ba94cda74285e93f18.tar.gz", 70 | "subDir": "", 71 | "packages": [ 72 | "jsony" 73 | ] 74 | }, 75 | { 76 | "method": "fetchzip", 77 | "path": "/nix/store/bqmdy8vic5wfvpc9hqp4rfrhjlxz4d7c-source", 78 | "rev": "23f3d90e60d7a233b5eb27fb13e57fd198c73697", 79 | "sha256": "1yz1lcclmhvji34ccymglzg535b3xfz0x4m12n3n22cxz156j63x", 80 | "srcDir": "", 81 | "url": "https://github.com/Araq/libcurl/archive/23f3d90e60d7a233b5eb27fb13e57fd198c73697.tar.gz", 82 | "subDir": "", 83 | "packages": [ 84 | "libcurl" 85 | ] 86 | }, 87 | { 88 | "method": "fetchzip", 89 | "path": "/nix/store/nzqdyy9q0q0rrlpmjmihrq084nyskidd-source", 90 | "rev": "44dc097236de00c09ffed13d4e4aeaff1473870e", 91 | "sha256": "0m7bdiz3dnmdb5cc8k4sksmb71mlg1n75582zv5hhvp2jsj9sxsa", 92 | "srcDir": "", 93 | "url": "https://github.com/johnnovak/nim-nanovg/archive/44dc097236de00c09ffed13d4e4aeaff1473870e.tar.gz", 94 | "subDir": "", 95 | "packages": [ 96 | "nanovg" 97 | ] 98 | }, 99 | { 100 | "method": "fetchzip", 101 | "path": "/nix/store/nqnhn3vpi49aj4pn722c1qpinnzq056b-source", 102 | "rev": "7b4266458b7435349b28a4468e0af58f1674b198", 103 | "sha256": "12j4rzlxpibxy2jfah21qj1lf63rbkkki971y5i52dmnp84bhjzp", 104 | "srcDir": "src", 105 | "url": "https://github.com/treeform/netty/archive/7b4266458b7435349b28a4468e0af58f1674b198.tar.gz", 106 | "subDir": "", 107 | "packages": [ 108 | "netty" 109 | ] 110 | }, 111 | { 112 | "method": "fetchzip", 113 | "path": "/nix/store/y4f3wxlh76h10kflz7vqmkd4vniqp6kw-source", 114 | "rev": "309d6ed8164ad184ed5bbb171c9f3d9d1c11ff81", 115 | "sha256": "0b7givvg0lij4qkv8xpisp0ahcadggavpb85jds5z5k19palh74c", 116 | "srcDir": "src", 117 | "url": "https://github.com/nimgl/nimgl/archive/309d6ed8164ad184ed5bbb171c9f3d9d1c11ff81.tar.gz", 118 | "subDir": "", 119 | "packages": [ 120 | "nimgl" 121 | ] 122 | }, 123 | { 124 | "method": "fetchzip", 125 | "path": "/nix/store/dvv6cgzl9xmax5rmjxnp5wrr08ibvjaw-source", 126 | "rev": "8e2e098f82dc5eefd874488c37b5830233cd18f4", 127 | "sha256": "01csz5bl4jiv7jx76k7izgknl7k73y2i9hd9s6brlhfqhq7cqxmz", 128 | "srcDir": "src", 129 | "url": "https://github.com/nim-lang/opengl/archive/8e2e098f82dc5eefd874488c37b5830233cd18f4.tar.gz", 130 | "subDir": "", 131 | "packages": [ 132 | "opengl" 133 | ] 134 | }, 135 | { 136 | "method": "fetchzip", 137 | "path": "/nix/store/ax2p5d7wz6ipj0y2zpd9rckzzj7a6f0q-source", 138 | "rev": "861092dc931e754650a735af590fbc34becc3942", 139 | "sha256": "100vxxdpzayj44syfkwn5nrpk5189qiky43xh7w3k908yxrq0jbj", 140 | "srcDir": "", 141 | "url": "https://github.com/can-lehmann/owlkettle/archive/861092dc931e754650a735af590fbc34becc3942.tar.gz", 142 | "subDir": "", 143 | "packages": [ 144 | "owlkettle" 145 | ] 146 | }, 147 | { 148 | "method": "fetchzip", 149 | "path": "/nix/store/idsbhi7xb4dmfqbmbl5dq47qh2vs6mjj-source", 150 | "rev": "9e770046c5cdf23d395d6b21c4657345481b1c76", 151 | "sha256": "1li0r6ng3ynzh5qb12qs6czmaaay7gw45khs2niz291nia6navl1", 152 | "srcDir": "src", 153 | "url": "https://github.com/treeform/pretty/archive/9e770046c5cdf23d395d6b21c4657345481b1c76.tar.gz", 154 | "subDir": "", 155 | "packages": [ 156 | "pretty" 157 | ] 158 | }, 159 | { 160 | "method": "fetchzip", 161 | "path": "/nix/store/17gj9sw2hw818cbxvd6i94n734inm1vf-source", 162 | "rev": "df8113dda4c2d74d460a8fa98252b0b771bf1f27", 163 | "sha256": "1h7amas16sbhlr7zb7n3jb5434k98ji375vzw72k1fsc86vnmcr9", 164 | "srcDir": "", 165 | "url": "https://github.com/arnetheduck/nim-results/archive/df8113dda4c2d74d460a8fa98252b0b771bf1f27.tar.gz", 166 | "subDir": "", 167 | "packages": [ 168 | "results" 169 | ] 170 | }, 171 | { 172 | "method": "fetchzip", 173 | "path": "/nix/store/h98460b96pynrpwxawaq21w6rjhamlvi-source", 174 | "rev": "ec7732a4810441a937fe3059494ba338090c4957", 175 | "sha256": "0dw33jprxrc23bj0b6ypbg6n940nzxlrxa57df88q4ly1xvi6w1h", 176 | "srcDir": "src", 177 | "url": "https://github.com/euantorano/semver.nim/archive/ec7732a4810441a937fe3059494ba338090c4957.tar.gz", 178 | "subDir": "", 179 | "packages": [ 180 | "semver" 181 | ] 182 | }, 183 | { 184 | "method": "fetchzip", 185 | "path": "/nix/store/d6c7dvmzzvc1ja7kf65jbclbjv74zll7-source", 186 | "rev": "2086c99608b4bf472e1ef5fe063710f280243396", 187 | "sha256": "1m7c9bvxarw167kd5mpfnddzydji03azhz347hvad592qfw4vwrc", 188 | "srcDir": "", 189 | "url": "https://github.com/status-im/nim-serialization/archive/2086c99608b4bf472e1ef5fe063710f280243396.tar.gz", 190 | "subDir": "", 191 | "packages": [ 192 | "serialization" 193 | ] 194 | }, 195 | { 196 | "method": "fetchzip", 197 | "path": "/nix/store/85w9njq4kkp7cjhz40bmmksiv0053p50-source", 198 | "rev": "9ce9aa3efa84f55bbf3d29ef0517b2411d08a357", 199 | "sha256": "1lm4iynl0c8hzizwc723b29ss6cw78hhr6k62a7x7ddycmxyxsnm", 200 | "srcDir": "src", 201 | "url": "https://github.com/levovix0/siwin/archive/9ce9aa3efa84f55bbf3d29ef0517b2411d08a357.tar.gz", 202 | "subDir": "", 203 | "packages": [ 204 | "siwin" 205 | ] 206 | }, 207 | { 208 | "method": "fetchzip", 209 | "path": "/nix/store/a5kmnnbk27rxk9vsx1vchiiq9znkpijf-source", 210 | "rev": "79e4fa5a9d3374db17ed63622714d3e1094c7f34", 211 | "sha256": "0x92sgnxczwx5ak067d6169j9qm0cdpbrcpp1ijrzgyfgknpyq0r", 212 | "srcDir": "", 213 | "url": "https://github.com/status-im/nim-stew/archive/79e4fa5a9d3374db17ed63622714d3e1094c7f34.tar.gz", 214 | "subDir": "", 215 | "packages": [ 216 | "stew" 217 | ] 218 | }, 219 | { 220 | "method": "fetchzip", 221 | "path": "/nix/store/f3ghbm17akdg7dj5sarr616hvma09dr5-source", 222 | "rev": "fea85b27f0badcf617033ca1bc05444b5fd8aa7a", 223 | "sha256": "1m96c3k83sj1z2vgjp55fplzf0kym6hhhym4ywydjl9x4zw1a5la", 224 | "srcDir": "", 225 | "url": "https://github.com/status-im/nim-toml-serialization/archive/fea85b27f0badcf617033ca1bc05444b5fd8aa7a.tar.gz", 226 | "subDir": "", 227 | "packages": [ 228 | "toml_serialization" 229 | ] 230 | }, 231 | { 232 | "method": "fetchzip", 233 | "path": "/nix/store/2ksmfd7p93a1a7ibcv3qzsk8h3c3shz7-source", 234 | "rev": "845b6af28b9f68f02d320e03ad18eccccea7ddb9", 235 | "sha256": "1c55kl05pbavm9v5dv42n43sql9qcrblhh3hnp99p5xmlv20c9vf", 236 | "srcDir": "", 237 | "url": "https://github.com/status-im/nim-unittest2/archive/845b6af28b9f68f02d320e03ad18eccccea7ddb9.tar.gz", 238 | "subDir": "", 239 | "packages": [ 240 | "unittest2" 241 | ] 242 | }, 243 | { 244 | "method": "fetchzip", 245 | "path": "/nix/store/f9dp6njaay5rf32f6l9gkw0dm25gim47-source", 246 | "rev": "7282ae1247f2f384ebeaec3826d7fa38fd0e1df1", 247 | "sha256": "1plw9lfrm42qar01rnjhm0d9mkzsc7c3b8kz43w5pb8j8drx1lyn", 248 | "srcDir": "src", 249 | "url": "https://github.com/treeform/vmath/archive/7282ae1247f2f384ebeaec3826d7fa38fd0e1df1.tar.gz", 250 | "subDir": "", 251 | "packages": [ 252 | "vmath" 253 | ] 254 | }, 255 | { 256 | "method": "fetchzip", 257 | "path": "/nix/store/4q986rlniaxascxkvx4q8rsx12frjd51-source", 258 | "rev": "325d6ade0970562bee7d7d53961a2c3287f0c4bc", 259 | "sha256": "0qa8hzvamsdszygra3lcc92zk6rzm3gh1mzgjq9khbanzbg3y67n", 260 | "srcDir": "src", 261 | "url": "https://github.com/treeform/webby/archive/325d6ade0970562bee7d7d53961a2c3287f0c4bc.tar.gz", 262 | "subDir": "", 263 | "packages": [ 264 | "webby" 265 | ] 266 | }, 267 | { 268 | "method": "fetchzip", 269 | "path": "/nix/store/8qaywzr8nzsiddjba77nhf75hzmxx0d9-source", 270 | "rev": "29aca5e519ebf5d833f63a6a2769e62ec7bfb83a", 271 | "sha256": "16npqgmi2qawjxaddj9ax15rfpdc7sqc37i2r5vg23lyr6znq4wc", 272 | "srcDir": "", 273 | "url": "https://github.com/nim-lang/x11/archive/29aca5e519ebf5d833f63a6a2769e62ec7bfb83a.tar.gz", 274 | "subDir": "", 275 | "packages": [ 276 | "x11" 277 | ] 278 | }, 279 | { 280 | "method": "fetchzip", 281 | "path": "/nix/store/zcd2hmjxlkp1bpb7c9xrpg153ssj3w0b-source", 282 | "rev": "a99f6a7d8a8e3e0213b3cad0daf0ea974bf58e3f", 283 | "sha256": "16qdnyql8d7nm7nwwpq0maflm3p6cpbb2jfaqx6xkld9xkc9lsbv", 284 | "srcDir": "src", 285 | "url": "https://github.com/guzba/zippy/archive/a99f6a7d8a8e3e0213b3cad0daf0ea974bf58e3f.tar.gz", 286 | "subDir": "", 287 | "packages": [ 288 | "zippy" 289 | ] 290 | } 291 | ] 292 | } 293 | -------------------------------------------------------------------------------- /screenshots/demo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xTrayambak/lucem/ce0e6180321ef3b4c0113044d45df2dc26d463bf/screenshots/demo.jpg -------------------------------------------------------------------------------- /screenshots/demo.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xTrayambak/lucem/ce0e6180321ef3b4c0113044d45df2dc26d463bf/screenshots/demo.webp -------------------------------------------------------------------------------- /screenshots/roblox_fonts.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xTrayambak/lucem/ce0e6180321ef3b4c0113044d45df2dc26d463bf/screenshots/roblox_fonts.png -------------------------------------------------------------------------------- /screenshots/settings_gui_1.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xTrayambak/lucem/ce0e6180321ef3b4c0113044d45df2dc26d463bf/screenshots/settings_gui_1.webp -------------------------------------------------------------------------------- /screenshots/settings_gui_2.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xTrayambak/lucem/ce0e6180321ef3b4c0113044d45df2dc26d463bf/screenshots/settings_gui_2.webp -------------------------------------------------------------------------------- /screenshots/settings_gui_3.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xTrayambak/lucem/ce0e6180321ef3b4c0113044d45df2dc26d463bf/screenshots/settings_gui_3.webp -------------------------------------------------------------------------------- /screenshots/settings_gui_4.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xTrayambak/lucem/ce0e6180321ef3b4c0113044d45df2dc26d463bf/screenshots/settings_gui_4.webp -------------------------------------------------------------------------------- /shell.nix: -------------------------------------------------------------------------------- 1 | (import ( 2 | let 3 | lock = builtins.fromJSON (builtins.readFile ./flake.lock); 4 | nodeName = lock.nodes.root.inputs.flake-compat; 5 | in 6 | fetchTarball { 7 | url = 8 | lock.nodes.${nodeName}.locked.url 9 | or "https://github.com/edolstra/flake-compat/archive/${lock.nodes.${nodeName}.locked.rev}.tar.gz"; 10 | sha256 = lock.nodes.${nodeName}.locked.narHash; 11 | } 12 | ) { src = ./.; }).shellNix 13 | -------------------------------------------------------------------------------- /src/IBMPlexSans-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xTrayambak/lucem/ce0e6180321ef3b4c0113044d45df2dc26d463bf/src/IBMPlexSans-Regular.ttf -------------------------------------------------------------------------------- /src/api/games.nim: -------------------------------------------------------------------------------- 1 | ## Roblox games/places ("experiences") API 2 | ## Copyright (C) 2024 Trayambak Rai 3 | import std/[logging, strutils, json] 4 | import ../[cache_calls, http, sugar] 5 | import jsony 6 | 7 | type 8 | PlaceID* = int64 9 | CreatorID* = int64 10 | UniverseID* = int64 11 | 12 | Creator* = object 13 | id*: CreatorID 14 | name*: string 15 | `type`*: string 16 | isRNVAccount*: bool 17 | hasVerifiedBadge*: bool 18 | 19 | AvatarType* = enum 20 | MorphToR6 = "MorphToR6" 21 | PlayerChoice = "PlayerChoice" 22 | MorphToR15 = "MorphToR15" 23 | 24 | StubData*[T] = object 25 | data*: seq[T] 26 | 27 | GameDetail* = object 28 | id*, rootPlaceId*: PlaceID 29 | name*, description*, sourceName*, sourceDescription*: string 30 | creator*: Creator 31 | price*: Option[int64] 32 | allowedGearGenres*: seq[string] 33 | allowedGearCategories*: seq[string] 34 | isGenreEnforced*, copyingAllowed*: bool 35 | playing*, visits*: int64 36 | maxPlayers*: int32 37 | created*, updated*: string 38 | studioAccessToApisAllowed*, createVipServersAllowed*: bool 39 | avatarType*: AvatarType 40 | genre*: string 41 | isAllGenre*, isFavoritedByUser*: bool 42 | favoritedCount*: int64 43 | 44 | PlaceDetail* = object 45 | id*: PlaceID 46 | name*, description*, sourceName*, sourceDescription*, url*, builder*: string 47 | builderId*: CreatorID 48 | hasVerifiedBadge*, isPlayable*: bool 49 | reasonProhibited*: string 50 | universeId*: UniverseID 51 | universeRootPlaceId*: PlaceID 52 | price*: Option[int64] 53 | imageToken*: string 54 | 55 | proc getUniverseFromPlace*(placeId: string): UniverseID {.inline.} = 56 | if ( 57 | let cached = 58 | findCacheSingleParam[UniverseID]("roblox.getUniverseFromPlace", placeId, 8765'u64) 59 | *cached 60 | ): 61 | return &cached 62 | 63 | let payload = httpGet( 64 | "https://apis.roblox.com/universes/v1/places/$1/universe" % [placeId] 65 | ) 66 | .parseJson()["universeId"] 67 | .getInt() 68 | .UniverseID() 69 | cacheSingleParam("roblox.getUniverseFromPlace", placeId, payload) 70 | 71 | payload 72 | 73 | proc getGameDetail*(id: UniverseID): Option[GameDetail] = 74 | if ( 75 | let cached = findCacheSingleParam[GameDetail]("roblox.getGameDetail", $id, 2) 76 | *cached 77 | ): 78 | return cached 79 | 80 | let 81 | url = "https://games.roblox.com/v1/games/?universeIds=" & $id 82 | resp = httpGet(url) 83 | 84 | info "getGameDetail($1): $2" % [$id, resp] 85 | let payload = fromJson(resp, StubData[GameDetail]).data[0] 86 | cacheSingleParam("roblox.getGameDetail", $id, payload) 87 | 88 | payload.some() 89 | -------------------------------------------------------------------------------- /src/api/ipinfo.nim: -------------------------------------------------------------------------------- 1 | ## ipinfo.io wrapper 2 | ## Copyright (C) 2024 Trayambak Rai 3 | import std/[logging] 4 | import ../[cache_calls, sugar, http] 5 | import jsony 6 | 7 | type IPInfoResponse* = ref object 8 | ip*: string 9 | city*: string 10 | country*: string 11 | region*: string 12 | loc*: string 13 | org*: string 14 | postal*: string 15 | timezone*: string 16 | readme*: string 17 | 18 | proc getIPInfo*(ip: string): Option[IPInfoResponse] {.inline.} = 19 | if ( 20 | let cached = findCacheSingleParam[IPInfoResponse]("ipinfo.getIPInfo", ip, 8765'u64) 21 | *cached 22 | ): 23 | return cached 24 | 25 | try: 26 | info "ipinfo: fetching IP data for " & ip 27 | let body = httpGet("https://ipinfo.io/" & ip & "/json") 28 | debug "ipinfo: response length: " & $body.len & "; parsing JSON" 29 | debug "ipinfo: " & body 30 | 31 | let payload = fromJson(body, IPInfoResponse) 32 | cacheSingleParam("ipinfo.getIPInfo", ip, payload) 33 | 34 | return some(payload) 35 | except JsonError as exc: 36 | error "ipinfo: failed to parse JSON: " & exc.msg 37 | except CatchableError as exc: 38 | error "ipinfo: caught an exception: " & exc.msg 39 | -------------------------------------------------------------------------------- /src/api/thumbnails.nim: -------------------------------------------------------------------------------- 1 | ## Roblox thumbnails API wrapper 2 | ## Copyright (C) 2024 Trayambak Rai 3 | 4 | import std/[strutils, logging] 5 | import jsony 6 | import ./games 7 | import ../[cache_calls, sugar, http] 8 | 9 | type 10 | ThumbnailState* {.pure.} = enum 11 | Error = "Error" 12 | Completed = "Completed" 13 | InReview = "InReview" 14 | Pending = "Pending" 15 | Blocked = "Blocked" 16 | TemporarilyUnavailable = "TemporarilyAvailable" 17 | 18 | ReturnPolicy* = enum 19 | Placeholder = "PlaceHolder" 20 | AutoGenerated = "AutoGenerated" 21 | ForceAutoGenerated = "ForceAutoGenerated" 22 | 23 | ThumbnailFormat* = enum 24 | Png = "png" 25 | Jpeg = "Jpeg" 26 | 27 | Thumbnail* = object 28 | targetId*: int64 29 | state*: ThumbnailState 30 | imageUrl*, version*: string 31 | 32 | proc getGameIcon*(id: UniverseID): Option[Thumbnail] = 33 | if ( 34 | let cached = findCacheSingleParam[Thumbnail]("roblox.getGameIcon", $id, 1) 35 | *cached 36 | ): 37 | debug "getGameIcon($1): cache hit!" % [$id] 38 | return cached 39 | 40 | let 41 | url = 42 | "https://thumbnails.roblox.com/v1/games/icons?universeIds=$1&returnPolicy=PlaceHolder&size=512x512&format=Png&isCircular=false" % 43 | [$id] 44 | resp = httpGet(url) 45 | 46 | debug "getGameIcon($1): $2 ($3)" % [$id, resp, url] 47 | 48 | let payload = fromJson(resp, StubData[Thumbnail]).data[0] 49 | cacheSingleParam[Thumbnail]("roblox.getGameIcon", $id, payload) 50 | 51 | payload.some() 52 | -------------------------------------------------------------------------------- /src/argparser.nim: -------------------------------------------------------------------------------- 1 | ## Argument parser for Lucem, based on `std/parseopt` 2 | ## Copyright (C) 2024 Trayambak Rai 3 | import std/[os, parseopt, logging, tables, strutils] 4 | import ./[sugar] 5 | 6 | type Input* = object 7 | command*: string 8 | arguments*: seq[string] 9 | flags: Table[string, string] 10 | switches: seq[string] 11 | 12 | proc enabled*(input: Input, switch: string): bool {.inline.} = 13 | input.switches.contains(switch) 14 | 15 | proc enabled*(input: Input, switchBig, switchSmall: string): bool {.inline.} = 16 | input.switches.contains(switchBig) or input.switches.contains(switchSmall) 17 | 18 | proc flag*(input: Input, value: string): Option[string] {.inline.} = 19 | if input.flags.contains(value): 20 | return some(input.flags[value]) 21 | 22 | proc parseInput*(): Input {.inline.} = 23 | var 24 | foundCmd = false 25 | input: Input 26 | 27 | let params = commandLineParams() 28 | 29 | debug "argparser: params string is `" & params & "`" 30 | 31 | var parser = initOptParser(params) 32 | while true: 33 | parser.next() 34 | case parser.kind 35 | of cmdEnd: 36 | debug "argparser: hit end of argument stream" 37 | break 38 | of cmdShortOption, cmdLongOption: 39 | if parser.val.len < 1: 40 | debug "argparser: found switch: " & parser.key 41 | input.switches &= parser.key 42 | else: 43 | debug "argparser: found flag: " & parser.key & '=' & parser.val 44 | input.flags[parser.key] = parser.val 45 | of cmdArgument: 46 | if not foundCmd: 47 | debug "argparser: found command: " & parser.key 48 | input.command = parser.key 49 | foundCmd = true 50 | else: 51 | debug "argparser: found argument: " & parser.key 52 | input.arguments &= parser.key 53 | 54 | if input.command.len < 1 and not getAppFilename().contains("lucemd") and not getAppFilename().contains("lucem_overlay"): # lucemd and lucem_overlay don't need a command 55 | error "lucem: expected command, got none. Run `lucem help` for more information." 56 | quit(1) 57 | 58 | input 59 | -------------------------------------------------------------------------------- /src/assets/lucem-title.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 20 | 39 | 41 | 52 | 55 | 59 | 63 | 64 | 81 | 92 | 98 | 109 | 110 | 115 | 127 | Lucem 140 | 141 | 142 | -------------------------------------------------------------------------------- /src/assets/lucem.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xTrayambak/lucem/ce0e6180321ef3b4c0113044d45df2dc26d463bf/src/assets/lucem.png -------------------------------------------------------------------------------- /src/assets/lucem.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 15 | 25 | 33 | 34 | 36 | 47 | 50 | 54 | 58 | 59 | 76 | 77 | 81 | 93 | 94 | 95 | -------------------------------------------------------------------------------- /src/cache_calls.nim: -------------------------------------------------------------------------------- 1 | ## Cache API calls so that we don't look stupid 2 | ## Copyright (C) 2024 Trayambak Rai 3 | import std/[os, logging, json, times] 4 | import ./[sugar, meta] 5 | import jsony 6 | 7 | type CacheStruct* = object 8 | version*: string 9 | createdAt*: float64 10 | 11 | payload*: string 12 | 13 | proc createCacheDir() {.inline.} = 14 | if not existsOrCreateDir(getCacheDir() / "lucem"): 15 | debug "cache_calls: creating cache directory" 16 | 17 | if not existsOrCreateDir(getCacheDir() / "lucem" / "state"): 18 | debug "cache_calls: creating state directory" 19 | 20 | proc storeState*[T](prop: string, val: T) {.inline.} = 21 | let serialized = toJson(val) 22 | debug "cache_calls: storing state property: " & prop & " = " & serialized 23 | 24 | writeFile(getCacheDir() / "lucem" / "state" / prop & ".lucem", serialized) 25 | 26 | proc getState*[T](prop: string, kind: typedesc[T], fallback: T): T = 27 | if not fileExists(getCacheDir() / "lucem" / "state" / prop & ".lucem"): 28 | debug "cache_calls: state property doesn't exist, using fallback" 29 | return fallback 30 | 31 | try: 32 | return readFile(getCacheDir() / "lucem" / "state" / prop & ".lucem").fromJson(kind) 33 | except jsony.JsonError as exc: 34 | warn "cache_calls: failed to read state property \"" & prop & "\": " & exc.msg 35 | 36 | proc clearCache*(): float = 37 | var sizeMb: int 38 | debug "cache_calls: clearing cache" 39 | 40 | for file in walkDirRec(getCacheDir() / "lucem"): 41 | if fileExists(file): 42 | debug "cache_calls: remove file: " & file 43 | sizeMb += getFileSize(file) 44 | removeFile(file) 45 | elif dirExists(file): 46 | debug "cache_calls: remove directory: " & file 47 | removeDir(file) 48 | 49 | sizeMb / (1024 * 1024) 50 | 51 | proc cacheSingleParam*[T](call: string, parameter: string, obj: T) = 52 | createCacheDir() 53 | 54 | let 55 | path = getCacheDir() / "lucem" / call & ".json" 56 | serialized = toJson obj 57 | 58 | var entries = 59 | if not fileExists(path): 60 | debug "cache_calls: first call in entry \"" & call & '"' 61 | newJObject() 62 | else: 63 | fromJson(readFile(path)) 64 | 65 | debug "cache_calls: caching for param \"" & parameter & '"' 66 | debug "cache_calls: payload: " & serialized 67 | 68 | entries[parameter] = 69 | %*CacheStruct(version: Version, createdAt: epochTime(), payload: serialized) 70 | 71 | writeFile(path, $(%*entries)) 72 | 73 | proc findCacheSingleParam*[T]( 74 | call: string, parameter: string, expectsFreshness: uint64 75 | ): Option[T] = 76 | debug "cache_calls: finding cached data for param \"" & parameter & "\" for call \"" & 77 | call & '"' 78 | createCacheDir() 79 | 80 | let path = getCacheDir() / "lucem" / call & ".json" 81 | if not fileExists(path): 82 | debug "cache_calls: cache file not found: " & path 83 | return 84 | 85 | try: 86 | debug "cache_calls: deserializing cache struct: " & path 87 | let 88 | ctime = epochTime() 89 | index = fromJson(readFile(path)) 90 | 91 | if not index.contains(parameter): 92 | debug "cache_calls: cache struct MISSED!" 93 | return 94 | 95 | let struct = index[parameter].to(CacheStruct) 96 | if (ctime - struct.createdAt) > float64(expectsFreshness * 60 * 60): 97 | let diff = ctime - struct.createdAt 98 | debug "cache_calls: cache struct MISSED due to age! (" & $diff & 99 | " seconds older than threshold): " & path 100 | else: 101 | debug "cache_calls: cache struct HIT! " & path 102 | 103 | try: 104 | debug "cache_calls: decoding cache struct inner payload: " & struct.payload 105 | return fromJson(struct.payload, T).some() 106 | except jsony.JsonError as exc: 107 | error "cache_calls: error whilst decoding cache struct's inner payload: " & path 108 | error "cache_calls: " & struct.payload & " (" & exc.msg & ')' 109 | except jsony.JsonError as exc: 110 | error "cache_calls: error whilst decoding cache struct: " & path 111 | error "cache_calls: " & readFile(path) & "\n (" & exc.msg & ')' 112 | return 113 | -------------------------------------------------------------------------------- /src/commands/edit_config.nim: -------------------------------------------------------------------------------- 1 | ## Edit the Lucem configuration file 2 | ## Copyright (C) 2024 Trayambak Rai 3 | import std/[os, logging, osproc] 4 | 5 | proc editConfiguration*(editor: string, quitOnSuccess: bool = true) = 6 | if execCmd(editor & ' ' & getConfigDir() / "lucem" / "config.toml") != 0: 7 | error "lucem: the editor (" & editor & ") exited with an unsuccessful exit code." 8 | quit(1) 9 | else: 10 | if quitOnSuccess: 11 | quit(0) 12 | -------------------------------------------------------------------------------- /src/commands/explain.nim: -------------------------------------------------------------------------------- 1 | ## Provide docs for the TOML configuration 2 | ## Copyright (C) 2024 Trayambak Rai 3 | import std/[logging, strutils, terminal] 4 | import ../argparser 5 | 6 | type 7 | QuestionKind* = enum 8 | Command 9 | RuntimeFlag 10 | Configuration 11 | 12 | Question* = object 13 | case kind*: QuestionKind 14 | of Command: 15 | command*: string 16 | of RuntimeFlag: 17 | flag*: string 18 | of Configuration: 19 | category*: string 20 | name*: string 21 | 22 | proc explainCommand*(question: Question) = 23 | assert question.kind == Command 24 | case question.command 25 | of "run": 26 | stdout.styledWriteLine( 27 | repeat(' ', int(terminalWidth() / 2)), "lucem ", fgGreen, "run", resetStyle 28 | ) 29 | stdout.styledWriteLine(styleBright, "NAME", resetStyle) 30 | stdout.write("\trun - run the Roblox client\n\n") 31 | stdout.styledWriteLine(styleBright, "DESCRIPTION", resetStyle) 32 | stdout.styledWriteLine( 33 | "\tThis command runs the Roblox client alongside Lucem's event watcher thread." 34 | ) 35 | else: 36 | error "lucem: no documentation exists for command \"" & question.command & '"' 37 | quit(1) 38 | 39 | proc explainConfig*(question: Question) = 40 | assert question.kind == Configuration 41 | template noDocs() {.dirty.} = 42 | error "lucem: no documentation exists for " & question.category & ':' & question.name 43 | quit(1) 44 | 45 | case question.category 46 | of "apk": 47 | stdout.styledWriteLine( 48 | styleBright, "NOTICE", resetStyle, ": ", styleUnderscore, 49 | "this category is now deprecated and serves no purpose.", resetStyle, 50 | ) 51 | return 52 | of "lucem": 53 | case question.name 54 | of "discord_rpc": 55 | stdout.styledWriteLine( 56 | repeat(' ', int(terminalWidth() / 2)), 57 | fgGreen, 58 | "lucem", 59 | resetStyle, 60 | ":", 61 | fgYellow, 62 | "discord_rpc", 63 | resetStyle, 64 | ) 65 | 66 | stdout.styledWriteLine(styleBright, "TYPE", resetStyle) 67 | stdout.styledWriteLine("\t", fgBlue, "boolean", resetStyle) 68 | stdout.write '\n' 69 | 70 | stdout.styledWriteLine(styleBright, "DEFAULT VALUE", resetStyle) 71 | stdout.styledWriteLine("\t", fgBlue, "true", resetStyle) 72 | stdout.write '\n' 73 | 74 | stdout.styledWriteLine(styleBright, "DESCRIPTION", resetStyle) 75 | stdout.styledWriteLine( 76 | "\t", "When set to ", fgBlue, "true", resetStyle, 77 | ", Lucem will show the Roblox game you're currently playing via Discord's rich presence system.", 78 | ) 79 | of "notify_server_region": 80 | stdout.styledWriteLine( 81 | repeat(' ', int(terminalWidth() / 2)), 82 | fgGreen, 83 | "lucem", 84 | resetStyle, 85 | ":", 86 | fgYellow, 87 | "notify_server_region", 88 | resetStyle, 89 | ) 90 | 91 | stdout.styledWriteLine(styleBright, "TYPE", resetStyle) 92 | stdout.styledWriteLine("\t", fgBlue, "boolean", resetStyle) 93 | stdout.write '\n' 94 | 95 | stdout.styledWriteLine(styleBright, "DEFAULT VALUE", resetStyle) 96 | stdout.styledWriteLine("\t", fgBlue, "true", resetStyle) 97 | stdout.write '\n' 98 | 99 | stdout.styledWriteLine(styleBright, "DESCRIPTION", resetStyle) 100 | stdout.styledWriteLine( 101 | "\t", "When set to ", fgBlue, "true", resetStyle, 102 | ", Lucem will show you the location of the server you're connected to.", 103 | ) 104 | stdout.write '\n' 105 | 106 | stdout.styledWriteLine(styleBright, "PRIVACY", resetStyle) 107 | stdout.styledWriteLine( 108 | "\t", 109 | "Lucem makes an API call to ipinfo.io, who may or may not store your IP address for telemetry (we can never be sure). Lucem never contacts any other server other than that of ipinfo's when the location is being fetched. Subsequent API calls are omitted if the IP is found in the local cache that Lucem maintains to save on bandwidth.", 110 | ) 111 | of "loading_screen": 112 | stdout.styledWriteLine( 113 | repeat(' ', int(terminalWidth() / 2)), 114 | fgGreen, 115 | "lucem", 116 | resetStyle, 117 | ":", 118 | fgYellow, 119 | "loading_screen", 120 | resetStyle, 121 | ) 122 | 123 | stdout.styledWriteLine(styleBright, "TYPE", resetStyle) 124 | stdout.styledWriteLine("\t", fgBlue, "boolean", resetStyle) 125 | stdout.write '\n' 126 | 127 | stdout.styledWriteLine(styleBright, "DEFAULT VALUE", resetStyle) 128 | stdout.styledWriteLine("\t", fgBlue, "true", resetStyle) 129 | stdout.write '\n' 130 | 131 | stdout.styledWriteLine(styleBright, "DESCRIPTION", resetStyle) 132 | stdout.styledWriteLine( 133 | "\t", "When set to ", fgBlue, "true", resetStyle, 134 | ", Lucem will show a loading screen when Sober is initializing Roblox.", 135 | ) 136 | stdout.write '\n' 137 | of "polling_delay": 138 | stdout.styledWriteLine( 139 | repeat(' ', int(terminalWidth() / 2)), 140 | fgGreen, 141 | "lucem", 142 | resetStyle, 143 | ":", 144 | fgYellow, 145 | "polling_delay", 146 | resetStyle, 147 | ) 148 | 149 | stdout.styledWriteLine(styleBright, "TYPE", resetStyle) 150 | stdout.styledWriteLine("\t", fgGreen, "unsigned integer", resetStyle) 151 | stdout.write '\n' 152 | 153 | stdout.styledWriteLine(styleBright, "DEFAULT VALUE", resetStyle) 154 | stdout.styledWriteLine("\t", fgGreen, "100", resetStyle) 155 | stdout.write '\n' 156 | 157 | stdout.styledWriteLine(styleBright, "DESCRIPTION", resetStyle) 158 | stdout.styledWriteLine( 159 | "\t", 160 | "This value dictates how much time Lucem's event watcher thread sleeps for after polling Sober's log file, in seconds. This barely impacts performance even when set to zero, this simply exists to allow people with ", 161 | styleItalic, "very", resetStyle, " weak CPUs to save on resources.", 162 | ) 163 | else: 164 | noDocs 165 | of "tweaks": 166 | case question.name 167 | of "oldoof": 168 | stdout.styledWriteLine( 169 | repeat(' ', int(terminalWidth() / 2)), 170 | fgGreen, 171 | "tweaks", 172 | resetStyle, 173 | ":", 174 | fgYellow, 175 | "oldOof", 176 | resetStyle, 177 | ) 178 | 179 | stdout.styledWriteLine(styleBright, "TYPE", resetStyle) 180 | stdout.styledWriteLine("\t", fgGreen, "boolean", resetStyle) 181 | stdout.write '\n' 182 | 183 | stdout.styledWriteLine(styleBright, "DEFAULT VALUE", resetStyle) 184 | stdout.styledWriteLine("\t", fgGreen, "true", resetStyle) 185 | stdout.write '\n' 186 | 187 | stdout.styledWriteLine(styleBright, "DESCRIPTION", resetStyle) 188 | stdout.styledWriteLine( 189 | "\t", 190 | "This setting lets you bring back the old \"Oof!\" sound, which was recently replaced to the \"Eurgh\" sound by Roblox due to copyright issues.", 191 | "\n\tYou can revert this by setting the value to ", fgGreen, "false", 192 | resetStyle, ".", 193 | ) 194 | of "moon": 195 | stdout.styledWriteLine( 196 | repeat(' ', int(terminalWidth() / 2)), 197 | fgGreen, 198 | "tweaks", 199 | resetStyle, 200 | ":", 201 | fgYellow, 202 | "moon", 203 | resetStyle, 204 | ) 205 | 206 | stdout.styledWriteLine(styleBright, "TYPE", resetStyle) 207 | stdout.styledWriteLine("\t", fgGreen, "string", resetStyle) 208 | stdout.write '\n' 209 | 210 | stdout.styledWriteLine(styleBright, "DEFAULT VALUE", resetStyle) 211 | stdout.styledWriteLine("\t", fgGreen, "Not set by default.", resetStyle) 212 | stdout.write '\n' 213 | 214 | stdout.styledWriteLine(styleBright, "DESCRIPTION", resetStyle) 215 | stdout.styledWriteLine( 216 | "\t", 217 | "This setting lets you override Roblox's moon texture, granted that the game you're playing doesn't use a custom one.", 218 | "\n\tYou can revert the changes by leaving this option empty (or not defining it at all)", 219 | ) 220 | of "sun": 221 | stdout.styledWriteLine( 222 | repeat(' ', int(terminalWidth() / 2)), 223 | fgGreen, 224 | "tweaks", 225 | resetStyle, 226 | ":", 227 | fgYellow, 228 | "sun", 229 | resetStyle, 230 | ) 231 | 232 | stdout.styledWriteLine(styleBright, "TYPE", resetStyle) 233 | stdout.styledWriteLine("\t", fgGreen, "string", resetStyle) 234 | stdout.write '\n' 235 | 236 | stdout.styledWriteLine(styleBright, "DEFAULT VALUE", resetStyle) 237 | stdout.styledWriteLine("\t", fgGreen, "Not set by default.", resetStyle) 238 | stdout.write '\n' 239 | 240 | stdout.styledWriteLine(styleBright, "DESCRIPTION", resetStyle) 241 | stdout.styledWriteLine( 242 | "\t", 243 | "This setting lets you override Roblox's sun texture, granted that the game you're playing doesn't use a custom one.", 244 | "\n\tYou can revert the changes by leaving this option empty (or not defining it at all)", 245 | ) 246 | of "font": 247 | stdout.styledWriteLine( 248 | repeat(' ', int(terminalWidth() / 2)), 249 | fgGreen, 250 | "tweaks", 251 | resetStyle, 252 | ":", 253 | fgYellow, 254 | "font", 255 | resetStyle, 256 | ) 257 | 258 | stdout.styledWriteLine(styleBright, "TYPE", resetStyle) 259 | stdout.styledWriteLine("\t", fgGreen, "string", resetStyle) 260 | stdout.write '\n' 261 | 262 | stdout.styledWriteLine(styleBright, "DEFAULT VALUE", resetStyle) 263 | stdout.styledWriteLine("\t", fgGreen, "Not set by default.", resetStyle) 264 | stdout.write '\n' 265 | 266 | stdout.styledWriteLine(styleBright, "DESCRIPTION", resetStyle) 267 | stdout.styledWriteLine( 268 | "\t", "This setting lets you override all of Roblox's fonts with your own.", 269 | "\n\tYou can revert the changes by leaving this option empty (or not defining it at all)", 270 | ) 271 | else: 272 | noDocs 273 | of "client": 274 | case question.name 275 | of "fps": 276 | stdout.styledWriteLine( 277 | repeat(' ', int(terminalWidth() / 2)), 278 | fgGreen, 279 | "client", 280 | resetStyle, 281 | ":", 282 | fgYellow, 283 | "fps", 284 | resetStyle, 285 | ) 286 | 287 | stdout.styledWriteLine(styleBright, "TYPE", resetStyle) 288 | stdout.styledWriteLine("\t", fgGreen, "integer", resetStyle) 289 | stdout.write '\n' 290 | 291 | stdout.styledWriteLine(styleBright, "DEFAULT VALUE", resetStyle) 292 | stdout.styledWriteLine("\t", fgGreen, "60", resetStyle) 293 | stdout.write '\n' 294 | 295 | stdout.styledWriteLine(styleBright, "DESCRIPTION", resetStyle) 296 | stdout.styledWriteLine( 297 | "\t", 298 | "This setting lets you override all of Roblox's default framerate cap of 60 to anything you want, or disable it altogether.", 299 | ) 300 | of "launcher": 301 | stdout.styledWriteLine( 302 | repeat(' ', int(terminalWidth() / 2)), 303 | fgGreen, 304 | "client", 305 | resetStyle, 306 | ":", 307 | fgYellow, 308 | "launcher", 309 | resetStyle, 310 | ) 311 | 312 | stdout.styledWriteLine(styleBright, "TYPE", resetStyle) 313 | stdout.styledWriteLine("\t", fgGreen, "string", resetStyle) 314 | stdout.write '\n' 315 | 316 | stdout.styledWriteLine(styleBright, "DEFAULT VALUE", resetStyle) 317 | stdout.styledWriteLine("\t", fgGreen, "Not set by default.", resetStyle) 318 | stdout.write '\n' 319 | 320 | stdout.styledWriteLine(styleBright, "DESCRIPTION", resetStyle) 321 | stdout.styledWriteLine( 322 | "\t", 323 | "This setting lets you run Roblox (Sober) with a particular launcher, like ", 324 | styleBright, "gamemoderun", resetStyle, ".", 325 | ) 326 | of "backend": 327 | stdout.styledWriteLine( 328 | repeat(' ', int(terminalWidth() / 2)), 329 | fgGreen, 330 | "client", 331 | resetStyle, 332 | ":", 333 | fgYellow, 334 | "backend", 335 | resetStyle, 336 | ) 337 | 338 | stdout.styledWriteLine(styleBright, "TYPE", resetStyle) 339 | stdout.styledWriteLine("\t", fgGreen, "string", resetStyle) 340 | stdout.write '\n' 341 | 342 | stdout.styledWriteLine(styleBright, "DEFAULT VALUE", resetStyle) 343 | stdout.styledWriteLine( 344 | "\t", fgGreen, "Autodetected by Lucem using the ", resetStyle, styleBright, 345 | "XDG_SESSION_TYPE", resetStyle, fgGreen, " environment variable.", resetStyle, 346 | ) 347 | stdout.write '\n' 348 | 349 | stdout.styledWriteLine(styleBright, "DESCRIPTION", resetStyle) 350 | stdout.styledWriteLine( 351 | "\t", 352 | "This setting lets you force Roblox (Sober) to either run with the Wayland windowing backend or the X11 windowing backend.", 353 | "\n\tIf you leave this empty, Lucem automatically detects which backend would be the best for you.", 354 | "\n\tValid options are:\n", fgRed, "\t-", resetStyle, styleBright, " x11\n", 355 | resetStyle, fgRed, "\t-", resetStyle, styleBright, " wayland\n", resetStyle, 356 | ) 357 | of "telemetry": 358 | stdout.styledWriteLine( 359 | repeat(' ', int(terminalWidth() / 2)), 360 | fgGreen, 361 | "client", 362 | resetStyle, 363 | ":", 364 | fgYellow, 365 | "telemetry", 366 | resetStyle, 367 | ) 368 | 369 | stdout.styledWriteLine(styleBright, "TYPE", resetStyle) 370 | stdout.styledWriteLine("\t", fgGreen, "boolean", resetStyle) 371 | stdout.write '\n' 372 | 373 | stdout.styledWriteLine(styleBright, "DEFAULT VALUE", resetStyle) 374 | stdout.styledWriteLine("\t", fgGreen, "false", resetStyle) 375 | stdout.write '\n' 376 | 377 | stdout.styledWriteLine(styleBright, "DESCRIPTION", resetStyle) 378 | stdout.styledWriteLine( 379 | "\t", 380 | "This setting attempts to disable most of the telemetry FFlags that the Roblox client exposes. It does not guarantee to make your Roblox experience 100% private, but it is recommended to be set to ", 381 | fgGreen, "false", resetStyle, " in order to disable these flags.", 382 | ) 383 | of "fflags": 384 | stdout.styledWriteLine( 385 | repeat(' ', int(terminalWidth() / 2)), 386 | fgGreen, 387 | "client", 388 | resetStyle, 389 | ":", 390 | fgYellow, 391 | "fflags", 392 | resetStyle, 393 | ) 394 | 395 | stdout.styledWriteLine(styleBright, "TYPE", resetStyle) 396 | stdout.styledWriteLine("\t", fgGreen, "string of key-value pairs", resetStyle) 397 | stdout.write '\n' 398 | 399 | stdout.styledWriteLine(styleBright, "DEFAULT VALUE", resetStyle) 400 | stdout.styledWriteLine("\t", fgGreen, "Empty by default.", resetStyle) 401 | stdout.write '\n' 402 | 403 | stdout.styledWriteLine(styleBright, "DESCRIPTION", resetStyle) 404 | stdout.styledWriteLine( 405 | "\t", 406 | "This string lets you define FFlags that will be applied to Roblox upon launch. You must use the key-value syntax like this:", 407 | fgRed, "-", resetStyle, " ", fgGreen, "FFlagName", resetStyle, styleBright, "=", 408 | resetStyle, fgYellow, "\"my string value\"", resetStyle, fgRed, "-", resetStyle, 409 | " ", fgGreen, "FFlagName", resetStyle, styleBright, "=", resetStyle, fgGreen, 410 | "1337", resetStyle, fgRed, "-", resetStyle, " ", fgGreen, "FFlagName", 411 | resetStyle, styleBright, "=", resetStyle, fgBlue, "false", resetStyle, 412 | "If you do not understand this, it's best to use the GUI FFlag editor that the Lucem shell provides, as it instantly validates everything and shows you", 413 | " friendly error messages if you make a mistake.", 414 | ) 415 | of "apkupdates": 416 | stdout.styledWriteLine( 417 | repeat(' ', int(terminalWidth() / 2)), 418 | fgGreen, 419 | "client", 420 | resetStyle, 421 | ":", 422 | fgYellow, 423 | "apkUpdates", 424 | resetStyle, 425 | ) 426 | 427 | stdout.styledWriteLine(styleBright, "TYPE", resetStyle) 428 | stdout.styledWriteLine("\t", fgGreen, "boolean", resetStyle) 429 | stdout.write '\n' 430 | 431 | stdout.styledWriteLine(styleBright, "DEFAULT VALUE", resetStyle) 432 | stdout.styledWriteLine("\t", fgGreen, "true", resetStyle) 433 | stdout.write '\n' 434 | 435 | stdout.styledWriteLine(styleBright, "DESCRIPTION", resetStyle) 436 | stdout.styledWriteLine( 437 | "\t", 438 | "This is a shorthand way of modifying Sober's state to check for Roblox APK updates upon startup. It is recommended that you keep this enabled.", 439 | ) 440 | else: 441 | noDocs 442 | else: 443 | error "lucem: no documentation exists for category \"" & question.category & '"' 444 | quit(1) 445 | 446 | proc explain*(question: Question) {.inline.} = 447 | case question.kind 448 | of Configuration: 449 | explainConfig(question) 450 | of Command: 451 | explainCommand(question) 452 | else: 453 | discard 454 | 455 | proc generateQuestion*(input: Input): Question = 456 | template showErrorAndDie() = 457 | stderr.styledWriteLine( 458 | styleUnderscore, "Usage", resetStyle, ": lucem ", fgGreen, "explain ", resetStyle, 459 | "<", styleItalic, "kind", resetStyle, "> <", styleItalic, "arguments", resetStyle, 460 | ">", 461 | ) 462 | stderr.write '\n' 463 | 464 | stderr.styledWriteLine( 465 | styleBright, "where ", resetStyle, fgYellow, "kind", resetStyle, styleBright, 466 | " is", resetStyle, ":", 467 | ) 468 | stderr.styledWriteLine(fgRed, "* ", resetStyle, styleBright, "command", resetStyle) 469 | stderr.styledWriteLine(fgRed, "* ", resetStyle, styleBright, "flag", resetStyle) 470 | stderr.styledWriteLine(fgRed, "* ", resetStyle, styleBright, "config", resetStyle) 471 | stderr.write '\n' 472 | 473 | stderr.styledWriteLine( 474 | styleBright, "where ", resetStyle, fgYellow, "arguments", resetStyle, styleBright, 475 | " can be", resetStyle, ":", 476 | ) 477 | stderr.styledWriteLine( 478 | fgRed, "* ", resetStyle, fgGreen, "command", resetStyle, ": ", styleBright, 479 | " (the command to explain)", 480 | ) 481 | stderr.styledWriteLine( 482 | fgRed, "* ", resetStyle, fgGreen, "flag", resetStyle, ": ", styleBright, 483 | " (the flag to explain)", 484 | ) 485 | stderr.styledWriteLine( 486 | fgRed, "* ", resetStyle, fgGreen, "config", resetStyle, ": ", styleBright, 487 | " (eg, lucem discord_rpc)", 488 | ) 489 | quit(1) 490 | 491 | if input.arguments.len < 1: 492 | showErrorAndDie 493 | 494 | let kind = 495 | case input.arguments[0].toLowerAscii() 496 | of "command": 497 | Command 498 | of "flag": 499 | RuntimeFlag 500 | of "config", "configuration": 501 | Configuration 502 | else: 503 | showErrorAndDie 504 | Command 505 | 506 | var question = Question(kind: kind) 507 | 508 | case kind 509 | of Command: 510 | if input.arguments.len < 2: 511 | showErrorAndDie 512 | question.command = input.arguments[1].toLowerAscii() 513 | of RuntimeFlag: 514 | if input.arguments.len < 2: 515 | showErrorAndDie 516 | question.flag = input.arguments[1].toLowerAscii() 517 | of Configuration: 518 | if input.arguments.len < 3: 519 | showErrorAndDie 520 | question.category = input.arguments[1].toLowerAscii() 521 | question.name = input.arguments[2].toLowerAscii() 522 | 523 | question 524 | -------------------------------------------------------------------------------- /src/commands/init.nim: -------------------------------------------------------------------------------- 1 | ## This file implements `lucem init` 2 | ## Copyright (C) 2024 Trayambak Rai 3 | 4 | import std/[logging] 5 | import ../[flatpak, argparser] 6 | 7 | const SOBER_FLATPAK_URL* {.strdefine: "SoberFlatpakUrl".} = 8 | "https://sober.vinegarhq.org/sober.flatpakref" 9 | 10 | proc initializeSober*(input: Input) {.inline.} = 11 | info "lucem: initializing sober" 12 | 13 | if not flatpakInstall(SOBER_FLATPAK_URL): 14 | error "lucem: failed to initialize sober." 15 | quit(1) 16 | 17 | info "lucem: Installed Sober successfully!" 18 | info "lucem: You may run Roblox using `lucem run`" 19 | -------------------------------------------------------------------------------- /src/commands/run.nim: -------------------------------------------------------------------------------- 1 | ## Run the Roblox client, update FFlags and optionally, provide Discord RPC and other features. 2 | ## Copyright (C) 2024 Trayambak Rai 3 | import std/[os, logging, strutils, json, tables, times, locks, sets] 4 | import pkg/[colored_logger, netty, jsony, pretty] 5 | import ../api/[games, thumbnails, ipinfo] 6 | import 7 | ../patches/[bring_back_oof, patch_fonts, sun_and_moon_textures, windowing_backend] 8 | import ../shell/loading_screen 9 | import ../[updater, sober_config, proto] 10 | import 11 | ../[ 12 | argparser, config, flatpak, common, meta, sugar, notifications, fflags, log_file, 13 | sober_state 14 | ] 15 | 16 | const FFlagsFile* = 17 | "$1/.var/app/$2/data/sober/exe/ClientSettings/ClientAppSettings.json" 18 | 19 | let fflagsFile = FFlagsFile % [getHomeDir(), SOBER_APP_ID] 20 | 21 | proc updateConfig*(input: Input, config: Config) = 22 | info "lucem: updating config" 23 | if not fileExists(fflagsFile): 24 | error "lucem: could not open pre-existing FFlags file. Run `lucem init` first." 25 | quit(1) 26 | 27 | var sober = getSoberConfig() 28 | 29 | info "lucem: target FPS is set to: " & $config.client.fps 30 | sober.fflags["DFIntTaskSchedulerTargetFps"] = newJInt(int(config.client.fps)) 31 | sober.useOpengl = config.client.renderer == Renderer.OpenGL 32 | 33 | if not config.client.telemetry: 34 | info "lucem: disabling telemetry FFlags" 35 | else: 36 | warn "lucem: enabling telemetry FFlags. This is not recommended!" 37 | 38 | if not input.enabled("skip-patching", "N"): 39 | enableOldOofSound(config.tweaks.oldOof) 40 | setWindowingBackend(config.backend()) 41 | patchSoberState(input, config) 42 | setClientFont(config.tweaks.font, config.tweaks.excludeFonts) 43 | setSunTexture(config.tweaks.sun) 44 | setMoonTexture(config.tweaks.moon) 45 | else: 46 | info "lucem: skipping patching (--skip-patching or -S was provided)" 47 | 48 | for flag in [ 49 | "FFlagDebugDisableTelemetryEphemeralCounter", 50 | "FFlagDebugDisableTelemetryEphemeralStat", "FFlagDebugDisableTelemetryEventIngest", 51 | "FFlagDebugDisableTelemetryPoint", "FFlagDebugDisableTelemetryV2Counter", 52 | "FFlagDebugDisableTelemetryV2Event", "FFlagDebugDisableTelemetryV2Stat", 53 | ]: 54 | debug "lucem: set flag `" & flag & "` to " & $(not config.client.telemetry) 55 | sober.fflags[flag] = newJBool(not config.client.telemetry) 56 | 57 | parseFFlags(config, sober.fflags) 58 | sober.saveSoberConfig() 59 | 60 | proc eventWatcher*( 61 | config: Config, 62 | input: Input 63 | ) = 64 | var verbose = false 65 | 66 | let port = 67 | if (let opt = input.flag("port"); *opt): 68 | parseUint(&opt) 69 | else: 70 | config.daemon.port 71 | 72 | if input.enabled("verbose", "v"): 73 | verbose = true 74 | setLogFilter(lvlAll) 75 | 76 | var reactor = newReactor() 77 | debug "lucem: connecting to lucemd at port " & $port 78 | var server = reactor.connect("127.0.0.1", int port) 79 | 80 | template send[T](data: T) = 81 | let serialized = data.serialize() 82 | debug "lucem: sending to daemon: " & serialized 83 | reactor.send(server, serialized) 84 | 85 | for _ in 0 .. 32: 86 | reactor.tick() 87 | 88 | var 89 | line = 0 90 | startedPlayingAt = 0.0 91 | startingTime = 0.0 92 | hasntStarted = true 93 | 94 | soberIsRunning = false 95 | ticksUntilSoberRunCheck = 0 96 | 97 | while hasntStarted or soberIsRunning: 98 | reactor.tick() 99 | 100 | let logFile = readFile(getSoberLogPath()).splitLines() 101 | 102 | if ticksUntilSoberRunCheck < 1: 103 | # debug "lucem: checking if sober is still running" 104 | soberIsRunning = soberRunning() 105 | ticksUntilSoberRunCheck = 5000 106 | 107 | dec ticksUntilSoberRunCheck 108 | 109 | if logFile.len - 1 < line: 110 | continue 111 | 112 | let data = logFile[line] 113 | if data.len < 1: 114 | inc line 115 | continue 116 | 117 | if verbose: 118 | data.echo 119 | 120 | if data.contains( 121 | "[FLog::GameJoinUtil] GameJoinUtil::joinGamePostStandard: URL: https://gamejoin.roblox.com/v1/join-game BODY:" 122 | ): 123 | startedPlayingAt = epochTime() 124 | startingTime = startedPlayingAt 125 | 126 | info "lucem: joined game" 127 | 128 | send( 129 | Packet( 130 | magic: mgOnGameJoin, 131 | arguments: @[ 132 | %* data 133 | ] 134 | ) 135 | ) 136 | 137 | if data.contains("[FLog::Network] UDMUX Address ="): 138 | let str = data.split(" = ")[1].split(",")[0] 139 | 140 | info "lucem: server IP: " & str 141 | 142 | send( 143 | Packet( 144 | magic: mgOnServerIp, 145 | arguments: @[ 146 | %* str 147 | ] 148 | ) 149 | ) 150 | 151 | if data.contains("[FLog::Network] Client:Disconnect") or 152 | data.contains("[FLog::Network] Connection lost - Cannot contact server/client"): 153 | continue 154 | 155 | # sleep(config.lucem.pollingDelay.int) 156 | hasntStarted = false 157 | inc line 158 | 159 | info "lucem: Sober seems to have exited - we'll stop here too. Adios!" 160 | 161 | proc runRoblox*(input: Input, config: Config) = 162 | info "lucem: running Roblox via Sober" 163 | runUpdateChecker(config) 164 | 165 | writeFile(getSoberLogPath(), newString(0)) 166 | 167 | info "lucem: redirecting sober logs to: " & getSoberLogPath() 168 | discard flatpakRun(SOBER_APP_ID, getSoberLogPath(), config.client.launcher, config) 169 | 170 | eventWatcher(input = input, config = config) 171 | 172 | quit(0) 173 | -------------------------------------------------------------------------------- /src/common.nim: -------------------------------------------------------------------------------- 1 | ## Common stuff 2 | 3 | const SOBER_APP_ID* {.strdefine: "SoberAppId".} = "org.vinegarhq.Sober" 4 | -------------------------------------------------------------------------------- /src/config.nim: -------------------------------------------------------------------------------- 1 | import std/[os, logging, strutils] 2 | import toml_serialization 3 | import ./[argparser, sugar] 4 | 5 | type WindowingBackend* {.pure.} = enum 6 | X11 7 | Wayland 8 | 9 | func `$`*(backend: WindowingBackend): string {.inline.} = 10 | case backend 11 | of WindowingBackend.Wayland: "Wayland" 12 | of WindowingBackend.X11: "X11" 13 | 14 | proc autodetectWindowingBackend*(): WindowingBackend {.inline.} = 15 | case getEnv("XDG_SESSION_TYPE") 16 | of "wayland": 17 | return WindowingBackend.Wayland 18 | of "x11": 19 | return WindowingBackend.X11 20 | else: 21 | warn "lucem: XDG_SESSION_TYPE was set to \"" & getEnv("XDG_SESSION_TYPE") & 22 | "\"; defaulting to X11" 23 | return WindowingBackend.X11 24 | 25 | type 26 | Renderer* {.pure.} = enum 27 | Vulkan 28 | OpenGL 29 | 30 | APKConfig* = object 31 | version*: string = "" 32 | 33 | LucemConfig* = object 34 | discord_rpc*: bool = false 35 | auto_updater*: bool = true 36 | notify_server_region*: bool = true 37 | loading_screen*: bool = true 38 | polling_delay*: uint = 100 39 | 40 | ClientConfig* = object 41 | fps*: int = 60 42 | launcher*: string = "" 43 | resolve_exe*: bool = true ## Whether Lucem should try to find the absolute path to a launcher binary 44 | renderer*: Renderer = Renderer.Vulkan 45 | backend*: string 46 | telemetry*: bool = false 47 | fflags*: string 48 | apkUpdates*: bool = true 49 | 50 | Tweaks* = object 51 | oldOof*: bool = false 52 | moon*: string = "" 53 | sun*: string = "" 54 | font*: string = "" 55 | excludeFonts*: seq[string] = @["RobloxEmoji.ttf", "TwemojiMozilla.ttf"] 56 | 57 | DaemonConfig* = object 58 | port*: uint = 9898 59 | 60 | OverlayConfig* = object 61 | width*: uint = 600 62 | height*: uint = 200 63 | headingSize*: float = 32f 64 | descriptionSize*: float = 18f 65 | font*: string = "" 66 | anchors*: string = "top-right" 67 | 68 | Config* = object 69 | apk*: APKConfig 70 | lucem*: LucemConfig 71 | tweaks*: Tweaks 72 | client*: ClientConfig 73 | overlay*: OverlayConfig 74 | daemon*: DaemonConfig 75 | 76 | proc backend*(config: Config): WindowingBackend = 77 | if config.client.backend.len < 1: 78 | debug "lucem: backend name was not set, defaulting to autodetection" 79 | return autodetectWindowingBackend() 80 | 81 | case config.client.backend.toLowerAscii() 82 | of "wayland", "wl", "waeland": 83 | return WindowingBackend.Wayland 84 | of "x11", "xorg", "bloat", "garbage": 85 | return WindowingBackend.X11 86 | else: 87 | warn "lucem: invalid backend name \"" & config.client.backend & 88 | "\"; using autodetection" 89 | return autodetectWindowingBackend() 90 | 91 | const 92 | DefaultConfig* = 93 | """ 94 | [lucem] 95 | discord_rpc = true 96 | notify_server_region = true 97 | loading_screen = true 98 | polling_delay = 0 99 | 100 | [tweaks] 101 | oldOof = true 102 | moon = "" 103 | sun = "" 104 | font = "" 105 | excludeFonts = ["RobloxEmoji.ttf", "TwemojiMozilla.ttf"] 106 | 107 | [daemon] 108 | port = 9898 109 | 110 | [overlay] 111 | width = 600 112 | height = 200 113 | headingSize = 32 114 | descriptionSize = 18 115 | anchors = "top-right" 116 | 117 | [client] 118 | fps = 60 119 | launcher = "" 120 | telemetry = false 121 | fflags = "\n" 122 | apkUpdates = true 123 | """ 124 | 125 | ConfigLocation* {.strdefine: "LucemConfigLocation".} = "$1/.config/lucem/" 126 | 127 | proc save*(config: Config) {.inline.} = 128 | writeFile(ConfigLocation % [getHomeDir()] / "config.toml", Toml.encode(config)) 129 | 130 | proc parseConfig*(input: Input): Config {.inline.} = 131 | discard existsOrCreateDir(ConfigLocation % [getHomeDir()]) 132 | 133 | let 134 | inputFile = input.flag("config-file") 135 | config = readFile( 136 | if *inputFile: 137 | &inputFile 138 | elif fileExists(ConfigLocation % [getHomeDir()] / "config.toml"): 139 | ConfigLocation % [getHomeDir()] / "config.toml" 140 | else: 141 | warn "lucem: cannot find config file, defaulting to built-in config file." 142 | writeFile(ConfigLocation % [getHomeDir()] / "config.toml", DefaultConfig) 143 | ConfigLocation % [getHomeDir()] / "config.toml" 144 | ) 145 | 146 | try: 147 | Toml.decode(config, Config) 148 | except TomlFieldReadingError as exc: 149 | warn "lucem: unable to read configuration: " & exc.msg 150 | warn "lucem: falling back to internal default configuration: your changes will NOT be respected!" 151 | Toml.decode(DefaultConfig, Config) 152 | -------------------------------------------------------------------------------- /src/desktop_files.nim: -------------------------------------------------------------------------------- 1 | ## Make a .desktop entry for Lucem 2 | ## Copyright (C) 2024 Trayambak Rai 3 | import std/[os, strutils, logging] 4 | import ./internal_fonts 5 | 6 | const 7 | ApplicationsPath* {.strdefine: "LucemAppsPath".} = "$1/.local/share/applications" 8 | 9 | SoberRunDesktopFile* = 10 | """ 11 | [Desktop Entry] 12 | Version=1.0 13 | Type=Application 14 | Name=Lucem 15 | Exec=$1 16 | Comment=Run Roblox with quality of life fixes 17 | GenericName=Wrapper around Sober 18 | Terminal=false 19 | Categories=Games 20 | Icon=lucem 21 | Keywords=roblox 22 | Categories=Game 23 | """ 24 | 25 | SoberGUIDesktopFile* = 26 | """ 27 | [Desktop Entry] 28 | Version=1.0 29 | Type=Application 30 | Name=Lucem Settings 31 | Exec=$1 32 | Comment=Configure Lucem as per your needs 33 | GenericName=Lucem Settings 34 | Terminal=false 35 | Categories=Utility 36 | Keywords=settings 37 | Icon=lucem 38 | """ 39 | 40 | proc createLucemDesktopFile*() = 41 | debug "lucem: create desktop files for lucem" 42 | let 43 | base = ApplicationsPath % [getHomeDir()] 44 | pathToLucem = getAppFilename() 45 | 46 | if not existsOrCreateDir(base): 47 | warn "lucem: `" & base & 48 | "` did not exist prior to this, your system seems to be a bit weird. Lucem has created it itself." 49 | 50 | var iconsPath = getHomeDir() / ".local" 51 | 52 | for value in ["share", "icons", "hicolor", "scalable"]: 53 | debug "lucem: creating directory " & iconsPath & " if it doesn't exist" 54 | discard existsOrCreateDir(iconsPath) 55 | iconsPath = iconsPath / value 56 | 57 | discard existsOrCreateDir(iconsPath) 58 | discard existsOrCreateDir(iconsPath / "apps") 59 | writeFile(iconsPath / "apps" / "lucem.svg", LucemIcon) 60 | 61 | debug "lucem: path to lucem binary is: " & pathToLucem 62 | 63 | debug "lucem: writing alternative to `lucem run` to " & base 64 | writeFile(base / "lucem.desktop", SoberRunDesktopFile % [pathToLucem & " run"]) 65 | 66 | debug "lucem: writing alternative to `lucem shell` to " & base 67 | writeFile( 68 | base / "lucem_shell.desktop", SoberGUIDesktopFile % [pathToLucem & " shell"] 69 | ) 70 | 71 | info "lucem: created desktop entries successfully!" 72 | -------------------------------------------------------------------------------- /src/fflags.nim: -------------------------------------------------------------------------------- 1 | ## FFlag "parser" 2 | ## Copyright (C) 2024 Trayambak Rai 3 | import std/[json, tables, logging, strutils] 4 | import ./[sober_config, config] 5 | 6 | type FFlagParseError* = object of ValueError 7 | 8 | proc parseFFlags*(config: Config, fflags: var SoberFFlags) = 9 | if config.client.fflags.len > 0: 10 | for flag in config.client.fflags.split('\n'): 11 | let splitted = flag.split('=') 12 | 13 | if splitted.len < 2: 14 | if flag.len > 0: 15 | error "lucem: error whilst parsing FFlag (" & flag & 16 | "): only got key, no value to complete the pair was found." 17 | raise newException( 18 | FFlagParseError, 19 | "Error whilst parsing FFlag (" & flag & 20 | "). Only got key, no value to complete the pair was found.", 21 | ) 22 | else: 23 | continue 24 | 25 | if splitted.len > 2: 26 | error "lucem: error whilst parsing FFlag (" & flag & 27 | "): got more than two splits, key and value were already found." 28 | raise newException( 29 | FFlagParseError, 30 | "Error whilst parsing FFlag (" & flag & 31 | "). Got more than two splits, key and value were already found!", 32 | ) 33 | 34 | let 35 | key = splitted[0] 36 | val = splitted[1] 37 | 38 | if val.startsWith('"') and val.endsWith('"') or 39 | val.startsWith('\'') and val.endsWith('\''): 40 | fflags[key] = newJString(val) 41 | elif val in ["true", "false"]: 42 | fflags[key] = newJBool(parseBool(val)) 43 | else: 44 | var allInt = false 45 | 46 | for c in val: 47 | if c in {'0' .. '9'}: 48 | allInt = true 49 | else: 50 | allInt = false 51 | break 52 | 53 | if allInt: 54 | fflags[key] = newJInt(parseInt(val)) 55 | else: 56 | raise newException( 57 | FFlagParseError, 58 | "Cannot handle FFlag pair of key (" & key & ") and value (" & val & 59 | "); did you mean " & key & '=' & '\'' & val & "'?", 60 | ) 61 | -------------------------------------------------------------------------------- /src/flatpak.nim: -------------------------------------------------------------------------------- 1 | ## Flatpak helper 2 | ## Copyright (C) 2024 Trayambak Rai 3 | 4 | import std/[os, osproc, posix, logging, strutils] 5 | import ./[config] 6 | 7 | proc flatpakInstall*(id: string, user: bool = true): bool {.inline, discardable.} = 8 | if findExe("flatpak").len < 1: 9 | error "flatpak: could not find flatpak executable! Are you sure that you have flatpak installed?" 10 | 11 | info "flatpak: install package \"" & id & '"' 12 | let (output, exitCode) = 13 | execCmdEx("flatpak install --assumeyes " & id & (if user: " --user" else: "")) 14 | 15 | if exitCode != 0 and not output.contains("is already installed"): 16 | error "flatpak: failed to install package \"" & id & 17 | "\"; flatpak process exited with abnormal exit code " & $exitCode 18 | error "flatpak: it also outputted the following:" 19 | error output 20 | false 21 | else: 22 | info "flatpak: successfully installed \"" & id & "\"!" 23 | true 24 | 25 | proc soberRunning*(): bool {.inline.} = 26 | execCmdEx("pidof sober").output.len > 2 27 | 28 | proc flatpakRun*( 29 | id: string, path: string = "/dev/stdout", launcher: string = "", 30 | config: Config 31 | ): bool {.inline.} = 32 | info "flatpak: launching flatpak app \"" & id & '"' 33 | debug "flatpak: launcher = " & launcher 34 | 35 | let launcherExe = 36 | if config.client.resolveExe: 37 | debug "flatpak: resolving executable to launcher program: " & launcher 38 | findExe(launcher) 39 | else: 40 | debug "flatpak: user has asked executable path to not be resolved: " & launcher 41 | launcher 42 | 43 | if config.client.resolveExe and launcherExe.len < 1 and launcher.len > 0: 44 | warn "flatpak: failed to find launcher executable for `" & launcher & 45 | "`; are you sure that it's in your PATH?" 46 | warn "flatpak: ignoring for now." 47 | 48 | if fork() == 0: 49 | var file = posix.open(path, O_WRONLY or O_CREAT or O_TRUNC, 0644) 50 | assert(file >= 0) 51 | 52 | debug "flatpak: we are the child - launching \"" & id & '"' 53 | var cmd = launcherExe & " flatpak run " & id 54 | 55 | debug "flatpak: final command: " & cmd 56 | if dup2(file, STDOUT_FILENO) < 0: 57 | error "lucem: dup2() for stdout failed: " & $strerror(errno) 58 | else: 59 | debug "lucem: dup2() successful, sober's logs are now directed at: " & path 60 | 61 | discard execCmd(cmd) 62 | debug "lucem: sober has exited, forked lucem process is exiting..." 63 | quit(0) 64 | else: 65 | debug "flatpak: we are the parent - continuing" 66 | 67 | proc flatpakKill*(id: string): bool {.inline, discardable.} = 68 | info "flatpak: killing flatpak app \"" & id & '"' 69 | bool(execCmd("flatpak kill " & id)) 70 | -------------------------------------------------------------------------------- /src/fs.nim: -------------------------------------------------------------------------------- 1 | ## File utilities 2 | ## Copyright (C) 2024 Trayambak Rai 3 | import std/[os] 4 | 5 | proc isAccessible*(file: string): bool = 6 | if not fileExists(file): 7 | return false 8 | 9 | let perms = getFilePermissions(file) 10 | 11 | if fpGroupRead notin perms and fpUserRead notin perms: 12 | return false 13 | 14 | try: 15 | discard readFile(file) 16 | except CatchableError: 17 | return false 18 | 19 | return true 20 | -------------------------------------------------------------------------------- /src/gpu_info.nim: -------------------------------------------------------------------------------- 1 | ## Gather information about the GPU that will be used to render Rob lock 2 | ## Copyright (C) 2024 Trayambak Rai 3 | import std/[logging] 4 | import nimgl/vulkan 5 | 6 | type GPU* = string 7 | 8 | proc getAllGPUs*(instance: VkInstance): seq[GPU] = 9 | debug "lucem: checking GPU devices" 10 | 11 | var deviceCount: uint32 12 | let enumRes = $vkEnumeratePhysicalDevices(instance, deviceCount.addr, nil) 13 | debug "lucem: vkEnumeratePhysicalDevices(): " & enumRes 14 | debug "lucem: found " & $deviceCount & " GPU(s) in this system that support Vulkan" 15 | 16 | var devices = newSeq[VkPhysicalDevice](deviceCount) 17 | discard vkEnumeratePhysicalDevices(instance, deviceCount.addr, devices[0].addr) 18 | 19 | for pDevice in devices: 20 | var props: VkPhysicalDeviceProperties 21 | vkGetPhysicalDeviceProperties(pDevice, props.addr) 22 | 23 | var name = newStringOfCap(VK_MAX_PHYSICAL_DEVICE_NAME_SIZE) 24 | for i, value in props.deviceName: 25 | name &= value 26 | 27 | debug "lucem: found GPU \"" & name & '"' 28 | 29 | result &= move(name) 30 | 31 | proc deinitVulkan*(instance: VkInstance) = 32 | info "lucem: destroying Vulkan instance" 33 | vkDestroyInstance(instance, nil) 34 | 35 | proc initVulkan*(): VkInstance = 36 | info "lucem: trying to initialize Vulkan..." 37 | 38 | if not vkInit(): 39 | error "lucem: failed to initialize Vulkan!" 40 | error "lucem: this probably means that your GPU does not support Vulkan, or your drivers are too old." 41 | error "lucem: Sober probably won't run either." 42 | error "lucem: if Lucem worked fine for you prior to this, file a bug report. You can pass `--dont-check-vulkan` to bypass this check for now." 43 | quit(1) 44 | 45 | info "lucem: successfully initialized Vulkan! This GPU is ready for Sober!" 46 | info "lucem: initializing Vulkan instance..." 47 | 48 | var appInfo = newVkApplicationInfo( 49 | pApplicationName = "Lucem", 50 | pEngineName = "Lucem", 51 | apiVersion = vkApiVersion1_2, 52 | applicationVersion = vkMakeVersion(0, 1, 0), 53 | engineVersion = vkMakeVersion(0, 1, 0), 54 | ) 55 | 56 | var instanceCreateInfo = newVkInstanceCreateInfo( 57 | pApplicationInfo = appInfo.addr, 58 | enabledLayerCount = 0, 59 | ppEnabledLayerNames = nil, 60 | enabledExtensionCount = 0, 61 | ppEnabledExtensionNames = nil, 62 | ) 63 | 64 | if vkCreateInstance(instanceCreateInfo.addr, nil, result.addr) != VkSuccess: 65 | error "lucem: failed to create Vulkan instance!" 66 | error "lucem: this means that your system's Vulkan drivers are malfunctioning." 67 | error "lucem: this might not affect Sober. To check that, pass `--dont-check-vulkan` to bypass this check for now." 68 | quit(1) 69 | -------------------------------------------------------------------------------- /src/http.nim: -------------------------------------------------------------------------------- 1 | ## Reducing code clutter when making HTTP requests 2 | import std/[logging, monotimes] 3 | import pkg/[curly, webby] 4 | import ./meta 5 | 6 | {.passC: gorge("pkg-config --cflags libcurl").} 7 | {.passL: gorge("pkg-config --libs libcurl").} 8 | 9 | var curl = newCurly() 10 | proc httpGet*(url: string): string = 11 | debug "http: making HTTP/GET request to " & url & "; allocating HttpClient" 12 | let 13 | headers = toWebby(@[ 14 | ("User-Agent", "lucem/" & Version) 15 | ]) 16 | startReq = getMonoTime() 17 | req = curl.get(url, headers) 18 | endReq = getMonoTime() 19 | 20 | debug "http: HTTP/GET request to " & url & " took " & $(endReq - startReq) 21 | debug "http: response body:\n" & req.body 22 | 23 | req.body 24 | -------------------------------------------------------------------------------- /src/internal_fonts.nim: -------------------------------------------------------------------------------- 1 | ## Assets for Lucem, baked directly into the binary 2 | 3 | const 4 | IbmPlexSans* = staticRead("IBMPlexSans-Regular.ttf") 5 | LucemIcon* = staticRead("assets/lucem.svg") 6 | LucemIconPng* = staticRead("assets/lucem.png") 7 | -------------------------------------------------------------------------------- /src/log_file.nim: -------------------------------------------------------------------------------- 1 | ## Determines where the Sober log file has to be stored. 2 | ## Copyright (C) 2024 Trayambak Rai 3 | import std/[os] 4 | 5 | proc getLucemDir*(): string {.inline.} = 6 | let tmp = getEnv("XDG_RUNTIME_DIR", "/tmp") 7 | 8 | if not dirExists(tmp / "lucem"): 9 | createDir(tmp / "lucem") 10 | 11 | tmp / "lucem" 12 | 13 | proc getSoberLogPath*(): string {.inline.} = 14 | getLucemDir() / "sober.log" 15 | -------------------------------------------------------------------------------- /src/lucem.nim: -------------------------------------------------------------------------------- 1 | ## Lucem - a QoL wrapper over Sober 2 | ## 3 | ## Copyright (C) 2024 Trayambak Rai 4 | 5 | import std/[os, logging, strutils, terminal] 6 | import colored_logger, nimgl/vulkan 7 | import ./[meta, argparser, config, cache_calls, desktop_files, sober_state, gpu_info, systemd, updater] 8 | import ./shell/core 9 | import ./commands/[init, run, edit_config, explain] 10 | 11 | proc showHelp(exitCode: int = 1) {.inline, noReturn.} = 12 | echo """ 13 | lucem [command] [arguments] 14 | 15 | Commands: 16 | init Install Sober and initialize Lucem's internals 17 | run Run Sober 18 | meta Get build metadata 19 | list-gpus List all GPUs on this system 20 | update Check for Lucem updates and install them 21 | edit-config Edit the configuration file 22 | clear-cache Clear the API caches that Lucem maintains 23 | shell Launch the Lucem configuration GUI 24 | install-desktop-files Install Lucem's desktop files 25 | explain Get documentation on a Lucem configuration value or command 26 | help Show this message 27 | 28 | Flags: 29 | --verbose, -v Show additional debug logs, useful for diagnosing issues. 30 | --skip-patching, -N Don't apply your selected patches to Roblox, use this to see if a crash is caused by them. This won't undo patches! 31 | --use-sober-patching, -P Use Sober's patches (bring back old oof) instead of Lucem's. There's no need to use this since Lucem already works just as well. 32 | --dont-check-vulkan Don't try to initialize Vulkan to ensure that Sober can run on your GPU. 33 | """ 34 | quit(exitCode) 35 | 36 | proc showMeta() {.inline, noReturn.} = 37 | let state = loadSoberState() 38 | 39 | echo """ 40 | Lucem $1 41 | Copyright (C) 2024 Trayambak Rai 42 | This software is licensed under the MIT license. 43 | 44 | * Compiled with Nim $2 45 | * Compiled on $3 46 | * Roblox client version $6 47 | * Protocol: $7 48 | 49 | [ $4 ] 50 | 51 | ==== LICENSE ==== 52 | $5 53 | ==== LEGAL DISCLAIMER ==== 54 | Lucem is a free unofficial application that wraps around Sober, a runtime for Roblox on Linux. Lucem does not generate any revenue for its authors whatsoever. 55 | Lucem is NOT affiliated with Roblox or its partners, nor is it endorsed by them. The Lucem developers do not support misuse of the Roblox platform and there are restrictions 56 | in place to prevent such abuse. The Lucem developers or anyone involved with the project is NOT responsible for any damages caused by this software as it comes with NO WARRANTY. 57 | """ % 58 | [ 59 | Version, 60 | NimVersion, 61 | CompileDate & ' ' & CompileTime, 62 | when defined(release): "Release Build" else: "Development Build", 63 | LicenseString, 64 | state.v1.appVersion, 65 | $autodetectWindowingBackend(), 66 | ] 67 | 68 | proc listGpus(inst: VkInstance) = 69 | let gpus = inst.getAllGPUs() 70 | 71 | info "Found " & $gpus.len & 72 | (if gpus.len == 1: " GPU" else: " GPUs" & " that support Vulkan.") 73 | for gpu in gpus: 74 | stdout.styledWriteLine(fgRed, "-", resetStyle, " ", styleBright, gpu, resetStyle) 75 | 76 | proc main() {.inline.} = 77 | addHandler(newColoredLogger()) 78 | setLogFilter(lvlInfo) 79 | let input = parseInput() 80 | 81 | if input.enabled("verbose", "v"): 82 | setLogFilter(lvlAll) 83 | 84 | let config = parseConfig(input) 85 | 86 | if config.apk.version.len > 0: 87 | warn "lucem: you have set up an APK version in the configuration - that feature is now deprecated as Sober now has a built-in APK fetcher." 88 | warn "lucem: feel free to remove it." 89 | 90 | case input.command 91 | of "meta": 92 | showMeta() 93 | of "help": 94 | showHelp(0) 95 | of "init": 96 | initializeSober(input) 97 | createLucemDesktopFile() 98 | installSystemdService() 99 | of "update": 100 | updateLucem() 101 | of "check-for-updates": 102 | runUpdateChecker(parseConfig(input)) 103 | of "install-systemd-service": 104 | installSystemdService() 105 | of "relaunch-daemon": 106 | relaunchSystemdService() 107 | of "explain": 108 | input.generateQuestion().explain() 109 | of "edit-config": 110 | if existsEnv("EDITOR"): 111 | let editor = getEnv("EDITOR") 112 | debug "lucem: editor is `" & editor & '`' 113 | 114 | editConfiguration(editor, false) 115 | else: 116 | warn "lucem: you have not specified an editor in your environment variables." 117 | 118 | for editor in ["nano", "vscode", "vim", "nvim", "emacs", "vi", "ed"]: 119 | warn "lucem: trying editor `" & editor & '`' 120 | editConfiguration(editor) 121 | 122 | # validate the config on-the-go 123 | updateConfig(input, config) 124 | of "run": 125 | info "lucem@" & Version & " is now starting up!" 126 | if input.enabled("dont-check-vulkan"): 127 | info "lucem: --dont-check-vulkan is enabled, ignoring Vulkan initialization test." 128 | else: 129 | deinitVulkan(initVulkan()) 130 | 131 | updateConfig(input, config) 132 | runRoblox(input, config) 133 | of "install-desktop-files": 134 | createLucemDesktopFile() 135 | of "list-gpus": 136 | let instance = initVulkan() 137 | listGpus(instance) 138 | deinitVulkan(instance) 139 | of "clear-cache": 140 | let savedMb = clearCache() 141 | info "lucem: cleared cache calls to reclaim " & $savedMb & " MB of space" 142 | of "shell": 143 | initLucemShell(input) 144 | else: 145 | error "lucem: invalid command `" & input.command & 146 | "`; run `lucem help` for more information." 147 | 148 | when isMainModule: 149 | main() 150 | -------------------------------------------------------------------------------- /src/lucem_overlay.nim: -------------------------------------------------------------------------------- 1 | ## Lucem Overlay 2 | ## Copyright (C) 2024 Trayambak Rai 3 | 4 | import std/[os, osproc, logging, strutils, importutils, base64, times] 5 | import ./[argparser, sugar, config, internal_fonts] 6 | import pkg/[siwin, opengl, nanovg, colored_logger, vmath] 7 | import pkg/siwin/platforms/wayland/[window, windowOpengl] 8 | 9 | privateAccess(WindowWaylandOpengl) 10 | privateAccess(WindowWaylandObj) 11 | privateAccess(WindowWayland) 12 | privateAccess(Window) 13 | 14 | {.passC: gorge("pkg-config --cflags wayland-client").} 15 | {.passL: gorge("pkg-config --libs wayland-client").} 16 | {.passC: gorge("pkg-config --cflags x11").} 17 | {.passL: gorge("pkg-config --libs x11").} 18 | {.passC: gorge("pkg-config --cflags xcursor").} 19 | {.passL: gorge("pkg-config --libs xcursor").} 20 | {.passC: gorge("pkg-config --cflags xext").} 21 | {.passL: gorge("pkg-config --libs xext").} 22 | {.passC: gorge("pkg-config --cflags xkbcommon").} 23 | {.passL: gorge("pkg-config --libs xkbcommon").} 24 | {.passC: gorge("pkg-config --cflags gl").} 25 | {.passL: gorge("pkg-config --libs gl").} 26 | 27 | type 28 | OverlayState* = enum 29 | osOverlay 30 | osUpdateAlert 31 | 32 | Overlay* = object 33 | heading*: string 34 | description*: string 35 | expireTime*: float 36 | state*: OverlayState = osOverlay 37 | 38 | icon*: Option[string] 39 | closed*: bool 40 | config*: Config 41 | 42 | lucemImage*: Image 43 | 44 | vg*: NVGContext 45 | wl*: WindowWaylandOpengl 46 | size*: IVec2 = ivec2(600, 200) 47 | 48 | lastEpoch*: float 49 | timeSpent*: float 50 | 51 | headingFont*: Font 52 | 53 | proc draw*(overlay: var Overlay) = 54 | debug "overlay: redrawing surface" 55 | glViewport(0, 0, overlay.size.x, overlay.size.y) 56 | glClearColor(0, 0, 0, 0) 57 | glClear(GL_COLOR_BUFFER_BIT or 58 | GL_DEPTH_BUFFER_BIT or 59 | GL_STENCIL_BUFFER_BIT) 60 | 61 | overlay.vg.beginFrame(overlay.size.x.cfloat, overlay.size.y.cfloat, 1f) # TODO: fractional scaling support 62 | overlay.vg.roundedRect(0, 0, overlay.size.x.cfloat - 16f, overlay.size.y.cfloat, 16f) 63 | overlay.vg.fillColor(rgba(0.1, 0.1, 0.1, 0.6)) 64 | overlay.wl.m_transparent = true 65 | overlay.vg.fill() 66 | 67 | overlay.vg.fontFace("heading") 68 | overlay.vg.textAlign(haLeft, vaTop) 69 | overlay.vg.fontSize(overlay.config.overlay.headingSize) 70 | overlay.vg.fillColor(white(255)) 71 | 72 | var icon = cast[seq[byte]](LucemIconPng) 73 | overlay.lucemImage = overlay.vg.createImageMem(data = icon) 74 | 75 | if overlay.state == osOverlay: 76 | discard overlay.vg.text(16f, 16f, overlay.heading) 77 | else: 78 | let imgPaint = overlay.vg.imagePattern(16, 16, 60, 60, 0, overlay.lucemImage, 1f) 79 | overlay.vg.beginPath() 80 | overlay.vg.rect(16, 16, 60, 60) 81 | overlay.vg.fillPaint(imgPaint) 82 | overlay.vg.fill() 83 | 84 | discard overlay.vg.text(100f, 16f, overlay.heading) 85 | 86 | overlay.vg.fontFace("heading") 87 | overlay.vg.textAlign(haLeft, vaTop) 88 | overlay.vg.fontSize(overlay.config.overlay.descriptionSize) 89 | overlay.vg.fillColor(white(255)) 90 | overlay.vg.textBox(16f, 100f, 512f, overlay.description.cstring, nil) 91 | 92 | # TODO: icon rendering, even though we don't use them yet 93 | # but it'd be useful for the future 94 | 95 | overlay.vg.endFrame() 96 | 97 | proc initOverlay*(input: Input) {.noReturn.} = 98 | var overlay: Overlay 99 | 100 | let opts = if not input.enabled("update-alert"): 101 | @[ 102 | "heading", 103 | "description", 104 | "expire-time" 105 | ] 106 | else: 107 | @[ 108 | "update-heading", 109 | "update-message" 110 | ] 111 | 112 | overlay.state = if input.enabled("update-alert"): 113 | osUpdateAlert 114 | else: 115 | osOverlay 116 | 117 | for opt in opts: 118 | if (let maybeOpt = input.flag(opt); *maybeOpt): 119 | case opt 120 | of "heading", "update-heading": overlay.heading = decode(&maybeOpt) 121 | of "description", "update-message": overlay.description = decode(&maybeOpt) 122 | of "expire-time": overlay.expireTime = parseFloat(&maybeOpt) 123 | else: 124 | error "overlay: expected flag: " & opt 125 | quit(1) 126 | 127 | if (let oIcon = input.flag("icon"); *oIcon): 128 | overlay.icon = oIcon 129 | 130 | debug "overlay: got all arguments, parsing config" 131 | var config = parseConfig(input) 132 | 133 | debug "overlay: creating surface" 134 | overlay.size = ivec2(config.overlay.width.int32, config.overlay.height.int32) 135 | overlay.wl = newOpenglWindowWayland( 136 | kind = WindowWaylandKind.LayerSurface, 137 | layer = Layer.Overlay, 138 | size = overlay.size, 139 | namespace = "lucem" 140 | ) 141 | overlay.wl.setKeyboardInteractivity(LayerInteractivityMode.OnDemand) 142 | var anchors: seq[LayerEdge] 143 | 144 | if overlay.state == osOverlay: 145 | for value in config.overlay.anchors.split('-'): 146 | debug "overlay: got anchor: " & value 147 | case value.toLowerAscii() 148 | of "left", "l": anchors &= LayerEdge.Left 149 | of "right", "r": anchors &= LayerEdge.Right 150 | of "top", "up", "u": anchors &= LayerEdge.Top 151 | of "bottom", "down", "d": anchors &= LayerEdge.Bottom 152 | else: 153 | warn "overlay: unhandled anchor: " & value 154 | else: 155 | anchors = @[LayerEdge.Left, LayerEdge.Right, LayerEdge.Top, LayerEdge.Bottom] 156 | 157 | overlay.wl.setAnchor(anchors) 158 | # overlay.wl.setExclusiveZone(10000) 159 | overlay.wl.m_transparent = true 160 | 161 | overlay.config = move(config) 162 | 163 | debug "overlay: loading OpenGL" 164 | loadExtensions() 165 | 166 | debug "overlay: creating NanoVG instance" 167 | nvgInit(glGetProc) 168 | overlay.vg = nvgCreateContext({ 169 | nifAntialias 170 | }) 171 | var data = 172 | if (config.overlay.font.len > 0 and fileExists(config.overlay.font)): 173 | cast[seq[byte]](readFile(config.overlay.font)) 174 | else: 175 | cast[seq[byte]](IbmPlexSans) 176 | 177 | overlay.headingFont = overlay.vg.createFontMem( 178 | "heading", 179 | data 180 | ) 181 | overlay.lastEpoch = epochTime() 182 | overlay.timeSpent = 0f 183 | 184 | overlay.wl.eventsHandler.onRender = proc(event: RenderEvent) = 185 | overlay.draw() 186 | 187 | overlay.wl.eventsHandler.onTick = proc(event: TickEvent) = 188 | if overlay.expireTime == 0f: 189 | return # Infinite alert 190 | 191 | let epoch = epochTime() 192 | let elapsed = epoch - overlay.lastEpoch 193 | 194 | overlay.timeSpent += elapsed 195 | overlay.lastEpoch = epoch 196 | 197 | debug "overlay: " & $overlay.timeSpent & "s / " & $overlay.expireTime & 's' 198 | 199 | if overlay.timeSpent >= overlay.expireTime: 200 | info "overlay: Completed lifetime. Closing!" 201 | overlay.wl.close() 202 | 203 | overlay.wl.eventsHandler.onKey = proc(event: KeyEvent) = 204 | if overlay.state == osUpdateAlert: 205 | case event.key 206 | of enter: 207 | overlay.description = "Lucem is updating itself. Please wait." 208 | discard execCmd(findExe("lucem") & " update") 209 | overlay.description = "Done!" 210 | overlay.wl.close() 211 | else: overlay.wl.close() 212 | else: 213 | overlay.wl.close() 214 | 215 | overlay.wl.run() 216 | quit(0) 217 | 218 | proc main = 219 | addHandler(newColoredLogger()) 220 | let input = parseInput() 221 | 222 | initOverlay(input) 223 | 224 | when isMainModule: main() 225 | -------------------------------------------------------------------------------- /src/lucemd.nim: -------------------------------------------------------------------------------- 1 | ## Lucem daemon 2 | ## Copyright (C) 2024 Trayambak Rai 3 | import std/[os, logging, strutils, json] 4 | import ./[argparser, config, sugar, proto, notifications] 5 | import ./api/[games, thumbnails, ipinfo] 6 | import pkg/[colored_logger, netty, pretty] 7 | 8 | const 9 | Version* {.strdefine: "NimblePkgVersion".} = "???" 10 | 11 | type 12 | Daemon* = object 13 | reactor*: Reactor 14 | config*: Config 15 | shouldQuit*: bool = false 16 | 17 | proc onGameJoined*(daemon: var Daemon, data: string) = 18 | var 19 | foundBeginningOfJson = false 20 | jdata: string 21 | 22 | for c in data: 23 | if c == '}': 24 | jdata &= '}' 25 | break 26 | 27 | if not foundBeginningOfJson: 28 | if c == '{': 29 | foundBeginningOfJson = true 30 | jdata &= c 31 | 32 | continue 33 | else: 34 | jdata &= c 35 | 36 | debug "lucem: join metadata: " & jdata 37 | 38 | let 39 | placeId = $parseJson(jdata)["placeId"].getInt() 40 | universeId = getUniverseFromPlace(placeId) 41 | 42 | gameData = getGameDetail(universeId) 43 | thumbnail = getGameIcon(universeId) 44 | 45 | if !gameData: 46 | warn "lucem: failed to fetch game data; RPC will not be set." 47 | return 48 | 49 | if !thumbnail: 50 | warn "lucem: failed to fetch game thumbnail; RPC will not be set." 51 | return 52 | 53 | let 54 | data = &gameData 55 | icon = &thumbnail 56 | 57 | info "lucem: Joined game!" 58 | info "Name: " & data.name 59 | info "Description: " & data.description 60 | info "Price: " & (if *data.price: $(&data.price) & " robux" else: "free") 61 | info "Developer: " 62 | info " Name: " & data.creator.name 63 | info " Verified: " & $data.creator.hasVerifiedBadge 64 | 65 | proc onServerIPRevealed*(daemon: var Daemon, ipAddr: string) = 66 | #[if not daemon.config.lucem.notifyServerRegion: 67 | return]# 68 | 69 | debug "lucem: server IP is: " & ipAddr 70 | 71 | if (let ipinfo = getIpInfo(ipAddr); *ipinfo): 72 | let data = &ipinfo 73 | notify( 74 | "Server Location", 75 | "This server is located in $1, $2, $3" % [data.city, data.region, data.country], 76 | 10000, 77 | ) 78 | else: 79 | warn "lucem: failed to get server location data!" 80 | notify("Server Location", "Failed to fetch server location data.", 10000) 81 | 82 | proc loop*(daemon: var Daemon) = 83 | info "lucemd: entering loop" 84 | while not daemon.shouldQuit: 85 | sleep(5) 86 | daemon.reactor.tick() 87 | for message in daemon.reactor.messages: 88 | let opacket = message.getPacket() 89 | if not *opacket: 90 | warn "lucemd: got bogus data, ignoring." 91 | continue 92 | 93 | let packet = &opacket 94 | case packet.magic 95 | of mgOnGameJoin: 96 | let data = packet.arguments[0].getDecodedString() 97 | daemon.onGameJoined(data) 98 | of mgOnServerIp: 99 | let data = packet.arguments[0].getDecodedString() 100 | daemon.onServerIPRevealed(data) 101 | 102 | proc initDaemon*(input: Input, config: Config) = 103 | info "lucemd: initializing daemon..." 104 | let 105 | port = 106 | if (let opt = input.flag("port"); *opt): 107 | parseUint(&opt) 108 | else: 109 | config.daemon.port 110 | 111 | info "lucemd: initializing reactor at port " & $port 112 | var daemon: Daemon 113 | daemon.reactor = newReactor("127.0.0.1", int port) 114 | daemon.loop() 115 | 116 | proc main = 117 | addHandler(newColoredLogger()) 118 | 119 | info "lucemd@" & Version & " starting up!" 120 | let input = parseInput() 121 | 122 | if input.enabled("verbose"): 123 | setLogFilter(lvlAll) 124 | else: 125 | setLogFilter(lvlInfo) 126 | 127 | let config = parseConfig(input) 128 | initDaemon(input, config) 129 | 130 | when isMainModule: 131 | main() 132 | -------------------------------------------------------------------------------- /src/meta.nim: -------------------------------------------------------------------------------- 1 | const 2 | Version* {.strdefine: "NimblePkgVersion".} = "" 3 | DiscordRpcId* {.intdefine: "DiscordRpcId".} = 1276893796679942195 4 | LicenseString* = 5 | """ 6 | Copyright 2024 Trayambak Rai (xTrayambak) and Lucem Authors 7 | 8 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 11 | 12 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 13 | """ 14 | -------------------------------------------------------------------------------- /src/notifications.nim: -------------------------------------------------------------------------------- 1 | ## Copyright (C) 2024 Trayambak Rai 2 | import std/[os, osproc, logging, strutils, posix, base64] 3 | import ./sugar 4 | 5 | proc notifyFallback*( 6 | heading: string, 7 | description: string, 8 | expireTime: uint64 = 240000, 9 | icon: Option[string] = none(string), 10 | ) = 11 | debug "notifications: using libnotify fallback (cringe guhnome user detected)" 12 | debug "notifications: preparing notify-send command" 13 | debug "notifications: heading = $1, description = $2, expireTime = $3" % 14 | [heading, description, $expireTime] 15 | 16 | let exe = findExe("notify-send") 17 | if exe.len < 1: 18 | warn "notifications: notify-send was not found; ignoring." 19 | return 20 | 21 | var cmd = exe & ' ' 22 | cmd &= '"' & heading & "\" " 23 | cmd &= '"' & description & "\" " 24 | cmd &= "--expire-time=" & $expireTime & ' ' 25 | cmd &= "--app-name=Lucem " 26 | 27 | if *icon: 28 | debug "notifications: icon was specified: " & &icon 29 | cmd &= "--icon=" & &icon 30 | else: 31 | debug "notifications: icon was not specified." 32 | 33 | let code = execCmd(cmd) 34 | if code == 0: 35 | debug "notifications: notify-send exited successfully." 36 | else: 37 | warn "notifications: notify-send exited with abnormal exit code (" & $code & ')' 38 | warn "notifications: command was: " & cmd 39 | 40 | proc notify*( 41 | heading: string, 42 | description: string, 43 | expireTime: uint64 = 240000, 44 | icon: Option[string] = none(string) 45 | ) = 46 | var worker = findExe("lucem_overlay") 47 | if getEnv("XDG_CURRENT_DESKTOP") == "GNOME" or (defined(release) and worker.len < 1): 48 | warn "notifications: we're either on GNOME or the lucem overlay binary is missing, something went horribly wrong!" 49 | notifyFallback(heading, description, expireTime, icon) 50 | return 51 | 52 | let pid = fork() 53 | 54 | if worker.len < 1 and not defined(release): 55 | worker = "./lucem_overlay" 56 | 57 | if pid == 0: 58 | let cmd = worker & " --heading:\"" & heading.encode() & "\" --description:\"" & description.encode() & "\" --expire-time:" & $(expireTime.int / 1000) & ' ' & (if *icon: "--icon:" & &icon else: "") 59 | debug "notifications: executing command: " & cmd 60 | discard execCmd(cmd) 61 | quit(0) 62 | 63 | proc presentUpdateAlert*( 64 | heading: string, 65 | message: string, 66 | blocks: bool = false 67 | ) = 68 | var worker = findExe("lucem_overlay") 69 | if getEnv("XDG_CURRENT_DESKTOP") == "GNOME" or (defined(release) and worker.len < 1): 70 | warn "notifications: we're either on GNOME or the lucem overlay binary is missing, something went horribly wrong!" 71 | notifyFallback(heading, message, 240000) 72 | return 73 | 74 | let pid = if not blocks: 75 | debug "notifications: blocks = false, forking process" 76 | fork() 77 | else: 78 | debug "notifications: blocks = true, billions must hang up" 79 | 0 80 | 81 | if worker.len < 1 and not defined(release): 82 | worker = "./lucem_overlay" 83 | 84 | if pid == 0: 85 | let cmd = worker & " --update-alert --update-heading:\"" & heading.encode() & "\" --update-message:\"" & message.encode() & '"' 86 | debug "notifications: executing command: " & cmd 87 | discard execCmd(cmd) 88 | quit(0) 89 | -------------------------------------------------------------------------------- /src/patches/bring_back_oof.nim: -------------------------------------------------------------------------------- 1 | ## Patch to bring back the old "Oof" sound 2 | ## Copyright (C) 2024 Trayambak Rai 3 | import std/[os, logging, strutils] 4 | import ../[http, common] 5 | 6 | const 7 | LucemPatchOofSoundUrl* {.strdefine.} = 8 | "https://github.com/pizzaboxer/bloxstrap/raw/main/Bloxstrap/Resources/Mods/Sounds/OldDeath.ogg" 9 | SoberSoundResourcesPath* {.strdefine.} = 10 | "$1/.var/app/" & SOBER_APP_ID & "/data/sober/assets/content/sounds/" 11 | 12 | proc enableOldOofSound*(enable: bool = true) = 13 | let 14 | basePath = SoberSoundResourcesPath % [getHomeDir()] 15 | newFp = basePath / "ouch.ogg.new" 16 | usedFp = basePath / "ouch.ogg" 17 | oldFp = basePath / "ouch.ogg.old" 18 | 19 | if enable: 20 | if not fileExists(newFp): 21 | info "patches: bringing back old oof sound" 22 | debug "patches: moving new oof sound to separate file" 23 | moveFile(usedFp, newFp) 24 | 25 | if not fileExists(oldFp): 26 | debug "patches: fetching old oof sound" 27 | let oldSound = httpGet(LucemPatchOofSoundUrl) 28 | writeFile(usedFp, oldSound) 29 | else: 30 | debug "patches: old sound is already downloaded, simply moving it instead." 31 | moveFile(oldFp, usedFp) 32 | 33 | info "patches: old oof sound should be restored!" 34 | else: 35 | debug "patches: old oof sound patch seems to be applied, ignoring." 36 | else: 37 | if not fileExists(newFp): 38 | debug "patches: old oof sound was not enabled, ignoring." 39 | else: 40 | info "patches: bringing back new oof sound" 41 | debug "patches: moving old oof sound to separate file" 42 | moveFile(usedFp, oldFp) 43 | moveFile(newFp, usedFp) 44 | 45 | info "patches: new oof sound should be restored!" 46 | -------------------------------------------------------------------------------- /src/patches/patch_fonts.nim: -------------------------------------------------------------------------------- 1 | ## Patch for forcing a particular font on all text in the Roblox client 2 | ## Copyright (C) 2024 Trayambak Rai 3 | import std/[os, logging, strutils] 4 | import ../common 5 | 6 | const SoberFontsPath* {.strdefine.} = 7 | "$1/.var/app/" & SOBER_APP_ID & "/data/sober/assets/content/fonts/" 8 | 9 | proc setClientFont*(fontPath: string, exclude: seq[string]) = 10 | let basePath = SoberFontsPath % [getHomeDir()] 11 | if fontPath.len > 0: 12 | debug "lucem: patching client font to `" & fontPath & '`' 13 | if not fileExists(fontPath): 14 | error "lucem: cannot set client font to `" & fontPath & "`: file not found" 15 | return 16 | 17 | when defined(release): 18 | if fileExists(basePath / "lucem_patched") and 19 | readFile(basePath / "lucem_patched") == fontPath: 20 | debug "lucem: font patch is already applied, ignoring." 21 | return 22 | 23 | writeFile(basePath / "lucem_patched", fontPath) 24 | discard existsOrCreateDir(basePath / "old_roblox_fonts") 25 | var patched: int 26 | 27 | for kind, file in walkDir(basePath): 28 | if kind != pcFile: 29 | continue 30 | if file.contains("lucem_patched"): 31 | continue 32 | let splitted = file.splitFile() 33 | 34 | if file.splitPath().tail in exclude: 35 | info "lucem: font file \"" & file & 36 | "\" is in the exclusion list, not overriding it." 37 | continue 38 | 39 | moveFile(file, basePath / "old_roblox_fonts" / splitted.name & splitted.ext) 40 | copyFile(fontPath, file) 41 | inc patched 42 | debug "lucem: " & fontPath & " >> " & file 43 | 44 | info "lucem: patched " & $patched & " fonts successfully!" 45 | else: 46 | if not fileExists(basePath / "lucem_patched"): 47 | return 48 | 49 | debug "lucem: restoring client font to defaults" 50 | 51 | # clear out all patched fonts 52 | for kind, file in walkDir(basePath): 53 | if kind != pcFile: 54 | continue 55 | if not file.endsWith("otf") and not file.endsWith("ttf"): 56 | continue 57 | 58 | removeFile(file) 59 | 60 | debug "lucem: moving old fonts back to their place" 61 | 62 | if not dirExists(basePath / "old_roblox_fonts"): 63 | error "lucem: the old Roblox fonts were somehow deleted!" 64 | error "lucem: you probably messed something up, run `lucem init` to fix it up." 65 | quit(1) 66 | 67 | var restored: int 68 | for kind, file in walkDir(basePath / "old_roblox_fonts"): 69 | if kind != pcFile: 70 | continue 71 | let splitted = file.splitFile() 72 | 73 | debug "lucem: " & file & " >> " & basePath / splitted.name & splitted.ext 74 | moveFile(file, basePath / splitted.name & splitted.ext) 75 | 76 | inc restored 77 | 78 | removeFile(basePath / "lucem_patched") 79 | removeDir(basePath / "old_roblox_fonts") 80 | info "lucem: restored " & $restored & " fonts to their defaults!" 81 | -------------------------------------------------------------------------------- /src/patches/sun_and_moon_textures.nim: -------------------------------------------------------------------------------- 1 | ## Tweak the sun and moon textures 2 | ## Copyright (C) 2024 Trayambak Rai 3 | import std/[os, strutils, logging] 4 | import ../common 5 | 6 | const SoberSkyTexturesPath* {.strdefine.} = 7 | "$1/.var/app/" & SOBER_APP_ID & "/data/sober/assets/content/sky/" 8 | 9 | proc setSunTexture*(path: string) = 10 | var path = deepCopy(path) 11 | let basePath = SoberSkyTexturesPath % [getHomeDir()] 12 | 13 | if fileExists(basePath / "lucem_patched_sun") and 14 | readFile(basePath / "lucem_patched_sun") == path: 15 | debug "lucem: skipping patching sun texture - already marked as patched" 16 | return 17 | 18 | if path.len > 0: 19 | debug "lucem: patching sun texture to: " & path 20 | if not fileExists(path) and not symlinkExists(path): 21 | error "lucem: cannot find file: " & path & " as a substitute for the sun texture!" 22 | quit(1) 23 | 24 | if symlinkExists(path): 25 | path = expandSymlink(path) 26 | debug "lucem: resolving symlink to: " & path 27 | 28 | moveFile(basePath / "sun.jpg", basePath / "sun.jpg.old") 29 | copyFile(path, basePath / "sun.jpg") 30 | writeFile(basePath / "lucem_patched_sun", path) 31 | 32 | info "lucem: patched sun texture successfully!" 33 | else: 34 | if not fileExists(basePath / "lucem_patched_sun"): 35 | return 36 | 37 | debug "lucem: reverting sun texture to default" 38 | 39 | if not fileExists(basePath / "sun.jpg.old"): 40 | error "lucem: cannot restore sun texture to default as `sun.jpg.old` is missing!" 41 | error "lucem: you probably messed around with the files, run `lucem init` to fix everything." 42 | quit(1) 43 | 44 | removeFile(basePath / "lucem_patched_sun") 45 | moveFile(basePath / "sun.jpg.old", basePath / "sun.jpg") 46 | 47 | info "lucem: restored sun texture back to default successfully!" 48 | 49 | proc setMoonTexture*(path: string) = 50 | let basePath = SoberSkyTexturesPath % [getHomeDir()] 51 | if fileExists(basePath / "lucem_patched_moon") and 52 | readFile(basePath / "lucem_patched_moon") == path: 53 | debug "lucem: skipping patching moon texture - already marked as patched" 54 | return 55 | 56 | if path.len > 0: 57 | debug "lucem: patching moon texture to: " & path 58 | if not fileExists(path): 59 | error "lucem: cannot find file: " & path & " as a substitute for the moon texture!" 60 | quit(1) 61 | 62 | moveFile(basePath / "moon.jpg", basePath / "moon.jpg.old") 63 | copyFile(path, basePath / "moon.jpg") 64 | writeFile(basePath / "lucem_patched_moon", path) 65 | 66 | info "lucem: patched moon texture successfully!" 67 | else: 68 | if not fileExists(basePath / "lucem_patched_moon"): 69 | return 70 | 71 | debug "lucem: reverting moon texture to default" 72 | 73 | if not fileExists(basePath / "moon.jpg.old"): 74 | error "lucem: cannot restore sun texture to default as `moon.jpg.old` is missing!" 75 | error "lucem: you probably messed around with the files, run `lucem init` to fix everything." 76 | quit(1) 77 | 78 | removeFile(basePath / "lucem_patched_moon") 79 | moveFile(basePath / "moon.jpg.old", basePath / "moon.jpg") 80 | 81 | info "lucem: restored moon texture back to default successfully!" 82 | -------------------------------------------------------------------------------- /src/patches/windowing_backend.nim: -------------------------------------------------------------------------------- 1 | ## Force Sober to use X11 or Wayland 2 | ## Copyright (C) 2024 Trayambak Rai 3 | import std/[os, osproc, logging] 4 | import ../[config, common, cache_calls] 5 | 6 | proc setWindowingBackend*(backend: WindowingBackend) = 7 | if getState("windowing_backend", WindowingBackend, autodetectWindowingBackend()) == 8 | backend: 9 | debug "lucem: windowing backend already set, ignoring." 10 | return 11 | 12 | let pkexec = findExe("pkexec") & ' ' 13 | 14 | debug "lucem: setting windowing backend to " & $backend 15 | 16 | if backend == WindowingBackend.Wayland: 17 | debug "lucem: restricting X11 socket permissions from Sober" 18 | if execCmd( 19 | pkexec & "flatpak override --nofilesystem=/tmp/.X11-unix " & SOBER_APP_ID 20 | ) != 0: 21 | error "lucem: failed to restrict Sober's access to the X11 socket!" 22 | return 23 | 24 | debug "lucem: giving Sober access to the Wayland socket(s)" 25 | if execCmd(pkexec & "flatpak override --socket=wayland " & SOBER_APP_ID) != 0: 26 | error "lucem: failed to give Sober access to the Wayland socket!" 27 | return 28 | else: 29 | debug "lucem: giving Sober access to the X11 socket" 30 | if execCmd(pkexec & "flatpak override --filesystem=/tmp/.X11-unix " & SOBER_APP_ID) != 31 | 0: 32 | error "lucem: failed to give Sober access to the X11 socket!" 33 | return 34 | 35 | debug "lucem: restricting Wayland socket permissions from Sober" 36 | if execCmd( 37 | pkexec & "flatpak override --no-talk-name=org.freedesktop.Platform.Wayland " & 38 | SOBER_APP_ID 39 | ) != 0: 40 | error "lucem: failed to revoke Sober's access to the Wayland socket!" 41 | return 42 | 43 | storeState("windowing_backend", backend) 44 | -------------------------------------------------------------------------------- /src/proto.nim: -------------------------------------------------------------------------------- 1 | ## Shared protocol between lucem client and daemon 2 | ## Copyright (C) 2024 Trayambak Rai 3 | import std/[json, options, logging, base64] 4 | import pkg/[jsony, netty] 5 | 6 | type 7 | Magic* = enum 8 | mgOnGameJoin 9 | mgOnServerIp 10 | 11 | Packet* = object 12 | magic*: Magic 13 | arguments*: seq[JsonNode] 14 | 15 | proc getDecodedString*(node: JsonNode): string {.inline.} = 16 | node 17 | .getStr() 18 | .decode() 19 | 20 | proc getPacket*(message: Message): Option[Packet] = 21 | try: 22 | return some( 23 | fromJson(message.data, Packet) 24 | ) 25 | except JsonParsingError as exc: 26 | warn "proto: unable to reinterpret JSON as packet: " & exc.msg 27 | warn "proto: buffer is as follows:" 28 | echo message.data 29 | 30 | proc serialize*(packet: Packet): string = 31 | var pckt: Packet 32 | pckt.magic = packet.magic 33 | for arg in packet.arguments: 34 | pckt.arguments &= ( 35 | case arg.kind 36 | of JString: 37 | newJString(encode(arg.getStr())) 38 | else: arg 39 | ) 40 | 41 | pckt.toJson 42 | -------------------------------------------------------------------------------- /src/shell/core.nim: -------------------------------------------------------------------------------- 1 | ## Lucem shell 2 | ## "soon:tm:" - tray 3 | ## Copyright (C) 2024 Trayambak Rai 4 | import std/[os, strutils, json, logging, posix, tables, osproc] 5 | import owlkettle, owlkettle/adw 6 | import 7 | ../[config, argparser, cache_calls, fflags, notifications, desktop_files, fs, sober_config] 8 | 9 | type ShellState* {.pure.} = enum 10 | Client 11 | Lucem 12 | Tweaks 13 | FflagEditor 14 | 15 | viewable LucemShell: 16 | state: 17 | ShellState = Client 18 | config: 19 | ptr Config 20 | 21 | showFpsCapOpt: 22 | bool 23 | showFpsCapBuff: 24 | string 25 | 26 | telemetryOpt: 27 | bool 28 | 29 | backendOpt: 30 | seq[string] = @["Wayland", "X11"] 31 | backendBuff: 32 | int 33 | 34 | launcherBuff: 35 | string 36 | 37 | discordRpcOpt: 38 | bool 39 | serverLocationOpt: 40 | bool 41 | 42 | oldOofSound: 43 | bool 44 | customFontPath: 45 | string 46 | 47 | sunImgPath: 48 | string 49 | moonImgPath: 50 | string 51 | 52 | apkVersionBuff: 53 | string 54 | currFflagBuff: 55 | string 56 | 57 | prevFflagBuff: 58 | string 59 | 60 | automaticApkUpdates: 61 | bool 62 | 63 | pollingDelayBuff: 64 | string 65 | 66 | method view(app: LucemShellState): Widget = 67 | var parsedFflags: SoberFFlags 68 | try: 69 | parseFflags(app.config[], parsedFflags) 70 | except FFlagParseError as exc: 71 | warn "shell: failed to parse fflags: " & exc.msg 72 | notify("Failed to parse FFlags", exc.msg) 73 | debug "shell: reverting to previous state" 74 | app.config[].client.fflags = app.prevFflagBuff 75 | app.currFflagBuff = app.prevFflagBuff 76 | 77 | result = gui: 78 | Window: 79 | title = "Lucem" 80 | defaultSize = (860, 640) 81 | 82 | AdwHeaderBar {.addTitlebar.}: 83 | centeringPolicy = CenteringPolicyLoose 84 | showTitle = true 85 | sizeRequest = (-1, -1) 86 | 87 | Button {.addLeft.}: 88 | sensitive = true 89 | #icon = "view-list-bullet-rtl-symbolic" 90 | text = "Features" 91 | tooltip = "The features provided by Lucem" 92 | 93 | proc clicked() = 94 | app.state = ShellState.Lucem 95 | 96 | Button {.addLeft.}: 97 | sensitive = true 98 | #icon = "applications-games-symbolic" 99 | text = "Client" 100 | tooltip = "Basic settings for Sober (e.g. framerate cap)" 101 | 102 | proc clicked() = 103 | app.state = ShellState.Client 104 | 105 | Button {.addLeft.}: 106 | sensitive = true 107 | #icon = "applications-science-symbolic" 108 | text = "Tweaks" 109 | tooltip = "Restore the Oof sound, use custom fonts and more" 110 | 111 | proc clicked() = 112 | app.state = ShellState.Tweaks 113 | 114 | Button {.addLeft.}: 115 | sensitive = true 116 | #icon = "utilities-terminal-symbolic" 117 | text = "FFlags" 118 | tooltip = "Add and remove FFlags easily" 119 | 120 | proc clicked() = 121 | app.state = ShellState.FflagEditor 122 | 123 | Button {.addRight.}: 124 | style = [ButtonFlat] 125 | icon = "media-floppy-symbolic" # floppy disk as a save icon (system icon) 126 | tooltip = "Save the modified configuration" 127 | 128 | proc clicked() = 129 | debug "shell: updated configuration file" 130 | app.config[].save() 131 | 132 | Button {.addRight.}: 133 | style = [ButtonFlat] 134 | icon = "bookmark-new-symbolic" 135 | tooltip = "Add desktop entries for Lucem" 136 | 137 | proc clicked() = 138 | debug "shell: created .desktop files" 139 | createLucemDesktopFile() 140 | 141 | Button {.addRight.}: 142 | style = [ButtonFlat] 143 | icon = "input-gaming-symbolic" 144 | tooltip = "Save configuration and launch Sober through Lucem" 145 | 146 | proc clicked() = 147 | debug "shell: save config, exit config editor and launch lucem" 148 | app.config[].save() 149 | app.scheduleCloseWindow() 150 | 151 | if fork() == 0: 152 | debug "shell: we are the child - launching `lucem run`" 153 | quit(execCmd("lucem run")) 154 | else: 155 | debug "shell: we are the parent - quitting" 156 | quit(0) 157 | 158 | Box: 159 | 160 | case app.state 161 | of ShellState.Tweaks: 162 | PreferencesPage: 163 | 164 | PreferencesGroup: 165 | sizeRequest = (760, 560) 166 | title = "Tweaks and Patches" 167 | description = "These are some optional tweaks to customize your experience." 168 | 169 | ActionRow: 170 | title = "Bring Back the \"Oof\" Sound" 171 | subtitle = 172 | "This replaces the new \"Eugh\" death sound with the classic \"Oof\" sound." 173 | CheckButton {.addSuffix.}: 174 | state = app.oldOofSound 175 | 176 | proc changed(state: bool) = 177 | app.oldOofSound = not app.oldOofSound 178 | app.config[].tweaks.oldOof = app.oldOofSound 179 | 180 | debug "shell: old oof sound state: " & $app.oldOofSound 181 | 182 | ActionRow: 183 | title = "Custom Client Font" 184 | subtitle = 185 | "Override the Roblox fonts using your own font, Note: Emojis will not be overriden." 186 | 187 | Entry {.addSuffix.}: 188 | text = app.customFontPath 189 | 190 | proc changed(text: string) = 191 | debug "shell: custom font entry changed: " & text 192 | app.customFontPath = text 193 | 194 | proc activate() = 195 | let font = app.customFontPath.expandTilde() 196 | if font.len > 0 and not isAccessible(font): 197 | notify("Cannot set custom font", "File not accessible.") 198 | return 199 | 200 | app.config[].tweaks.font = font 201 | debug "shell: custom font path is set to: " & app.customFontPath 202 | 203 | ActionRow: 204 | title = "Custom Sun Texture" 205 | subtitle = 206 | "For games that don't use a custom sun texture, your specified texture will be shown instead." 207 | 208 | Entry {.addSuffix.}: 209 | text = app.sunImgPath 210 | placeholder = "" 211 | 212 | proc changed(text: string) = 213 | debug "shell: sun image entry changed: " & text 214 | app.sunImgPath = text 215 | 216 | proc activate() = 217 | let path = app.sunImgPath.expandTilde() 218 | if path.len > 0 and not isAccessible(path): 219 | notify("Cannot set sun texture", "Texture file not accessible.") 220 | return 221 | 222 | app.config[].tweaks.sun = path 223 | debug "shell: custom sun texture path is set to: " & app.sunImgPath 224 | 225 | ActionRow: 226 | title = "Custom Moon Texture" 227 | subtitle = 228 | "For games that don't use a custom moon texture, your specified texture will be shown instead." 229 | 230 | Entry {.addSuffix.}: 231 | text = app.moonImgPath 232 | placeholder = "" 233 | 234 | proc changed(text: string) = 235 | debug "shell: moon image entry changed: " & text 236 | app.moonImgPath = text 237 | 238 | proc activate() = 239 | let path = app.moonImgPath.expandTilde() 240 | if path.len > 0 and not isAccessible(path): 241 | notify("Cannot set moon texture", "Texture file not accessible.") 242 | return 243 | 244 | app.config[].tweaks.moon = path 245 | debug "shell: custom moon texture path is set to: " & app.sunImgPath 246 | 247 | of ShellState.Lucem: 248 | PreferencesPage: 249 | 250 | PreferencesGroup: 251 | sizeRequest = (760, 560) 252 | title = "Lucem Settings" 253 | description = 254 | "These are settings to tweak the features that Lucem provides." 255 | 256 | ActionRow: 257 | title = "Discord Rich Presence" 258 | subtitle = 259 | "This requires you to have either the official Discord client or an arRPC-based one." 260 | CheckButton {.addSuffix.}: 261 | state = app.discordRpcOpt 262 | 263 | proc changed(state: bool) = 264 | app.discordRpcOpt = not app.discordRpcOpt 265 | app.config[].lucem.discordRpc = app.discordRpcOpt 266 | 267 | debug "shell: discord rpc option state: " & 268 | $app.config[].lucem.discordRpc 269 | 270 | ActionRow: 271 | title = "Notify the Server Region" 272 | subtitle = 273 | "When joining a game, a notification will be sent containing the location of the server." 274 | CheckButton {.addSuffix.}: 275 | state = app.serverLocationOpt 276 | 277 | proc changed(state: bool) = 278 | app.serverLocationOpt = not app.serverLocationOpt 279 | app.config[].lucem.notifyServerRegion = app.serverLocationOpt 280 | 281 | debug "shell: notify server region option state: " & 282 | $app.config[].lucem.notifyServerRegion 283 | 284 | ActionRow: 285 | title = "Clear all API caches" 286 | subtitle = 287 | "This will clear all the API call caches. Some features might be slower the next time you run Lucem." 288 | Button {.addSuffix.}: 289 | icon = "user-trash-symbolic" 290 | style = [ButtonDestructive] 291 | 292 | proc clicked() = 293 | let savedMb = clearCache() 294 | info "shell: cleared out cache and reclaimed " & $savedMb & 295 | " MB of space." 296 | notify("Cleared API cache", $savedMb & " MB of space was freed.") 297 | 298 | of ShellState.FflagEditor: 299 | PreferencesPage: 300 | 301 | PreferencesGroup: 302 | sizeRequest = (760, 560) 303 | title = "FFlag Editor" 304 | description = 305 | "Please keep in mind that some games prohibit the modifications of FFlags. You might get banned from them due to modifying FFlags. Modifying FFlags can also make the Roblox client unstable in some cases. Do not touch these if you don't know what you're doing!" 306 | 307 | Box(orient = OrientY, spacing = 6, margin = 12): 308 | Box(orient = OrientX, spacing = 6) {.expand: false.}: 309 | Entry: 310 | text = app.currFflagBuff 311 | placeholder = "Key=Value" 312 | 313 | proc changed(text: string) = 314 | app.currFflagBuff = text 315 | debug "shell: fflag entry mutated: " & app.currFflagBuff 316 | 317 | proc activate() = 318 | debug "shell: fflag entry: " & app.currFflagBuff 319 | 320 | # TODO: add validation 321 | app.config.client.fflags &= '\n' & app.currFflagBuff 322 | 323 | Button {.expand: false.}: 324 | icon = "list-add-symbolic" 325 | style = [ButtonSuggested] 326 | 327 | proc clicked() = 328 | # TODO: add validation 329 | app.config[].client.fflags &= '\n' & app.currFflagBuff 330 | 331 | debug "shell: fflag entry: " & app.currFflagBuff 332 | 333 | Frame: 334 | ScrolledWindow: 335 | ListBox: 336 | for key, value in parsedFflags: 337 | Box: 338 | spacing = 6 339 | Label: 340 | xAlign = 0 341 | text = 342 | key & " = " & ( 343 | if value.kind == JString: 344 | value.getStr() 345 | elif value.kind == JInt: 346 | $value.getInt() 347 | elif value.kind == JBool: 348 | $value.getBool() 349 | elif value.kind == JFloat: 350 | $value.getFloat() 351 | else: "" 352 | ) 353 | 354 | Button {.expand: false.}: 355 | icon = "list-remove-symbolic" 356 | style = [ButtonDestructive] 357 | 358 | proc clicked() = 359 | # FIXME: move the line selection and deletion code to src/fflags.nim! this is a total mess! 360 | debug "shell: deleting fflag: " & key 361 | app.prevFflagBuff = app.currFflagBuff 362 | 363 | var 364 | i = -1 365 | line = -1 366 | fflags = 367 | app.config[].client.fflags.splitLines().deepCopy() 368 | 369 | for l in app.config[].client.fflags.splitLines(): 370 | inc i 371 | if l.startsWith(key): 372 | line = i 373 | break 374 | 375 | assert line != -1, 376 | "Cannot find line at which key \"" & key & "\" is defined!" 377 | debug "shell: config key to delete is at line " & $line 378 | fflags.del(line) 379 | 380 | app.config[].client.fflags = newString(0) 381 | for i, line in fflags: 382 | app.config[].client.fflags &= line 383 | if i >= fflags.len - 1: 384 | app.config[].client.fflags &= '\n' 385 | 386 | of ShellState.Client: 387 | PreferencesPage: 388 | 389 | PreferencesGroup: 390 | sizeRequest = (760, 560) 391 | title = "Client Settings" 392 | description = "These settings are mostly applied via FFlags." 393 | 394 | ActionRow: 395 | title = "Disable Telemetry" 396 | subtitle = 397 | "Disable the Roblox client telemetry via FFlags. Note: This only enables/disables relevant FFLags." 398 | CheckButton {.addSuffix.}: 399 | state = app.telemetryOpt 400 | 401 | proc changed(state: bool) = 402 | app.telemetryOpt = not app.telemetryOpt 403 | app.config[].client.telemetry = app.telemetryOpt 404 | 405 | debug "shell: disable telemetry is now set to: " & $app.telemetryOpt 406 | 407 | ActionRow: 408 | title = "Disable FPS cap" 409 | subtitle = "Some games might ban you if they detect this. Note: Games dependent on framerate might misbehave." 410 | CheckButton {.addSuffix.}: 411 | state = app.showFpsCapOpt 412 | 413 | proc changed(state: bool) = 414 | app.showFpsCapOpt = not app.showFpsCapOpt 415 | app.config[].client.fps = if state: 60 else: 60 416 | 417 | debug "shell: disable/enable fps cap button state: " & 418 | $app.showFpsCapOpt 419 | debug "shell: fps is now set to: " & $app.config[].client.fps 420 | 421 | if app.showFpsCapOpt: 422 | ActionRow: 423 | title = "FPS Cap" 424 | subtitle = "Change the FPS cap to values Roblox doesn't offer. Avoid using a value above the monitor refresh rate." 425 | Entry {.addSuffix.}: 426 | text = app.showFpsCapBuff 427 | placeholder = "e.g. 30, 60 (default), 144, etc." 428 | 429 | proc changed(text: string) = 430 | debug "shell: fps cap entry changed: " & text 431 | app.showFpsCapBuff = text 432 | 433 | proc activate() = 434 | try: 435 | debug "shell: parse fps cap buffer as integer: " & 436 | app.showFpsCapBuff 437 | let val = parseInt(app.showFpsCapBuff) 438 | app.config[].client.fps = val 439 | debug "shell: fps cap is now set to: " & $app.config[].client.fps 440 | except ValueError as exc: 441 | debug "shell: fps cap buffer has invalid value: " & 442 | app.showFpsCapBuff 443 | debug "shell: " & exc.msg 444 | 445 | ComboRow: 446 | title = "Backend" 447 | subtitle = 448 | "Which display server Sober will use, on Wayland X11 will use Xwayland." 449 | items = app.backendOpt 450 | selected = app.backendBuff 451 | 452 | proc select(selectedIndex: int) = 453 | debug "shell: launcher entry changed: " & app.backendOpt[selectedIndex] 454 | app.backendBuff = selectedIndex 455 | 456 | proc activate() = 457 | app.config[].client.backend = app.backendOpt[app.backendBuff] 458 | debug "shell: backend is set to: " & app.backendOpt[app.backendBuff] 459 | 460 | ActionRow: 461 | title = "Launcher" 462 | subtitle = 463 | "Lucem will launch Sober with a specified command. This is optional." 464 | Entry {.addSuffix.}: 465 | text = app.launcherBuff 466 | placeholder = "e.g. gamemoderun" 467 | 468 | proc changed(text: string) = 469 | debug "shell: launcher entry changed: " & text 470 | app.launcherBuff = text 471 | 472 | proc activate() = 473 | app.config[].client.launcher = app.launcherBuff 474 | debug "shell: launcher is set to: " & app.launcherBuff 475 | 476 | ActionRow: 477 | title = "Polling Delay" 478 | subtitle = 479 | "Add a tiny delay in seconds to the event watcher thread. This is unlikely to impact performance on modern systems." 480 | 481 | Entry {.addSuffix.}: 482 | text = app.pollingDelayBuff 483 | placeholder = "100 is sufficient for most modern systems" 484 | 485 | proc changed(text: string) = 486 | debug "shell: polling delay entry changed: " & text 487 | app.pollingDelayBuff = text 488 | 489 | proc activate() = 490 | try: 491 | app.config[].lucem.pollingDelay = app.pollingDelayBuff.parseUint() 492 | debug "shell: polling delay is set to: " & app.pollingDelayBuff 493 | except ValueError as exc: 494 | warn "shell: failed to parse polling delay (" & app.pollingDelayBuff & 495 | "): " & exc.msg 496 | 497 | ActionRow: 498 | title = "Automatic APK Updates" 499 | subtitle = 500 | "If enabled, Sober will automatically fetch the latest versions of Roblox's APK for you from the Play Store." 501 | CheckButton {.addSuffix.}: 502 | state = app.automaticApkUpdates 503 | 504 | proc changed(state: bool) = 505 | app.automaticApkUpdates = state 506 | app.config[].client.apkUpdates = state 507 | 508 | proc initLucemShell*(input: Input) {.inline.} = 509 | info "shell: initializing GTK4 shell" 510 | info "shell: libadwaita version: v" & $AdwVersion[0] & '.' & $AdwVersion[1] 511 | var config = parseConfig(input) 512 | 513 | adw.brew( 514 | gui( 515 | LucemShell( 516 | config = addr(config), 517 | state = ShellState.Lucem, 518 | showFpsCapOpt = config.client.fps != 60, 519 | showFpsCapBuff = $config.client.fps, 520 | discordRpcOpt = config.lucem.discordRpc, 521 | telemetryOpt = config.client.telemetry, 522 | launcherBuff = config.client.launcher, 523 | serverLocationOpt = config.lucem.notifyServerRegion, 524 | customFontPath = config.tweaks.font, 525 | oldOofSound = config.tweaks.oldOof, 526 | sunImgPath = config.tweaks.sun, 527 | moonImgPath = config.tweaks.moon, 528 | pollingDelayBuff = $config.lucem.pollingDelay, 529 | automaticApkUpdates = config.client.apkUpdates, 530 | ) 531 | ) 532 | ) 533 | 534 | info "lucem: saving configuration changes" 535 | config.save() 536 | info "lucem: done!" 537 | -------------------------------------------------------------------------------- /src/shell/loading_screen.nim: -------------------------------------------------------------------------------- 1 | ## Loading screen which shows up when `lucem run` is invoked 2 | ## Copyright (C) 2024 Trayambak Rai 3 | import std/[logging, locks] 4 | import owlkettle, owlkettle/adw, owlkettle/bindings/gtk 5 | 6 | type LoadingState* = enum 7 | WaitingForLaunch 8 | WaitingForRoblox 9 | Done 10 | Exited 11 | 12 | viewable LoadingScreen: 13 | state: 14 | ptr LoadingState 15 | 16 | slock: 17 | Lock 18 | 19 | method view*(app: LoadingScreenState): Widget = 20 | debug "shell: loading screen is being reupdated" 21 | debug "shell: app state: \"" & $app.state[] & '"' 22 | 23 | proc refresh(): bool = 24 | debug "shell: refresh: acquiring lock on `ptr LoadingState`" 25 | withLock app.slock: 26 | if app.state[] == Done: 27 | debug "shell: loading screen is done, hiding surface" 28 | gtk_widget_hide(app.unwrapInternalWidget()) 29 | elif app.state[] == Exited: 30 | debug "shell: roblox exited, we're quitting" 31 | quit(0) 32 | 33 | true 34 | 35 | discard addGlobalTimeout(100, refresh) 36 | 37 | result = gui: 38 | Window: 39 | title = "Lucem" 40 | defaultSize = (637, 246) 41 | 42 | Box: 43 | Label {.hAlign: AlignCenter, vAlign: AlignCenter.}: 44 | text = "Loading Roblox..." 45 | useMarkup = true 46 | 47 | proc initLoadingScreen*(state: ptr LoadingState, lock: Lock) {.inline.} = 48 | adw.brew(gui(LoadingScreen(state = state, slock = lock))) 49 | -------------------------------------------------------------------------------- /src/sober_config.nim: -------------------------------------------------------------------------------- 1 | ## Sober configuration manager 2 | ## literally 1984 3 | import std/[os, logging, json, tables] 4 | import pkg/jsony 5 | import ./common 6 | 7 | type 8 | SoberFFlags* = Table[string, JsonNode] 9 | 10 | SoberConfig* = object 11 | fflags*: SoberFFlags 12 | bring_back_oof*: bool = false 13 | discord_rpc_enabled*: bool = true 14 | touch_mode*: string = "off" 15 | use_opengl*: bool = false 16 | 17 | proc getSoberConfigPath*: string {.inline.} = 18 | getHomeDir() / ".var" / "app" / SOBER_APP_ID / "config" / "sober" / "config.json" 19 | 20 | proc getSoberConfig*: SoberConfig = 21 | let path = getSoberConfigPath() 22 | debug "lucem: getting sober config file at: " & path 23 | 24 | try: 25 | return fromJson(readFile(path), SoberConfig) 26 | except jsony.JsonError as exc: 27 | warn "lucem: cannot read sober config file: " & exc.msg 28 | 29 | proc saveSoberConfig*(config: SoberConfig) {.inline.} = 30 | writeFile( 31 | getSoberConfigPath(), toJson config 32 | ) 33 | -------------------------------------------------------------------------------- /src/sober_state.nim: -------------------------------------------------------------------------------- 1 | ## Manage Sober's state 2 | ## Copyright (C) 2024 Trayambak Rai 3 | import std/[os, logging] 4 | import ./[common, argparser, config] 5 | import jsony 6 | 7 | type 8 | StateV1* = object 9 | app_version*: string = "2.642.635" 10 | bring_back_oof*: bool = false 11 | brought_back_oof*: bool = false 12 | enable_discord_rpc*: bool = true 13 | fixed_assets*: bool = false 14 | fullscreen*: bool = true 15 | 16 | StateV2* = object 17 | has_seen_onboarding*: bool = false 18 | r1_enabled*: bool = false 19 | 20 | SoberState* = object 21 | v1*: StateV1 = default(StateV1) 22 | v2*: StateV2 = default(StateV2) 23 | 24 | func getSoberStatePath*(): string {.inline.} = 25 | getHomeDir() / ".var" / "app" / SOBER_APP_ID / "data" / "sober" / "state" 26 | 27 | proc loadSoberState*(): SoberState = 28 | ## Load Sober's state JSON file. 29 | ## This function is guaranteed to succeed. 30 | debug "lucem: loading sober's internal state" 31 | if not fileExists(getSoberStatePath()): 32 | error "lucem: sober has not been launched before as the internal state file could not be found!" 33 | error "lucem: falling back to lucem's preferred configuration" 34 | return default(SoberState) 35 | 36 | template deserializationFailure() = 37 | warn "lucem: failed to deserialize sober's internal state: " & exc.msg 38 | warn "lucem: falling back to lucem's preferred configuration" 39 | return default(SoberState) 40 | 41 | template readFailure() = 42 | warn "lucem: failed to read sober's internal state: " & exc.msg 43 | warn "lucem: falling back to lucem's preferred configuration" 44 | return default(SoberState) 45 | 46 | template unknownFailure() = 47 | warn "lucem: an unknown error occured during reading sober's internal state: " & 48 | exc.msg 49 | warn "lucem: falling back to lucem's preferred configuration" 50 | return default(SoberState) 51 | 52 | try: 53 | return fromJson(readFile(getSoberStatePath()), SoberState) 54 | except JsonError as exc: 55 | deserializationFailure 56 | except OSError as exc: 57 | readFailure 58 | except IOError as exc: 59 | readFailure 60 | except ValueError as exc: 61 | deserializationFailure 62 | except CatchableError as exc: 63 | unknownFailure 64 | 65 | proc patchSoberState*(input: Input, config: Config) = 66 | var state = loadSoberState() 67 | 68 | if config.lucem.discord_rpc: 69 | state.v1.enableDiscordRpc = true 70 | 71 | if not input.enabled("use-sober-patching", "P"): 72 | debug "lucem: disabling sober's builtin patching" 73 | state.v1.bringBackOof = false 74 | state.v1.broughtBackOof = false 75 | else: 76 | warn "lucem: you have explicitly stated that you wish to use Sober's oof sound patcher." 77 | warn "lucem: we already provide this feature, but if you choose to use Sober's patcher, do not report any bugs that arise from this to us." 78 | 79 | state.v2.r1_enabled = config.client.apkUpdates 80 | if state.v2.r1_enabled: 81 | debug "lucem: enabling apk updates" 82 | else: 83 | debug "lucem: disabling apk updates" 84 | 85 | writeFile(getSoberStatePath(), toJson(state)) 86 | -------------------------------------------------------------------------------- /src/sugar.nim: -------------------------------------------------------------------------------- 1 | import std/options 2 | 3 | proc `*`*[T](opt: Option[T]): bool {.inline.} = 4 | opt.isSome 5 | 6 | proc `!`*[T](opt: Option[T]): bool {.inline.} = 7 | opt.isNone 8 | 9 | proc `&`*[T](opt: Option[T]): T {.inline.} = 10 | opt.get() 11 | 12 | export options 13 | -------------------------------------------------------------------------------- /src/systemd.nim: -------------------------------------------------------------------------------- 1 | ## professional lucemd systemd service installer 2 | ## Copyright (C) 2024 Trayambak Rai 3 | import std/[os, strutils, logging, osproc] 4 | import ./meta 5 | 6 | const 7 | SystemdServiceTemplate = """ 8 | [Unit] 9 | Description=Lucem $1 10 | After=network.target 11 | 12 | [Service] 13 | ExecStart=$2 14 | Restart=on-failure 15 | 16 | [Install] 17 | WantedBy=default.target 18 | """ 19 | 20 | proc installSystemdService* = 21 | info "lucem: installing systemd service" 22 | let service = SystemdServiceTemplate % [ 23 | Version, getAppDir() / "lucemd" 24 | ] 25 | let servicesDir = getConfigDir() / "systemd" / "user" 26 | 27 | if not dirExists(servicesDir): 28 | discard existsOrCreateDir(getConfigDir() / "systemd") 29 | discard existsOrCreateDir(servicesDir) 30 | 31 | writeFile(servicesDir / "lucem.service", service) 32 | if execCmd(findExe("systemctl") & " enable lucem.service --user --now") != 0: 33 | error "lucem: failed to install systemd service for daemon!" 34 | 35 | proc relaunchSystemdService* = 36 | info "lucem: relaunching systemd service" 37 | if execCmd( 38 | findExe("systemctl") & " restart lucem.service --user" 39 | ) != 0: 40 | error "lucem: failed to restart lucem daemon!" 41 | -------------------------------------------------------------------------------- /src/updater.nim: -------------------------------------------------------------------------------- 1 | ## Lucem auto-updater 2 | ## Copyright (C) 2024 Trayambak Rai 3 | import std/[os, osproc, logging, tempfiles, distros, posix] 4 | import pkg/[semver, jsony] 5 | import ./[http, argparser, config, sugar, meta, notifications, desktop_files, systemd] 6 | import ./commands/init 7 | 8 | type 9 | ReleaseAuthor* = object 10 | login*: string 11 | id*: uint32 12 | node_id*, avatar_url*, gravatar_id*, url*, html_url*, followers_url*, following_url*, gists_url*, starred_url*, subscriptions_url*, organizations_url*, repos_url*, events_url*, received_events_url*, `type`*, user_view_type*: string 13 | site_admin*: bool 14 | 15 | LucemRelease* = object 16 | url*, assets_url*, upload_url*, html_url*: string 17 | id*: uint64 18 | author*: ReleaseAuthor 19 | node_id*, tag_name*, target_commitish*, name*: string 20 | draft*, prerelease*: bool 21 | created_at*, published_at*: string 22 | assets*: seq[string] 23 | tarball_url*, zipball_url*: string 24 | 25 | const 26 | LucemReleaseUrl {.strdefine.} = "https://api.github.com/repos/xTrayambak/lucem/releases/latest" 27 | 28 | proc getLatestRelease*(): Option[LucemRelease] {.inline.} = 29 | debug "lucem: auto-updater: fetching latest release" 30 | try: 31 | return httpGet( 32 | LucemReleaseUrl 33 | ).fromJson( 34 | LucemRelease 35 | ).some() 36 | except JsonError as exc: 37 | warn "lucem: auto-updater: cannot parse release data: " & exc.msg 38 | except CatchableError as exc: 39 | warn "lucem: auto-updater: cannot get latest release: " & exc.msg & " (" & $exc.name & ')' 40 | 41 | proc runUpdateChecker*(config: Config) = 42 | if not config.lucem.autoUpdater: 43 | debug "lucem: auto-updater: skipping update checks as auto-updater is disabled in config" 44 | return 45 | 46 | when defined(lucemDisableAutoUpdater): 47 | debug "lucem: auto-updater: skipping update checks as auto-updater is disabled by a compile-time flag (--define:lucemDisableAutoUpdater)" 48 | return 49 | 50 | debug "lucem: auto-updater: running update checks" 51 | let release = getLatestRelease() 52 | 53 | if !release: 54 | warn "lucem: auto-updater: cannot get release, skipping checks." 55 | return 56 | 57 | let data = &release 58 | let newVersion = try: 59 | parseVersion(data.tagName).some() 60 | except semver.ParseError as exc: 61 | warn "lucem: auto-updater: cannot parse new semver: " & exc.msg & " (" & data.tagName & ')' 62 | none(semver.Version) 63 | 64 | if !newVersion: 65 | return 66 | 67 | let currVersion = parseVersion(meta.Version) 68 | 69 | debug "lucem: auto-updater: new version: " & $(&newVersion) 70 | debug "lucem: auto-updater: current version: " & $currVersion 71 | 72 | let newVer = &newVersion 73 | 74 | if newVer > currVersion: 75 | info "lucem: found a new release! (" & $newVer & ')' 76 | presentUpdateAlert( 77 | "Lucem " & $newVer & " is out!", 78 | "A new version of Lucem is out. You are strongly advised to update to this release for bug fixes and other improvements. Press Enter to update. Press any other key to close this dialog.", blocks = true 79 | ) 80 | elif newVer == currVersion: 81 | debug "lucem: user is on the latest version of lucem" 82 | elif newVer < currVersion: 83 | warn "lucem: version mismatch (newest release: " & $newVer & ", version this binary was tagged as: " & $currVersion & ')' 84 | warn "lucem: are you using a development version? :P" 85 | 86 | proc postUpdatePreparation = 87 | info "lucem: beginning post-update preparation" 88 | 89 | debug "lucem: killing any running lucem instances and lucemd" 90 | 91 | # FIXME: Use POSIX APIs for this. 92 | discard execCmd("kill $(pidof lucemd)") 93 | discard execCmd("kill $(pidof lucem)") 94 | 95 | debug "lucem: initializing lucem" 96 | initializeSober(default(Input)) 97 | createLucemDesktopFile() 98 | installSystemdService() 99 | 100 | info "lucem: completed post-update preparation" 101 | 102 | proc updateLucem* = 103 | info "lucem: checking for updates" 104 | let release = getLatestRelease() 105 | 106 | if !release: 107 | error "lucem: cannot get current release" 108 | return 109 | 110 | let currVersion = parseVersion(meta.Version) 111 | let newVer = parseVersion((&release).tagName) 112 | 113 | if newVer != currVersion: 114 | info "lucem: found new version! (" & $newVer & ')' 115 | let wd = getCurrentDir() 116 | let tmpDir = createTempDir("lucem-", '-' & $newVer) 117 | 118 | let git = findExe("git") 119 | let nimble = findExe("nimble") 120 | 121 | if nimble.len < 1: 122 | error "lucem: cannot find `nimble`!" 123 | quit(1) 124 | 125 | if git.len < 1: 126 | error "lucem: cannot find `git`!" 127 | quit(1) 128 | 129 | info "lucem: cloning source code" 130 | if (let code = execCmd(git & " clone https://github.com/xTrayambak/lucem.git " & tmpDir); code != 0): 131 | error "lucem: git exited with non-zero exit code: " & $code 132 | quit(1) 133 | 134 | discard chdir(tmpDir.cstring) 135 | 136 | info "lucem: switching to " & $newVer & " branch" 137 | if (let code = execCmd(git & " checkout " & $newVer); code != 0): 138 | error "lucem: git exited with non-zero exit code: " & $code 139 | quit(1) 140 | 141 | info "lucem: compiling lucem" 142 | if not detectOs(NixOS): 143 | if (let code = execCmd(nimble & " install"); code != 0): 144 | error "lucem: nimble exited with non-zero exit code: " & $code 145 | quit(1) 146 | else: 147 | info "lucem: Nix environment detected, entering Nix shell" 148 | let nix = findExe("nix-shell") & "-shell" # FIXME: for some reason, `nix-shell` returns the `nix` binary instead here. Perhaps a Nim STL bug 149 | if nix.len < 1: 150 | error "lucem: cannot find `nix-shell`!" 151 | quit(1) 152 | 153 | if (let code = execCmd(nix & " --run \"" & nimble & " install\""); code != 0): 154 | error "lucem: nix-shell or nimble exited with non-zero exit code: " & $code 155 | quit(1) 156 | 157 | info "lucem: updated successfully!" 158 | info "Lucem is now at version " & $newVer 159 | 160 | postUpdatePreparation() 161 | else: 162 | info "lucem: nothing to do." 163 | quit(0) 164 | --------------------------------------------------------------------------------