The hack toolbox is a single application, that opens and closes windows ("toolbox windows") at an appropriate time when Hack apps are flipped over. Its responsibility is to make sure, when the user clicks the flip-to-hack button, that there is a toolbox window open and ready. GNOME Shell does the actual job of matching the toolbox window to the app window and executing the flipping animation.
The toolbox application exports an action on DBus, called flip
.
When the user clicks the flip-to-hack button, this action is activated,
with two parameters: the application ID (e.g. com.hack_computer.Fizzics
)
and the DBus object path of the window that was flipped (e.g.
/com/hack_computer/Fizzics/window/1
).
The latter parameter is theoretically to be able to handle flipping
applications with more than one window, but that wasn't currently used.
The hack toolbox application picks out the proper toolbox widget with a
switch
statement, creates an instance of it, and puts it in a toolbox
window.
Most toolbox widgets inherit from Toolbox
in toolbox.js
, which
provides all the toolbox chrome such as the "masthead" in the upper left
corner, an interface to add "topics", and the collapsing feature.
The toolbox code should communicate with the application over DBus, either through a DBus interface provided especially for this purpose, or through a general-purpose interface such as Clippy.
A toolbox window may export an action on DBus called flip-back
.
If it does, GNOME Shell will not flip back to the application window
immediately, but wait for the application window to be closed and for a
new one to appear.
This is in order to implement toolboxes that cannot adjust the app
instantly, but instead have to restart it for the changes to be applied,
such as the Framework toolbox.
In this case, it's the toolbox's responsibility to close the app and
restart it with any necessary arguments.
(Note! If the toolbox exports the flip-back
action but no new window
appears, then GNOME Shell will never flip the toolbox window.)
In most toolboxes, there are two ways to hack an application; either
through changing GUI widgets in a "control panel", or assigning values
to variables, using JavaScript syntax, in a "code view".
The toolbox generally follows a model-view architecture, where the
control panel and code view are both views of the same model.
It's the model's responsibility to communicate with the application
that's being hacked.
For applications using the hack-toy-apps mechanism, this communication
happens via Clippy; see src/clippyWrapper.js
.
Code views do some validation of the ranges and types of the values assigned to the variables. If the code in a code view doesn't pass validation, then the model isn't updated and therefore neither is the control panel.
It's often possible to assign values in a code view that are outside the
ranges allowed by the control panel widgets.
This is intentional.
It's also possible to do some rudimentary programming in the code view
such as obstacleSize = shipSize / 3;
.
A relationship such as this can't be reflected in the model or the
control panel widgets, but it will be preserved in the code view,
unless it's edited some other way.
(That is, given the above example, changing the 'obstacle size' control
panel widget will clobber the code view's relationship between
obstacleSize
and shipSize
.)
Topics are exported as objects on DBus which can be manipulated by the quest scripts. You can hide or reveal a topic, make it insensitive (ignore mouse and keyboard input), and get a notification when it is clicked on. Topics can also have lockscreens (see below.)
User functions are code views that contain a function definition, instead of a list of variable assignments. They define behaviour, so they don't have a control panel counterpart.
The user functions and variables belonging to an app, together basically form an API for what the player can hack in that app. Note that these functions and variables aren't literally "injected" into the app's code. The variables are turned into DBus properties, and the user functions are passed in as strings and executed by the app (via a mechanism similar to "Validation" below.) It's necessary for these to be passed via such a well-defined API so that internal app code can be as complicated as it needs to be without the additional requirement of being didactic, and so that refactors of app code don't break the user's work.
The code in a code view (variables or user functions) is validated,
basically by executing it with a glorified eval()
.
Errors are indicated in the margin, just like a lot of real code
editors.
Each user function has a "player API" available to it. This is best illustrated with an example. In the Sidetrack toolbox's Instructions topic, the user function looks like this:
function instructions() {
riley.jump();
riley.forward();
// etc.
}
The player API here consists of a riley
object with some methods.
Internally, the player API is placed onto a "scope" object, and the user
code is executed inside the scope of a with
block:
with (scope) {
function instructions() {
riley.jump();
riley.forward();
// ...
}
}
The with
statement ensures that when trying to resolve the name
riley
, the value of scope.riley
will be used.
(This is probably one of a very few justified uses of the with
statement in modern JavaScript.)
With the above, we can create a function that executes the user code, pass in a scope object with the player API, then observe any changes that happen to the scope object after executing the user function:
// Note: simplified for clarity, compared to the actual code
const userCode = new Function('scope', `
with (scope) {
function instructions() {
riley.jump();
riley.forward();
// ...
}
}`);
const scope = {
riley: {
queue: [],
jump() {
this.queue.push('jump');
},
forward() {
this.queue.push('forward');
},
},
};
userCode(scope);
if (scope.riley.queue.length !== 8)
throw new Error('Not enough instructions');
For some areas, the player has to get past a "lockscreen" by opening it with an inventory item, usually a "key". The presence of the inventory item is detected via the game state service.
A lockscreen consists of two static images and an animation.
The first static image, no-key
, is shown when the lockscreen is locked
and the player doesn't have the inventory item to open it.
The second, has-key
, is usually a glowy version of the first, and is
shown when the needed inventory item is present.
The animation is shown when the player clicks on the has-key
image,
and is a green-screen transition showing the lock sliding away,
revealing the toolbox or other thing underneath.
Code view — A code editor widget that allows controlling parameters of an application by setting the values of pre-filled variables. It also allows changing the behaviour of an application by injecting a user function.
Control panel — A graphical UI that allows to control some parameters of an application through sliders, dropdowns, checkboxes, text entries, and other GUI widgets.
Lockscreen — A "shield" that blocks access to a toolbox, a control panel widget, or a topic. Each lockscreen can be opened with a certain inventory item.
Masthead — The upper left corner of a toolbox that displays relevant information such as the application being hacked.
Toolbox — The contents of a toolbox window, often including a
masthead, topics, control panel, and code view.
See src/toolbox.js
and its app-specific subclasses.
Toolbox app — The single application that opens and closes toolbox
windows.
See src/app.js
.
Toolbox window — The window that is paired with an application
window by GNOME Shell, when the user clicks flip-to-hack.
See src/window.js
.
Topic — A grouping of related control panel widgets. Topics are listed along the left-hand edge of the toolbox window. When a topic is selected, its control panel widgets appear in the toolbox window.
User function — A code view with a function definition that the player can use to change the behaviour of an app.
Use the ./build-flatpak.sh
script to build a Flatpak from the latest
git commit.
Use the build-local-flatpak.sh
script for developing. The script
also takes any extra arguments for flatpak-builder
, thus, if you
want to quickly build a Flatpak with any changes you may have done,
and install it in the user installation base, you can do:
./tools/build-local-flatpak.sh --install
Commit your local changes.
Use the ./build-flatpak.sh
script to build a Flatpak from the latest
git commit.
Update the local application using flatpak update --assumeyes --no-deps com.hack_computer.HackToolbox
Run the local version using flatpak run --env=HACK_TOOLBOX_PERSIST=1 com.hack_computer.HackToolbox
The continuous integration tool runs checks to validate the PRs. To run the checks locally before sending your PR, you will need to install:
Then call:
eslint .
yamllint .
If you add a new widget, you will have to add it to the Glade catalog.
Install the Glade app (it's available in Endless OS through
flathub). Go to Preferences. In Extra Catalog Paths, click on
the plus icon, then find your checkout directory, and select the
directory data/glade/
where the toolbox.glade.xml
is.
Restart Glade. If the catalog was imported, you will see entries for "Hack Toolbox" widgets in the Widgets menu of the central panel. Your new widget should be among them.
The topics were added later in this codebase's lifetime, after a number of toolboxes had already been designed. Some toolboxes have not yet been adapted to the topics system, and are overcrowded because everything is stuck into one topic. These toolboxes would need a reorganization. For some toolboxes, it is probably necessary to figure out how to group related topics together and expand / collapse them.
We currently have several ways of restricting access to toolboxes, individual topics within toolboxes, and individual control panel widgets within topics. A lockscreen can be placed at any of these levels. As well, a topic can be initially hidden and revealed later via a DBus interface, or a topic can be made temporarily insensitive (not reacting to mouse or keyboard input.)
Future work should consolidate these methods of restricting and granting access as players progress through the curriculum.
For historical reasons, user functions are passed back to the application without the surrounding function declaration. See https://phabricator.endlessm.com/T26818 for a proposal to change this, and why it should be done.
The method of validation described above isn't good enough for a good
learning environment.
For one thing, since we use the built-in JavaScript parser by way of
new Function
(which is eval()
in disguise), the parser bails out at
the first error.
So if there's more than one syntax error in user code, we can only
detect the first one.
And if there's any syntax error at all in user code, we can't execute
the code on the scope object to perform any further validation.
Future work could investigate more sophisticated JavaScript parsers from
the Node.js world, such as Esprima.
Second, while it will never be possible to detect all runtime errors while running the code, we should be able to detect more than we do now with static type analysis. For example,
function spawnEnemy() {
if (ticksSinceSpawn > 9.46e8)
return 'finalboss';
}
Such an error wouldn't be detected unless we evaluated the user code
with all possible values of ticksSinceSpawn
.
But with type analysis, we could detect that 'finalboss'
isn't a valid
return value according to the player API of that function.
Future work could investigate using TypeScript or Flow for user code,
without exposing those complications to the player.