diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..65060f8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +bin/* +fatjar diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d159169 --- /dev/null +++ b/LICENSE @@ -0,0 +1,339 @@ + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, write to the Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + , 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. diff --git a/README.md b/README.md index c3f6b2f..02adef5 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,122 @@ -# bloxbox-java +
Image
+

BloxBox

+

A safer way to have your kids play. Since April 2026

+ +

Keeping secrets safe. Since April 2026

+ +## Overview + The Roblox launcher that puts parents in control for PC using java. Only showing approved games. The parent-controlled Roblox launcher whitelists approved games, block everything else, and let kids request new ones. + +Please submit all problems/issues/sugeestions to https://github.com/stormtheory/bloxbox-java/issues + +Image + +--- + +## ๐Ÿ–ฅ๏ธ Features and Design + +--- + +## ๐Ÿ–ฅ๏ธ Platforms Supported (Tested On) + + โœ… Debian 11+ + โœ… Ubuntu 20.04/22.04+ + โœ… Linux Mint 20+ + โœ… Windows 7/10/11 + +## โš™๏ธ Requirements + +* Java JDK 17+ (tested on newer versions) + +No external database or installer required. + +--- + +## ๐Ÿš€ Future Improvements + +**[ New Features ]** + + +**[ Big Ticket Items ]** + + +**[ New Data Storage ]** + + +--- + +## INSTALL: +1) Download the latest released .jar package files off of github at https://github.com/stormtheory/bloxbox-java/releases and install on your system. + + #### Windows/Linux/MacOS #### + # Download then execute like normal or use Linux command: + + java -jar BloxBox-Java-*.jar + + +2) Manual Install without Package Manager, run commands: + + Download the zip file of the code, off of Github. This is found under the [<> Code] button on https://github.com/stormtheory/bloxbox-java. + + Extract directory from the zip file. Run the following commands within the directory. + + #/In Folder Requirements + Backend.java + GUI.java + IdleTimeoutManager.java + lib/sqlite-jdbc-3.53.0.0.jar + lib/argon2-jvm-2.12.jar + lib/bcprov-jdk18on-1.84.jar + bin/ + icons/ + + + # Linux Install or edit code: + cd java-password-vault + ./build.sh -br # Build and Run + + # or + + ./build.sh -r # Run + + + # Windows Install or edit code: + .\run.bat -br # Build and Run + + # or + + .\run.bat + + +## RUN: +### run the local App + + # Linux: + cd bloxbox-java + ./build.sh -r + + # Windows: + Within the folder run command: + .\run.bat + +## Game Manage / Approvals / Requests + ## Use the following command and arguments: + sudo /opt/bloxbox-launcher/admin.py init โ€” first-time setup + sudo /opt/bloxbox-launcher/admin.py list โ€” show approved games + sudo /opt/bloxbox-launcher/admin.py add โ€” approve a new game + sudo /opt/bloxbox-launcher/admin.py remove โ€” remove an approved game + sudo /opt/bloxbox-launcher/admin.py requests โ€” view pending requests from child + sudo /opt/bloxbox-launcher/admin.py clear-requests โ€” clear all reviewed requests + +## Create .jar file, run commands: + โœ” Works on all platforms + โœ” No classpath needed + โœ” No extra files + + Download the zip file of the code, off of Github. This is found under the `[<> Code]` button on `https://github.com/stormtheory/bloxbox-java`. + + Extract directory from the zip file. Run the following commands within the directory. + + On windows run the `.\run.bat -j` and for Linux run `./build.sh -j` + diff --git a/admin.py b/admin.py new file mode 100755 index 0000000..23c7384 --- /dev/null +++ b/admin.py @@ -0,0 +1,337 @@ +#!/usr/bin/env python3 +""" +admin.py โ€” Parent/admin CLI to manage the BloxBox game whitelist. + +Must be run with sudo to write to /etc/bloxbox_whitelist.json. +The child's account cannot run this without the root password. + +Usage: + sudo admin.py init โ€” first-time setup + sudo admin.py list โ€” show approved games + sudo admin.py add โ€” approve a new game + sudo admin.py remove โ€” remove an approved game + sudo admin.py requests โ€” view pending requests from child + sudo admin.py clear-requests โ€” clear all reviewed requests +""" + +import json +import os +import sys +from pathlib import Path +import importlib.util + +# Load system config from /etc โ€” keeps config out of the app directory +_spec = importlib.util.spec_from_file_location("config", "/etc/bloxbox/config.py") +if _spec is None or _spec.loader is None: + raise FileNotFoundError("System config not found at /etc/bloxbox/config.py") + sys.exit(1) +_config = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_config) + +# Reference values directly +CONFIG_PATH = _config.CONFIG_PATH +CACHE_DIR = _config.CACHE_DIR +CHILD_USER = _config.CHILD_USER +REQUESTS_PATH = _config.REQUESTS_PATH + +# Fallback for testing without root (remove in production) +if os.geteuid() != 0: + print("[admin] โš ๏ธ Not running as root") + print("[admin] Run with sudo.\n") + sys.exit(1) + + +# โ”€โ”€ Whitelist helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +def load_config() -> dict: + """Load existing whitelist config, or return a fresh empty structure.""" + if os.path.exists(CONFIG_PATH): + with open(CONFIG_PATH) as f: + return json.load(f) + return {"games": []} + + +def save_config(data: dict): + """ + Save whitelist config with restrictive permissions. + Root owns the file; world-readable so the launcher can read it as the child user. + """ + with open(CONFIG_PATH, "w") as f: + json.dump(data, f, indent=2) + # 644 = root read/write, everyone else read-only + os.chmod(CONFIG_PATH, 0o644) + print(f"[admin] โœ… Whitelist saved โ†’ {CONFIG_PATH}") + + +def find_place_id(url_or_id: str) -> str: + """ + Extract the Roblox place ID from a game URL or return the raw value if numeric. + + Supports: + - https://www.roblox.com/games/1234567890/Game-Name + - 1234567890 (bare numeric ID) + """ + val = url_or_id.strip() + + # Already a numeric place ID + if val.isdigit(): + return val + + # Extract from roblox.com/games//... URL pattern + import re + match = re.search(r"roblox\.com/games/(\d+)", val) + if match: + return match.group(1) + + print(f"[admin] โš ๏ธ Could not parse place ID from '{val}' โ€” using as-is") + return val + + +# โ”€โ”€ Requests helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +def load_requests() -> list: + """Load pending game requests submitted by the child, or return empty list.""" + if os.path.exists(REQUESTS_PATH): + try: + with open(REQUESTS_PATH) as f: + return json.load(f).get("requests", []) + except (json.JSONDecodeError, PermissionError) as e: + print(f"[admin] Could not read requests file: {e}") + return [] + + +def save_requests(requests: list): + """ + Save the requests list back to disk. + File is world-writable (0622) so the child's account can append new requests. + """ + with open(REQUESTS_PATH, "w") as f: + json.dump({"requests": requests}, f, indent=2) + # 622 = root read/write, everyone else write-only (can't read others' requests) + import shutil + os.chmod(REQUESTS_PATH, 0o622) + shutil.chown(REQUESTS_PATH, user='root', group='root') + print(f"[admin] โœ… Requests file saved โ†’ {REQUESTS_PATH}") + + +# โ”€โ”€ Commands โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +def cmd_list(): + """List all currently approved games.""" + games = load_config().get("games", []) + + if not games: + print("No games approved yet. Use 'add' to approve one.") + return + + print(f"\n{'#':<4} {'Name':<30} {'Place ID':<15} Description") + print("โ”€" * 72) + for i, g in enumerate(games, 1): + print(f"{i:<4} {g['name']:<30} {g['place_id']:<15} {g.get('description', '')}") + print(f"\n{len(games)} game(s) approved.\n") + + +def cmd_add(): + """Interactively approve a new game and add it to the whitelist.""" + print("\nโ”€โ”€ Approve New Game โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€") + print("Paste the Roblox game URL or just the place ID.") + print("Example: https://www.roblox.com/games/185655149/Welcome-to-Bloxburg\n") + + raw = input("Game URL or Place ID: ").strip() + place_id = find_place_id(raw) + + if not place_id: + print("[admin] โŒ No place ID found. Aborting.") + return + + # Friendly display name shown in the launcher + name = input("Display name (shown in launcher): ").strip() + if not name: + name = f"Game {place_id}" + + # Optional short description shown on the card + desc = input("Short description (optional, Enter to skip): ").strip() + + # Confirm before writing + print(f"\n Name: {name}") + print(f" Place ID: {place_id}") + print(f" Description: {desc or '(none)'}") + if input("\nApprove this game? [y/N]: ").strip().lower() != "y": + print("[admin] Cancelled.") + return + + data = load_config() + + # Guard against duplicate place IDs + if any(g["place_id"] == place_id for g in data["games"]): + print(f"[admin] โš ๏ธ Place ID {place_id} is already in the whitelist.") + return + + data["games"].append({ + "name": name, + "place_id": place_id, + "description": desc + }) + save_config(data) + print(f"[admin] โœ… '{name}' added to whitelist.") + + +def cmd_remove(): + """Interactively remove an approved game from the whitelist.""" + data = load_config() + games = data.get("games", []) + + if not games: + print("No games to remove.") + return + + cmd_list() + + try: + num = int(input("Enter number to remove (0 to cancel): ").strip()) + except ValueError: + print("[admin] Invalid input.") + return + + if num == 0: + print("[admin] Cancelled.") + return + + if num < 1 or num > len(games): + print(f"[admin] โŒ Must be 1โ€“{len(games)}.") + return + + game = games[num - 1] + if input(f"Remove '{game['name']}'? [y/N]: ").strip().lower() != "y": + print("[admin] Cancelled.") + return + + data["games"].pop(num - 1) + save_config(data) + print(f"[admin] โœ… '{game['name']}' removed.") + + +def cmd_requests(): + """ + View all pending game requests submitted by the child via the launcher. + Optionally approve one directly from here (calls cmd_add with the URL pre-filled). + """ + requests = load_requests() + + if not requests: + print("\nNo pending requests. ๐ŸŽ‰\n") + return + + print(f"\nโ”€โ”€ Pending Game Requests ({len(requests)}) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€") + for i, r in enumerate(requests, 1): + print(f"\n [{i}] {r.get('timestamp', 'unknown time')}") + print(f" Game: {r.get('game_name', '(none)')} (Place ID: {r.get('place_id', '?')}) (URL: {r.get('url', '?')})") + note = r.get('note', '') + if note: + print(f" Note: {note}") + print() + + # Offer to approve one immediately + ans = input("Enter request number to approve it now, or Enter to skip: ").strip() + if ans.isdigit(): + idx = int(ans) - 1 + if 0 <= idx < len(requests): + place_id = requests[idx].get("place_id", "") + game_name = requests[idx].get("game_name", "") + url = requests[idx].get("url", "") + print(f"\n[admin] Pre-filling: {game_name} (Place ID: {place_id})") + + # Re-use add flow with URL pre-populated + name = input(f"Display name (shown in launcher): ").strip() or f"{game_name}" + desc = input("Short description (optional): ").strip() + + print(f"\n Name: {name}") + print(f" Place ID: {place_id}") + if input("Approve? [y/N]: ").strip().lower() == "y": + data = load_config() + if any(g["place_id"] == place_id for g in data["games"]): + print(f"[admin] โš ๏ธ Already in whitelist.") + else: + data["games"].append({"name": name, "place_id": place_id, "description": desc, "url": url}) + save_config(data) + print(f"[admin] โœ… '{name}' approved and added to whitelist.") + + +def cmd_clear_requests(): + """Clear all pending requests after you've reviewed them.""" + requests = load_requests() + + if not requests: + print("No requests to clear.") + return + + print(f"\n{len(requests)} request(s) will be deleted.") + if input("Clear all requests? [y/N]: ").strip().lower() != "y": + print("[admin] Cancelled.") + return + + save_requests([]) + print("[admin] โœ… All requests cleared.") + + +def cmd_init(): + """ + First-time setup: create both config files with correct permissions. + Run this once after copying the scripts to /opt/bloxbox-launcher/. + """ + import shutil + # โ”€โ”€ Whitelist config โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + if os.path.exists(CONFIG_PATH): + if input(f"Whitelist already exists at {CONFIG_PATH}. Overwrite? [y/N]: ").strip().lower() != "y": + print("[admin] Skipping whitelist init.") + else: + save_config({"games": []}) + print(f"[admin] โœ… Fresh whitelist created โ†’ {CONFIG_PATH}") + else: + save_config({"games": []}) + print(f"[admin] โœ… Whitelist created โ†’ {CONFIG_PATH}") + shutil.chown(CONFIG_PATH, user='root', group='root') + os.chmod(CONFIG_PATH, 0o644) + + # โ”€โ”€ Requests file โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + if not os.path.exists(REQUESTS_PATH): + save_requests([]) + print(f"[admin] โœ… Requests file created โ†’ {REQUESTS_PATH}") + shutil.chown(REQUESTS_PATH, user='root', group='root') + os.chmod(REQUESTS_PATH, 0o622) + else: + print(f"[admin] โ„น๏ธ Requests file already exists โ†’ {REQUESTS_PATH}") + shutil.chown(REQUESTS_PATH, user='root', group='root') + os.chmod(REQUESTS_PATH, 0o622) + + print("\n[admin] Setup complete. Next steps:") + print(" sudo python3 admin.py add โ€” approve your first game") + print(" python3 bloxbox-launcher.py โ€” launch the kid-facing launcher") + +# โ”€โ”€ Entry point โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +COMMANDS = { + "init": cmd_init, + "list": cmd_list, + "add": cmd_add, + "remove": cmd_remove, + "requests": cmd_requests, + "clear-requests": cmd_clear_requests, +} + +def print_usage(): + print("\nUsage: sudo python3 admin.py ") + print("\nCommands:") + for name, fn in COMMANDS.items(): + # Print first line of each function's docstring as a one-liner description + doc = (fn.__doc__ or "").strip().splitlines()[0] + print(f" {name:<20} {doc}") + print() + + +if __name__ == "__main__": + if len(sys.argv) < 2 or sys.argv[1] not in COMMANDS: + print_usage() + sys.exit(1) + + COMMANDS[sys.argv[1]]() diff --git a/bin/.gitkeep b/bin/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/buid.sh b/buid.sh new file mode 100755 index 0000000..0dbfefc --- /dev/null +++ b/buid.sh @@ -0,0 +1,181 @@ +#!/usr/bin/bash +cd "$(dirname "$0")" + +#################################################################### +## +## LINUX ONLY +## Script is to make building, launching, +## and running easier with command line (CLI) arguments +## +## With Love, Stormtheory +## +#################################################################### + + +JAR_FILENAME=BloxBox-Java.jar +DIR_NAME=bloxbox-java + +# No running as root! +ID=$(id -u) +if [ "$ID" == '0' ];then + echo "Not safe to run as root... exiting..." + exit +fi + + + +# ๐Ÿงพ Help text +show_help() { + cat < fatjar/META-INF/MANIFEST.MF + + # ===== Package ===== + cd fatjar && jar cfm ../$JAR_FILENAME META-INF/MANIFEST.MF . && cd .. + echo "#### Done #### run with: java -jar $JAR_FILENAME" +} + +BUILD() { + rm -f ./bin/* + echo "javac -d bin *.java" + + if [ "$DEBUG" != true ];then + javac -d bin *.java + else + javac -Xlint:deprecation -d bin *.java + + fi +} + +RUN(){ + echo "java -cp ./bin launcher $ARGUMENTS" + java -cp ./bin launcher $ARGUMENTS +} + +DEBUG=false +HELP=true +ARGUMENTS= +# ๐Ÿ” Parse options +while getopts ":a:ijdcbrh" opt; do + case ${opt} in + a) + ARGUMENTS=$OPTARG + ;; + c) + TAR_UP=true + DOWNLOADS=true + HELP=false + ;; + j) + JAR + exit + ;; + i) + BUILD=true + HELP=false + ;; + b) + BUILD=true + HELP=false + ;; + r) RUN=true + HELP=false + ;; + d) DEBUG=true + ;; + h) + show_help + exit 0 + ;; + \?) + echo "โŒ Invalid option: -$OPTARG" >&2 + show_help + exit 1 + ;; + :) + echo "โŒ Option -$OPTARG requires an argument." >&2 + show_help + exit 1 + ;; + esac +done + + +if [ "$BUILD" == true ];then + BUILD +fi + +if [ "$TAR_UP" == true ];then + TAR_UP +fi + +if [ "$RUN" == true ];then + RUN +fi + + +if [ "$HELP" == true ];then + show_help + exit 1 +fi diff --git a/icon/bloxbox-icon.png b/icon/bloxbox-icon.png new file mode 100644 index 0000000..6ffaca2 Binary files /dev/null and b/icon/bloxbox-icon.png differ diff --git a/icon/bloxbox-icon.svg b/icon/bloxbox-icon.svg new file mode 100644 index 0000000..201e2d4 --- /dev/null +++ b/icon/bloxbox-icon.svg @@ -0,0 +1,51 @@ + +BloxBox launcher icon +A stylised icon showing a blue box with a game controller, representing the BloxBox parental Roblox launcher + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/icon/bloxbox-words-icon.svg b/icon/bloxbox-words-icon.svg new file mode 100644 index 0000000..6784395 --- /dev/null +++ b/icon/bloxbox-words-icon.svg @@ -0,0 +1,55 @@ + +BloxBox launcher icon +A stylised icon showing a blue box with a game controller, representing the BloxBox parental Roblox launcher + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +BloxBox +LAUNCHER + + diff --git a/install-BloxBox.sh b/install-BloxBox.sh new file mode 100755 index 0000000..3a82960 --- /dev/null +++ b/install-BloxBox.sh @@ -0,0 +1,355 @@ +#!/usr/bin/bash + +# DEFAULTS + LOCK_REQUEST_GAMES=False # True / False + LOCK_REQUEST_PIN_PASS_HASH="" + child_USERNAME= + +ID=$(id -u) +if [ "$ID" != 0 ];then + echo " RUN: sudo $0 ../bloxbox-java.tgz" + exit +else + cd "$(dirname "$0")" +fi + +pwd_current=$(pwd) +current_dir_path=$(echo "${pwd_current%/*}") +current_dir=$(echo "${pwd_current##*/}") + + +if [ -z "$1" ];then + echo " RUN: sudo $0 bloxbox-java.tgz" + exit +else + if echo "$1"|grep -q '.tgz' ;then + echo 'Go time' + else + exit + fi +fi + + SU_USER= + DIR=/opt/bloxbox-java + ETC=/etc/bloxbox + DECKTOP_ICON_FILENAME=bloxbox.desktop + WHITELIST_FILENAME=roblox_whitelist.json + APP_WINDOW_TITLE_NAME='BloxBox' + + + echo " Installing in at $DIR" + echo '';read -p ' Press enter to continue...' THREE + sleep 3 + + tar -C /opt -xzvf $1 + sudo mkdir -p $ETC + + chmod 755 $DIR + chmod 755 $ETC + chmod 644 $DIR/* + chmod 755 $DIR/*.sh + chmod 644 $DIR/*.py + chmod 600 $DIR/tar-up.sh + chmod 700 $DIR/admin.py + chmod 600 $DIR/install-BloxBox.sh + chmod 755 $DIR/icon + chmod 644 $DIR/icon/* + chown root:root -R $DIR + chown root:root -R $ETC + + +INSTALL_ETC_CONFIG() { + if [ -z $child_USERNAME ];then + echo '';echo " INFO: Child's username will be installed into the configuration file. $ETC/config.py" + read -p " What is the child's username? $> " child_USERNAME + + if [ ! -z $child_USERNAME ];then + HOME_DIR=$(echo "/home/$child_USERNAME") + if [ ! -d $HOME_DIR ];then + echo "$HOME_DIR was not found..." + exit + fi + else + echo "ERROR: \$child_USERNAME not found" + exit + fi + + if [ -f $ETC/config.py ];then + mv $ETC/config.py $ETC/old.config.py + chmod 600 $ETC/old.config.py + fi + + while true;do + read -p " Lock the request for games behind a password/pin? [y/n] $> " YESSIR + if [ "$YESSIR" == y ];then + read -rsp " Type your password/pin/passcode you would like to use to protect the Request Games button $> " PINPASSWORD + COUNT=0 + COUNT=$(echo -n "$PINPASSWORD" | wc -c) + if [ "$COUNT" -ge 2 ];then + LOCK_REQUEST_PIN_PASS_HASH=$(echo -n "$PINPASSWORD" | sha256sum | awk '{print $1}') + LOCK_REQUEST_GAMES=True + break + else + echo " ### ERROR: needs to be greater than 2 characters... ###" + continue + fi + fi + done + +#### CONFIG FILE +echo "from pathlib import Path + +# โ”€โ”€ Paths โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +CHILD_USER = \"$child_USERNAME\" # โ† change this to your son's username + +CONFIG_PATH = \"/etc/bloxbox/roblox_whitelist.json\" # Approved games list (root-owned) +APP_WINDOW_TITLE_NAME = \"$APP_WINDOW_TITLE_NAME\" + +ROBLOX_GAME_SEARCH_URL = \"https://www.roblox.com/charts?device=computer&country=us\" + +# Requests file โ€” lives in the child's home directory so they can write to it freely +# Parent reads it with: sudo cat /home/CHILDNAME/.bloxbox_requests.json +REQUESTS_PATH = f\"/home/{CHILD_USER}/.cache/bloxbox_launcher/requests.json\" + +### Launcher GUI +# Thumbnail cache directory โ€” stored in child's home, safe to delete any time +CACHE_DIR = Path.home() / \".cache\" / \"bloxbox_launcher\" / \"thumbnails\" +CLIENT_REQUESTS_PATH = Path.home() / \".cache\" / \"bloxbox_launcher\" / \"requests.json\" + +GAME_CATEGORIES = ['๐ŸŽญ Role Playing', '๐ŸŽ๏ธ Racing', '๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘ง Family', '๐Ÿ“š Education', '๐ŸŒ World Exploring'] +GAME_UNSORTED_CATEGORY = '๐Ÿ”ง Unsorted' + +LOCK_REQUEST_GAMES = \"$LOCK_REQUEST_GAMES\" # True / False +LOCK_REQUEST_PIN_PASS_HASH = \"$LOCK_REQUEST_PIN_PASS_HASH\" + +GAME_APPROVAL_NEEDED = \"True\" # True / False +LOCK_APPROVAL_PIN = \"True\" # True / False +LOCK_APPROVAL_PIN_PASS_HASH = \"\" + +ROBLOX_GAME_SEARCH_URL = 'https://www.roblox.com/charts?device=computer&country=us' +" > $ETC/config.py + chmod 644 $ETC/config.py + chown root:root -R $ETC + fi + chmod 644 $ETC/config.py + chown root:root -R $ETC +} + +DEFAULT_JSON() { + if [ -f $ETC/$WHITELIST_FILENAME ];then + mv $ETC/$WHITELIST_FILENAME $ETC/old.$WHITELIST_FILENAME + chmod 600 $ETC/old.$WHITELIST_FILENAME + fi + +echo '{ + "games": [ + { + "name": "[\ud83c\udf7c] Welcome to Bloxburg \ud83c\udfe1", + "place_id": "185655149", + "category": "Role Playing", + "description": "", + "url": "https://www.roblox.com/games/185655149/Welcome-to-Bloxburg" + }, + { + "name": "\ud83d\udc23 Creatures of Sonaria \ud83d\udc07", + "place_id": "5233782396", + "category": "Role Playing", + "description": "", + "url": "https://www.roblox.com/games/5233782396/Creatures-of-Sonaria-Survive-Kaiju-Animals" + }, + { + "name": "\ud83c\udfc0Basketball Legends\ud83c\udfc0", + "place_id": "14259168147", + "description": "", + "url": "https://www.roblox.com/games/14259168147/Basketball-Legends" + }, + { + "name": "Brookhaven \ud83c\udfe1", + "place_id": "4924922222", + "url": "https://www.roblox.com/games/4924922222/Brookhaven-RP", + "category": "Role Playing", + "description": "" + }, + { + "name": "Bike of Hell", + "place_id": "14943334555", + "category": "Racing", + "description": "" + }, + { + "name": "Waterpark", + "place_id": "76731635", + "description": "" + }, + { + "name": "Car Suspension Test", + "place_id": "6816975827", + "category": "Racing", + "description": "" + }, + { + "name": "Driving-Empire-Car-Racing", + "place_id": "3351674303", + "category": "Racing", + "description": "" + }, + { + "name": "Feather Family", + "place_id": "1365404657", + "category": "Family", + "description": "" + }, + { + "name": "Car Crushers 2", + "place_id": "654732683", + "category": "Racing", + "description": "" + }, + { + "name": "Basketball: Zero", + "place_id": "130739873848552", + "description": "" + }, + { + "name": "United States Capitol [RP]", + "place_id": "120992074793516", + "category": "Education", + "description": "" + }, + { + "name": "Math Tower \ud83e\udde0", + "place_id": "76490888522129", + "category": "Education", + "description": "", + "url": "https://www.roblox.com/games/76490888522129/Math-Tower" + }, + { + "name": "Infinite Math \ud83e\udde0", + "place_id": "77972109461154", + "category": "Education", + "description": "", + "url": "https://www.roblox.com/games/77972109461154/Infinite-Math" + } + ] +} +' > $ETC/$WHITELIST_FILENAME +chmod 644 $ETC/$WHITELIST_FILENAME +echo "Config File placed at: $ETC/$WHITELIST_FILENAME" +ls -al $ETC/$WHITELIST_FILENAME +} + + +DEFAULT_DESKTOP_ICON() { +echo '[Desktop Entry] +Type=Application +Name=Bloxbox Roblox Launcher +GenericName=Bloxbox Roblox Launcher +Comment=Play, chat & explore more safely on Roblox +Icon=/opt/bloxbox-java/icon/bloxbox-icon.svg +Exec=/opt/bloxbox-java/run_bloxbox_gui.sh +Name[en_US]=Bloxbox Roblox Launcher +Keywords=roblox;vinegar;game;gaming;social;experience;launcher; +MimeType=x-scheme-handler/roblox;x-scheme-handler/roblox-player; +Categories=Game; +Terminal=false +PrefersNonDefaultGPU=true +Actions=open-settings; +X-Flatpak=org.vinegarhq.Sober +' > /usr/share/applications/$DECKTOP_ICON_FILENAME +chmod 755 /usr/share/applications/$DECKTOP_ICON_FILENAME +update-desktop-database +echo "Desktop Icon placed at: /usr/share/applications/$DECKTOP_ICON_FILENAME" +ls -al /usr/share/applications/$DECKTOP_ICON_FILENAME + +echo '[Desktop Entry] +Type=Application +Name=Bloxbox Roblox Launcher +GenericName=Bloxbox Roblox Launcher +Comment=Play, chat & explore more safely on Roblox +Icon=/opt/bloxbox-java/icon/bloxbox-icon.svg +Exec=/opt/bloxbox-java/run_bloxbox_gui.sh +Name[en_US]=Bloxbox Roblox Launcher +Keywords=roblox;vinegar;game;gaming;social;experience;launcher; +MimeType=x-scheme-handler/roblox;x-scheme-handler/roblox-player; +Categories=Game; +Terminal=false +PrefersNonDefaultGPU=true +Actions=open-settings; +X-Flatpak=org.vinegarhq.Sober +' > $HOME_DIR/Desktop/$DECKTOP_ICON_FILENAME +chmod 755 $HOME_DIR/Desktop/$DECKTOP_ICON_FILENAME +#chown root:root $HOME_DIR/Desktop/$DECKTOP_ICON_FILENAME +chown $child_USERNAME:$child_USERNAME $HOME_DIR/Desktop/$DECKTOP_ICON_FILENAME +echo "Desktop Icon placed at: $HOME_DIR/Desktop/$DECKTOP_ICON_FILENAME" +ls -al $HOME_DIR/Desktop/$DECKTOP_ICON_FILENAME +} + + + +if [ ! -f $ETC/config.py ];then + INSTALL_ETC_CONFIG +else + echo "";echo " INFO: this will backup the current file at $ETC/old.config.py" + read -p " Install [Default/New/Fresh] /etc/config.py? [y] $> " SAYnoMore + + if [ "$SAYnoMore" == y ];then + INSTALL_ETC_CONFIG + fi +fi + + +if [ ! -f $ETC/$WHITELIST_FILENAME ];then + sudo python3 $DIR/admin.py init + DEFAULT_JSON +else + echo "";echo " INFO: this will backup the current file at $ETC/old.$WHITELIST_FILENAME" + read -p " Install Default Whitelist Config of Games? [y] $> " SAY + + if [ "$SAY" == y ];then + DEFAULT_JSON + fi +fi + + +if [ -z $child_USERNAME ];then + echo '';read -p " What is the child's username? $> " child_USERNAME + + if [ ! -z $child_USERNAME ];then + HOME_DIR=$(echo "/home/$child_USERNAME") + if [ ! -d $HOME_DIR ];then + echo "$HOME_DIR was not found..." + exit + fi + else + echo "ERROR: \$child_USERNAME not found" + exit + fi +fi + +if [ ! -z $child_USERNAME ];then + if [ ! -f $HOME_DIR/Desktop/$DECKTOP_ICON_FILENAME ];then + DEFAULT_DESKTOP_ICON + else + echo "";echo " WARNING: this will overwrite the current file at $HOME_DIR/Desktop/$DECKTOP_ICON_FILENAME" + read -p " Install Default Desktop Icon? [y] $> " SAYyou + + if [ "$SAYyou" == y ];then + DEFAULT_DESKTOP_ICON + fi + fi +fi +#sudo apt install flatpak + +##### Not sure which is needed + #sudo sysctl -w kernel.unprivileged_userns_clone=1 + #echo 'kernel.unprivileged_userns_clone=1' | sudo tee /etc/sysctl.d/99-userns.conf + #sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 + #echo 'kernel.apparmor_restrict_unprivileged_userns=0' | sudo tee /etc/sysctl.d/99-userns.conf + + + + +#su $SU_USER -c 'flatpak install flathub org.vinegarhq.Sober' + + +exit diff --git a/launcher.java b/launcher.java new file mode 100644 index 0000000..c135796 --- /dev/null +++ b/launcher.java @@ -0,0 +1,1269 @@ +// Compile: javac launcher.java +// Run: java launcher [--debug] [--game-log-output] +/* + * BloxBoxLauncher.java โ€” Kid-facing Roblox whitelist launcher (Java Swing port). + * + * Features: + * - Shows only parent-approved games as tiles with cover art thumbnails + * - Launches directly into the game via roblox:// URI (bypasses Roblox homepage) + * - Request button lets the child submit a new game URL for parent review + * - Requests are saved to ~/.cache/bloxbox_launcher/requests.json (root-owned, parent reviews it) + * + * Run as the child's user account (no sudo needed to launch). + * The config and requests files are root-owned so only the parent can modify them. + * + * Compile: javac launcher.java + * Run: java launcher [--debug] [--game-log-output] + * Or single-file (Java 21+): java launcher.java + * + * Dependencies (all standard JDK โ€” no extra JARs needed): + * javax.swing, java.awt, java.net.http, org.json (bundled via simple parser below) + * + * NOTE: For thumbnail display this uses javax.imageio โ€” no Pillow equivalent needed. + * For JSON parsing a minimal built-in parser is used to avoid external deps; + * swap in org.json or Jackson if you prefer a full JSON library. + */ + +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.Component; +import java.awt.Cursor; +import java.awt.Dimension; +import java.awt.FlowLayout; +import java.awt.Font; +import java.awt.GridLayout; +import java.awt.Image; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.awt.image.BufferedImage; +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.logging.ConsoleHandler; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import javax.imageio.ImageIO; +import javax.swing.BorderFactory; +import javax.swing.Box; +import javax.swing.BoxLayout; +import javax.swing.ImageIcon; +import javax.swing.JButton; +import javax.swing.JDialog; +import javax.swing.JFrame; +import javax.swing.JLabel; +import javax.swing.JOptionPane; +import javax.swing.JPanel; +import javax.swing.JPasswordField; +import javax.swing.JScrollPane; +import javax.swing.JTextField; +import javax.swing.SwingConstants; +import javax.swing.SwingUtilities; +import javax.swing.UIManager; + +public class launcher { + + // โ”€โ”€ Logging โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + private static final Logger LOG = Logger.getLogger("bloxbox"); + + // โ”€โ”€ CLI flags โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + // Mirrors Python argparse: --debug and --game-log-output + private static boolean DEBUG_MODE = false; + private static boolean GAME_LOG_OUTPUT = false; + + // โ”€โ”€ System config paths โ€” root-owned, child cannot modify โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + // These mirror /etc/bloxbox/config.py values โ€” update to match your install + private static final String CONFIG_PATH = "/etc/bloxbox/roblox_whitelist.json"; + private static final String REQUESTS_PATH = System.getProperty("user.home") + ".cache/bloxbox_launcher/requests.json"; + private static final Path CACHE_DIR = Path.of(System.getProperty("user.home"), ".cache", "bloxbox_launcher", "thumbnails"); + private static final String WINDOW_TITLE = "BloxBox Game Launcher"; + + // PIN lock โ€” set LOCK_REQUEST_GAMES=true and provide SHA-256 hash of the PIN + // Generate hash: echo -n "yourpin" | sha256sum + private static final boolean LOCK_REQUEST_GAMES = true; + private static final String LOCK_REQUEST_PIN_PASS_HASH = "d74ff0ee8da3b9806b18c877dbf29bbde50b5bd8e4dad7a3a725000feb82e8f1"; // Set your hash here + + // Roblox browse URL โ€” shown in the fallback request dialog browser + private static final String ROBLOX_GAME_SEARCH_URL = + "https://www.roblox.com/charts?device=computer&country=us"; + + // โ”€โ”€ Roblox API โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + // Direct thumbnail endpoint โ€” takes place ID, no universe ID lookup needed + private static final String THUMBNAIL_API = + "https://thumbnails.roblox.com/v1/places/gameicons?placeIds=%s&size=256x256&format=Png"; + + // โ”€โ”€ Visual settings โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + private static final Color BG_COLOR = Color.decode("#0f0f1a"); // Dark navy background + private static final Color CARD_COLOR = Color.decode("#1a1a2e"); // Slightly lighter card background + private static final Color ACCENT_COLOR = Color.decode("#e94560"); // Red accent (play button) + private static final Color REQUEST_COLOR = Color.decode("#2a6496"); // Blue (request a game button) + private static final Color TEXT_COLOR = Color.decode("#eaeaea"); // Light text + private static final Color SUBTEXT_COLOR = Color.decode("#888888"); // Muted subtext + private static final Color HOVER_COLOR = Color.decode("#252540"); // Card hover highlight + + private static final Font FONT_TITLE = new Font("Georgia", Font.BOLD, 28); + private static final Font FONT_CARD = new Font("Georgia", Font.BOLD, 12); + private static final Font FONT_SMALL = new Font("Monospaced", Font.PLAIN, 10); + private static final Font FONT_BTN = new Font("Georgia", Font.BOLD, 10); + + // Card dimensions โ€” tall enough for thumbnail + name + play button + private static final int CARD_WIDTH = 200; + private static final int CARD_HEIGHT = 300; + private static final int THUMB_SIZE = 160; // Thumbnail display size in pixels + private static final int COLS = 4; // Game cards per row + + // โ”€โ”€ Shared HTTP client โ€” reused for all Roblox API calls โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + private static final HttpClient HTTP = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(8)) + .followRedirects(HttpClient.Redirect.NORMAL) + .build(); + + // โ”€โ”€ Entry point โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + public static void main(String[] args) { + // Parse CLI flags โ€” mirrors Python argparse + for (String arg : args) { + if (arg.equals("--debug") || arg.equals("-d")) DEBUG_MODE = true; + if (arg.equals("--game-log-output") || arg.equals("-l")) GAME_LOG_OUTPUT = true; + } + + // Configure logging level based on --debug flag + LOG.setLevel(DEBUG_MODE ? Level.ALL : Level.INFO); + ConsoleHandler ch = new ConsoleHandler(); + ch.setLevel(DEBUG_MODE ? Level.ALL : Level.INFO); + LOG.addHandler(ch); + LOG.setUseParentHandlers(false); + + // Warn early if config file is missing โ€” parallel to Python FileNotFoundError + if (!Files.exists(Path.of(CONFIG_PATH))) { + LOG.severe("[bloxbox] System config not found at " + CONFIG_PATH); + JOptionPane.showMessageDialog(null, + "System config not found at:\n" + CONFIG_PATH + + "\n\nAsk a parent to set up BloxBox.", + "Config Missing", JOptionPane.ERROR_MESSAGE); + System.exit(1); + } + + // Create cache directory on first run โ€” equivalent to CACHE_DIR.mkdir(parents=True) + try { Files.createDirectories(CACHE_DIR); } + catch (IOException e) { LOG.warning("[bloxbox] Could not create cache dir: " + e.getMessage()); } + + // Launch Swing UI on the Event Dispatch Thread (EDT) โ€” Swing is not thread-safe + SwingUtilities.invokeLater(() -> { + try { UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName()); } + catch (Exception ignored) {} // Fall back to default Metal L&F + + LauncherApp app = new LauncherApp(); + app.setSize(940, 680); + app.setLocationRelativeTo(null); // Centre on screen + app.setVisible(true); + }); + } + + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + // CONFIG HELPERS + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + + /** + * Load the approved games list from the root-owned config file. + * Config format (JSON): {"games": [{"name": "...", "place_id": "...", "description": "..."}, ...]} + * Returns a list of game maps. Returns empty list on error (never null). + */ + static List> loadConfig() { + Path p = Path.of(CONFIG_PATH); + if (!Files.exists(p)) return List.of(); + try { + String raw = Files.readString(p); + return parseGamesJson(raw); + } catch (Exception e) { + LOG.severe("[launcher] Could not read config " + CONFIG_PATH + ": " + e.getMessage()); + return List.of(); + } + } + + /** + * Load pending game requests from the requests file. + * File: /etc/bloxbox/requests.json (written as the child's user, readable by parent) + * Returns list of request maps: [{place_id, game_name, url, note, timestamp}, ...] + */ + static List> loadRequests() { + Path p = Path.of(REQUESTS_PATH); + if (!Files.exists(p)) return new ArrayList<>(); + try { + String raw = Files.readString(p); + return parseRequestsJson(raw); + } catch (Exception e) { + LOG.severe("[launcher] Could not read requests file: " + e.getMessage()); + return new ArrayList<>(); + } + } + + /** + * Append a new game request to the requests JSON file. + * Equivalent to Python's save_request() โ€” reads, appends, writes atomically-ish. + * Returns true on success, false on permission/IO error. + */ + static boolean saveRequest(String placeId, String gameName, String note, String url) { + LOG.fine("[bloxbox] save_request called: " + placeId + " / " + gameName); + LOG.fine("[bloxbox] REQUESTS_PATH: " + REQUESTS_PATH); + LOG.fine("[bloxbox] File exists: " + Files.exists(Path.of(REQUESTS_PATH))); + + List> requests = new ArrayList<>(loadRequests()); + LOG.fine("[bloxbox] Existing requests: " + requests.size()); + + // Build the new request entry + Map entry = new LinkedHashMap<>(); + entry.put("place_id", placeId.strip()); + entry.put("game_name", gameName.strip()); + entry.put("url", url.strip()); + entry.put("note", note.strip()); + entry.put("timestamp", LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)); + requests.add(entry); + + // Serialize and write โ€” simple JSON builder (no external dep) + try { + String json = buildRequestsJson(requests); + Files.writeString(Path.of(REQUESTS_PATH), json, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); + LOG.fine("[bloxbox] Request saved โ†’ " + REQUESTS_PATH); + return true; + } catch (Exception e) { + LOG.severe("[bloxbox] Failed to save request: " + e.getMessage()); + return false; + } + } + + /** + * Verify input PIN against the stored SHA-256 hash. + * If LOCK_REQUEST_GAMES is false, always returns true (PIN check disabled). + * Mirrors Python verify_pin() โ€” hash generated by: echo -n "pin" | sha256sum + */ + static boolean verifyPin(String inputPin) { + if (!LOCK_REQUEST_GAMES) return true; // PIN lock disabled in config + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] dig = md.digest(inputPin.getBytes(StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(); + for (byte b : dig) sb.append(String.format("%02x", b)); + return sb.toString().equals(LOCK_REQUEST_PIN_PASS_HASH); + } catch (NoSuchAlgorithmException e) { + LOG.severe("[bloxbox] SHA-256 not available: " + e.getMessage()); + return false; + } + } + + /** + * Kill any running Sober process โ€” mirrors Python terminateSober(). + * Uses pkill on Linux. Safe to call even if Sober isn't running. + */ + static void terminateSober() { + try { + Process result = Runtime.getRuntime().exec(new String[]{"pkill", "sober"}); + int code = result.waitFor(); + LOG.info("[bloxbox] pkill exit code: " + code); + } catch (Exception e) { + LOG.warning("[bloxbox] terminateSober failed: " + e.getMessage()); + } + } + + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + // THUMBNAIL FETCHING + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + + /** + * Fetch the thumbnail image URL from the Roblox gameicons endpoint. + * No universe ID lookup needed โ€” place ID is sufficient. + * Returns the CDN image URL string, or null on failure. + */ + static String fetchThumbnailUrl(String placeId) { + String url = String.format(THUMBNAIL_API, placeId); + try { + HttpRequest req = HttpRequest.newBuilder(URI.create(url)).GET().build(); + HttpResponse resp = HTTP.send(req, HttpResponse.BodyHandlers.ofString()); + // Parse: {"data":[{"imageUrl":"https://..."}]} + String body = resp.body(); + int idx = body.indexOf("\"imageUrl\""); + if (idx < 0) return null; + int s = body.indexOf('"', idx + 10) + 1; + int e = body.indexOf('"', s); + return body.substring(s, e); + } catch (Exception e) { + LOG.severe("[launcher] Thumbnail URL fetch failed for place " + placeId + ": " + e.getMessage()); + return null; + } + } + + /** + * Fetch the game name from Roblox using the economy assets API. + * Returns the game name string, or null on failure. + */ + static String fetchGameName(String placeId) { + String url = "https://economy.roblox.com/v2/assets/" + placeId + "/details"; + try { + HttpRequest req = HttpRequest.newBuilder(URI.create(url)) + .header("User-Agent", "Mozilla/5.0") + .GET().build(); + HttpResponse resp = HTTP.send(req, HttpResponse.BodyHandlers.ofString()); + String body = resp.body(); + // Parse: {"Name":"..."} + int idx = body.indexOf("\"Name\""); + if (idx < 0) return null; + int s = body.indexOf('"', idx + 6) + 1; + int e = body.indexOf('"', s); + return body.substring(s, e); + } catch (Exception e) { + LOG.severe("[bloxbox] Game name lookup failed for " + placeId + ": " + e.getMessage()); + return null; + } + } + + /** + * Full pipeline: place ID โ†’ thumbnail URL โ†’ BufferedImage. + * + * Uses a disk cache to avoid re-fetching on every app launch. + * Cache lives at: ~/.cache/bloxbox_launcher/thumbnails/.png + * + * Returns a BufferedImage, or null if anything fails. + * Mirrors Python fetch_thumbnail_image() โ€” no Pillow needed, uses javax.imageio. + */ + static BufferedImage fetchThumbnailImage(String placeId) { + // โ”€โ”€ Check disk cache first to avoid unnecessary API calls โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + Path cacheFile = CACHE_DIR.resolve(placeId + ".png"); + + if (Files.exists(cacheFile)) { + try { + return ImageIO.read(cacheFile.toFile()); + } catch (Exception e) { + LOG.severe("[launcher] Cache read failed for " + placeId + ": " + e.getMessage()); + try { Files.deleteIfExists(cacheFile); } catch (IOException ignored) {} // Purge corrupt cache file + } + } + + // โ”€โ”€ Cache miss โ€” fetch directly using place ID โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + String thumbUrl = fetchThumbnailUrl(placeId); + if (thumbUrl == null) return null; + + try { + // Download the raw PNG bytes + HttpRequest req = HttpRequest.newBuilder(URI.create(thumbUrl)) + .timeout(Duration.ofSeconds(8)).GET().build(); + HttpResponse resp = HTTP.send(req, HttpResponse.BodyHandlers.ofByteArray()); + byte[] imgData = resp.body(); + + // Write to cache for next time + Files.write(cacheFile, imgData); + + return ImageIO.read(new ByteArrayInputStream(imgData)); + } catch (Exception e) { + LOG.severe("[launcher] Thumbnail download failed for place " + placeId + ": " + e.getMessage()); + return null; + } + } + + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + // GAME LAUNCHING + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + + /** + * Launch a Roblox game directly, bypassing the Roblox homepage entirely. + * + * Launch strategy (tried in order): + * 1. Flatpak Sober with full roblox:// URI โ€” confirmed working on Linux Mint 22.3 + * 2. xdg-open roblox:// URI โ€” fallback if Sober registered the URI handler + * 3. Friendly error dialog with install instructions + * + * Sober (org.vinegarhq.Sober) is the community Linux Roblox client. + * Vinegar (org.vinegarhq.Vinegar) does NOT support direct place launching. + * + * Runs Sober in a background thread so the UI stays responsive. + */ + static void launchGame(String placeId, String gameName, JFrame parentFrame) { + placeId = placeId.strip(); + String uri = "roblox://experiences/start?placeId=" + placeId; + LOG.info("[launcher] Launching '" + gameName + "' โ†’ " + uri); + + final String finalPlaceId = placeId; + final String finalUri = uri; + + // Background thread โ€” keeps UI responsive while Sober loads + new Thread(() -> { + // โ”€โ”€ Strategy 1: Flatpak Sober โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + // Pass the full roblox:// URI โ€” bare place ID is silently ignored by Sober. + try { + ProcessBuilder pb = new ProcessBuilder( + "flatpak", "run", "org.vinegarhq.Sober", finalUri + ); + pb.redirectErrorStream(true); // Merge stdout+stderr for log monitoring + Process proc = pb.start(); + + // Monitor Sober's log in a daemon thread for error patterns + Thread monitor = new Thread(() -> monitorSoberLog(proc, gameName, parentFrame)); + monitor.setDaemon(true); + monitor.start(); + return; // Handed off to Sober โ€” done + } catch (IOException e) { + LOG.severe("[launcher] flatpak not found, falling back to xdg-open..."); + } + + // โ”€โ”€ Strategy 2: xdg-open roblox:// URI โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + // Works if Sober registered the roblox:// protocol handler during install + try { + new ProcessBuilder("xdg-open", finalUri).start(); + return; + } catch (IOException ignored) {} + + // โ”€โ”€ Strategy 3: Nothing worked โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + SwingUtilities.invokeLater(() -> + JOptionPane.showMessageDialog(parentFrame, + "Could not launch '" + gameName + "'.\n\n" + + "Make sure Sober is installed:\n" + + " flatpak install flathub org.vinegarhq.Sober", + "Launch Failed", JOptionPane.ERROR_MESSAGE) + ); + }, "sober-launch").start(); + } + + /** + * Background thread: reads Sober's log line by line watching for known error patterns. + * On error: kills Sober, shows a friendly popup on the EDT. + * Mirrors Python _monitor_sober_log(). + */ + static void monitorSoberLog(Process proc, String gameName, JFrame parentFrame) { + // Error patterns map: log substring โ†’ {dialog title, user-friendly message} + Map ERROR_PATTERNS = new LinkedHashMap<>(); + ERROR_PATTERNS.put( + "App not yet initialized, returning from game", + new String[]{ + "Login / Session Error", + "Roblox kicked back to the home screen before the game loaded.\n\n" + + "Fix: Open Sober manually, log in again, then try Bloxbox." + }); + ERROR_PATTERNS.put( + "HTTP error code:`nil`", + new String[]{ + "Network / Auth Error", + "Roblox reported a network or authentication error.\n\n" + + "Check your internet connection and try again." + }); + ERROR_PATTERNS.put( + "SessionReporterState_GameExitRequested", + new String[]{ + "Kicked by Server", + "The Roblox server ended the session before the game started.\n\n" + + "The server may be full or restarting โ€” try again shortly." + }); + + // Watch patterns โ€” logged at debug level, not shown to user + Map WATCH_PATTERNS = new LinkedHashMap<>(); + WATCH_PATTERNS.put("524", "Error 524 โ€” Server Timeout"); + WATCH_PATTERNS.put("server", "The Roblox game server didn't respond in time."); + WATCH_PATTERNS.put("Wait", "This is a temporary Roblox issue โ€” wait and try again."); + + String[] detectedError = null; + + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(proc.getInputStream()))) { + String line; + while ((line = reader.readLine()) != null) { + + // Optional raw game log output โ€” mirrors Python --game-log-output flag + if (GAME_LOG_OUTPUT) LOG.fine(line); + + // Debug-level watch pattern logging + if (DEBUG_MODE) { + for (Map.Entry watch : WATCH_PATTERNS.entrySet()) { + if (line.contains(watch.getKey())) { + LOG.severe("[bloxbox] Watch Error detected: " + watch.getKey()); + } + } + } + + // Check for hard error patterns + for (Map.Entry err : ERROR_PATTERNS.entrySet()) { + if (line.contains(err.getKey())) { + detectedError = err.getValue(); + LOG.severe("[bloxbox] Error detected: " + err.getKey()); + break; + } + } + if (detectedError != null) break; + } + } catch (Exception e) { + LOG.severe("[bloxbox] Monitor thread error: " + e.getMessage()); + return; + } + + if (detectedError != null) { + final String[] error = detectedError; + terminateSober(); + // Show error dialog on the EDT โ€” never touch Swing from background threads + SwingUtilities.invokeLater(() -> + JOptionPane.showMessageDialog(parentFrame, + error[1], + "โš ๏ธ " + error[0] + " โ€” " + gameName, + JOptionPane.ERROR_MESSAGE) + ); + } + } + + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + // MINIMAL JSON HELPERS (no external dep) + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + + /** + * Parse {"games":[{"name":"...","place_id":"...","description":"..."},...]} + * Returns list of maps. Basic parser โ€” sufficient for controlled config format. + */ + static List> parseGamesJson(String json) { + List> result = new ArrayList<>(); + // Find the "games" array + int arrStart = json.indexOf("["); + int arrEnd = json.lastIndexOf("]"); + if (arrStart < 0 || arrEnd < 0) return result; + String arr = json.substring(arrStart + 1, arrEnd); + // Split on object boundaries โ€” find each {...} block + for (String obj : splitObjects(arr)) { + Map m = parseStringMap(obj); + if (!m.isEmpty()) result.add(m); + } + return result; + } + + /** Parse {"requests":[...]} โ€” same structure as games but different key. */ + @SuppressWarnings("unchecked") + static List> parseRequestsJson(String json) { + List> result = new ArrayList<>(); + int arrStart = json.indexOf("["); + int arrEnd = json.lastIndexOf("]"); + if (arrStart < 0 || arrEnd < 0) return result; + String arr = json.substring(arrStart + 1, arrEnd); + for (String obj : splitObjects(arr)) { + Map m = parseStringMap(obj); + if (!m.isEmpty()) result.add(m); + } + return result; + } + + /** Split a JSON array body into individual object strings. */ + static List splitObjects(String arrayBody) { + List objs = new ArrayList<>(); + int depth = 0, start = -1; + for (int i = 0; i < arrayBody.length(); i++) { + char c = arrayBody.charAt(i); + if (c == '{') { if (depth++ == 0) start = i; } + else if (c == '}') { if (--depth == 0 && start >= 0) { objs.add(arrayBody.substring(start, i + 1)); start = -1; } } + } + return objs; + } + + /** Parse a flat JSON object {"key":"value",...} into a Map. */ + static Map parseStringMap(String obj) { + Map m = new LinkedHashMap<>(); + Pattern p = Pattern.compile("\"([^\"]+)\"\\s*:\\s*\"([^\"]*)\""); + Matcher mt = p.matcher(obj); + while (mt.find()) m.put(mt.group(1), mt.group(2)); + return m; + } + + /** Serialize list of request maps back to JSON string. */ + static String buildRequestsJson(List> requests) { + StringBuilder sb = new StringBuilder("{\n \"requests\": [\n"); + for (int i = 0; i < requests.size(); i++) { + sb.append(" {"); + Map r = requests.get(i); + List keys = new ArrayList<>(r.keySet()); + for (int j = 0; j < keys.size(); j++) { + String k = keys.get(j), v = r.get(k); + sb.append("\"").append(k).append("\": \"").append(v.replace("\"", "\\\"")).append("\""); + if (j < keys.size() - 1) sb.append(", "); + } + sb.append("}"); + if (i < requests.size() - 1) sb.append(","); + sb.append("\n"); + } + sb.append(" ]\n}"); + return sb.toString(); + } + + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + // GAME CARD COMPONENT + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + + /** + * A single game tile in the launcher grid. + * Shows game thumbnail (loaded async in background), name, and Play button. + * Hover highlights the card background. + * Mirrors Python GameCard(tk.Frame). + */ + static class GameCard extends JPanel { + + private final Map game; + private JLabel thumbLabel; // Placeholder โ†’ replaced with image async + private final JFrame parentFrame; + + GameCard(Map game, JFrame parentFrame) { + this.game = game; + this.parentFrame = parentFrame; + + setPreferredSize(new Dimension(CARD_WIDTH, CARD_HEIGHT)); + setBackground(CARD_COLOR); + setLayout(new BoxLayout(this, BoxLayout.Y_AXIS)); + setBorder(BorderFactory.createEmptyBorder(10, 8, 8, 8)); + + buildUI(); + bindHover(); + + // Kick off thumbnail fetch in background โ€” keeps UI snappy + // Mirrors Python threading.Thread(target=self._load_thumbnail, daemon=True).start() + CompletableFuture.runAsync(() -> loadThumbnail()); + } + + private void buildUI() { + // Thumbnail placeholder shown while image is loading + thumbLabel = new JLabel("โณ", SwingConstants.CENTER); + thumbLabel.setFont(new Font("Segoe UI Emoji", Font.PLAIN, 28)); + thumbLabel.setForeground(SUBTEXT_COLOR); + thumbLabel.setBackground(CARD_COLOR); + thumbLabel.setOpaque(true); + thumbLabel.setPreferredSize(new Dimension(THUMB_SIZE, THUMB_SIZE)); + thumbLabel.setMaximumSize(new Dimension(THUMB_SIZE, THUMB_SIZE)); + thumbLabel.setAlignmentX(Component.CENTER_ALIGNMENT); + add(thumbLabel); + add(Box.createVerticalStrut(4)); + + // Game name โ€” truncated with HTML wrapping if too long + String displayName = game.getOrDefault("name", "Unknown"); + JLabel nameLabel = new JLabel( + "
" + + displayName + "
", + SwingConstants.CENTER + ); + nameLabel.setFont(FONT_CARD); + nameLabel.setForeground(TEXT_COLOR); + nameLabel.setBackground(CARD_COLOR); + nameLabel.setOpaque(true); + nameLabel.setAlignmentX(Component.CENTER_ALIGNMENT); + add(nameLabel); + add(Box.createVerticalStrut(2)); + + // Play button โ€” triggers direct game launch via Sober + JButton playBtn = makeButton("โ–ถ Play", ACCENT_COLOR); + playBtn.addActionListener(e -> onLaunch()); + playBtn.setAlignmentX(Component.CENTER_ALIGNMENT); + add(Box.createVerticalStrut(8)); + add(playBtn); + } + + /** Load thumbnail image from cache or Roblox CDN in background. */ + private void loadThumbnail() { + String placeId = game.getOrDefault("place_id", ""); + LOG.fine("[bloxbox] Loading thumbnail for " + placeId); + + BufferedImage img = fetchThumbnailImage(placeId); + LOG.fine("[bloxbox] fetchThumbnailImage returned: " + img); + + if (img == null) { + // No thumbnail available โ€” swap spinner for game controller emoji + SwingUtilities.invokeLater(() -> setPlaceholder("๐ŸŽฎ")); + LOG.severe("No thumbnail available for " + placeId); + return; + } + + // Resize to fit the card neatly โ€” mirrors PIL Image.resize(LANCZOS) + Image scaled = img.getScaledInstance(THUMB_SIZE, THUMB_SIZE, Image.SCALE_SMOOTH); + LOG.fine("[bloxbox] resizing thumbnail for " + placeId); + + // Schedule the UI update on the EDT โ€” never touch Swing from background threads + SwingUtilities.invokeLater(() -> { + thumbLabel.setIcon(new ImageIcon(scaled)); + thumbLabel.setText(""); + LOG.fine("[bloxbox] thumbnail set ok for " + placeId); + }); + } + + /** Replace the spinner with a fallback emoji (API failed or no image). */ + private void setPlaceholder(String emoji) { + thumbLabel.setFont(new Font("Segoe UI Emoji", Font.PLAIN, 32)); + thumbLabel.setText(emoji); + thumbLabel.setIcon(null); + } + + /** Play button click handler โ€” kills existing Sober then launches the game. */ + private void onLaunch() { + // Kill any already-running Sober instance before launching + try { + Process check = Runtime.getRuntime().exec(new String[]{"pgrep", "sober"}); + if (check.waitFor() == 0) terminateSober(); // Sober is running โ€” kill it + } catch (Exception ignored) {} + + launchGame( + game.getOrDefault("place_id", ""), + game.getOrDefault("name", "Game"), + parentFrame + ); + } + + /** + * Highlight card on mouse-over with a slightly lighter background. + * Mirrors Python _bind_hover() โ€” applies to panel and all child components. + */ + private void bindHover() { + MouseAdapter hover = new MouseAdapter() { + @Override public void mouseEntered(MouseEvent e) { setCardBg(HOVER_COLOR); } + @Override public void mouseExited (MouseEvent e) { setCardBg(CARD_COLOR); } + }; + + // Apply to this panel and all direct children (labels, button) + addMouseListener(hover); + for (Component c : getComponents()) c.addMouseListener(hover); + } + + /** Set background on this card and all opaque children simultaneously. */ + private void setCardBg(Color bg) { + setBackground(bg); + for (Component c : getComponents()) { + if (c.isOpaque()) c.setBackground(bg); + } + repaint(); + } + } + + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + // PIN DIALOG + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + + /** + * Modal PIN entry dialog shown before the request flow starts. + * Verifies the entered PIN against the stored SHA-256 hash via verifyPin(). + * Returns verified=true only if the correct PIN was entered. + * Mirrors Python PinDialog(tk.Toplevel). + */ + static class PinDialog extends JDialog { + + boolean verified = false; // Set to true only on correct PIN + + PinDialog(JFrame parent) { + super(parent, "Enter Passcode", true); // true = modal + setResizable(false); + getContentPane().setBackground(BG_COLOR); + + JPasswordField pinField = new JPasswordField(12); + JLabel errorLbl = new JLabel(" "); // Reserve space for error message + + pinField.setFont(new Font("Georgia", Font.PLAIN, 18)); + pinField.setBackground(Color.decode("#252540")); + pinField.setForeground(TEXT_COLOR); + pinField.setCaretColor(TEXT_COLOR); + pinField.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8)); + pinField.setEchoChar('โ—'); // Mask PIN characters + + errorLbl.setFont(FONT_SMALL); + errorLbl.setForeground(ACCENT_COLOR); + + // โ”€โ”€ Layout โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + JPanel panel = new JPanel(); + panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS)); + panel.setBackground(BG_COLOR); + panel.setBorder(BorderFactory.createEmptyBorder(24, 30, 20, 30)); + + JLabel heading = new JLabel("๐Ÿ”’ Enter Passcode to Request a Game"); + heading.setFont(new Font("Georgia", Font.BOLD, 14)); + heading.setForeground(TEXT_COLOR); + heading.setAlignmentX(Component.CENTER_ALIGNMENT); + panel.add(heading); + panel.add(Box.createVerticalStrut(6)); + + JLabel sub = new JLabel("Ask a parent if you don't know the Passcode."); + sub.setFont(FONT_SMALL); + sub.setForeground(SUBTEXT_COLOR); + sub.setAlignmentX(Component.CENTER_ALIGNMENT); + panel.add(sub); + panel.add(Box.createVerticalStrut(14)); + + pinField.setAlignmentX(Component.CENTER_ALIGNMENT); + pinField.setMaximumSize(new Dimension(200, 40)); + panel.add(pinField); + panel.add(Box.createVerticalStrut(6)); + + errorLbl.setAlignmentX(Component.CENTER_ALIGNMENT); + panel.add(errorLbl); + panel.add(Box.createVerticalStrut(12)); + + // Buttons + JPanel btnRow = new JPanel(new FlowLayout(FlowLayout.CENTER, 8, 0)); + btnRow.setBackground(BG_COLOR); + + JButton confirmBtn = makeButton("Confirm", REQUEST_COLOR); + JButton cancelBtn = makeButton("Cancel", Color.decode("#333333")); + + btnRow.add(confirmBtn); + btnRow.add(cancelBtn); + panel.add(btnRow); + + add(panel); + + // โ”€โ”€ Event handlers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + Runnable onSubmit = () -> { + String pin = new String(pinField.getPassword()).strip(); + if (pin.isEmpty()) { + errorLbl.setText("โš ๏ธ Please enter a PIN."); + return; + } + if (verifyPin(pin)) { + verified = true; // Correct โ€” set verified and close + dispose(); + } else { + // Wrong โ€” clear entry, show error, let them try again + pinField.setText(""); + errorLbl.setText("โŒ Incorrect PIN. Try again."); + pinField.requestFocus(); + } + }; + + confirmBtn.addActionListener(e -> onSubmit.run()); + cancelBtn.addActionListener(e -> dispose()); + + // Bind Enter key to submit โ€” mirrors Python self.bind("", ...) + pinField.addActionListener(e -> onSubmit.run()); + + pack(); + setLocationRelativeTo(parent); + + // Focus the PIN entry immediately after dialog appears + SwingUtilities.invokeLater(pinField::requestFocusInWindow); + } + } + + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + // REQUEST DIALOG โ€” FALLBACK (no embedded browser) + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + + /** + * Fallback request dialog for when no embedded browser is available. + * Opens roblox.com/charts in the system browser (Firefox or xdg-open), + * then child enters the place ID from the URL bar manually. + * Mirrors Python RequestDialogFallback(tk.Toplevel). + * + * Note: A full embedded-browser dialog (equivalent to Python's RequestDialog + * using pywebview) would require JavaFX WebView or JCEF. For a pure-Swing + * build this fallback matches the Python RequestDialogFallback behaviour exactly. + */ + static class RequestDialogFallback extends JDialog { + + private String fetchedPlaceId = null; + private String fetchedGameName = null; + + private JTextField idEntry; + private JLabel thumbLabel; + private JLabel nameLabel; + private JLabel statusLabel; + private JTextField noteEntry; + private JButton submitBtn; + + RequestDialogFallback(JFrame parent) { + super(parent, "Request a Game", true); // true = modal + setResizable(false); + getContentPane().setBackground(BG_COLOR); + + buildUI(); + pack(); + setLocationRelativeTo(parent); + } + + private void buildUI() { + JPanel panel = new JPanel(); + panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS)); + panel.setBackground(BG_COLOR); + panel.setBorder(BorderFactory.createEmptyBorder(20, 24, 20, 24)); + + // โ”€โ”€ Title โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + JLabel title = new JLabel("Request a New Game"); + title.setFont(new Font("Georgia", Font.BOLD, 16)); + title.setForeground(TEXT_COLOR); + title.setAlignmentX(Component.CENTER_ALIGNMENT); + panel.add(title); + panel.add(Box.createVerticalStrut(4)); + + // โ”€โ”€ Instructions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + JLabel instructions = new JLabel( + "
" + + "Find a game in the browser that just opened.
" + + "Copy the number from the URL bar:
" + + "roblox.com/games/185655149/... โ†’ enter 185655149" + + "
", + SwingConstants.CENTER + ); + instructions.setFont(FONT_SMALL); + instructions.setForeground(SUBTEXT_COLOR); + instructions.setAlignmentX(Component.CENTER_ALIGNMENT); + panel.add(instructions); + panel.add(Box.createVerticalStrut(10)); + + // โ”€โ”€ Place ID entry + look-up button โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + JPanel entryRow = new JPanel(new FlowLayout(FlowLayout.LEFT, 0, 0)); + entryRow.setBackground(BG_COLOR); + + idEntry = new JTextField(20); + idEntry.setFont(FONT_SMALL); + idEntry.setBackground(Color.decode("#252540")); + idEntry.setForeground(TEXT_COLOR); + idEntry.setCaretColor(TEXT_COLOR); + idEntry.setBorder(BorderFactory.createEmptyBorder(6, 6, 6, 6)); + + JButton lookupBtn = makeButton("Look Up", REQUEST_COLOR); + lookupBtn.addActionListener(e -> onLookup()); + + entryRow.add(idEntry); + entryRow.add(Box.createHorizontalStrut(8)); + entryRow.add(lookupBtn); + panel.add(entryRow); + panel.add(Box.createVerticalStrut(10)); + + // โ”€โ”€ Preview area โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + thumbLabel = new JLabel("", SwingConstants.CENTER); + thumbLabel.setFont(new Font("Segoe UI Emoji", Font.PLAIN, 28)); + thumbLabel.setForeground(SUBTEXT_COLOR); + thumbLabel.setBackground(BG_COLOR); + thumbLabel.setOpaque(true); + thumbLabel.setPreferredSize(new Dimension(100, 100)); + thumbLabel.setAlignmentX(Component.CENTER_ALIGNMENT); + panel.add(thumbLabel); + panel.add(Box.createVerticalStrut(4)); + + nameLabel = new JLabel("", SwingConstants.CENTER); + nameLabel.setFont(FONT_CARD); + nameLabel.setForeground(TEXT_COLOR); + nameLabel.setBackground(BG_COLOR); + nameLabel.setOpaque(true); + nameLabel.setAlignmentX(Component.CENTER_ALIGNMENT); + panel.add(nameLabel); + + statusLabel = new JLabel("", SwingConstants.CENTER); + statusLabel.setFont(FONT_SMALL); + statusLabel.setForeground(SUBTEXT_COLOR); + statusLabel.setAlignmentX(Component.CENTER_ALIGNMENT); + panel.add(statusLabel); + panel.add(Box.createVerticalStrut(10)); + + // โ”€โ”€ Optional note โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + JLabel noteLbl = new JLabel("Why do you want to play it? (optional):"); + noteLbl.setFont(FONT_SMALL); + noteLbl.setForeground(TEXT_COLOR); + panel.add(noteLbl); + panel.add(Box.createVerticalStrut(4)); + + noteEntry = new JTextField(30); + noteEntry.setFont(FONT_SMALL); + noteEntry.setBackground(Color.decode("#252540")); + noteEntry.setForeground(TEXT_COLOR); + noteEntry.setCaretColor(TEXT_COLOR); + noteEntry.setBorder(BorderFactory.createEmptyBorder(6, 6, 6, 6)); + panel.add(noteEntry); + panel.add(Box.createVerticalStrut(16)); + + // โ”€โ”€ Buttons โ€” Submit disabled until lookup succeeds โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + JPanel btnRow = new JPanel(new FlowLayout(FlowLayout.CENTER, 8, 0)); + btnRow.setBackground(BG_COLOR); + + submitBtn = makeButton("Send Request", Color.decode("#444444")); + submitBtn.setForeground(Color.decode("#888888")); + submitBtn.setEnabled(false); // Enabled after successful lookup + + JButton cancelBtn = makeButton("Cancel", Color.decode("#333333")); + cancelBtn.addActionListener(e -> dispose()); + + submitBtn.addActionListener(e -> onSubmit()); + + btnRow.add(submitBtn); + btnRow.add(cancelBtn); + panel.add(btnRow); + + add(panel); + } + + /** Validate the place ID and kick off background name+thumbnail lookup. */ + private void onLookup() { + String placeId = idEntry.getText().strip(); + + // Validate: place IDs are all digits + if (!placeId.matches("\\d+")) { + statusLabel.setForeground(ACCENT_COLOR); + statusLabel.setText("โš ๏ธ Place ID must be a number."); + return; + } + + statusLabel.setForeground(SUBTEXT_COLOR); + statusLabel.setText("Looking up game..."); + nameLabel.setText(""); + thumbLabel.setIcon(null); + thumbLabel.setText("โณ"); + submitBtn.setEnabled(false); + submitBtn.setBackground(Color.decode("#444444")); + submitBtn.setForeground(Color.decode("#888888")); + + // Background lookup โ€” keeps the dialog responsive + CompletableFuture.runAsync(() -> { + String thumbUrl = fetchThumbnailUrl(placeId); + String gameName = fetchGameName(placeId); + SwingUtilities.invokeLater(() -> showPreview(placeId, gameName, thumbUrl)); + }); + } + + /** Update preview area and enable Submit if lookup succeeded. */ + private void showPreview(String placeId, String gameName, String thumbUrl) { + if (gameName == null && thumbUrl == null) { + statusLabel.setForeground(ACCENT_COLOR); + statusLabel.setText("โš ๏ธ Game not found. Check the Place ID."); + thumbLabel.setText("โ“"); + return; + } + + fetchedPlaceId = placeId; + fetchedGameName = gameName != null ? gameName : "Game " + placeId; + nameLabel.setText(fetchedGameName); + + if (thumbUrl != null) { + // Load thumbnail in background โ€” keep UI responsive + final String url = thumbUrl; + CompletableFuture.runAsync(() -> { + try { + HttpRequest req = HttpRequest.newBuilder(URI.create(url)) + .timeout(Duration.ofSeconds(8)).GET().build(); + byte[] data = HTTP.send(req, HttpResponse.BodyHandlers.ofByteArray()).body(); + BufferedImage img = ImageIO.read(new ByteArrayInputStream(data)); + if (img != null) { + Image scaled = img.getScaledInstance(100, 100, Image.SCALE_SMOOTH); + SwingUtilities.invokeLater(() -> { + thumbLabel.setIcon(new ImageIcon(scaled)); + thumbLabel.setText(""); + }); + } + } catch (Exception e) { + SwingUtilities.invokeLater(() -> { thumbLabel.setIcon(null); thumbLabel.setText("๐ŸŽฎ"); }); + } + }); + } else { + thumbLabel.setIcon(null); + thumbLabel.setText("๐ŸŽฎ"); + } + + statusLabel.setForeground(Color.decode("#4caf50")); + statusLabel.setText("โœ… Game found!"); + + // Enable submit now that lookup succeeded + submitBtn.setEnabled(true); + submitBtn.setBackground(REQUEST_COLOR); + submitBtn.setForeground(Color.WHITE); + } + + /** Save the request and close the dialog. */ + private void onSubmit() { + if (fetchedPlaceId == null) return; + + boolean ok = saveRequest( + fetchedPlaceId, + fetchedGameName, + noteEntry.getText().strip(), + "" + ); + dispose(); + + if (ok) { + JOptionPane.showMessageDialog(getParent(), + "'" + fetchedGameName + "' has been requested.\n" + + "Ask a parent to review it!", + "Request Sent! ๐ŸŽฎ", JOptionPane.INFORMATION_MESSAGE); + } else { + JOptionPane.showMessageDialog(getParent(), + "Permission error saving your request.\n" + + "Ask a parent to check the requests file.", + "Could Not Save", JOptionPane.ERROR_MESSAGE); + } + } + } + + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + // MAIN LAUNCHER WINDOW + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + + /** + * Main launcher window. + * Header with title + Request button, then a scrollable grid of game cards. + * Mirrors Python LauncherApp(tk.Tk). + */ + static class LauncherApp extends JFrame { + + LauncherApp() { + super(WINDOW_TITLE); + setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); + getContentPane().setBackground(BG_COLOR); + setLayout(new BorderLayout()); + + buildUI(); + } + + private void buildUI() { + // โ”€โ”€ Header bar โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + JPanel header = new JPanel(new BorderLayout()); + header.setBackground(BG_COLOR); + header.setBorder(BorderFactory.createEmptyBorder(24, 30, 8, 30)); + + // Left side: title + subtitle + JPanel titleGroup = new JPanel(new FlowLayout(FlowLayout.LEFT, 16, 6)); + titleGroup.setBackground(BG_COLOR); + + JLabel titleLbl = new JLabel("๐ŸŽฎ BloxBox Game Launcher"); + titleLbl.setFont(FONT_TITLE); + titleLbl.setForeground(TEXT_COLOR); + titleGroup.add(titleLbl); + + JLabel subLbl = new JLabel("Approved games only"); + subLbl.setFont(FONT_SMALL); + subLbl.setForeground(SUBTEXT_COLOR); + titleGroup.add(subLbl); + + // Right side: request button + JButton requestBtn = makeButton("๏ผ‹ Request a Game", REQUEST_COLOR); + requestBtn.addActionListener(e -> openRequestDialog()); + + header.add(titleGroup, BorderLayout.WEST); + header.add(requestBtn, BorderLayout.EAST); + add(header, BorderLayout.NORTH); + + // โ”€โ”€ Scrollable game grid โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + JPanel gridHolder = new JPanel(); + gridHolder.setBackground(BG_COLOR); + populateGrid(gridHolder); + + JScrollPane scroll = new JScrollPane(gridHolder); + scroll.setBackground(BG_COLOR); + scroll.getViewport().setBackground(BG_COLOR); + scroll.setBorder(BorderFactory.createEmptyBorder()); + scroll.getVerticalScrollBar().setUnitIncrement(16); // Smooth scrolling + + add(scroll, BorderLayout.CENTER); + } + + /** + * Load approved games and render a GameCard for each. + * Equivalent to Python _populate_grid() โ€” uses a FlowLayout grid. + */ + private void populateGrid(JPanel container) { + List> games = loadConfig(); + container.setBorder(BorderFactory.createEmptyBorder(10, 20, 10, 20)); + + if (games.isEmpty()) { + // Empty state โ€” point child to the request button + container.setLayout(new BorderLayout()); + JLabel empty = new JLabel( + "
" + + "No games approved yet.
Use '๏ผ‹ Request a Game' above!
", + SwingConstants.CENTER + ); + empty.setFont(new Font("Georgia", Font.PLAIN, 16)); + empty.setForeground(SUBTEXT_COLOR); + empty.setBorder(BorderFactory.createEmptyBorder(80, 40, 80, 40)); + container.add(empty, BorderLayout.CENTER); + return; + } + + // Render cards in a COLS-wide grid โ€” mirrors Python grid(row, column) + container.setLayout(new GridLayout(0, COLS, 12, 12)); // 0 rows = auto-expand + for (Map game : games) { + container.add(new GameCard(game, this)); + } + } + + /** + * Open the game request dialog. + * First verifies the PIN โ€” only proceeds if correct. + * Opens system browser (Firefox or xdg-open) then shows the fallback dialog. + * Note: the full embedded-browser experience (Python RequestDialog via pywebview) + * would require JavaFX WebView or JCEF โ€” this port uses the fallback by default. + */ + private void openRequestDialog() { + // Gate on PIN verification before showing request dialog + PinDialog pin = new PinDialog(this); + pin.setVisible(true); + if (!pin.verified) return; // Cancelled or wrong PIN โ€” do nothing + + // Open the system browser so the child can browse Roblox charts + openBrowser(ROBLOX_GAME_SEARCH_URL); + + // Show the fallback place-ID entry dialog + RequestDialogFallback dialog = new RequestDialogFallback(this); + dialog.setVisible(true); + } + + /** + * Open a URL in the system browser. + * Tries Firefox first, then xdg-open (same order as Python fallback chain). + */ + private void openBrowser(String url) { + // Strategy 1: Firefox + try { + new ProcessBuilder("firefox", url) + .redirectOutput(ProcessBuilder.Redirect.DISCARD) + .redirectError(ProcessBuilder.Redirect.DISCARD) + .start(); + return; + } catch (IOException ignored) {} + + // Strategy 2: xdg-open (desktop default browser) + try { + new ProcessBuilder("xdg-open", url) + .redirectOutput(ProcessBuilder.Redirect.DISCARD) + .redirectError(ProcessBuilder.Redirect.DISCARD) + .start(); + } catch (IOException ignored) {} // No browser available โ€” just show the dialog + } + } + + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + // UI UTILITY HELPERS + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + + /** + * Create a styled flat button matching the BloxBox dark theme. + * Mirrors the common tk.Button(..., relief="flat", cursor="hand2") pattern. + */ + static JButton makeButton(String text, Color bg) { + JButton btn = new JButton(text); + btn.setFont(FONT_BTN); + btn.setBackground(bg); + btn.setForeground(Color.WHITE); + btn.setFocusPainted(false); + btn.setBorderPainted(false); + btn.setOpaque(true); + btn.setBorder(BorderFactory.createEmptyBorder(6, 14, 6, 14)); + btn.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); + + // Darken on hover โ€” mirrors Python activebackground + Color hover = bg.darker(); + btn.addMouseListener(new MouseAdapter() { + @Override public void mouseEntered(MouseEvent e) { if (btn.isEnabled()) btn.setBackground(hover); } + @Override public void mouseExited (MouseEvent e) { if (btn.isEnabled()) btn.setBackground(bg); } + }); + + return btn; + } +} \ No newline at end of file diff --git a/run.bat b/run.bat new file mode 100644 index 0000000..249678b --- /dev/null +++ b/run.bat @@ -0,0 +1,610 @@ +@echo off +:: #################################################################### +:: ## +:: ## WINDOWS (7 / 10 / 11 / Server 2008+) +:: ## Script is to make building, launching, +:: ## and running easier with command line (CLI) arguments +:: ## +:: ## With Love, Stormtheory +:: ## +:: #################################################################### + +:: โ”€โ”€ Dependency filenames (update versions here only) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +:: N/A + +:: Jar output file +set JAR_FILENAME=JavaPasswordVault.jar + +set "PROJECT_NAME=bloxbox-java" + +:: ZIP goes into the parent folder, named after the project +set "ZIP_FILE=bloxbox-java.zip" +set "ZIP_PATH=%PARENT_DIR%%ZIP_FILE%" + +:: Stage into a temp copy that excludes .git (mirrors tar --exclude=.git) +set "STAGE=%TEMP%\bloxbox-java-stage" + +:: โ”€โ”€ Minimum required Java version โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +:: Argon2-jvm requires at least Java 11 (JNA bridge). +:: --enable-native-access was introduced in Java 17; we detect the +:: version at runtime and add the flag only when supported. +set JAVA_MIN=11 + +:: โ”€โ”€ Change to the directory containing this script โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +:: Equivalent to bash's: cd "$(dirname "$0")" +:: This ensures bin\, lib\, and *.java are found correctly regardless of +:: where the script is launched from (Desktop shortcut, Explorer, CLI). +cd /d "%~dp0" + +:: โ”€โ”€ Detect double-click vs CLI launch โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +:: SESSIONNAME is set by cmd.exe when launched from an existing terminal +:: session and absent when Explorer spawns a fresh console window. +:: This avoids the CMDCMDLINE approach which breaks when Explorer appends +:: a stray quote to arguments, e.g.: "run.bat" -h" +set DOUBLE_CLICKED=false +if not defined SESSIONNAME set DOUBLE_CLICKED=true + +:: โ”€โ”€ Safety: refuse to run as Administrator โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +:: net session succeeds only when the process has true elevation (admin +:: token). More reliable than matching SID S-1-16-12288 via whoami /groups +:: which false-positives on domain machines or certain UAC configurations. +:: +:: Security rationale: a password vault should never run as admin -- +:: doing so widens the blast radius of any exploit or misconfiguration. +net session >nul 2>&1 +if %errorlevel% == 0 ( + echo [SECURITY] This script must NOT be run as Administrator. + echo Please re-run as a normal ^(non-elevated^) user. + call :error_exit +) + +:: โ”€โ”€ Java presence and version check โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +:: Fail fast with a clear message rather than a cryptic JVM error. +:: We parse the major version out of "java -version" stderr output. +:: java -version always prints to stderr, so we redirect 2>&1. +where java >nul 2>&1 +if %errorlevel% neq 0 ( + echo [ERROR] java not found on PATH. Please install Java %JAVA_MIN%+ and retry. + call :error_exit +) + +:: Extract the major version number into JAVA_VER. +:: "java -version" prints e.g.: openjdk version "17.0.2" ... +:: or legacy: java version "1.8.0_361" ... +:: We grab the quoted version token, strip quotes, then take the part +:: before the first dot. For 1.x releases the major is the second token. +for /f "tokens=3" %%V in ('java -version 2^>^&1 ^| findstr /i "version"') do ( + set "JAVA_VER_RAW=%%~V" +) +:: Strip leading "1." for Java 8 and earlier (1.8 -> 8) +set "JAVA_VER=%JAVA_VER_RAW%" +if "%JAVA_VER_RAW:~0,2%"=="1." set "JAVA_VER=%JAVA_VER_RAW:~2%" +:: Keep only the major number (everything before the first dot) +for /f "delims=." %%M in ("%JAVA_VER%") do set "JAVA_MAJOR=%%M" + +:: Numeric comparison โ€” reject if below minimum +if %JAVA_MAJOR% LSS %JAVA_MIN% ( + echo [ERROR] Java %JAVA_MAJOR% detected. This application requires Java %JAVA_MIN% or newer. + echo Please upgrade your JDK: https://adoptium.net + call :error_exit +) + +:: --enable-native-access=ALL-UNNAMED is required by Argon2's JNA bridge +:: but the flag itself only exists in Java 17+. On Java 11-16 JNA works +:: without it (the module system is less strict). We set the flag +:: conditionally so the script runs on both old and new JVMs. +set "NATIVE_ACCESS_FLAG=" +if %JAVA_MAJOR% GEQ 17 set "NATIVE_ACCESS_FLAG=--enable-native-access=ALL-UNNAMED" + +echo [Java] Detected version %JAVA_MAJOR% โ€” native-access flag: %NATIVE_ACCESS_FLAG% + +:: โ”€โ”€ Default flag values โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +:: All flags start false; parsed flags flip them to true. +:: HELP starts true so that running with no args triggers the +:: smart auto-build/run behaviour (see dispatch section below). +set DOWNLOADS=false +set DO_BUILD=false +set DO_RUN=false +set DO_TAR=false +set DO_JAR=false +set DO_HELP=false +:: HELP=true means "no recognised flags were given" โ€” triggers auto-build/run. +:: It is flipped to false by every flag handler including -h (via DO_HELP). +set HELP=true + +:: โ”€โ”€ Jump past subroutine definitions to the argument parser โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +:: Batch falls through top-to-bottom; without this goto the interpreter +:: would execute subroutine bodies as main-line code on startup. +goto :parse_args + +:: ==================================================================== +:: FUNCTION: error_exit +:: Centralised error exit. When the script was double-clicked from +:: Explorer, pauses before closing so the user can read the message. +:: +:: IMPORTANT: uses "exit 1" (no /b) to terminate the entire cmd.exe +:: process, not just the current subroutine call frame. "exit /b 1" +:: only unwinds one CALL level โ€” the caller keeps running, which is +:: why errors were being ignored. The tradeoff is that this closes +:: the console window when run from an existing terminal; acceptable +:: for a build script where an error should always be fatal. +:: ==================================================================== +:error_exit +echo. +echo [ERROR] Script aborted. See message above for details. +if "%DOUBLE_CLICKED%"=="true" ( + echo. + pause +) +exit 1 + +:: ==================================================================== +:: FUNCTION: show_help +:: Prints usage information (mirrors the Linux EOF heredoc block). +:: ==================================================================== +:show_help +echo. +echo Usage: %~nx0 [OPTIONS] +echo. +echo (no args) First run: auto-builds then launches the program. +echo Subsequent runs: skips build and launches directly. +echo Use -b or -i to force a rebuild at any time. +echo Options: +echo -d Copy the zip to the Downloads directory +echo -i Force rebuild +echo -b Force rebuild +echo -r Run only (skips build even if bin\ is empty) +echo -j Create fat Jar file +echo -h Show this help message +echo. +echo Examples: +echo %~nx0 -- smart default: build once, then just run +echo %~nx0 -b -- force rebuild only +echo %~nx0 -br -- force rebuild then run +echo %~nx0 -r -- run only (no build check) +echo. +goto :eof + +:: ==================================================================== +:: FUNCTION: parse_args +:: Loops through all CLI tokens. Tokens starting with '-' are handed +:: to :parse_flags for character-by-character processing. +:: Unknown tokens print help and exit cleanly. +:: ==================================================================== +:parse_args +if "%~1"=="" goto end_parse + +set "arg=%~1" +:: Only process tokens that begin with a dash. +:: IMPORTANT: do NOT call :parse_flags from inside an if ( ) block โ€” +:: variables set inside a parenthesised block are expanded at parse time, +:: not execution time, so %flags% would always pass the stale (empty) +:: value. We use a goto to branch instead, keeping the call at top level. +if "%arg:~0,1%"=="-" goto :do_parse_flags +echo [ERROR] Unknown argument: %arg% >&2 +call :show_help +call :error_exit + +:do_parse_flags +:: Strip the leading dash and pass the rest to parse_flags +set "flags=%arg:~1%" +call :parse_flags "%flags%" +shift +goto parse_args +:end_parse + +:: โ”€โ”€ Dispatch based on parsed flags โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +:: -h is checked first so it always wins, even if combined with other flags +if "%DO_HELP%"=="true" ( + call :show_help + exit /b 0 +) + +if "%DO_JAR%"=="true" ( + call :JAR + exit /b 0 +) + +if "%DO_BUILD%"=="true" call :BUILD + +if "%DO_TAR%"=="true" call :TAR_UP + +:: โ”€โ”€ Default behaviour when no flags were passed โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +:: Goal: the user should always be able to just run this script and +:: have things work. The build should happen exactly once โ€” automatically +:: on first run โ€” and be skipped on every subsequent run unless the +:: user explicitly requests a rebuild with -b or -i. +:: +:: Logic: +:: bin\*.class exists -> skip build, launch immediately +:: bin\*.class missing -> first-time setup: build then launch +:: +:: To force a rebuild at any time, pass -b or -br explicitly. +if "%HELP%"=="true" ( + if exist bin\*.class ( + :: Already built โ€” just launch + echo [Auto] Classes found -- launching program... + call :RUN + ) else ( + :: First run: no classes yet โ€” build once, then launch + echo [Auto] First run -- building before launch... + call :BUILD + echo [Auto] Build complete -- launching program... + call :RUN + ) + exit /b 0 +) + +if "%DO_RUN%"=="true" call :RUN + +exit /b 0 + +:: ==================================================================== +:: FUNCTION: parse_flags +:: Iterates each character in the passed flag string and sets booleans. +:: Called once per CLI token (e.g. "-br" sets BUILD and RUN). +:: ==================================================================== +:parse_flags +set "str=%~1" +:flag_loop +if "%str%"=="" goto :eof +set "char=%str:~0,1%" +set "str=%str:~1%" + +if "%char%"=="d" ( + set DO_TAR=true + set DOWNLOADS=true + set HELP=false +) +if "%char%"=="i" ( + set DO_BUILD=true + set HELP=false +) +if "%char%"=="b" ( + set DO_BUILD=true + set HELP=false +) +if "%char%"=="r" ( + set DO_RUN=true + set HELP=false +) +if "%char%"=="j" ( + set DO_JAR=true + set HELP=false +) +if "%char%"=="h" ( + :: Set DO_HELP and clear HELP so the dispatch section handles this + :: cleanly at the top level โ€” never try to goto or exit /b from + :: inside a nested call subroutine, it doesn't unwind reliably. + set DO_HELP=true + set HELP=false +) +goto flag_loop + +:: ==================================================================== +:: FUNCTION: BUILD +:: Cleans the bin directory and recompiles all .java sources. +:: Classpath uses semicolons on Windows (colons on Linux/Mac). +:: ==================================================================== +:BUILD +:: Clean old class files before recompile +if exist bin\* del /q bin\* + +:: -encoding UTF-8 is required on Windows because javac defaults to the +:: system codepage (usually windows-1252) which cannot represent Unicode +:: characters used in string literals and comments in the source files. +:: Without this flag, any non-ASCII character (emoji, bullet, en-dash etc.) +:: causes "unmappable character" errors and a broken build. +echo javac -encoding UTF-8 -cp ".;lib\%SQLITE_LIB%;lib\%ARGON2_LIB%;lib\%ARGON2_NOLIB%;lib\%BOUNCY_HOUSE_LIB%;lib\%JNA_LIB%;bin" -d bin *.java +javac -encoding UTF-8 -cp ".;lib\%SQLITE_LIB%;lib\%ARGON2_LIB%;lib\%ARGON2_NOLIB%;lib\%BOUNCY_HOUSE_LIB%;lib\%JNA_LIB%;bin" -d bin *.java + +:: Abort immediately if javac failed โ€” do not attempt to launch with +:: missing or stale class files, which produces a misleading +:: "ClassNotFoundException" instead of the real compile errors above. +if %errorlevel% neq 0 ( + echo [ERROR] Compilation failed -- see errors above. Launch aborted. + call :error_exit +) +goto :eof + +:: ==================================================================== +:: FUNCTION: RUN +:: Launches the compiled GUI. NATIVE_ACCESS_FLAG is set at startup: +:: Java 17+ -> --enable-native-access=ALL-UNNAMED (required by JNA) +:: Java 11-16 -> empty string (JNA works without it on older runtimes) +:: -Dorg.sqlite.tmpdir=. keeps SQLite temp files local (portable). +:: ==================================================================== +:RUN +echo java %NATIVE_ACCESS_FLAG% -Dorg.sqlite.tmpdir=. -cp ".;lib\%SQLITE_LIB%;lib\%ARGON2_LIB%;lib\%ARGON2_NOLIB%;lib\%BOUNCY_HOUSE_LIB%;lib\%JNA_LIB%;bin" GUI +java %NATIVE_ACCESS_FLAG% -Dorg.sqlite.tmpdir=. -cp ".;lib\%SQLITE_LIB%;lib\%ARGON2_LIB%;lib\%ARGON2_NOLIB%;lib\%BOUNCY_HOUSE_LIB%;lib\%JNA_LIB%;bin" GUI +goto :eof + +:: ==================================================================== +:: FUNCTION: JAR +:: Builds a fat (uber) jar containing all dependency classes so the +:: app can be distributed as a single executable .jar file. +:: Steps: +:: 1. Clean old artifacts +:: 2. Compile sources (calls BUILD) +:: 3. Explode dependency jars into a staging directory +:: 4. Write a manifest pointing at the GUI entry class +:: 5. Re-package everything into the final fat jar +:: ==================================================================== +:JAR +:: Resolve jar.exe from the same JDK that provides java.exe. +:: Modern Windows installs have TWO java stubs: +:: 1. A Windows Store app-execution alias in %LOCALAPPDATA%\Microsoft\WindowsApps +:: โ€” this is a shim that launches the Store; jar.exe is NOT beside it. +:: 2. The real JDK bin directory added by the installer. +:: "where java" returns ALL matches in PATH order; we skip the shim by +:: checking each candidate directory for jar.exe beside it. +:: +:: Resolution order: +:: 1. JAVA_HOME\bin\jar.exe โ€” explicit env var, most reliable +:: 2. Sibling of java.exe on PATH โ€” skips WindowsApps shims via where +:: 3. C:\Program Files\Java\jdk* โ€” Oracle JDK default +:: 4. C:\Program Files\Eclipse Adoptium\jdk* โ€” Temurin default +:: 5. C:\Program Files\Microsoft\jdk* โ€” Microsoft JDK default +:: 6. C:\Program Files\Amazon Corretto\jdk* โ€” Corretto default +:: 7. C:\Program Files\BellSoft\jdk* โ€” Liberica default +set "JAR_EXE=" + +:: Try JAVA_HOME first +if defined JAVA_HOME ( + if exist "%JAVA_HOME%\bin\jar.exe" set "JAR_EXE=%JAVA_HOME%\bin\jar.exe" +) + +:: Walk every "java" hit on PATH; take the first one that has jar.exe beside it. +:: This skips WindowsApps shims which never have jar.exe next to them. +if not defined JAR_EXE ( + for /f "delims=" %%I in ('where java 2^>nul') do ( + if not defined JAR_EXE ( + if exist "%%~dpIjar.exe" set "JAR_EXE=%%~dpIjar.exe" + ) + ) +) + +:: Last resort: probe well-known JDK install locations on Windows. +:: Covers Oracle, Adoptium/Temurin, Microsoft, Amazon Corretto, BellSoft. +:: Each root is guarded with if exist so missing directories are skipped +:: cleanly. The jdk* wildcard catches any major/patch version installed. +if not defined JAR_EXE ( + for %%R in ( + "C:\Program Files\Java" + "C:\Program Files\Eclipse Adoptium" + "C:\Program Files\Microsoft" + "C:\Program Files\Amazon Corretto" + "C:\Program Files\BellSoft" + ) do ( + if not defined JAR_EXE ( + if exist "%%~R\" ( + for /d %%D in ("%%~R\jdk*") do ( + if not defined JAR_EXE ( + if exist "%%~D\bin\jar.exe" set "JAR_EXE=%%~D\bin\jar.exe" + ) + ) + ) + ) + ) +) + +if not defined JAR_EXE ( + echo [ERROR] jar.exe not found. Tried PATH and common install locations. + echo Fix options: + echo 1. Set JAVA_HOME to your JDK root ^(e.g. C:\Program Files\Java\jdk-17^) + echo 2. Add your JDK bin to PATH ^(C:\Program Files\Java\jdk-17\bin^) + echo 3. Install a full JDK from https://adoptium.net + call :error_exit +) +echo [Jar] Using: %JAR_EXE% + +:: Step 1 โ€“ clean old build artifacts +echo [1/8] Cleaning old build artifacts... +if exist bin\* del /q bin\* +if exist "%JAR_FILENAME%" del /q "%JAR_FILENAME%" +if exist fatjar rmdir /s /q fatjar + +:: Step 2 โ€“ compile sources +echo [2/8] Compiling sources... +call :BUILD + +:: Step 3 โ€“ explode dependency jars into staging directory +echo [3/8] Staging class files... +mkdir fatjar +:: xcopy /e /i copies class tree from bin into fatjar +xcopy /e /i bin fatjar >nul +:: Explode each dependency jar โ€” these are the slow steps on large jars +echo [4/8] Exploding dependency jars ^(this may take a moment^)... +cd fatjar +echo - %SQLITE_LIB% +"%JAR_EXE%" xf "..\lib\%SQLITE_LIB%" +echo - %ARGON2_LIB% +"%JAR_EXE%" xf "..\lib\%ARGON2_LIB%" +echo - %ARGON2_NOLIB% +"%JAR_EXE%" xf "..\lib\%ARGON2_NOLIB%" +echo - %JNA_LIB% +"%JAR_EXE%" xf "..\lib\%JNA_LIB%" +echo - %BOUNCY_HOUSE_LIB% +"%JAR_EXE%" xf "..\lib\%BOUNCY_HOUSE_LIB%" +cd .. + +:: Step 4 โ€“ strip Bouncy Castle signature files to prevent JAR verification failure +echo [5/8] Stripping signature files ^(prevents BouncyCastle verification errors^)... +if exist fatjar\META-INF\*.SF del /q fatjar\META-INF\*.SF +if exist fatjar\META-INF\*.RSA del /q fatjar\META-INF\*.RSA +if exist fatjar\META-INF\*.DSA del /q fatjar\META-INF\*.DSA + +:: Step 5 โ€“ ensure META-INF exists then write the manifest +:: mkdir is guarded because an exploded jar may have already created it +echo [6/8] Writing manifest... +if not exist fatjar\META-INF mkdir fatjar\META-INF +:: Trailing blank line after Main-Class is required by the jar spec +( +echo Manifest-Version: 1.0 +echo Main-Class: GUI +echo. +) > fatjar\META-INF\MANIFEST.MF + +:: Step 6 โ€“ package into final fat jar โ€” slowest step, packs thousands of files +echo [7/8] Packaging fat jar ^(slowest step โ€” packing all classes^)... +cd fatjar +"%JAR_EXE%" cfm "..\%JAR_FILENAME%" META-INF\MANIFEST.MF . +cd .. +echo [7/8] Done -- %JAR_FILENAME% created. + +:: Step 7 โ€“ generate .vbs launcher and fix file association +echo [8/8] Writing launcher and registering file association... +call :MAKE_LAUNCHER +call :FIX_JAR_ASSOC + +echo. +echo #### All done #### +echo Double-click launcher: %~dp0%JAR_FILENAME:.jar=.vbs% +echo Or run directly: java -jar %JAR_FILENAME% +goto :eof + +:: ==================================================================== +:: FUNCTION: MAKE_LAUNCHER +:: Writes a .vbs file alongside the jar that launches it via javaw +:: with no console window. Safe to re-run โ€” overwrites each build. +:: Embeds NATIVE_ACCESS_FLAG so it honours the same Java version +:: detection that the .bat performs at startup. +:: ==================================================================== +:MAKE_LAUNCHER +set "VBS_FILE=%JAR_FILENAME:.jar=.vbs%" +:: Write each VBScript line individually with >> append redirection. +:: A single parenthesised ( echo ... ) block mis-parses closing parens +:: in VBScript expressions like InStrRev(...) even when escaped, because +:: batch counts parens for block balancing before processing escape chars. +:: Individual echo+>> sidesteps this entirely. +if exist "%VBS_FILE%" del /q "%VBS_FILE%" +echo ' JavaPasswordVault - windowless launcher>> "%VBS_FILE%" +echo ' Double-click this file to start the app with no console window.>> "%VBS_FILE%" +echo ' Requires Java (javaw.exe) to be on the system PATH.>> "%VBS_FILE%" +echo Set sh = CreateObject("WScript.Shell")>> "%VBS_FILE%" +echo ' Resolve the directory this .vbs lives in>> "%VBS_FILE%" +echo scriptDir = Left(WScript.ScriptFullName, InStrRev(WScript.ScriptFullName, "\"))>> "%VBS_FILE%" +echo jarPath = scriptDir ^& "%JAR_FILENAME%">> "%VBS_FILE%" +echo ' Run javaw (no console) with native access flag if Java 17+>> "%VBS_FILE%" +echo cmd = "javaw %NATIVE_ACCESS_FLAG% -Dorg.sqlite.tmpdir=. -jar """ ^& jarPath ^& """">> "%VBS_FILE%" +echo ' WindowStyle 0 = hidden, bWaitOnReturn False = non-blocking>> "%VBS_FILE%" +echo sh.Run cmd, 0, False>> "%VBS_FILE%" +echo [Launcher] Created: %VBS_FILE% +goto :eof + +:: ==================================================================== +:: FUNCTION: FIX_JAR_ASSOC +:: Registers .jar files to open with javaw.exe system-wide. +:: Requires Administrator elevation โ€” skips gracefully if not elevated. +:: Uses JAVA_HOME if set, otherwise searches PATH for javaw.exe. +:: ==================================================================== +:FIX_JAR_ASSOC +:: Locate javaw.exe using the same shim-aware strategy as jar.exe above. +set "JAVAW_EXE=" +if defined JAVA_HOME ( + if exist "%JAVA_HOME%\bin\javaw.exe" set "JAVAW_EXE=%JAVA_HOME%\bin\javaw.exe" +) +if not defined JAVAW_EXE ( + for /f "delims=" %%I in ('where java 2^>nul') do ( + if not defined JAVAW_EXE ( + if exist "%%~dpIjavaw.exe" set "JAVAW_EXE=%%~dpIjavaw.exe" + ) + ) +) +if not defined JAVAW_EXE ( + for %%R in ( + "C:\Program Files\Java" + "C:\Program Files\Eclipse Adoptium" + "C:\Program Files\Microsoft" + "C:\Program Files\Amazon Corretto" + "C:\Program Files\BellSoft" + ) do ( + if not defined JAVAW_EXE ( + if exist "%%~R\" ( + for /d %%D in ("%%~R\jdk*") do ( + if not defined JAVAW_EXE ( + if exist "%%~D\bin\javaw.exe" set "JAVAW_EXE=%%~D\bin\javaw.exe" + ) + ) + ) + ) + ) +) +if not defined JAVAW_EXE ( + echo [Assoc] javaw.exe not found -- skipping file association. + goto :eof +) + +:: Attempt to write the ftype/assoc entries (silently fails if not elevated) +:: NATIVE_ACCESS_FLAG is included so double-clicked jars also get the flag +ftype jarfile="%JAVAW_EXE%" %NATIVE_ACCESS_FLAG% -jar "%%1" %%* >nul 2>&1 +assoc .jar=jarfile >nul 2>&1 + +:: Check if assoc actually took by reading it back +assoc .jar 2>nul | find "jarfile" >nul 2>&1 +if %errorlevel% == 0 ( + echo [Assoc] .jar now opens with: %JAVAW_EXE% +) else ( + echo [Assoc] Could not set file association ^(not elevated^). + echo To fix: right-click this .bat, Run as Administrator, then -j again. + echo The .vbs launcher works regardless -- no admin needed. +) +goto :eof + +:: ==================================================================== +:: FUNCTION: TAR_UP +:: On Windows we create a .zip archive instead of a .tgz because +:: PowerShell's Compress-Archive is available natively on Win 10/11. +:: The -d flag also copies the zip to the user's Downloads folder. +:: +:: Note: unlike the Linux version, we do NOT rename the directory +:: because robocopy staging avoids the rename side-effect entirely. +:: The .git directory is excluded to keep the archive clean. +:: ==================================================================== +:TAR_UP +:: Use %~dp0 (the script's own directory, always with trailing backslash) +:: rather than %cd% to be immune to the working directory at call time. +:: Strip the trailing backslash for robocopy source path. +set "SCRIPT_DIR=%~dp0" +set "SCRIPT_DIR=%SCRIPT_DIR:~0,-1%" + +:: Derive parent directory. %%~dpI already includes a trailing backslash +:: so we use it directly in ZIP_PATH without stripping โ€” stripping a root +:: drive path like "C:\" would produce "C:" which is invalid. +for %%I in ("%SCRIPT_DIR%") do set "PARENT_DIR=%%~dpI" + + + +:: Remove stale archive if present +if exist "%ZIP_PATH%" del /q "%ZIP_PATH%" + +if exist "%STAGE%" rmdir /s /q "%STAGE%" +mkdir "%STAGE%\%PROJECT_NAME%" + +:: robocopy: /e = recurse subdirs incl. empty, /xd = exclude .git dir +robocopy "%SCRIPT_DIR%" "%STAGE%\%PROJECT_NAME%" /e /xd ".git" >nul + +:: Compress the staged copy using PowerShell (no third-party tools needed) +powershell -NoProfile -Command ^ + "Compress-Archive -Path '%STAGE%\%PROJECT_NAME%' -DestinationPath '%ZIP_PATH%' -Force" + +:: Clean up staging directory +rmdir /s /q "%STAGE%" + +echo Archive created: %ZIP_PATH% + +:: Optionally copy to Downloads (mirrors the -d flag behaviour) +if "%DOWNLOADS%"=="true" ( + copy /y "%ZIP_PATH%" "%USERPROFILE%\Downloads\" >nul + echo Copied to: %USERPROFILE%\Downloads\%PROJECT_NAME%.zip +) +goto :eof + +:: โ”€โ”€ Clean exit point reachable from any depth via goto โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +:: exit /b inside a nested CALL subroutine only unwinds one level. +:: goto :end_of_script jumps the entire call stack here unconditionally, +:: which is the only reliable way to stop execution from inside a nested +:: subroutine (e.g. parse_flags called from parse_args) without falling +:: through into unintended code paths. +:end_of_script +exit /b 0 \ No newline at end of file