├── .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 | 
17 | 
18 | 
19 | 
20 | 
21 | 
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 |
142 |
--------------------------------------------------------------------------------
/src/assets/lucem.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/xTrayambak/lucem/ce0e6180321ef3b4c0113044d45df2dc26d463bf/src/assets/lucem.png
--------------------------------------------------------------------------------
/src/assets/lucem.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
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 |
--------------------------------------------------------------------------------