shithub: puzzles

Download patch

ref: f9449af87a4f5420aa7683d3f15110bfa2f1bf17
parent: 241f68b543de62a800ff5cbf6c06580d45ab8a13
author: Ben Harris <bjh21@bjh21.me.uk>
date: Sat Oct 29 14:22:35 EDT 2022

kaios: Major parts of a build for KaiOS

KaiOS (which is based on Firefox OS, formerly Boot to Gecko) runs its
"native" apps in a Web browser, so this is essentially a rather
specialised version of the JavaScript front-end.  Indeed, the JavaScript
and C parts are the same as the Web version.

There are three major parts that are specific to the KaiOS build.
First, there's manifest.pl, which generates a KaiOS-specific JSON
manifest describing each puzzle.

Second, there's a new HTML page generator, apppage.pl, that generates an
HTML page that is much less like a Web page, and much more like an
application, than the one generated by jspage.pl. It expects to build a
single HTML page at a time and gets all its limited knowledge of the
environment from its command line.  This makes it gratuitously different
from jspage.pl and javapage.pl, but makes it easier to run from the
build system.

And finally, there's the CMake glue that assembles the necessary parts
for each application in a directory.  This includes the manifest, the
HTML, the JavaScript, the KaiOS-specific icons (generated as part of the
GTK build) and a copy of the HTML documentation.  The directory is
assembled using CMake's install() function, and can be installed on a
KaiOS device using the developer tools.

--- a/cmake/platforms/emscripten.cmake
+++ b/cmake/platforms/emscripten.cmake
@@ -6,6 +6,11 @@
 set(WASM ON
   CACHE BOOL "Compile to WebAssembly rather than plain JavaScript")
 
+find_program(HALIBUT halibut)
+if(NOT HALIBUT)
+  message(WARNING "HTML documentation cannot be built (did not find halibut)")
+endif()
+
 set(emcc_export_list
   # Event handlers for mouse and keyboard input
   _mouseup
@@ -62,4 +67,66 @@
 endfunction()
 
 function(build_platform_extras)
+  if(HALIBUT)
+    set(help_dir ${CMAKE_CURRENT_BINARY_DIR}/help)
+    add_custom_command(OUTPUT ${help_dir}/en
+      COMMAND ${CMAKE_COMMAND} -E make_directory ${help_dir}/en)
+    add_custom_command(OUTPUT ${help_dir}/en/index.html
+      COMMAND ${HALIBUT} --html -Chtml-template-fragment:%k
+        ${CMAKE_CURRENT_SOURCE_DIR}/puzzles.but
+      DEPENDS
+      ${help_dir}/en
+      ${CMAKE_CURRENT_SOURCE_DIR}/puzzles.but
+      WORKING_DIRECTORY ${help_dir}/en)
+    add_custom_target(kaios_help ALL
+      DEPENDS ${help_dir}/en/index.html)
+  endif()
+
+  # This is probably not the right way to set the destination.
+  set(CMAKE_INSTALL_PREFIX ${CMAKE_CURRENT_BINARY_DIR} CACHE PATH
+      "Installation path" FORCE)
+
+  add_custom_target(kaios-extras ALL)
+
+  foreach(name ${puzzle_names})
+    add_custom_command(
+      OUTPUT ${name}-manifest.webapp
+      COMMAND ${PERL_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/kaios/manifest.pl
+        "${name}" "${displayname_${name}}" "${description_${name}}"
+        "${objective_${name}}" > "${name}-manifest.webapp"
+      VERBATIM
+      DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/kaios/manifest.pl)
+
+    file(MAKE_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/kaios)
+    add_custom_command(
+      OUTPUT ${name}-kaios.html
+      COMMAND ${PERL_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/kaios/apppage.pl
+        "${name}" "${displayname_${name}}" > "${name}-kaios.html"
+      VERBATIM
+      DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/kaios/apppage.pl)
+
+    add_custom_target(${name}-kaios-extras
+      DEPENDS ${name}-manifest.webapp ${name}-kaios.html)
+    add_dependencies(kaios-extras ${name}-kaios-extras)
+
+    install(TARGETS ${name} DESTINATION kaios/${name})
+    # Release builds generate an initial memory image alongside the
+    # JavaScript, but CMake doesn't seem to know about it to install
+    # it.
+    install(FILES $<TARGET_FILE:${name}>.mem OPTIONAL
+      DESTINATION kaios/${name})
+    install(FILES ${ICON_DIR}/${name}-56kai.png ${ICON_DIR}/${name}-112kai.png
+      DESTINATION kaios/${name} OPTIONAL)
+    install(FILES ${CMAKE_CURRENT_BINARY_DIR}/${name}-kaios.html
+      RENAME ${name}.html
+      DESTINATION kaios/${name})
+    install(FILES ${CMAKE_CURRENT_BINARY_DIR}/${name}-manifest.webapp
+      RENAME manifest.webapp
+      DESTINATION kaios/${name})
+    if (HALIBUT)
+      install(DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/help
+        DESTINATION kaios/${name})
+    endif()
+
+  endforeach()
 endfunction()
--- a/emccpre.js
+++ b/emccpre.js
@@ -534,6 +534,14 @@
     }
     menuform.addEventListener("keydown", menukey);
 
+    // Open documentation links within the application in KaiOS.
+    for (var elem of document.querySelectorAll("#gamemenu a[href]")) {
+        elem.addEventListener("click", function(event) {
+            window.open(event.target.href);
+            event.preventDefault();
+        });
+    }
+
     // In IE, the canvas doesn't automatically gain focus on a mouse
     // click, so make sure it does
     onscreen_canvas.addEventListener("mousedown", function(event) {
@@ -567,7 +575,7 @@
             event.preventDefault();
             event.stopPropagation();
         }
-    }, true);
+    });
 
     // Event handler to fake :focus-within on browsers too old for
     // it (like KaiOS 2.5).  Browsers without :focus-within are also
--- /dev/null
+++ b/kaios/apppage.pl
@@ -1,0 +1,337 @@
+#!/usr/bin/perl
+
+use strict;
+use warnings;
+
+@ARGV == 2 or die "usage: apppage.pl <name> <displayname>";
+my ($name, $displayname) = @ARGV;
+
+print <<EOF;
+<!DOCTYPE html>
+<html>
+<head>
+<meta http-equiv="Content-Type" content="text/html; charset=ASCII" />
+<meta name="theme-color" content="rgb(50,50,50)" />    
+<title>${displayname}</title>
+<script defer type="text/javascript" src="${name}.js"></script>
+<!-- Override some defaults for small screens -->
+<script id="environment" type="application/json">
+{ "PATTERN_DEFAULT": "10x10" }
+</script>
+<style class="text/css">
+body {
+    margin: 0;
+    display: flex;
+    position: fixed;
+    width: 100%;
+    top: 0;
+    bottom: 30px;
+    font-size: 17px;
+}
+
+/* Top-level form for the game menu */
+#gamemenu {
+    /* Add a little mild text formatting */
+    font-weight: bold;
+    font-size: 14px;
+ }
+
+/* Inside that form, the main menu bar and every submenu inside it is a <ul> */
+#gamemenu ul {
+    list-style: none;  /* get rid of the normal unordered-list bullets */
+    display: flex;
+    margin: 0;
+    /* Compensate for the negative margins on menu items by adding a
+     * little bit of padding so that the borders of the items don't protrude
+     * beyond the menu. */
+    padding: 0.5px;
+    /* Switch to vertical stacking, for drop-down submenus */
+    flex-direction: column;
+    /* We must specify an explicit background colour for submenus, because
+     * they must be opaque (don't want other page contents showing through
+     * them). */
+    background: white;
+}
+
+/* Individual menu items are <li> elements within such a <ul> */
+#gamemenu li {
+    /* Suppress the text-selection I-beam pointer */
+    cursor: default;
+    /* Surround each menu item with a border. */
+    border: 1px solid rgb(180,180,180);
+    /* Arrange that the borders of each item overlap the ones next to it. */
+    margin: -0.5px;
+}
+
+#gamemenu ul li[role=separator] {
+    color: transparent;
+    border: 0;
+}
+
+/* The interactive contents of menu items are their child elements. */
+#gamemenu li > * {
+    padding: 0.2em 0.75em;
+    margin: 0;
+    display: block;
+}
+
+
+#gamemenu :disabled {
+    /* Grey out disabled buttons */
+    color: rgba(0,0,0,0.5);
+}
+
+#gamemenu li > :hover:not(:disabled),
+#gamemenu li > .focus-within {
+    /* When the mouse is over a menu item, highlight it */
+    background-color: rgba(0,0,0,0.3);
+}
+
+.transient {
+    /* When they are displayed, they are positioned immediately above
+     * their parent <li>, and with the left edge aligning */
+    position: fixed;
+    bottom: 30px;
+    max-height: calc(100vh - 30px);
+    left: 100%;
+    transition: left 0.1s;
+    box-sizing: border-box;
+    width: 100vw;
+    overflow: auto;
+    /* And make sure they appear in front. */
+    z-index: 50;
+}
+
+.transient.focus-within {
+    /* Once a menu is actually focussed, bring it on screen. */
+    left: 0;
+    /* Hiding what's behind. */
+    box-shadow: 0 0 1em 0 rgba(0, 0, 0, 0.8);
+}
+
+#gamemenu :hover > ul,
+#gamemenu .focus-within > ul {
+    /* Last but by no means least, the all-important line that makes
+     * submenus be displayed! Any <ul> whose parent <li> is being
+     * hovered over gets display:flex overriding the display:none
+     * from above. */
+    display: flex;
+}
+
+#gamemenu button {
+    /* Menu items that trigger an action.  We put some effort into
+     * removing the default button styling. */
+    -moz-appearance: none;
+    -webkit-appearance: none;
+    appearance: none;
+    font: inherit;
+    color: inherit;
+    background: initial;
+    border: initial;
+    border-radius: initial;
+    text-align: inherit;
+    width: 100%;
+}
+
+#gamemenu .tick {
+    /* The tick at the start of a menu item, or its unselected equivalent.
+     * This is represented by an <input type="radio">, so we put some
+     * effort into overriding the default style. */
+    -moz-appearance: none;
+    -webkit-appearance: none;
+    appearance: none;
+    margin: initial;
+    font: inherit;
+}
+
+#gamemenu .tick::before {
+    content: "\\2713";
+}
+
+#gamemenu .tick:not(:checked) {
+    /* Tick for an unselected menu entry. */
+    color: transparent;
+}
+
+#gamemenu li > div::after {
+    content: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='10'%20height='10'%3E%3Cpolygon%20points='0,0,10,5,0,10'/%3E%3C/svg%3E");
+    float: right;
+}
+
+#puzzle {
+    background: var(--puzzle-background, #e6e6e6);
+    flex: 1 1 auto;
+    flex-direction: column;
+    align-items: center;
+    display: flex;
+    width: 100%
+}
+
+#statusbar {
+    overflow: hidden;
+    text-align: left;
+    white-space: nowrap;
+    text-overflow: ellipsis;
+    line-height: 1;
+    background: #d8d8d8;
+    border-left: 2px solid #c8c8c8;
+    border-top: 2px solid #c8c8c8;
+    border-right: 2px solid #e8e8e8;
+    border-bottom: 2px solid #e8e8e8;
+    height: 1em;
+}
+
+#dlgdimmer {
+    width: 100%;
+    height: 100%;
+    background: #000000;
+    position: fixed;
+    opacity: 0.3;
+    left: 0;
+    top: 0;
+    z-index: 99;
+}
+
+#dlgform {
+    width: 66.6667vw;
+    opacity: 1;
+    background: #ffffff;
+    color: #000000;
+    position: absolute;
+    border: 2px solid black;
+    padding: 20px;
+    top: 10vh;
+    left: 16.6667vw;
+    z-index: 100;
+}
+
+#dlgform h2 {
+    margin-top: 0px;
+}
+
+#puzzlecanvascontain {
+    flex: 1 1 auto;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    min-width: 0;
+    min-height: 0;
+}
+
+#puzzlecanvas {
+    max-width: 100%;
+    max-height: 100%;
+    background-color: white;
+    font-weight: 600;
+}
+
+#puzzlecanvas:focus {
+    /* The focus will be here iff there's nothing else on
+     * screen that can be focused, so the outline is
+     * redundant. */
+    outline: none;
+}
+
+#puzzle > div {
+    width: 100%;
+}
+
+.softkey {
+    position: fixed;
+    left: 0;
+    right: 0;
+    bottom: 0;
+    height: 30px;
+    font-weight: 600;
+    font-size: 14px;
+    line-height: 1;
+    white-space: nowrap;
+    background: rgb(50,50,50);
+    color: white;
+    z-index: 150;
+}
+
+:not(.focus-within) > .softkey {
+    display: none;
+}
+
+.softkey > * {
+    position: absolute;
+    padding: 8px;
+}
+
+.lsk {
+    left: 0;
+    right: 70%;
+    text-align: left;
+    padding-right: 0;
+}
+
+.csk {
+    left: 30%;
+    right: 30%;
+    text-align: center;
+    text-transform: uppercase;
+    padding-left: 0;
+    padding-right: 0;
+}
+
+.rsk {
+    right: 0;
+    left: 70%;
+    text-align: right;
+    padding-left: 0
+}
+
+</style>
+</head>
+<body>
+<div id="puzzle">
+  <div id="puzzlecanvascontain">
+    <canvas id="puzzlecanvas" width="1px" height="1px" tabindex="0">
+    </canvas>
+  </div>
+  <div id="statusbar">
+  </div>
+  <div class="softkey"><div class="rsk">Menu</div></div>
+</div>
+<form id="gamemenu" class="transient">
+ <ul>
+  <li><div tabindex="0">Game<ul class="transient">
+    <li><button type="button" id="specific">Enter game ID...</button></li>
+    <li><button type="button" id="random">Enter random seed...</button></li>
+    <li><button type="button" id="save">Download save file...</button></li>
+    <li><button type="button" id="load">Upload save file...</button></li>
+  </ul></div></li>
+  <li><div tabindex="0">Type<ul id="gametype" class="transient"></ul></div></li>
+  <li role="separator"></li>
+  <li><button type="button" id="new">
+    New<span class="verbiage"> game</span>
+  </button></li>
+  <li><button type="button" id="restart">
+    Restart<span class="verbiage"> game</span>
+  </button></li>
+  <li><button type="button" id="undo">
+    Undo<span class="verbiage"> move</span>
+  </button></li>
+  <li><button type="button" id="redo">
+    Redo<span class="verbiage"> move</span>
+  </button></li>
+  <li><button type="button" id="solve">
+    Solve<span class="verbiage"> game</span>
+    </button></li>
+  <li><a target="_blank" href="help/en/${name}.html#${name}">
+    Instructions
+  </a></li>
+  <li><a target="_blank" href="help/en/index.html">
+    Full manual
+  </a></li>
+ </ul>
+ <div class="softkey">
+   <div class="csk">Select</div>
+   <div class="rsk">Dismiss</div>
+ </div>
+</form>
+</body>
+</html>
+EOF
--- /dev/null
+++ b/kaios/manifest.pl
@@ -1,0 +1,36 @@
+#!/usr/bin/perl
+
+use strict;
+use warnings;
+
+use JSON::PP;
+
+@ARGV == 4 or
+    die "usage: manifest.pl <name> <displayname> <description> <objective>";
+my ($name, $displayname, $description, $objective) = @ARGV;
+
+# Limits from
+# https://developer.kaiostech.com/docs/getting-started/main-concepts/manifest
+length($displayname) <= 20 or die "Name too long: $displayname";
+length($description) <= 40 or die "Subtitle too long: $description";
+$objective .= "  Part of Simon Tatham's Portable Puzzle Collection.";
+# https://developer.kaiostech.com/docs/distribution/submission-guideline
+length($objective) <= 220 or die "Description too long: $objective";
+
+print encode_json({
+    name => $displayname,
+    subtitle => $description,
+    description => $objective,
+    launch_path => "/${name}.html",
+    icons => {
+        "56" =>  "/${name}-56kai.png",
+        "112" => "/${name}-112kai.png",
+    },
+    developer => {
+        name => "Ben Harris",
+        url => "https://bjh21.me.uk",
+    },
+    default_locale => "en-GB",
+    categories => ["games"],
+    cursor => JSON::PP::false,
+})