carlos / todomvc-bakeoff Public
feat(spinel): TodoMVC in Ruby compiled by Spinel via tep
Server-rendered TodoMVC with a small fetch-based script, SQLite in WAL mode, per-visitor lists by anonymous cookie. tep is copied into .cache and patched for the CARLOS socket contract, --listen on 127.0.0.1, 303 reasons and an Assets._add return type. arm64 builds run the host Spinel for Ruby -> C and the arm64 root's gcc for a static link.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
14 files changed,
+1775
−0
apps/spinel/.gitignore+2 −0apps/spinel/AGENTS.md+30 −0apps/spinel/DEPS+0 −0apps/spinel/Makefile+144 −0apps/spinel/app.rb+526 −0apps/spinel/assets/static/app.css+64 −0apps/spinel/assets/static/app.js+80 −0apps/spinel/assets/static/base.css+141 −0apps/spinel/assets/static/index.css+393 −0apps/spinel/patches/1-tep-carlos.patch+198 −0apps/spinel/patches/2-tep-bakeoff.patch+152 −0apps/spinel/scripts/arm64-cc+21 −0apps/spinel/scripts/regen-patch+16 −0apps/spinel/scripts/spinel-cross+8 −0
diff --git a/apps/spinel/.gitignore b/apps/spinel/.gitignore| new file mode 100644 |
| index 0000000..4ff8203 |
| --- /dev/null |
| +++ b/apps/spinel/.gitignore |
| @@ -0,0 +1,2 @@ |
| +.app.tep.rb |
| +version.rb |
diff --git a/apps/spinel/AGENTS.md b/apps/spinel/AGENTS.md| new file mode 100644 |
| index 0000000..95d5864 |
| --- /dev/null |
| +++ b/apps/spinel/AGENTS.md |
| @@ -0,0 +1,30 @@ |
| +# 🤖 AGENTS.md: todo-spinel |
| + |
| +The one rule: **this app is Ruby compiled ahead of time, and nothing else |
| +runs in production.** Everything the server does is written in the |
| +Spinel-compilable subset of Ruby (`app.rb`, via tep) or in the small C |
| +shims tep already ships. No Ruby interpreter, no shared library beyond |
| +libc, no client framework, no JS build step. |
| + |
| +## Tenancy |
| + |
| +- **One instance for the whole app.** A single process and a single |
| + SQLite file (`--db`) serve every visitor. |
| +- **Each visitor's list is kept separately by an anonymous cookie.** The |
| + first visit sets `tid=<22 base64url characters>` (128 bits from the |
| + kernel's `getrandom`), |
| + `HttpOnly; SameSite=Lax; Path=/`, plus `Secure` over https. Every query |
| + is scoped by that value; an id from someone else's list is a 404. |
| +- **No sign-in.** There are no accounts, passwords or e-mail addresses. |
| +- **Trust class C.** Todo text is stored as plain text in SQLite. That's |
| + a deliberate choice for a demo app: nothing in it is meant to be |
| + secret, and the platform's at-rest protection of the database is the |
| + only protection it gets. |
| + |
| +## Working here |
| + |
| +- Work only in `apps/spinel/`. `BRIEF.md` and `CONTRACT.md` at the repo |
| + root are binding. |
| +- The toolchain checkouts (spinel, tep, spinelkit) are read-only. The |
| + Makefile copies tep into `.cache/` and applies `patches/*.patch` there. |
| +- Servers bind `127.0.0.1` only. Scratch goes in `$TMPDIR`. No `sudo`. |
diff --git a/apps/spinel/DEPS b/apps/spinel/DEPS| new file mode 100644 |
| index 0000000..e69de29 |
diff --git a/apps/spinel/Makefile b/apps/spinel/Makefile| new file mode 100644 |
| index 0000000..56bf18b |
| --- /dev/null |
| +++ b/apps/spinel/Makefile |
| @@ -0,0 +1,144 @@ |
| +# todo-spinel. See README.md for what each piece is. |
| +# |
| +# make build dist/app for this machine (x86-64, dynamic glibc) |
| +# make build-arm64 dist/app-linux-arm64 (static, aarch64) |
| +# make test test/run.sh against dist/app |
| +# make deploy ship dist/app-linux-arm64 to Carloku |
| + |
| +SHELL := bash |
| +.SHELLFLAGS := -euo pipefail -c |
| +.DELETE_ON_ERROR: |
| + |
| +TC ?= $(HOME)/.local/share/bakeoff-toolchains |
| +SPINEL_HOST ?= $(TC)/spinel-src/spinel/bin/spinel |
| +SPINEL_ARM64?= $(TC)/spinel-arm64/bin/spinel |
| +TEP_SRC ?= $(TC)/spinel-src/tep |
| +SPINELKIT ?= $(TC)/spinel-src/spinelkit |
| +RUBY ?= $(shell PATH="$(HOME)/.pixi/bin:$$PATH" command -v ruby) |
| +ARM64 := $(abspath ../../scripts/arm64.sh) |
| + |
| +SQLITE_VER := 3500400 |
| +SQLITE_ZIP := .cache/src/sqlite-amalgamation-$(SQLITE_VER).zip |
| +SQLITE_URL := https://www.sqlite.org/2025/sqlite-amalgamation-$(SQLITE_VER).zip |
| +SQLITE_SHA := 1d3049dd0f830a025a53105fc79fd2ab9431aea99e137809d064d8ee8356b032 |
| +SQLITE_DIR := .cache/src/sqlite-amalgamation-$(SQLITE_VER) |
| +# One connection, used from one thread; no extension loading (static glibc |
| +# can't dlopen anyway). |
| +SQLITE_DEFS := -DSQLITE_THREADSAFE=0 -DSQLITE_OMIT_LOAD_EXTENSION -DSQLITE_DQS=0 \ |
| + -DSQLITE_DEFAULT_MEMSTATUS=0 -DSQLITE_LIKE_DOESNT_MATCH_BLOBS |
| + |
| +PATCHES := $(sort $(wildcard patches/*.patch)) |
| +SOURCES := app.rb $(wildcard assets/static/*) |
| +VERSION := $(shell git rev-parse --short HEAD 2>/dev/null || echo dev) |
| + |
| +export LANG := C.UTF-8 |
| +export LC_ALL := C.UTF-8 |
| +export SPINELKIT_DIR := $(SPINELKIT) |
| +export TEP_QUIET := 1 |
| + |
| +.PHONY: build build-arm64 test clean clean-deps bump unbump deploy deps FORCE |
| + |
| +build: dist/app |
| +build-arm64: dist/app-linux-arm64 |
| + |
| +# ---- version stamp --------------------------------------------------------- |
| +# Rewritten only when the commit changes, so it doesn't force rebuilds. |
| +version.rb: FORCE |
| + @printf '# Written by make; not checked in.\nAPP_VERSION = "%s"\n' '$(VERSION)' > $@.tmp |
| + @if cmp -s $@.tmp $@; then rm $@.tmp; else mv $@.tmp $@; fi |
| + |
| +# ---- third-party sources (kept by clean-deps) ------------------------------- |
| +$(SQLITE_ZIP): |
| + mkdir -p $(@D) |
| + curl -sSfL -o $@.part $(SQLITE_URL) |
| + echo "$(SQLITE_SHA) $@.part" | sha256sum -c --quiet |
| + mv $@.part $@ |
| + |
| +$(SQLITE_DIR)/sqlite3.c: $(SQLITE_ZIP) |
| + python3 -I -m zipfile -e $< .cache/src |
| + touch $@ |
| + |
| +# ---- per-target compiled deps (removed by clean-deps) ----------------------- |
| +# A patched copy of tep per target: tep bakes the paths of its C shims |
| +# (sphttp.o, tep_sqlite.o) into the translated source, so each target's |
| +# copy holds that target's objects. |
| +.cache/%/tep/.patched: $(PATCHES) |
| + rm -rf .cache/$*/tep |
| + mkdir -p .cache/$* |
| + cp -a $(TEP_SRC) .cache/$*/tep |
| + rm -rf .cache/$*/tep/.git |
| + for p in $(PATCHES); do patch -s -p1 -d .cache/$*/tep < $$p; done |
| + touch $@ |
| + |
| +# Host (x86-64): the system cc. |
| +.cache/x86/libsqlite3.a: $(SQLITE_DIR)/sqlite3.c |
| + mkdir -p $(@D) |
| + cc -O2 $(SQLITE_DEFS) -c $< -o .cache/x86/sqlite3.o |
| + ar rcs $@ .cache/x86/sqlite3.o |
| + |
| +.cache/x86/tep/tep/sphttp.o: .cache/x86/tep/.patched |
| + cc -O2 -c .cache/x86/tep/tep/sphttp.c -o $@ |
| + |
| +.cache/x86/tep/native/sqlite/tep_sqlite.o: .cache/x86/tep/.patched $(SQLITE_DIR)/sqlite3.c |
| + cc -O2 -I$(SQLITE_DIR) -c .cache/x86/tep/native/sqlite/tep_sqlite.c -o $@ |
| + |
| +# arm64: gcc inside the emulated arm64 root (slow: SQLite takes minutes). |
| +.cache/arm64/libsqlite3.a: $(SQLITE_DIR)/sqlite3.c |
| + mkdir -p $(@D) |
| + $(ARM64) cc -O2 $(SQLITE_DEFS) -c $< -o .cache/arm64/sqlite3.o |
| + $(ARM64) ar rcs $@ .cache/arm64/sqlite3.o |
| + |
| +.cache/arm64/tep/tep/sphttp.o: .cache/arm64/tep/.patched |
| + $(ARM64) cc -O2 -c .cache/arm64/tep/tep/sphttp.c -o $@ |
| + |
| +.cache/arm64/tep/native/sqlite/tep_sqlite.o: .cache/arm64/tep/.patched $(SQLITE_DIR)/sqlite3.c |
| + $(ARM64) cc -O2 -I$(SQLITE_DIR) -c .cache/arm64/tep/native/sqlite/tep_sqlite.c -o $@ |
| + |
| +X86_DEPS := .cache/x86/libsqlite3.a .cache/x86/tep/tep/sphttp.o .cache/x86/tep/native/sqlite/tep_sqlite.o |
| +ARM64_DEPS := .cache/arm64/libsqlite3.a .cache/arm64/tep/tep/sphttp.o .cache/arm64/tep/native/sqlite/tep_sqlite.o |
| + |
| +deps: $(X86_DEPS) $(ARM64_DEPS) |
| + |
| +# ---- the app --------------------------------------------------------------- |
| +# tep translates app.rb (writing .app.tep.rb beside it) and runs Spinel. |
| +dist/app: $(SOURCES) version.rb $(X86_DEPS) |
| + mkdir -p dist .cache/x86/tmp |
| + TMPDIR=$(abspath .cache/x86/tmp) SPINEL=$(SPINEL_HOST) LIBRARY_PATH=$(abspath .cache/x86) \ |
| + $(RUBY) .cache/x86/tep/bin/tep build app.rb -o $@ |
| + |
| +# The host Spinel turns Ruby into C (fast, native); scripts/arm64-cc then |
| +# compiles and links that C statically inside the arm64 root. |
| +dist/app-linux-arm64: $(SOURCES) version.rb $(ARM64_DEPS) |
| + mkdir -p dist .cache/arm64/tmp |
| + TMPDIR=$(abspath .cache/arm64/tmp) SPINEL=$(abspath scripts/spinel-cross) \ |
| + SPINEL_HOST=$(SPINEL_HOST) SPINEL_ARM64=$(SPINEL_ARM64) \ |
| + ARM64_WORK=$(abspath .cache/arm64/work) ARM64_LIB_DIR=$(abspath .cache/arm64) \ |
| + $(RUBY) .cache/arm64/tep/bin/tep build app.rb -o $@ |
| + file $@ | grep -q 'ARM aarch64.*statically linked' |
| + |
| +# ---- the rest ---------------------------------------------------------------- |
| +test: dist/app |
| + test/run.sh dist/app |
| + |
| +clean: |
| + rm -rf dist .app.tep.rb version.rb .cache/x86/tmp .cache/arm64/tmp .cache/arm64/work |
| + |
| +# Every compiled third-party artifact goes; the downloaded SQLite source stays. |
| +clean-deps: clean |
| + rm -rf .cache/x86 .cache/arm64 |
| + |
| +BUMP_FROM := BUILD_NOTE = "todo-spinel" |
| +BUMP_TO := BUILD_NOTE = "todo-spinel (bumped)" |
| + |
| +bump: |
| + grep -qF '$(BUMP_FROM)' app.rb |
| + sed -i 's/^$(BUMP_FROM)$$/$(BUMP_TO)/' app.rb |
| + |
| +unbump: |
| + grep -qF '$(BUMP_TO)' app.rb |
| + sed -i 's/^$(BUMP_TO)$$/$(BUMP_FROM)/' app.rb |
| + |
| +MESSAGE ?= Deploy todo-spinel $(VERSION) |
| + |
| +deploy: dist/app-linux-arm64 |
| + carlos deploy --account bfj --app todo-spinel --target linux-arm64 --message "$(MESSAGE)" dist/app-linux-arm64 |
diff --git a/apps/spinel/app.rb b/apps/spinel/app.rb| new file mode 100644 |
| index 0000000..7919e8f |
| --- /dev/null |
| +++ b/apps/spinel/app.rb |
| @@ -0,0 +1,526 @@ |
| +# todo-spinel: TodoMVC, server-rendered, compiled to one native binary. |
| +# |
| +# tep (`bin/tep build app.rb`) translates this Sinatra-style source and |
| +# drives Spinel, matz's ahead-of-time Ruby compiler, to produce a static |
| +# executable. There is no Ruby at runtime, so this file sticks to the |
| +# subset Spinel compiles: explicit loops, String/Integer values, and |
| +# tep's request/response objects. |
| +# |
| +# app --socket <path> --db <path> (on Carloku) |
| +# app --listen 127.0.0.1:<port> --db <path> |
| +require 'sinatra' # documentation only; the tep translator ignores it |
| + |
| +require_relative "version" # APP_VERSION, written by the Makefile |
| + |
| +# `make bump` rewrites this string so the next build has to recompile the |
| +# file holding the GET / handler. It ends up in an HTML comment. |
| +BUILD_NOTE = "todo-spinel" |
| + |
| +COOKIE_NAME = "tid" |
| + |
| +# --------------------------------------------------------------------------- |
| +# Command line and environment. |
| + |
| +# The value after `flag` in ARGV, or "" when it isn't there. |
| +def arg_value(flag) |
| + v = "" |
| + i = 0 |
| + while i < ARGV.length |
| + if ARGV[i] == flag && i + 1 < ARGV.length |
| + v = ARGV[i + 1] |
| + end |
| + i += 1 |
| + end |
| + v |
| +end |
| + |
| +# This app's own origin, which a POST's Origin header must match: |
| +# https://$CARLOS_HOST on Carloku, http://<listen address> locally. |
| +def own_origin |
| + host = ENV["CARLOS_HOST"].to_s |
| + if host.length > 0 |
| + return "https://" + host |
| + end |
| + listen = arg_value("--listen") |
| + if listen.length > 0 |
| + return "http://" + listen |
| + end |
| + "" |
| +end |
| + |
| +def served_over_https(req) |
| + if ENV["CARLOS_HOST"].to_s.length > 0 |
| + return true |
| + end |
| + req.headers["x-forwarded-proto"].to_s == "https" |
| +end |
| + |
| +# --------------------------------------------------------------------------- |
| +# Storage. One SQLite file in WAL mode. Migrations only ever add. |
| + |
| +DB = Tep::SQLite.new |
| + |
| +MIGRATIONS = [ |
| + "CREATE TABLE todos (id INTEGER PRIMARY KEY, list TEXT NOT NULL, title TEXT NOT NULL, completed INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL DEFAULT (unixepoch()))", |
| + "CREATE INDEX todos_list ON todos (list, id)" |
| +] |
| + |
| +def open_db(path) |
| + if path.length == 0 |
| + puts "todo-spinel: --db <path> is required" |
| + exit(2) |
| + end |
| + if !DB.open(path) |
| + puts "todo-spinel: cannot open " + path |
| + exit(1) |
| + end |
| + DB.exec("PRAGMA journal_mode=WAL") |
| + DB.exec("PRAGMA synchronous=NORMAL") |
| + DB.exec("PRAGMA busy_timeout=5000") |
| + version = DB.first_int("PRAGMA user_version", "") |
| + while version < MIGRATIONS.length |
| + DB.exec("BEGIN") |
| + if !DB.exec(MIGRATIONS[version]) |
| + DB.exec("ROLLBACK") |
| + puts "todo-spinel: migration " + (version + 1).to_s + " failed" |
| + exit(1) |
| + end |
| + version += 1 |
| + DB.exec("PRAGMA user_version=" + version.to_s) |
| + DB.exec("COMMIT") |
| + end |
| +end |
| + |
| +configure do |
| + open_db(arg_value("--db")) |
| +end |
| + |
| +# One visitor's todos, read in id order into parallel arrays (tep's |
| +# SQLite shim has a single cursor, so rows are copied out before any |
| +# other query runs). |
| +class TodoList |
| + attr_accessor :ids, :titles, :done |
| + |
| + def initialize |
| + @ids = [0] |
| + @ids.delete_at(0) |
| + @titles = [""] |
| + @titles.delete_at(0) |
| + @done = [0] |
| + @done.delete_at(0) |
| + end |
| + |
| + def self.load(list) |
| + l = TodoList.new |
| + DB.prepare("SELECT id, title, completed FROM todos WHERE list = ? ORDER BY id") |
| + DB.bind_str(1, list) |
| + while DB.step == 1 |
| + l.ids.push(DB.col_int(0)) |
| + l.titles.push(DB.col_str(1) + "") |
| + l.done.push(DB.col_int(2)) |
| + end |
| + DB.finalize |
| + l |
| + end |
| + |
| + def size |
| + @ids.length |
| + end |
| + |
| + def completed_count |
| + n = 0 |
| + i = 0 |
| + while i < @done.length |
| + if @done[i] != 0 |
| + n += 1 |
| + end |
| + i += 1 |
| + end |
| + n |
| + end |
| +end |
| + |
| +def run_sql(sql, list, id) |
| + DB.prepare(sql) |
| + DB.bind_str(1, list) |
| + if id > 0 |
| + DB.bind_int(2, id) |
| + end |
| + DB.step |
| + DB.finalize |
| +end |
| + |
| +def todo_exists(list, id) |
| + DB.prepare("SELECT 1 FROM todos WHERE list = ? AND id = ?") |
| + DB.bind_str(1, list) |
| + DB.bind_int(2, id) |
| + found = DB.step == 1 |
| + DB.finalize |
| + found |
| +end |
| + |
| +def add_todo(list, title) |
| + DB.prepare("INSERT INTO todos (list, title) VALUES (?, ?)") |
| + DB.bind_str(1, list) |
| + DB.bind_str(2, title) |
| + DB.step |
| + DB.finalize |
| +end |
| + |
| +def rename_todo(list, id, title) |
| + DB.prepare("UPDATE todos SET title = ? WHERE list = ? AND id = ?") |
| + DB.bind_str(1, title) |
| + DB.bind_str(2, list) |
| + DB.bind_int(3, id) |
| + DB.step |
| + DB.finalize |
| +end |
| + |
| +# --------------------------------------------------------------------------- |
| +# Visitors. Each one's list is keyed by an anonymous cookie holding 128 |
| +# random bits (22 base64url characters from getrandom). |
| + |
| +def valid_token(t) |
| + if t.length != 22 |
| + return false |
| + end |
| + i = 0 |
| + while i < t.length |
| + c = t[i] |
| + ok = (c >= "a" && c <= "z") || (c >= "A" && c <= "Z") || (c >= "0" && c <= "9") || c == "-" || c == "_" |
| + if !ok |
| + return false |
| + end |
| + i += 1 |
| + end |
| + true |
| +end |
| + |
| +# The caller's list token. A missing or unrecognised cookie gets a fresh |
| +# token (and so a fresh, empty list), set on this response. |
| +def visitor(req, res) |
| + t = req.cookies[COOKIE_NAME].to_s |
| + if valid_token(t) |
| + return t |
| + end |
| + t = Crypto.sp_crypto_random_b64url(16).to_s |
| + opts = Tep.str_hash |
| + opts["Path"] = "/" |
| + opts["Max-Age"] = "31536000" |
| + opts["HttpOnly"] = "" |
| + opts["SameSite"] = "Lax" |
| + if served_over_https(req) |
| + opts["Secure"] = "" |
| + end |
| + res.set_cookie(COOKIE_NAME, t, opts) |
| + t |
| +end |
| + |
| +# --------------------------------------------------------------------------- |
| +# Rendering. |
| + |
| +def h(s) |
| + s.gsub("&", "&").gsub("<", "<").gsub(">", ">").gsub("\"", """).gsub("'", "'") |
| +end |
| + |
| +def filter_name(f) |
| + if f == "active" || f == "completed" |
| + return f |
| + end |
| + "all" |
| +end |
| + |
| +def filter_path(f) |
| + if f == "active" |
| + return "/active" |
| + end |
| + if f == "completed" |
| + return "/completed" |
| + end |
| + "/" |
| +end |
| + |
| +def filter_hidden(f) |
| + "<input type=\"hidden\" name=\"filter\" value=\"" + f + "\">" |
| +end |
| + |
| +def shown(filter, done) |
| + if filter == "active" |
| + return done == 0 |
| + end |
| + if filter == "completed" |
| + return done != 0 |
| + end |
| + true |
| +end |
| + |
| +def render_item(id, title, done, filter, editing) |
| + sid = id.to_s |
| + et = h(title) |
| + cls = "" |
| + if done != 0 |
| + cls = "completed" |
| + end |
| + if editing |
| + cls = (cls + " editing").strip |
| + end |
| + out = "<li data-id=\"" + sid + "\"" |
| + if cls.length > 0 |
| + out = out + " class=\"" + cls + "\"" |
| + end |
| + out = out + ">\n<div class=\"view\">\n" |
| + out = out + "<input class=\"toggle\" type=\"checkbox\" form=\"toggle-" + sid + "\"" |
| + if done != 0 |
| + out = out + " checked" |
| + end |
| + out = out + ">\n<label>" + et + "</label>\n" |
| + out = out + "<button class=\"nojs toggle-submit\" data-action=\"toggle\" form=\"toggle-" + sid + "\">Toggle</button>\n" |
| + out = out + "<a class=\"nojs edit-link\" data-action=\"edit\" href=\"" + filter_path(filter) + "?edit=" + sid + "\">edit</a>\n" |
| + out = out + "<button class=\"destroy\" form=\"destroy-" + sid + "\" title=\"Delete\"></button>\n</div>\n" |
| + out = out + "<form class=\"edit-form\" method=\"post\" action=\"/todos/" + sid + "/edit\">" |
| + out = out + "<input class=\"edit\" name=\"title\" value=\"" + et + "\"" |
| + if editing |
| + out = out + " autofocus" |
| + end |
| + out = out + ">" + filter_hidden(filter) + "</form>\n" |
| + out = out + "<form id=\"toggle-" + sid + "\" method=\"post\" action=\"/todos/" + sid + "/toggle\" hidden>" + filter_hidden(filter) + "</form>\n" |
| + out = out + "<form id=\"destroy-" + sid + "\" method=\"post\" action=\"/todos/" + sid + "/destroy\" hidden>" + filter_hidden(filter) + "</form>\n" |
| + out + "</li>\n" |
| +end |
| + |
| +def filter_link(f, label, current) |
| + sel = "" |
| + if f == current |
| + sel = " class=\"selected\"" |
| + end |
| + "<li>\n<a" + sel + " href=\"" + filter_path(f) + "\">" + label + "</a>\n</li>\n" |
| +end |
| + |
| +# section.main and footer.footer for one filter: the part of the page an |
| +# action changes, and what the JavaScript swaps in after each POST. |
| +# Empty when the list is. |
| +def render_lists(list, filter, edit_id) |
| + l = TodoList.load(list) |
| + if l.size == 0 |
| + return "" |
| + end |
| + done = l.completed_count |
| + left = l.size - done |
| + out = "<section class=\"main\">\n" |
| + out = out + "<form id=\"toggle-all-form\" method=\"post\" action=\"/todos/toggle-all\" hidden>" + filter_hidden(filter) + "</form>\n" |
| + out = out + "<input id=\"toggle-all\" class=\"toggle-all\" type=\"checkbox\" form=\"toggle-all-form\"" |
| + if left == 0 |
| + out = out + " checked" |
| + end |
| + out = out + ">\n<label for=\"toggle-all\">Mark all as complete</label>\n" |
| + out = out + "<button class=\"nojs toggle-all-submit\" data-action=\"toggle-all\" form=\"toggle-all-form\">Mark all as complete</button>\n" |
| + out = out + "<ul class=\"todo-list\">\n" |
| + i = 0 |
| + while i < l.size |
| + if shown(filter, l.done[i]) |
| + out = out + render_item(l.ids[i], l.titles[i], l.done[i], filter, l.ids[i] == edit_id) |
| + end |
| + i += 1 |
| + end |
| + out = out + "</ul>\n</section>\n<footer class=\"footer\">\n" |
| + word = "items" |
| + if left == 1 |
| + word = "item" |
| + end |
| + out = out + "<span class=\"todo-count\"><strong>" + left.to_s + "</strong> " + word + " left</span>\n" |
| + out = out + "<ul class=\"filters\">\n" + filter_link("all", "All", filter) + filter_link("active", "Active", filter) + filter_link("completed", "Completed", filter) + "</ul>\n" |
| + if done > 0 |
| + out = out + "<form id=\"clear-completed-form\" method=\"post\" action=\"/todos/clear-completed\" hidden>" + filter_hidden(filter) + "</form>\n" |
| + out = out + "<button class=\"clear-completed\" form=\"clear-completed-form\">Clear completed</button>\n" |
| + end |
| + out + "</footer>\n" |
| +end |
| + |
| +# --------------------------------------------------------------------------- |
| +# Actions. Every POST answers 303 back to its filter, or, when the page's |
| +# own script asks with `X-Fragment: 1`, 200 with the fresh fragment. |
| + |
| +def origin_allowed(req) |
| + o = req.headers["origin"].to_s |
| + if o.length == 0 |
| + return true |
| + end |
| + o == own_origin |
| +end |
| + |
| +def finish_post(req, res, list) |
| + f = filter_name(req.params["filter"].to_s) |
| + if req.headers["x-fragment"].to_s == "1" |
| + res.headers["Cache-Control"] = "no-store" |
| + return render_lists(list, f, 0) |
| + end |
| + res.set_status(303) |
| + res.headers["Location"] = filter_path(f) |
| + "" |
| +end |
| + |
| +def refuse(res, code, msg) |
| + res.set_status(code) |
| + res.headers["Content-Type"] = "text/plain; charset=utf-8" |
| + msg |
| +end |
| + |
| +# The numeric id in the path, or 0 when it isn't one. |
| +def path_id(req) |
| + s = req.params["id"].to_s |
| + if s.length == 0 || s.length > 18 |
| + return 0 |
| + end |
| + i = 0 |
| + while i < s.length |
| + if s[i] < "0" || s[i] > "9" |
| + return 0 |
| + end |
| + i += 1 |
| + end |
| + s.to_i |
| +end |
| + |
| +# The whole page. Its markup follows TodoMVC's own template |
| +# (shared/reference.html); render_lists fills in the middle. |
| +def layout(filter, lists, autofocus) |
| + out = "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n" |
| + out = out + "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n" |
| + out = out + "<title>TodoMVC</title>\n" |
| + out = out + "<link rel=\"stylesheet\" href=\"/static/base.css\">\n" |
| + out = out + "<link rel=\"stylesheet\" href=\"/static/index.css\">\n" |
| + out = out + "<link rel=\"stylesheet\" href=\"/static/app.css\">\n" |
| + out = out + "<script>document.documentElement.classList.add(\"js\")</script>\n" |
| + out = out + "<script type=\"module\" src=\"/static/app.js\"></script>\n" |
| + out = out + "<!-- " + BUILD_NOTE + " -->\n</head>\n<body>\n" |
| + out = out + "<section class=\"todoapp\">\n<header class=\"header\">\n<h1>todos</h1>\n" |
| + out = out + "<form method=\"post\" action=\"/todos\">\n" |
| + out = out + "<input class=\"new-todo\" name=\"title\" placeholder=\"What needs to be done?\" autocomplete=\"off\"" |
| + if autofocus |
| + out = out + " autofocus" |
| + end |
| + out = out + ">\n" + filter_hidden(filter) + "\n</form>\n</header>\n" |
| + out = out + lists |
| + out = out + "</section>\n<footer class=\"info\">\n<p>Double-click to edit a todo</p>\n" |
| + out = out + "<p>Created by <a href=\"https://bakeoff.bfj.oncarlos.com\">the CARLOS bakeoff</a> \u00b7 Spinel</p>\n" |
| + out = out + "<p>Part of <a href=\"http://todomvc.com\">TodoMVC</a></p>\n</footer>\n</body>\n</html>\n" |
| + out |
| +end |
| + |
| +def page(req, res, filter) |
| + list = visitor(req, res) |
| + edit_id = 0 |
| + e = req.params["edit"].to_s |
| + if e.length > 0 && e.length < 19 |
| + edit_id = e.to_i |
| + end |
| + res.headers["Cache-Control"] = "no-store" |
| + lists = render_lists(list, filter, edit_id) |
| + # The item being edited takes the focus; otherwise the new-todo box. |
| + layout(filter, lists, !lists.include?(" autofocus>")) |
| +end |
| + |
| +# --------------------------------------------------------------------------- |
| +# Routes. |
| + |
| +get '/' do |
| + page(request, response, "all") |
| +end |
| + |
| +get '/active' do |
| + page(request, response, "active") |
| +end |
| + |
| +get '/completed' do |
| + page(request, response, "completed") |
| +end |
| + |
| +get '/healthz' do |
| + content_type 'text/plain; charset=utf-8' |
| + "ok" |
| +end |
| + |
| +get '/api/version' do |
| + content_type 'text/plain; charset=utf-8' |
| + res.headers["Cache-Control"] = "no-store" |
| + APP_VERSION |
| +end |
| + |
| +post '/todos' do |
| + if !origin_allowed(request) |
| + return refuse(response, 403, "cross-origin request refused") |
| + end |
| + list = visitor(request, response) |
| + title = request.params["title"].to_s.strip |
| + if title.length > 0 |
| + add_todo(list, title) |
| + end |
| + finish_post(request, response, list) |
| +end |
| + |
| +post '/todos/toggle-all' do |
| + if !origin_allowed(request) |
| + return refuse(response, 403, "cross-origin request refused") |
| + end |
| + list = visitor(request, response) |
| + l = TodoList.load(list) |
| + if l.size > 0 && l.completed_count == l.size |
| + run_sql("UPDATE todos SET completed = 0 WHERE list = ?", list, 0) |
| + else |
| + run_sql("UPDATE todos SET completed = 1 WHERE list = ?", list, 0) |
| + end |
| + finish_post(request, response, list) |
| +end |
| + |
| +post '/todos/clear-completed' do |
| + if !origin_allowed(request) |
| + return refuse(response, 403, "cross-origin request refused") |
| + end |
| + list = visitor(request, response) |
| + run_sql("DELETE FROM todos WHERE list = ? AND completed != 0", list, 0) |
| + finish_post(request, response, list) |
| +end |
| + |
| +post '/todos/:id/toggle' do |
| + if !origin_allowed(request) |
| + return refuse(response, 403, "cross-origin request refused") |
| + end |
| + list = visitor(request, response) |
| + id = path_id(request) |
| + if id == 0 || !todo_exists(list, id) |
| + return refuse(response, 404, "no such todo") |
| + end |
| + run_sql("UPDATE todos SET completed = 1 - completed WHERE list = ? AND id = ?", list, id) |
| + finish_post(request, response, list) |
| +end |
| + |
| +post '/todos/:id/edit' do |
| + if !origin_allowed(request) |
| + return refuse(response, 403, "cross-origin request refused") |
| + end |
| + list = visitor(request, response) |
| + id = path_id(request) |
| + if id == 0 || !todo_exists(list, id) |
| + return refuse(response, 404, "no such todo") |
| + end |
| + title = request.params["title"].to_s.strip |
| + if title.length > 0 |
| + rename_todo(list, id, title) |
| + else |
| + run_sql("DELETE FROM todos WHERE list = ? AND id = ?", list, id) |
| + end |
| + finish_post(request, response, list) |
| +end |
| + |
| +post '/todos/:id/destroy' do |
| + if !origin_allowed(request) |
| + return refuse(response, 403, "cross-origin request refused") |
| + end |
| + list = visitor(request, response) |
| + id = path_id(request) |
| + if id == 0 || !todo_exists(list, id) |
| + return refuse(response, 404, "no such todo") |
| + end |
| + run_sql("DELETE FROM todos WHERE list = ? AND id = ?", list, id) |
| + finish_post(request, response, list) |
| +end |
diff --git a/apps/spinel/assets/static/app.css b/apps/spinel/assets/static/app.css| new file mode 100644 |
| index 0000000..7cb340c |
| --- /dev/null |
| +++ b/apps/spinel/assets/static/app.css |
| @@ -0,0 +1,64 @@ |
| +/* Controls for when JavaScript is off. Each sits where its TodoMVC |
| + counterpart is, so the page looks the same either way; the script |
| + adds the `js` class to <html> and these disappear. */ |
| +.js .nojs { |
| + display: none; |
| +} |
| + |
| +.nojs { |
| + margin: 0; |
| + padding: 0; |
| + border: 0; |
| + background: none; |
| + font: inherit; |
| + color: inherit; |
| + cursor: pointer; |
| +} |
| + |
| +/* Over the "mark all" chevron. */ |
| +.toggle-all-submit { |
| + position: absolute; |
| + top: -65px; |
| + left: 0; |
| + width: 45px; |
| + height: 65px; |
| + opacity: 0; |
| +} |
| + |
| +/* Over the round checkbox at the start of each item. */ |
| +.toggle-submit { |
| + position: absolute; |
| + top: 0; |
| + bottom: 0; |
| + left: 0; |
| + width: 40px; |
| + opacity: 0; |
| +} |
| + |
| +/* A small "edit" link, left of where the delete cross appears. */ |
| +.edit-link { |
| + position: absolute; |
| + top: 0; |
| + bottom: 0; |
| + right: 50px; |
| + height: 20px; |
| + margin: auto 0; |
| + font-size: 14px; |
| + line-height: 20px; |
| + color: #949494; |
| + text-decoration: none; |
| +} |
| + |
| +.edit-link:hover, |
| +.edit-link:focus { |
| + color: #4d4d4d; |
| + text-decoration: underline; |
| +} |
| + |
| +html:not(.js) .todo-list li label { |
| + padding-right: 100px; |
| +} |
| + |
| +.edit-form { |
| + margin: 0; |
| +} |
diff --git a/apps/spinel/assets/static/app.js b/apps/spinel/assets/static/app.js| new file mode 100644 |
| index 0000000..3648464 |
| --- /dev/null |
| +++ b/apps/spinel/assets/static/app.js |
| @@ -0,0 +1,80 @@ |
| +// TodoMVC behaviour on top of the server-rendered page. Every change is |
| +// still one of the page's own form POSTs; this script sends it with |
| +// fetch and an `X-Fragment: 1` header, and the server answers with the |
| +// fresh section.main and footer.footer, which replace the old ones. |
| +// Without this script the same forms submit normally. |
| + |
| +const app = document.querySelector('.todoapp'); |
| +const header = app.querySelector('.header'); |
| + |
| +let queue = Promise.resolve(); |
| + |
| +function render(html) { |
| + for (const el of app.querySelectorAll(':scope > .main, :scope > .footer')) el.remove(); |
| + header.insertAdjacentHTML('afterend', html); |
| +} |
| + |
| +// POSTs run one after another, in the order they were made. |
| +function send(form) { |
| + const body = new URLSearchParams(new FormData(form)); |
| + queue = queue.then(async () => { |
| + try { |
| + const res = await fetch(form.action, { |
| + method: 'POST', |
| + body, |
| + headers: { 'X-Fragment': '1' }, |
| + credentials: 'same-origin', |
| + }); |
| + if (!res.ok) throw new Error(`${res.status}`); |
| + render(await res.text()); |
| + } catch { |
| + location.reload(); |
| + } |
| + }); |
| + return queue; |
| +} |
| + |
| +app.addEventListener('submit', (e) => { |
| + const form = e.target; |
| + e.preventDefault(); |
| + if (form.classList.contains('edit-form')) { |
| + const li = form.closest('li'); |
| + if (!li.classList.contains('editing')) return; |
| + li.classList.remove('editing'); |
| + } |
| + send(form); |
| + const input = form.querySelector('.new-todo'); |
| + if (input) input.value = ''; |
| +}); |
| + |
| +// The checkboxes belong to their item's toggle form (and the toggle-all |
| +// form) through the `form` attribute; a change submits that form. |
| +app.addEventListener('change', (e) => { |
| + const box = e.target; |
| + if (box.matches('.toggle, .toggle-all') && box.form) box.form.requestSubmit(); |
| +}); |
| + |
| +app.addEventListener('dblclick', (e) => { |
| + const label = e.target.closest('.todo-list li label'); |
| + if (!label) return; |
| + const li = label.closest('li'); |
| + const input = li.querySelector('.edit'); |
| + input.value = label.textContent; |
| + li.classList.add('editing'); |
| + input.focus(); |
| + input.setSelectionRange(input.value.length, input.value.length); |
| +}); |
| + |
| +app.addEventListener('keydown', (e) => { |
| + if (e.key !== 'Escape' || !e.target.matches('.edit')) return; |
| + const li = e.target.closest('li'); |
| + e.target.value = li.querySelector('label').textContent; |
| + li.classList.remove('editing'); |
| +}); |
| + |
| +// Leaving the editor saves, as in TodoMVC. focusout bubbles; blur doesn't. |
| +app.addEventListener('focusout', (e) => { |
| + if (!e.target.matches('.edit')) return; |
| + const li = e.target.closest('li'); |
| + if (li && li.classList.contains('editing')) e.target.form.requestSubmit(); |
| +}); |
diff --git a/apps/spinel/assets/static/base.css b/apps/spinel/assets/static/base.css| new file mode 100644 |
| index 0000000..da65968 |
| --- /dev/null |
| +++ b/apps/spinel/assets/static/base.css |
| @@ -0,0 +1,141 @@ |
| +hr { |
| + margin: 20px 0; |
| + border: 0; |
| + border-top: 1px dashed #c5c5c5; |
| + border-bottom: 1px dashed #f7f7f7; |
| +} |
| + |
| +.learn a { |
| + font-weight: normal; |
| + text-decoration: none; |
| + color: #b83f45; |
| +} |
| + |
| +.learn a:hover { |
| + text-decoration: underline; |
| + color: #787e7e; |
| +} |
| + |
| +.learn h3, |
| +.learn h4, |
| +.learn h5 { |
| + margin: 10px 0; |
| + font-weight: 500; |
| + line-height: 1.2; |
| + color: #000; |
| +} |
| + |
| +.learn h3 { |
| + font-size: 24px; |
| +} |
| + |
| +.learn h4 { |
| + font-size: 18px; |
| +} |
| + |
| +.learn h5 { |
| + margin-bottom: 0; |
| + font-size: 14px; |
| +} |
| + |
| +.learn ul { |
| + padding: 0; |
| + margin: 0 0 30px 25px; |
| +} |
| + |
| +.learn li { |
| + line-height: 20px; |
| +} |
| + |
| +.learn p { |
| + font-size: 15px; |
| + font-weight: 300; |
| + line-height: 1.3; |
| + margin-top: 0; |
| + margin-bottom: 0; |
| +} |
| + |
| +#issue-count { |
| + display: none; |
| +} |
| + |
| +.quote { |
| + border: none; |
| + margin: 20px 0 60px 0; |
| +} |
| + |
| +.quote p { |
| + font-style: italic; |
| +} |
| + |
| +.quote p:before { |
| + content: '“'; |
| + font-size: 50px; |
| + opacity: .15; |
| + position: absolute; |
| + top: -20px; |
| + left: 3px; |
| +} |
| + |
| +.quote p:after { |
| + content: '”'; |
| + font-size: 50px; |
| + opacity: .15; |
| + position: absolute; |
| + bottom: -42px; |
| + right: 3px; |
| +} |
| + |
| +.quote footer { |
| + position: absolute; |
| + bottom: -40px; |
| + right: 0; |
| +} |
| + |
| +.quote footer img { |
| + border-radius: 3px; |
| +} |
| + |
| +.quote footer a { |
| + margin-left: 5px; |
| + vertical-align: middle; |
| +} |
| + |
| +.speech-bubble { |
| + position: relative; |
| + padding: 10px; |
| + background: rgba(0, 0, 0, .04); |
| + border-radius: 5px; |
| +} |
| + |
| +.speech-bubble:after { |
| + content: ''; |
| + position: absolute; |
| + top: 100%; |
| + right: 30px; |
| + border: 13px solid transparent; |
| + border-top-color: rgba(0, 0, 0, .04); |
| +} |
| + |
| +.learn-bar > .learn { |
| + position: absolute; |
| + width: 272px; |
| + top: 8px; |
| + left: -300px; |
| + padding: 10px; |
| + border-radius: 5px; |
| + background-color: rgba(255, 255, 255, .6); |
| + transition-property: left; |
| + transition-duration: 500ms; |
| +} |
| + |
| +@media (min-width: 899px) { |
| + .learn-bar { |
| + width: auto; |
| + padding-left: 300px; |
| + } |
| + |
| + .learn-bar > .learn { |
| + left: 8px; |
| + } |
| +} |
diff --git a/apps/spinel/assets/static/index.css b/apps/spinel/assets/static/index.css| new file mode 100644 |
| index 0000000..2c0b4b6 |
| --- /dev/null |
| +++ b/apps/spinel/assets/static/index.css |
| @@ -0,0 +1,393 @@ |
| +@charset 'utf-8'; |
| + |
| +html, |
| +body { |
| + margin: 0; |
| + padding: 0; |
| +} |
| + |
| +button { |
| + margin: 0; |
| + padding: 0; |
| + border: 0; |
| + background: none; |
| + font-size: 100%; |
| + vertical-align: baseline; |
| + font-family: inherit; |
| + font-weight: inherit; |
| + color: inherit; |
| + -webkit-appearance: none; |
| + appearance: none; |
| + -webkit-font-smoothing: antialiased; |
| + -moz-osx-font-smoothing: grayscale; |
| +} |
| + |
| +body { |
| + font: 14px 'Helvetica Neue', Helvetica, Arial, sans-serif; |
| + line-height: 1.4em; |
| + background: #f5f5f5; |
| + color: #111111; |
| + min-width: 230px; |
| + max-width: 550px; |
| + margin: 0 auto; |
| + -webkit-font-smoothing: antialiased; |
| + -moz-osx-font-smoothing: grayscale; |
| + font-weight: 300; |
| +} |
| + |
| +.hidden { |
| + display: none; |
| +} |
| + |
| +.todoapp { |
| + background: #fff; |
| + margin: 130px 0 40px 0; |
| + position: relative; |
| + box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2), |
| + 0 25px 50px 0 rgba(0, 0, 0, 0.1); |
| +} |
| + |
| +.todoapp input::-webkit-input-placeholder { |
| + font-style: italic; |
| + font-weight: 400; |
| + color: rgba(0, 0, 0, 0.4); |
| +} |
| + |
| +.todoapp input::-moz-placeholder { |
| + font-style: italic; |
| + font-weight: 400; |
| + color: rgba(0, 0, 0, 0.4); |
| +} |
| + |
| +.todoapp input::input-placeholder { |
| + font-style: italic; |
| + font-weight: 400; |
| + color: rgba(0, 0, 0, 0.4); |
| +} |
| + |
| +.todoapp h1 { |
| + position: absolute; |
| + top: -140px; |
| + width: 100%; |
| + font-size: 80px; |
| + font-weight: 200; |
| + text-align: center; |
| + color: #b83f45; |
| + -webkit-text-rendering: optimizeLegibility; |
| + -moz-text-rendering: optimizeLegibility; |
| + text-rendering: optimizeLegibility; |
| +} |
| + |
| +.new-todo, |
| +.edit { |
| + position: relative; |
| + margin: 0; |
| + width: 100%; |
| + font-size: 24px; |
| + font-family: inherit; |
| + font-weight: inherit; |
| + line-height: 1.4em; |
| + color: inherit; |
| + padding: 6px; |
| + border: 1px solid #999; |
| + box-shadow: inset 0 -1px 5px 0 rgba(0, 0, 0, 0.2); |
| + box-sizing: border-box; |
| + -webkit-font-smoothing: antialiased; |
| + -moz-osx-font-smoothing: grayscale; |
| +} |
| + |
| +.new-todo { |
| + padding: 16px 16px 16px 60px; |
| + height: 65px; |
| + border: none; |
| + background: rgba(0, 0, 0, 0.003); |
| + box-shadow: inset 0 -2px 1px rgba(0,0,0,0.03); |
| +} |
| + |
| +.main { |
| + position: relative; |
| + z-index: 2; |
| + border-top: 1px solid #e6e6e6; |
| +} |
| + |
| +.toggle-all { |
| + width: 1px; |
| + height: 1px; |
| + border: none; /* Mobile Safari */ |
| + opacity: 0; |
| + position: absolute; |
| + right: 100%; |
| + bottom: 100%; |
| +} |
| + |
| +.toggle-all + label { |
| + display: flex; |
| + align-items: center; |
| + justify-content: center; |
| + width: 45px; |
| + height: 65px; |
| + font-size: 0; |
| + position: absolute; |
| + top: -65px; |
| + left: -0; |
| +} |
| + |
| +.toggle-all + label:before { |
| + content: '❯'; |
| + display: inline-block; |
| + font-size: 22px; |
| + color: #949494; |
| + padding: 10px 27px 10px 27px; |
| + -webkit-transform: rotate(90deg); |
| + transform: rotate(90deg); |
| +} |
| + |
| +.toggle-all:checked + label:before { |
| + color: #484848; |
| +} |
| + |
| +.todo-list { |
| + margin: 0; |
| + padding: 0; |
| + list-style: none; |
| +} |
| + |
| +.todo-list li { |
| + position: relative; |
| + font-size: 24px; |
| + border-bottom: 1px solid #ededed; |
| +} |
| + |
| +.todo-list li:last-child { |
| + border-bottom: none; |
| +} |
| + |
| +.todo-list li.editing { |
| + border-bottom: none; |
| + padding: 0; |
| +} |
| + |
| +.todo-list li.editing .edit { |
| + display: block; |
| + width: calc(100% - 43px); |
| + padding: 12px 16px; |
| + margin: 0 0 0 43px; |
| +} |
| + |
| +.todo-list li.editing .view { |
| + display: none; |
| +} |
| + |
| +.todo-list li .toggle { |
| + text-align: center; |
| + width: 40px; |
| + /* auto, since non-WebKit browsers doesn't support input styling */ |
| + height: auto; |
| + position: absolute; |
| + top: 0; |
| + bottom: 0; |
| + margin: auto 0; |
| + border: none; /* Mobile Safari */ |
| + -webkit-appearance: none; |
| + appearance: none; |
| +} |
| + |
| +.todo-list li .toggle { |
| + opacity: 0; |
| +} |
| + |
| +.todo-list li .toggle + label { |
| + /* |
| + Firefox requires `#` to be escaped - https://bugzilla.mozilla.org/show_bug.cgi?id=922433 |
| + IE and Edge requires *everything* to be escaped to render, so we do that instead of just the `#` - https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/7157459/ |
| + */ |
| + background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23949494%22%20stroke-width%3D%223%22/%3E%3C/svg%3E'); |
| + background-repeat: no-repeat; |
| + background-position: center left; |
| +} |
| + |
| +.todo-list li .toggle:checked + label { |
| + background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%2359A193%22%20stroke-width%3D%223%22%2F%3E%3Cpath%20fill%3D%22%233EA390%22%20d%3D%22M72%2025L42%2071%2027%2056l-4%204%2020%2020%2034-52z%22%2F%3E%3C%2Fsvg%3E'); |
| +} |
| + |
| +.todo-list li label { |
| + overflow-wrap: break-word; |
| + padding: 15px 15px 15px 60px; |
| + display: block; |
| + line-height: 1.2; |
| + transition: color 0.4s; |
| + font-weight: 400; |
| + color: #484848; |
| +} |
| + |
| +.todo-list li.completed label { |
| + color: #949494; |
| + text-decoration: line-through; |
| +} |
| + |
| +.todo-list li .destroy { |
| + display: none; |
| + position: absolute; |
| + top: 0; |
| + right: 10px; |
| + bottom: 0; |
| + width: 40px; |
| + height: 40px; |
| + margin: auto 0; |
| + font-size: 30px; |
| + color: #949494; |
| + transition: color 0.2s ease-out; |
| +} |
| + |
| +.todo-list li .destroy:hover, |
| +.todo-list li .destroy:focus { |
| + color: #C18585; |
| +} |
| + |
| +.todo-list li .destroy:after { |
| + content: '×'; |
| + display: block; |
| + height: 100%; |
| + line-height: 1.1; |
| +} |
| + |
| +.todo-list li:hover .destroy { |
| + display: block; |
| +} |
| + |
| +.todo-list li .edit { |
| + display: none; |
| +} |
| + |
| +.todo-list li.editing:last-child { |
| + margin-bottom: -1px; |
| +} |
| + |
| +.footer { |
| + padding: 10px 15px; |
| + height: 20px; |
| + text-align: center; |
| + font-size: 15px; |
| + border-top: 1px solid #e6e6e6; |
| +} |
| + |
| +.footer:before { |
| + content: ''; |
| + position: absolute; |
| + right: 0; |
| + bottom: 0; |
| + left: 0; |
| + height: 50px; |
| + overflow: hidden; |
| + box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2), |
| + 0 8px 0 -3px #f6f6f6, |
| + 0 9px 1px -3px rgba(0, 0, 0, 0.2), |
| + 0 16px 0 -6px #f6f6f6, |
| + 0 17px 2px -6px rgba(0, 0, 0, 0.2); |
| +} |
| + |
| +.todo-count { |
| + float: left; |
| + text-align: left; |
| +} |
| + |
| +.todo-count strong { |
| + font-weight: 300; |
| +} |
| + |
| +.filters { |
| + margin: 0; |
| + padding: 0; |
| + list-style: none; |
| + position: absolute; |
| + right: 0; |
| + left: 0; |
| +} |
| + |
| +.filters li { |
| + display: inline; |
| +} |
| + |
| +.filters li a { |
| + color: inherit; |
| + margin: 3px; |
| + padding: 3px 7px; |
| + text-decoration: none; |
| + border: 1px solid transparent; |
| + border-radius: 3px; |
| +} |
| + |
| +.filters li a:hover { |
| + border-color: #DB7676; |
| +} |
| + |
| +.filters li a.selected { |
| + border-color: #CE4646; |
| +} |
| + |
| +.clear-completed, |
| +html .clear-completed:active { |
| + float: right; |
| + position: relative; |
| + line-height: 19px; |
| + text-decoration: none; |
| + cursor: pointer; |
| +} |
| + |
| +.clear-completed:hover { |
| + text-decoration: underline; |
| +} |
| + |
| +.info { |
| + margin: 65px auto 0; |
| + color: #4d4d4d; |
| + font-size: 11px; |
| + text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5); |
| + text-align: center; |
| +} |
| + |
| +.info p { |
| + line-height: 1; |
| +} |
| + |
| +.info a { |
| + color: inherit; |
| + text-decoration: none; |
| + font-weight: 400; |
| +} |
| + |
| +.info a:hover { |
| + text-decoration: underline; |
| +} |
| + |
| +/* |
| + Hack to remove background from Mobile Safari. |
| + Can't use it globally since it destroys checkboxes in Firefox |
| +*/ |
| +@media screen and (-webkit-min-device-pixel-ratio:0) { |
| + .toggle-all, |
| + .todo-list li .toggle { |
| + background: none; |
| + } |
| + |
| + .todo-list li .toggle { |
| + height: 40px; |
| + } |
| +} |
| + |
| +@media (max-width: 430px) { |
| + .footer { |
| + height: 50px; |
| + } |
| + |
| + .filters { |
| + bottom: 10px; |
| + } |
| +} |
| + |
| +:focus, |
| +.toggle:focus + label, |
| +.toggle-all:focus + label { |
| + box-shadow: 0 0 2px 2px #CF7D7D; |
| + outline: 0; |
| +} |
diff --git a/apps/spinel/patches/1-tep-carlos.patch b/apps/spinel/patches/1-tep-carlos.patch| new file mode 100644 |
| index 0000000..ef334f5 |
| --- /dev/null |
| +++ b/apps/spinel/patches/1-tep-carlos.patch |
| @@ -0,0 +1,198 @@ |
| +diff --git a/bin/tep b/bin/tep |
| +index a66fcb1..1c34e08 100755 |
| +--- a/bin/tep |
| ++++ b/bin/tep |
| +@@ -898,6 +898,7 @@ def translate(input_path, lax: ENV["TEP_LAX"] == "1") |
| + out << <<~RB |
| + __port = #{config[:port] || 4567} |
| + __workers = #{config[:workers] || 1} |
| ++ __sockpath = "" |
| + __quiet = false |
| + __scheduled = #{scheduled ? 'true' : 'false'} |
| + __i = 0 |
| +@@ -909,6 +910,18 @@ def translate(input_path, lax: ENV["TEP_LAX"] == "1") |
| + elsif __a == "-w" && __i + 1 < ARGV.length |
| + __workers = ARGV[__i + 1].to_i |
| + __i += 2 |
| ++ elsif __a == "--socket" && __i + 1 < ARGV.length |
| ++ # Serve over this unix-domain socket path instead of TCP (the |
| ++ # argv deployment platforms like CARLOS spawn instances with). |
| ++ __sockpath = ARGV[__i + 1] |
| ++ __i += 2 |
| ++ elsif __a == "--db" && __i + 1 < ARGV.length |
| ++ # Platform-supplied DB path; tolerated so a platform can pass it |
| ++ # unconditionally. Apps that want it read it from ARGV themselves. |
| ++ __i += 2 |
| ++ elsif __a == "serve" |
| ++ # Tolerated no-op: platform unit templates default to `<bin> serve`. |
| ++ __i += 1 |
| + elsif __a == "-q" |
| + __quiet = true |
| + __i += 1 |
| +@@ -921,7 +934,11 @@ def translate(input_path, lax: ENV["TEP_LAX"] == "1") |
| + __i += 1 |
| + end |
| + end |
| +- Tep.run!(__port, __workers, __quiet, __scheduled) |
| ++ if __sockpath.length > 0 |
| ++ Tep.run_unix!(__sockpath, __quiet) |
| ++ else |
| ++ Tep.run!(__port, __workers, __quiet, __scheduled) |
| ++ end |
| + RB |
| + out << "" |
| + |
| +diff --git a/tep.rb b/tep.rb |
| +index 92a8ae4..7fb112a 100644 |
| +--- a/tep.rb |
| ++++ b/tep.rb |
| +@@ -903,6 +903,11 @@ module Tep |
| + # accepts the call without the 4th arg only because it supports |
| + # default-value params; without this, the 3-arg call silently |
| + # miscompiled (matz/spinel arity-warning shape, tep#13). |
| ++ # Unix-socket twin of run! -- see Server#run_unix. |
| ++ def self.run_unix!(path, quiet) |
| ++ Server.new(APP).run_unix(path, quiet) |
| ++ end |
| ++ |
| + def self.run!(port, workers, quiet, scheduled = false) |
| + if scheduled |
| + Server::Scheduled.new(APP).run(port, workers, quiet) |
| +diff --git a/tep/app.rb b/tep/app.rb |
| +index 7668be8..500da09 100644 |
| +--- a/tep/app.rb |
| ++++ b/tep/app.rb |
| +@@ -63,6 +63,13 @@ module Tep |
| + # Late-seeded for the same reason as openai_backend. |
| + attr_accessor :openai_events |
| + attr_accessor :asset_bodies, :asset_mimes, :asset_etags |
| ++ # Serve every response with Connection: close (no keep-alive reuse). |
| ++ # Set by Server#run_unix: that mode is a single blocking worker, and |
| ++ # a reverse proxy's idle keep-alive connection would wedge it — the |
| ++ # worker blocks reading the idle socket while every other connection |
| ++ # queues behind it (observed live behind CARLOS's activator proxy: |
| ++ # intermittent hangs + dropped POSTs until Go's 90s idle timeout). |
| ++ attr_accessor :close_per_request |
| + attr_accessor :sched_fibers, :sched_wake_at, :sched_current |
| + attr_accessor :sched_io_fd, :sched_io_mode, :sched_io_ready |
| + # Tep::Job background-worker idempotency flag. App-level so a |
| +@@ -113,6 +120,7 @@ module Tep |
| + @asset_mimes = Tep.str_hash # by Tep::Assets._add lines |
| + # the bin/tep translator emits) |
| + @asset_etags = Tep.str_hash # path -> content-hash ETag (#152) |
| ++ @close_per_request = false |
| + # FiberSlot array for the cooperative scheduler. Initialise |
| + # with a noop-bodied slot to pin the array element type, then |
| + # drop it. Each slot holds one Fiber + a timer entry in the |
| +diff --git a/tep/net.rb b/tep/net.rb |
| +index 45d22e9..7a20724 100644 |
| +--- a/tep/net.rb |
| ++++ b/tep/net.rb |
| +@@ -24,6 +24,9 @@ module Sock |
| + ffi_lib "crypto" |
| + |
| + ffi_func :sphttp_listen, [:int, :int], :int |
| ++ # Unix-domain listener: bind+listen on a filesystem path (see |
| ++ # sphttp.c). -1 on failure; accept/read/write reuse the same fd paths. |
| ++ ffi_func :sphttp_listen_unix, [:str], :int |
| + ffi_func :sphttp_accept, [:int], :int |
| + # Non-blocking accept variant used by Tep::Server::Scheduled. |
| + # Listen fd must be in non-blocking mode (sphttp_set_nonblock). |
| +diff --git a/tep/server.rb b/tep/server.rb |
| +index 1bec928..8e81dea 100644 |
| +--- a/tep/server.rb |
| ++++ b/tep/server.rb |
| +@@ -96,6 +96,31 @@ module Tep |
| + end |
| + end |
| + |
| ++ |
| ++ # Serve over a unix-domain socket path: the shape deployment |
| ++ # platforms hand an instance app (`--socket <path>`). One process, |
| ++ # one listener -- prefork/SO_REUSEPORT does not apply to AF_UNIX, |
| ++ # and socket-path apps are typically hibernated single instances. |
| ++ def run_unix(path, quiet) |
| ++ if !quiet |
| ++ puts "[tep " + VERSION + "] listening on unix:" + path |
| ++ end |
| ++ # One blocking worker serves this socket: an idle kept-alive proxy |
| ++ # connection would starve every other connection. Close per request; |
| ++ # re-dialing a unix socket is effectively free. |
| ++ Tep::APP.close_per_request = true |
| ++ Sock.sphttp_install_term_handlers |
| ++ sfd = Sock.sphttp_listen_unix(path) |
| ++ if sfd < 0 |
| ++ puts "tep: bind failed on " + path |
| ++ exit(1) |
| ++ end |
| ++ worker_loop(sfd) |
| ++ if Sock.sphttp_shutdown_requested != 0 |
| ++ Tep.on_shutdown |
| ++ end |
| ++ end |
| ++ |
| + def worker_loop(sfd) |
| + loop do |
| + client = Sock.sphttp_accept(sfd) |
| +@@ -151,7 +176,7 @@ module Tep |
| + res = Response.new |
| + @app.dispatch(req, res) |
| + |
| +- keep_alive = req.keep_alive? && !res.halted_close? |
| ++ keep_alive = req.keep_alive? && !res.halted_close? && !Tep::APP.close_per_request |
| + write_response(client, req, res, keep_alive) |
| + keep_going = keep_alive |
| + end |
| +@@ -240,7 +265,12 @@ module Tep |
| + if !res.headers.key?("Content-Type") && res.status != 204 && res.status != 304 |
| + res.headers["Content-Type"] = "text/html; charset=utf-8" |
| + end |
| +- res.headers["Content-Length"] = res.body.length.to_s |
| ++ # bytesize, not length: Content-Length is an OCTET count, and a |
| ++ # body with multi-byte UTF-8 (an embedded asset, upstream API |
| ++ # text) otherwise declares fewer bytes than are written -- the |
| ++ # client truncates the tail (seen live: a bundled .js losing its |
| ++ # closing braces, killing the whole script). |
| ++ res.headers["Content-Length"] = res.body.bytesize.to_s |
| + if keep_alive |
| + res.headers["Connection"] = "keep-alive" |
| + else |
| +diff --git a/tep/sphttp.c b/tep/sphttp.c |
| +index 6b7863e..7af1b04 100644 |
| +--- a/tep/sphttp.c |
| ++++ b/tep/sphttp.c |
| +@@ -24,6 +24,7 @@ |
| + #include <sys/wait.h> |
| + #include <sys/stat.h> |
| + #include <netinet/in.h> |
| ++#include <sys/un.h> |
| + #include <netinet/tcp.h> |
| + #include <arpa/inet.h> |
| + #include <signal.h> |
| +@@ -126,6 +127,26 @@ int sphttp_sleep_ms(int ms) { |
| + * term-flag-aware pre-check + EINTR handling as the old body; the |
| + * shutdown flag is now sp_net's (set via sp_net_install_term_handlers, |
| + * which sphttp_install_term_handlers delegates to). */ |
| ++ |
| ++/* Unix-domain listener (deployment platforms that hand an app a socket |
| ++ * path -- e.g. CARLOS spawns instances as `<bin> --socket <path>` and |
| ++ * proxies HTTP over it). Unlinks a stale path, binds, listens. Single |
| ++ * fd, no SO_REUSEPORT (kernel load-balancing is a TCP concern; unix |
| ++ * instance apps run one process). Returns the listening fd or -1. */ |
| ++int sphttp_listen_unix(const char *path) { |
| ++ struct sockaddr_un addr; |
| ++ if (strlen(path) >= sizeof(addr.sun_path)) return -1; |
| ++ int fd = socket(AF_UNIX, SOCK_STREAM, 0); |
| ++ if (fd < 0) return -1; |
| ++ memset(&addr, 0, sizeof(addr)); |
| ++ addr.sun_family = AF_UNIX; |
| ++ strncpy(addr.sun_path, path, sizeof(addr.sun_path) - 1); |
| ++ unlink(path); |
| ++ if (bind(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { close(fd); return -1; } |
| ++ if (listen(fd, 128) < 0) { close(fd); unlink(path); return -1; } |
| ++ return fd; |
| ++} |
| ++ |
| + int sphttp_listen(int port, int reuseport) { return sp_net_listen(port, reuseport); } |
| + int sphttp_accept(int sfd) { return sp_net_accept(sfd); } |
| + int sphttp_accept_nb(int sfd) { return sp_net_accept_nb(sfd); } |
diff --git a/apps/spinel/patches/2-tep-bakeoff.patch b/apps/spinel/patches/2-tep-bakeoff.patch| new file mode 100644 |
| index 0000000..d6c95ac |
| --- /dev/null |
| +++ b/apps/spinel/patches/2-tep-bakeoff.patch |
| @@ -0,0 +1,152 @@ |
| +--- a/bin/tep |
| ++++ b/bin/tep |
| +@@ -899,6 +899,7 @@ |
| + __port = #{config[:port] || 4567} |
| + __workers = #{config[:workers] || 1} |
| + __sockpath = "" |
| ++ __listen_host = "" |
| + __quiet = false |
| + __scheduled = #{scheduled ? 'true' : 'false'} |
| + __i = 0 |
| +@@ -915,6 +916,16 @@ |
| + # argv deployment platforms like CARLOS spawn instances with). |
| + __sockpath = ARGV[__i + 1] |
| + __i += 2 |
| ++ elsif __a == "--listen" && __i + 1 < ARGV.length |
| ++ # host:port -- bind TCP on exactly that address (127.0.0.1 for |
| ++ # local runs) instead of -p's INADDR_ANY. |
| ++ __hp = ARGV[__i + 1] |
| ++ __colon = __hp.rindex(":") |
| ++ if __colon != nil |
| ++ __listen_host = __hp[0, __colon] |
| ++ __port = __hp[__colon + 1, __hp.length - __colon - 1].to_i |
| ++ end |
| ++ __i += 2 |
| + elsif __a == "--db" && __i + 1 < ARGV.length |
| + # Platform-supplied DB path; tolerated so a platform can pass it |
| + # unconditionally. Apps that want it read it from ARGV themselves. |
| +@@ -936,6 +947,8 @@ |
| + end |
| + if __sockpath.length > 0 |
| + Tep.run_unix!(__sockpath, __quiet) |
| ++ elsif __listen_host.length > 0 |
| ++ Tep.run_host!(__listen_host, __port, __quiet) |
| + else |
| + Tep.run!(__port, __workers, __quiet, __scheduled) |
| + end |
| +--- a/tep/assets.rb |
| ++++ b/tep/assets.rb |
| +@@ -25,8 +25,11 @@ |
| + # `Tep.public_dir` instead. |
| + module Tep |
| + class Assets |
| ++ # Returns 0, as sig/tep/assets.rbs declares (add_asset returns the |
| ++ # ETag String, which otherwise leaks out as this method's value). |
| + def self._add(path, body, mime) |
| + Tep::APP.add_asset(path, body, mime) |
| ++ 0 |
| + end |
| + |
| + def self.has?(path) |
| +--- a/tep/net.rb |
| ++++ b/tep/net.rb |
| +@@ -27,6 +27,8 @@ |
| + # Unix-domain listener: bind+listen on a filesystem path (see |
| + # sphttp.c). -1 on failure; accept/read/write reuse the same fd paths. |
| + ffi_func :sphttp_listen_unix, [:str], :int |
| ++ # TCP listener on one address (`--listen 127.0.0.1:<port>`). |
| ++ ffi_func :sphttp_listen_host, [:str, :int], :int |
| + ffi_func :sphttp_accept, [:int], :int |
| + # Non-blocking accept variant used by Tep::Server::Scheduled. |
| + # Listen fd must be in non-blocking mode (sphttp_set_nonblock). |
| +--- a/tep/server.rb |
| ++++ b/tep/server.rb |
| +@@ -8,6 +8,7 @@ |
| + if status == 204; return "No Content"; end |
| + if status == 301; return "Moved Permanently"; end |
| + if status == 302; return "Found"; end |
| ++ if status == 303; return "See Other"; end |
| + if status == 304; return "Not Modified"; end |
| + if status == 400; return "Bad Request"; end |
| + if status == 401; return "Unauthorized"; end |
| +@@ -121,6 +122,25 @@ |
| + end |
| + end |
| + |
| ++ # Serve TCP on one address (`--listen 127.0.0.1:<port>`), with the |
| ++ # same single-worker, close-per-request shape as run_unix. |
| ++ def run_host(host, port, quiet) |
| ++ if !quiet |
| ++ puts "[tep " + VERSION + "] listening on http://" + host + ":" + port.to_s |
| ++ end |
| ++ Tep::APP.close_per_request = true |
| ++ Sock.sphttp_install_term_handlers |
| ++ sfd = Sock.sphttp_listen_host(host, port) |
| ++ if sfd < 0 |
| ++ puts "tep: bind failed on " + host + ":" + port.to_s |
| ++ exit(1) |
| ++ end |
| ++ worker_loop(sfd) |
| ++ if Sock.sphttp_shutdown_requested != 0 |
| ++ Tep.on_shutdown |
| ++ end |
| ++ end |
| ++ |
| + def worker_loop(sfd) |
| + loop do |
| + client = Sock.sphttp_accept(sfd) |
| +@@ -152,6 +172,12 @@ |
| + end |
| + Sock.sphttp_set_recv_timeout(client, 0) |
| + end |
| ++ # Close-per-request mode serves one connection at a time, so a |
| ++ # client that connects and then sends nothing (a browser's |
| ++ # speculative preconnect) must not hold the worker: bound reads. |
| ++ if Tep::APP.close_per_request |
| ++ Sock.sphttp_set_recv_timeout(client, 5000) |
| ++ end |
| + handle_connection(client) |
| + end |
| + # Explicit Integer return: the `loop` falls through as nil (boxed), |
| +--- a/tep/sphttp.c |
| ++++ b/tep/sphttp.c |
| +@@ -85,6 +85,7 @@ |
| + extern int sp_net_install_term_handlers(void); |
| + extern int sp_net_shutdown_requested(void); |
| + extern int sp_net_listen(int port, int reuseport); |
| ++extern int sp_net_listen_host(const char *host, int port, int backlog); |
| + extern int sp_net_accept(int sfd); |
| + extern int sp_net_accept_nb(int sfd); |
| + extern int sp_net_connect(const char *host, int port); |
| +@@ -147,6 +148,17 @@ |
| + return fd; |
| + } |
| + |
| ++/* TCP listener bound to one address (e.g. 127.0.0.1) rather than |
| ++ * INADDR_ANY, for `--listen host:port`. -1 on failure. */ |
| ++int sphttp_listen_host(const char *host, int port) { |
| ++ int fd = sp_net_listen_host(host, port, 1024); |
| ++ if (fd >= 0) { |
| ++ int one = 1; |
| ++ setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one)); |
| ++ } |
| ++ return fd; |
| ++} |
| ++ |
| + int sphttp_listen(int port, int reuseport) { return sp_net_listen(port, reuseport); } |
| + int sphttp_accept(int sfd) { return sp_net_accept(sfd); } |
| + int sphttp_accept_nb(int sfd) { return sp_net_accept_nb(sfd); } |
| +--- a/tep.rb |
| ++++ b/tep.rb |
| +@@ -300,6 +300,11 @@ |
| + Server.new(APP).run_unix(path, quiet) |
| + end |
| + |
| ++ # TCP twin bound to one host address -- see Server#run_host. |
| ++ def self.run_host!(host, port, quiet) |
| ++ Server.new(APP).run_host(host, port, quiet) |
| ++ end |
| ++ |
| + def self.run!(port, workers, quiet, scheduled = false) |
| + if scheduled |
| + Server::Scheduled.new(APP).run(port, workers, quiet) |
diff --git a/apps/spinel/scripts/arm64-cc b/apps/spinel/scripts/arm64-cc| new file mode 100755 |
| index 0000000..dc1e21f |
| --- /dev/null |
| +++ b/apps/spinel/scripts/arm64-cc |
| @@ -0,0 +1,21 @@ |
| +#!/usr/bin/env bash |
| +# The C compiler Spinel calls for an arm64 build (see spinel-cross). |
| +# Rewrites the host Spinel's runtime paths to the arm64 Spinel's, copies |
| +# the generated C into the work directory (which the arm64 root can see), |
| +# and runs a static link with the arm64 root's gcc and glibc. |
| +set -euo pipefail |
| +here=$(cd "$(dirname "$0")" && pwd) |
| +repo=$(cd "$here/../../.." && pwd) |
| +host_lib="$(dirname "${SPINEL_HOST:?}")/../lib" |
| +arm_lib="$(dirname "${SPINEL_ARM64:?}")/../lib" |
| +work=${ARM64_WORK:?} |
| +mkdir -p "$work" |
| +args=() |
| +for a in "$@"; do |
| + case "$a" in |
| + *"$host_lib"*) a="${a//"$host_lib"/"$arm_lib"}" ;; |
| + */spinel_out_*.c) cp "$a" "$work/app.c"; a="$work/app.c" ;; |
| + esac |
| + args+=("$a") |
| +done |
| +exec "$repo/scripts/arm64.sh" cc -static -s -L"${ARM64_LIB_DIR:?}" "${args[@]}" -lz -lzstd |
diff --git a/apps/spinel/scripts/regen-patch b/apps/spinel/scripts/regen-patch| new file mode 100755 |
| index 0000000..74793dd |
| --- /dev/null |
| +++ b/apps/spinel/scripts/regen-patch |
| @@ -0,0 +1,16 @@ |
| +#!/usr/bin/env bash |
| +# Regenerates patches/2-tep-bakeoff.patch from the edited tep copy in |
| +# .cache/x86/tep (maintainer tool; the build only applies patches). |
| +set -euo pipefail |
| +cd "$(dirname "$0")/.." |
| +src=${TEP_SRC:-$HOME/.local/share/bakeoff-toolchains/spinel-src/tep} |
| +base=$(mktemp -d -p "${TMPDIR:-/tmp}") |
| +trap 'rm -rf "$base"' EXIT |
| +mkdir -p "$base/a" |
| +cp -a "$src/." "$base/a/" |
| +rm -rf "$base/a/.git" |
| +patch -s -p1 -d "$base/a" < patches/1-tep-carlos.patch |
| +cp -a .cache/x86/tep "$base/b" |
| +find "$base/b" -name '*.o' -delete |
| +rm -f "$base/b/.patched" |
| +(cd "$base" && diff -ruN a b || true) | sed -E 's#^(---|\+\+\+) ([^\t]*)\t.*#\1 \2#; /^diff -ruN /d' > patches/2-tep-bakeoff.patch |
diff --git a/apps/spinel/scripts/spinel-cross b/apps/spinel/scripts/spinel-cross| new file mode 100755 |
| index 0000000..425fcb7 |
| --- /dev/null |
| +++ b/apps/spinel/scripts/spinel-cross |
| @@ -0,0 +1,8 @@ |
| +#!/usr/bin/env bash |
| +# Stand-in for `spinel` that tep calls when building for linux-arm64. |
| +# The host (x86-64) Spinel does the slow part, Ruby -> C, natively; the |
| +# C compiler it then calls is scripts/arm64-cc, which compiles and links |
| +# that C inside the emulated arm64 root. Both Spinels are the same commit. |
| +set -euo pipefail |
| +here=$(cd "$(dirname "$0")" && pwd) |
| +exec "${SPINEL_HOST:?}" "$@" "--cc=$here/arm64-cc" |