Add Pebblexus watch app

Amolith created

Change summary

.clang-format     |   6 
.gitignore        |   7 
AGENTS.md         |  23 ++
PRODUCT.md        |  57 +++++
README.md         |  51 ++++
mise.toml         | 113 ++++++++++
package.json      |  65 +++++
src/c/main.c      | 547 +++++++++++++++++++++++++++++++++++++++++++++++++
src/pkjs/index.js | 455 ++++++++++++++++++++++++++++++++++++++++
wscript           |  43 +++
10 files changed, 1,367 insertions(+)

Detailed changes

.clang-format 🔗

@@ -0,0 +1,6 @@
+BasedOnStyle: LLVM
+ColumnLimit: 120
+IndentWidth: 2
+ContinuationIndentWidth: 4
+PointerAlignment: Right
+SortIncludes: false

.gitignore 🔗

@@ -0,0 +1,7 @@
+build/
+node_modules/
+.lock-waf_*_build
+*.pbw
+*.png
+*.gif
+*.log

AGENTS.md 🔗

@@ -0,0 +1,23 @@
+# pebblexus
+
+Pebble app for Plexus quotas.
+
+## Commands
+
+```sh
+mise run build # build the PBW
+mise run check # same as build; no emulator, tests, lint, or formatter
+mise run install # install on the Emery emulator
+mise run screenshot # writes screenshot_emery.png
+mise run logs # stream Emery emulator logs
+```
+
+Use `mise run emu:up`, `emu:select`, `emu:down`, and `emu:back` for button input.
+
+## Landmines
+
+- `mise.toml` and `package.json` both target `emery`; keep target/platform changes in sync.
+- Adding or renaming AppMessage keys requires updating `package.json` `pebble.messageKeys`, the C `MESSAGE_KEY_*` usage, and PebbleKit JS payload keys together.
+- The Plexus admin key belongs in phone-side PebbleKit JS storage only; never send it to the watch.
+- `mise run build` leaves `build/` and `.lock-waf_*_build`; they are ignored build artifacts.
+- PebbleKit JS here is written in ES5 style for Pebble compatibility: use `var` and functions, not modern syntax.

PRODUCT.md 🔗

@@ -0,0 +1,57 @@
+# Product
+
+## Register
+
+product
+
+## Users
+
+Pebblexus is for Plexus operators who want to glance at provider quotas
+and prepaid balances from a Pebble watch. They can be managing their
+agents away from a computer and need enough signal to understand each
+quota group without opening the Plexus management UI.
+
+## Product Purpose
+
+Pebblexus presents Plexus quota checker data as small, readable watch
+pages grouped by provider or checker ID. Success means a user can browse
+groups sorted by most to least urgent according to meters where there's
+a total and a remaining amount. Groups with only numerical meters
+without a max, like an OpenRouter or Hyper balance, should sort after
+groups with meters that do have a max because we can't infer the
+former's criticality while we can the latter's.
+
+Users must see each display name and group ID on each page, and avoid
+mistaking a prepaid meter for a percentage-based meter.
+
+## Brand Personality
+
+Light, Pebble-native, charming. The app should feel like a polished
+native Pebble utility: glanceable, tactile, and a little delightful
+without making the data harder to read.
+
+## Anti-references
+
+Do not copy Plexus' management UI directly. Avoid generic AI-dashboard
+dark mode, ornamental gradients, and heavy shadows.
+
+## Design Principles
+
+- Data semantics come first: balances are numbers, quotas are bars, and
+  unknowns should be honest.
+- Group related quota meters together so one provider or checker feels
+  like one page, then sort those pages so more urgent checkers surface
+  first. Wraparound lets them predictably go back to checkers without an
+  urgency signal.
+- Use calm, tactile, playful motion as feedback for state changes, not
+  as constant decoration.
+- Prefer Pebble-native restraint: strong contrast, simple shapes, clear
+  typography, small moments of charm.
+- Keep the app usable in a quick glance under imperfect ambient light.
+
+## Accessibility & Inclusion
+
+Use high-contrast light mode as the default. Do not rely on color alone
+for quota state; pair color with numbers, labels, and page grouping.
+Keep animation brief and avoid continuous motion except during an active
+refresh. Dark mode will come later.

README.md 🔗

@@ -0,0 +1,51 @@
+# Pebblexus
+
+Pebblexus is a Pebble watchapp for checking Plexus quota status from
+your wrist.
+
+Pebblexus groups quota meters from Plexus' management quota endpoint by
+provider/checker ID. Up/down move between groups, select refreshes the
+current list, and the footer shows the current page count. The
+phone-side PebbleKit JS stores the user's Plexus base URL and admin key;
+the key is never sent to the watch.
+
+## Requirements
+
+- `mise`
+- A Plexus instance reachable from the phone
+- A Plexus admin key for that instance
+
+The development environment is described in `mise.toml`. It installs the
+`pebble` CLI through mise's `pipx:pebble-tool` backend, pins the Python
+runtime used by that tool, and adds the Pebble SDK toolchain to `PATH`.
+
+## Build
+
+```sh
+mise run build
+```
+
+`mise run check` runs the same build without requiring an emulator.
+
+## Test on Emery
+
+```sh
+mise run install
+mise run screenshot
+```
+
+Interact with the app from the CLI:
+
+```sh
+mise run emu:up      # previous group
+mise run emu:select  # refresh quotas
+mise run emu:down    # next group
+mise run emu:back    # long-press back
+```
+
+Optional app-icon generation uses the local Pebble skill helper and the
+Pebble tool Python environment, which includes Pillow via mise:
+
+```sh
+mise run icons
+```

mise.toml 🔗

@@ -0,0 +1,113 @@
+min_version = "2026.2.4"
+
+[vars]
+platform = "emery"
+screenshot = "screenshot_emery.png"
+python_version = "3.13"
+pebble_tool_version = "5.0.37"
+pebble_tool_python = "{{env.HOME}}/.local/share/mise/installs/pipx-pebble-tool/{{vars.pebble_tool_version}}/pebble-tool/bin/python"
+app_icons_script = "{{env.HOME}}/.agents/skills/pebble-watchface/scripts/create_app_icons.py"
+preview_gif_script = "{{env.HOME}}/.agents/skills/pebble-watchface/scripts/create_preview_gif.py"
+
+[tools]
+node = "22"
+"aqua:tamasfe/taplo" = "0.10.0"
+"npm:prettier" = "3.8.1"
+"pipx:pebble-tool" = { version = "{{vars.pebble_tool_version}}", uvx_args = "--python {{vars.python_version}}" }
+
+[tasks.fmt]
+description = "Format C, PebbleKit JS, JSON, Markdown, and TOML"
+run = ["mise run c:fmt", "mise run prettier:fmt", "mise run toml:fmt"]
+
+[tasks.lint]
+description = "Check formatting"
+run = ["mise run c:lint", "mise run prettier:lint", "mise run toml:lint"]
+
+[tasks.fix]
+description = "Format and run lightweight checks"
+run = ["mise run fmt", "mise run lint"]
+
+[tasks.build]
+description = "Build the Pebble PBW"
+run = "pebble build"
+
+[tasks.check]
+description = "Format, lint, and build without requiring an emulator"
+run = ["mise run fix", "mise run build"]
+
+[tasks.clean]
+description = "Remove Pebble build artifacts"
+run = "pebble clean"
+
+[tasks."c:fmt"]
+description = "Format Pebble C code"
+run = "clang-format -i src/c/main.c"
+
+[tasks."c:lint"]
+description = "Check Pebble C formatting"
+run = "clang-format --dry-run --Werror src/c/main.c"
+
+[tasks."prettier:fmt"]
+description = "Format JS, JSON, and Markdown"
+run = "prettier --trailing-comma none --write README.md PRODUCT.md package.json src/pkjs/index.js"
+
+[tasks."prettier:lint"]
+description = "Check JS, JSON, and Markdown formatting"
+run = "prettier --trailing-comma none --check README.md PRODUCT.md package.json src/pkjs/index.js"
+
+[tasks."toml:fmt"]
+description = "Format TOML"
+run = "taplo fmt mise.toml"
+
+[tasks."toml:lint"]
+description = "Check TOML formatting"
+run = "taplo fmt --check mise.toml"
+
+[tasks.size]
+description = "Analyze Pebble app ELF size"
+depends = ["build"]
+run = "pebble analyze-size build/{{vars.platform}}/pebble-app.elf"
+
+[tasks.install]
+description = "Install the app in the Pebble emulator"
+run = "pebble install --emulator {{vars.platform}}"
+
+[tasks."emu:config"]
+description = "Open the app-provided config page in the Pebble emulator"
+run = "pebble emu-app-config --emulator {{vars.platform}}"
+
+[tasks."install:cloudpebble"]
+description = "Install the app on a paired device through CloudPebble"
+run = "pebble install --cloudpebble"
+
+[tasks.screenshot]
+description = "Capture an emulator screenshot"
+run = "pebble screenshot --no-open --emulator {{vars.platform}} {{vars.screenshot}}"
+
+[tasks.logs]
+description = "Stream emulator logs"
+run = "pebble logs --emulator {{vars.platform}}"
+
+[tasks."emu:up"]
+description = "Press the emulator up button"
+run = "pebble emu-button click up --emulator {{vars.platform}}"
+
+[tasks."emu:select"]
+description = "Press the emulator select button"
+run = "pebble emu-button click select --emulator {{vars.platform}}"
+
+[tasks."emu:down"]
+description = "Press the emulator down button"
+run = "pebble emu-button click down --emulator {{vars.platform}}"
+
+[tasks."emu:back"]
+description = "Long-press the emulator back button"
+run = "pebble emu-button click back --duration 2000 --emulator {{vars.platform}}"
+
+[tasks.icons]
+description = "Generate app icons from the current emulator screenshot"
+run = "{{vars.pebble_tool_python}} {{vars.app_icons_script}} ."
+
+[tasks.gif]
+description = "Generate an emulator preview GIF"
+run = "{{vars.pebble_tool_python}} {{vars.preview_gif_script}} . --frames 8 --delay 400"

package.json 🔗

@@ -0,0 +1,65 @@
+{
+  "name": "pebblexus",
+  "author": "Amolith",
+  "version": "0.1.0",
+  "keywords": [
+    "pebble-app",
+    "plexus",
+    "quota"
+  ],
+  "private": true,
+  "dependencies": {},
+  "pebble": {
+    "displayName": "Pebblexus",
+    "uuid": "00317a49-ec0e-4af4-a842-b4f76d3265c8",
+    "sdkVersion": "3",
+    "enableMultiJS": true,
+    "targetPlatforms": [
+      "emery"
+    ],
+    "capabilities": [
+      "configurable"
+    ],
+    "watchapp": {
+      "watchface": false
+    },
+    "messageKeys": [
+      "REQUEST_QUOTA",
+      "REQUEST_PAGE",
+      "QUOTA_TITLE",
+      "QUOTA_LABEL",
+      "QUOTA_DETAIL",
+      "QUOTA_PERCENT",
+      "QUOTA_STATUS",
+      "QUOTA_RESET",
+      "ROW_COUNT",
+      "ROW_0_LABEL",
+      "ROW_0_DETAIL",
+      "ROW_0_PERCENT",
+      "ROW_0_STATUS",
+      "ROW_0_HAS_BAR",
+      "ROW_1_LABEL",
+      "ROW_1_DETAIL",
+      "ROW_1_PERCENT",
+      "ROW_1_STATUS",
+      "ROW_1_HAS_BAR",
+      "ROW_2_LABEL",
+      "ROW_2_DETAIL",
+      "ROW_2_PERCENT",
+      "ROW_2_STATUS",
+      "ROW_2_HAS_BAR",
+      "ROW_3_LABEL",
+      "ROW_3_DETAIL",
+      "ROW_3_PERCENT",
+      "ROW_3_STATUS",
+      "ROW_3_HAS_BAR",
+      "PAGE_INDEX",
+      "PAGE_COUNT",
+      "UPDATED_AT",
+      "ERROR"
+    ],
+    "resources": {
+      "media": []
+    }
+  }
+}

src/c/main.c 🔗

@@ -0,0 +1,547 @@
+#include <pebble.h>
+
+#define MAX_ROWS 4
+#define ANIMATION_INTERVAL_MS 33
+#define ANIMATION_FRAMES 12
+
+typedef struct {
+  char label[24];
+  char detail[28];
+  int percent;
+  int status_rank;
+  bool has_bar;
+} QuotaRow;
+
+static Window *s_main_window;
+static Layer *s_card_layer;
+static AppTimer *s_animation_timer;
+
+static int s_page_index = 0;
+static int s_page_count = 0;
+static int s_row_count = 0;
+static int s_animation_frame = ANIMATION_FRAMES;
+static int s_refresh_frame = 0;
+static int s_animation_direction = 1;
+static bool s_is_refreshing = false;
+
+static char s_title_text[40] = "Pebblexus";
+static char s_group_text[40] = "";
+static char s_reset_text[40] = "Set URL and admin key";
+static char s_status_text[16] = "Waiting";
+static char s_updated_text[16] = "";
+static QuotaRow s_rows[MAX_ROWS];
+
+static uint32_t row_label_key(int row_index) {
+  switch (row_index) {
+  case 0:
+    return MESSAGE_KEY_ROW_0_LABEL;
+  case 1:
+    return MESSAGE_KEY_ROW_1_LABEL;
+  case 2:
+    return MESSAGE_KEY_ROW_2_LABEL;
+  default:
+    return MESSAGE_KEY_ROW_3_LABEL;
+  }
+}
+
+static uint32_t row_detail_key(int row_index) {
+  switch (row_index) {
+  case 0:
+    return MESSAGE_KEY_ROW_0_DETAIL;
+  case 1:
+    return MESSAGE_KEY_ROW_1_DETAIL;
+  case 2:
+    return MESSAGE_KEY_ROW_2_DETAIL;
+  default:
+    return MESSAGE_KEY_ROW_3_DETAIL;
+  }
+}
+
+static uint32_t row_percent_key(int row_index) {
+  switch (row_index) {
+  case 0:
+    return MESSAGE_KEY_ROW_0_PERCENT;
+  case 1:
+    return MESSAGE_KEY_ROW_1_PERCENT;
+  case 2:
+    return MESSAGE_KEY_ROW_2_PERCENT;
+  default:
+    return MESSAGE_KEY_ROW_3_PERCENT;
+  }
+}
+
+static uint32_t row_status_key(int row_index) {
+  switch (row_index) {
+  case 0:
+    return MESSAGE_KEY_ROW_0_STATUS;
+  case 1:
+    return MESSAGE_KEY_ROW_1_STATUS;
+  case 2:
+    return MESSAGE_KEY_ROW_2_STATUS;
+  default:
+    return MESSAGE_KEY_ROW_3_STATUS;
+  }
+}
+
+static uint32_t row_has_bar_key(int row_index) {
+  switch (row_index) {
+  case 0:
+    return MESSAGE_KEY_ROW_0_HAS_BAR;
+  case 1:
+    return MESSAGE_KEY_ROW_1_HAS_BAR;
+  case 2:
+    return MESSAGE_KEY_ROW_2_HAS_BAR;
+  default:
+    return MESSAGE_KEY_ROW_3_HAS_BAR;
+  }
+}
+
+static int clamp_percent(int percent) {
+  if (percent < 0)
+    return 0;
+  if (percent > 100)
+    return 100;
+  return percent;
+}
+
+static GColor color_background(void) { return PBL_IF_COLOR_ELSE(GColorWhite, GColorWhite); }
+
+static GColor color_card_outline(void) { return PBL_IF_COLOR_ELSE(GColorFromHEX(0xAAFFFF), GColorBlack); }
+
+static GColor color_text_primary(void) { return GColorBlack; }
+
+static GColor color_text_secondary(void) { return PBL_IF_COLOR_ELSE(GColorCobaltBlue, GColorBlack); }
+
+static GColor color_text_muted(void) { return PBL_IF_COLOR_ELSE(GColorDarkGray, GColorBlack); }
+
+static GColor color_text_soft(void) { return PBL_IF_COLOR_ELSE(GColorDukeBlue, GColorBlack); }
+
+static GColor color_track(void) { return PBL_IF_COLOR_ELSE(GColorLightGray, GColorBlack); }
+
+static GColor color_card_highlight(void) { return PBL_IF_COLOR_ELSE(GColorVividCerulean, GColorBlack); }
+
+static GColor color_warning_soft(void) { return PBL_IF_COLOR_ELSE(GColorIcterine, GColorWhite); }
+
+static GColor color_ok_soft(void) { return PBL_IF_COLOR_ELSE(GColorMintGreen, GColorWhite); }
+
+static GColor color_for_status(int status_rank) {
+#ifdef PBL_COLOR
+  if (status_rank >= 4)
+    return GColorRed;
+  if (status_rank >= 3)
+    return GColorOrange;
+  return GColorJaegerGreen;
+#else
+  return GColorBlack;
+#endif
+}
+
+static GColor color_status_fill(void) {
+  if (strcmp(s_status_text, "CRITICAL") == 0)
+    return PBL_IF_COLOR_ELSE(GColorMelon, GColorWhite);
+  if (strcmp(s_status_text, "WARNING") == 0)
+    return color_warning_soft();
+  return color_ok_soft();
+}
+
+static GColor color_for_status_text(void) {
+  if (strcmp(s_status_text, "CRITICAL") == 0)
+    return color_for_status(4);
+  if (strcmp(s_status_text, "WARNING") == 0)
+    return color_for_status(3);
+  return color_for_status(1);
+}
+
+static int current_slide_offset(void) { return 0; }
+
+static int current_bar_percent(int percent) {
+  int clamped = clamp_percent(percent);
+  if (s_animation_frame >= ANIMATION_FRAMES)
+    return clamped;
+  int frame = s_animation_frame;
+  int eased = ANIMATION_FRAMES - (((ANIMATION_FRAMES - frame) * (ANIMATION_FRAMES - frame)) / ANIMATION_FRAMES);
+  return (clamped * eased) / ANIMATION_FRAMES;
+}
+
+static void mark_card_dirty(void) {
+  if (s_card_layer != NULL)
+    layer_mark_dirty(s_card_layer);
+}
+
+static void animation_timer_callback(void *data) {
+  s_animation_timer = NULL;
+  if (s_animation_frame < ANIMATION_FRAMES || s_is_refreshing) {
+    if (s_animation_frame < ANIMATION_FRAMES) {
+      s_animation_frame += 1;
+    }
+    s_refresh_frame = (s_refresh_frame + 1) % ANIMATION_FRAMES;
+    mark_card_dirty();
+    s_animation_timer = app_timer_register(ANIMATION_INTERVAL_MS, animation_timer_callback, NULL);
+  }
+}
+
+static void start_card_animation(int direction) {
+  if (s_animation_timer != NULL) {
+    app_timer_cancel(s_animation_timer);
+    s_animation_timer = NULL;
+  }
+
+  s_animation_direction = direction < 0 ? -1 : 1;
+  s_animation_frame = 0;
+  s_refresh_frame = 0;
+  mark_card_dirty();
+  s_animation_timer = app_timer_register(ANIMATION_INTERVAL_MS, animation_timer_callback, NULL);
+}
+
+static void request_quota(void) {
+  DictionaryIterator *iter;
+  AppMessageResult result = app_message_outbox_begin(&iter);
+  if (result != APP_MSG_OK || iter == NULL) {
+    snprintf(s_status_text, sizeof(s_status_text), "Send fail");
+    s_is_refreshing = false;
+    mark_card_dirty();
+    return;
+  }
+
+  dict_write_uint8(iter, MESSAGE_KEY_REQUEST_QUOTA, 1);
+  dict_write_int32(iter, MESSAGE_KEY_PAGE_INDEX, s_page_index);
+  result = app_message_outbox_send();
+  if (result == APP_MSG_OK) {
+    snprintf(s_status_text, sizeof(s_status_text), "Refreshing");
+    s_updated_text[0] = '\0';
+    s_is_refreshing = true;
+    start_card_animation(1);
+  } else {
+    snprintf(s_status_text, sizeof(s_status_text), "Send fail");
+    s_is_refreshing = false;
+    mark_card_dirty();
+  }
+}
+
+static void request_page(int page_index) {
+  if (s_page_count <= 0)
+    return;
+  int direction = page_index >= s_page_index ? 1 : -1;
+  if (page_index < 0) {
+    page_index = s_page_count - 1;
+    direction = -1;
+  }
+  if (page_index >= s_page_count) {
+    page_index = 0;
+    direction = 1;
+  }
+
+  DictionaryIterator *iter;
+  AppMessageResult result = app_message_outbox_begin(&iter);
+  if (result != APP_MSG_OK || iter == NULL) {
+    snprintf(s_status_text, sizeof(s_status_text), "Page fail");
+    mark_card_dirty();
+    return;
+  }
+
+  s_page_index = page_index;
+  dict_write_int32(iter, MESSAGE_KEY_REQUEST_PAGE, page_index);
+  result = app_message_outbox_send();
+  if (result == APP_MSG_OK) {
+    start_card_animation(direction);
+  } else {
+    snprintf(s_status_text, sizeof(s_status_text), "Send fail");
+    mark_card_dirty();
+  }
+}
+
+static void up_click_handler(ClickRecognizerRef recognizer, void *context) { request_page(s_page_index - 1); }
+
+static void down_click_handler(ClickRecognizerRef recognizer, void *context) { request_page(s_page_index + 1); }
+
+static void select_click_handler(ClickRecognizerRef recognizer, void *context) { request_quota(); }
+
+static void click_config_provider(void *context) {
+  window_single_repeating_click_subscribe(BUTTON_ID_UP, 180, up_click_handler);
+  window_single_click_subscribe(BUTTON_ID_SELECT, select_click_handler);
+  window_single_repeating_click_subscribe(BUTTON_ID_DOWN, 180, down_click_handler);
+}
+
+static void draw_text(GContext *ctx, const char *text, GRect box, GFont font, GColor color, GTextAlignment alignment) {
+  graphics_context_set_text_color(ctx, color);
+  graphics_draw_text(ctx, text, font, box, GTextOverflowModeTrailingEllipsis, alignment, NULL);
+}
+
+static void draw_progress_bar(GContext *ctx, GRect frame, int percent, GColor color) {
+  graphics_context_set_fill_color(ctx, color_track());
+  graphics_fill_rect(ctx, frame, 3, GCornersAll);
+
+  int fill_width = (frame.size.w * current_bar_percent(percent)) / 100;
+  if (fill_width < 2 && percent > 0)
+    fill_width = 2;
+  graphics_context_set_fill_color(ctx, color);
+  graphics_fill_rect(ctx, GRect(frame.origin.x, frame.origin.y, fill_width, frame.size.h), 3, GCornersAll);
+}
+
+static void draw_status_pill(GContext *ctx, GRect frame) {
+  graphics_context_set_fill_color(ctx, color_status_fill());
+  graphics_fill_rect(ctx, frame, 6, GCornersAll);
+  graphics_context_set_stroke_color(ctx, color_for_status_text());
+  graphics_draw_rect(ctx, frame);
+  draw_text(ctx, s_status_text, GRect(frame.origin.x, frame.origin.y - 4, frame.size.w, frame.size.h + 4),
+            fonts_get_system_font(FONT_KEY_GOTHIC_18_BOLD), color_text_primary(), GTextAlignmentCenter);
+}
+
+static void draw_refresh_dots(GContext *ctx, GRect bounds) {
+  if (!s_is_refreshing)
+    return;
+  int phase = s_refresh_frame;
+  int center_x = bounds.size.w - 23;
+  int center_y = 18;
+
+  graphics_context_set_fill_color(ctx, color_text_muted());
+  graphics_fill_circle(ctx, GPoint(center_x - 7, center_y), phase < 4 ? 3 : 2);
+  graphics_fill_circle(ctx, GPoint(center_x, center_y), phase >= 3 && phase < 7 ? 3 : 2);
+  graphics_fill_circle(ctx, GPoint(center_x + 7, center_y), phase >= 6 ? 3 : 2);
+}
+
+static void draw_page_dots(GContext *ctx, GRect frame) {
+  if (s_page_count <= 1)
+    return;
+
+  int dots = s_page_count > 7 ? 7 : s_page_count;
+  int total_width = dots * 7 - 3;
+  int x = frame.origin.x + (frame.size.w - total_width) / 2;
+  int y = frame.origin.y + frame.size.h - 10;
+
+  for (int i = 0; i < dots; i += 1) {
+    graphics_context_set_fill_color(ctx, i == s_page_index || (s_page_count > 7 && i == 6 && s_page_index >= 6)
+                                             ? color_card_highlight()
+                                             : color_text_muted());
+    int active = i == s_page_index || (s_page_count > 7 && i == 6 && s_page_index >= 6);
+    int radius = active ? 2 : 1;
+    if (active && s_animation_frame < ANIMATION_FRAMES / 2)
+      radius = 3;
+    graphics_fill_circle(ctx, GPoint(x + (i * 7), y), radius);
+  }
+}
+
+static void card_update_proc(Layer *layer, GContext *ctx) {
+  GRect bounds = layer_get_bounds(layer);
+  graphics_context_set_fill_color(ctx, color_background());
+  graphics_fill_rect(ctx, bounds, 0, GCornerNone);
+
+  const int margin = 10;
+  int offset = current_slide_offset();
+  int content_x = margin;
+  int content_w = bounds.size.w - (margin * 2);
+  int y = 8 + offset;
+
+  draw_text(ctx, s_title_text, GRect(content_x, y, content_w - 50, 29), fonts_get_system_font(FONT_KEY_GOTHIC_24_BOLD),
+            color_text_primary(), GTextAlignmentLeft);
+
+  char page_text[16];
+  if (s_page_count > 0) {
+    int display_page_index = s_page_index + 1;
+    if (display_page_index < 1)
+      display_page_index = 1;
+    if (display_page_index > 99)
+      display_page_index = 99;
+    int display_page_count = s_page_count;
+    if (display_page_count < 1)
+      display_page_count = 1;
+    if (display_page_count > 99)
+      display_page_count = 99;
+    snprintf(page_text, sizeof(page_text), "%d/%d", display_page_index, display_page_count);
+  } else {
+    snprintf(page_text, sizeof(page_text), "--");
+  }
+  draw_text(ctx, page_text, GRect(content_x + content_w - 46, y + 2, 46, 24),
+            fonts_get_system_font(FONT_KEY_GOTHIC_18_BOLD), color_text_secondary(), GTextAlignmentRight);
+  draw_refresh_dots(ctx, bounds);
+
+  y += 26;
+  if (s_status_text[0] != '\0') {
+    draw_status_pill(ctx, GRect(content_x + content_w - 60, y + 1, 60, 18));
+  }
+
+  y += s_status_text[0] == '\0' ? 8 : 26;
+  if (s_row_count <= 0) {
+    graphics_context_set_fill_color(ctx, color_card_highlight());
+    graphics_fill_circle(ctx, GPoint(content_x + (content_w / 2), y + 26), 15);
+    graphics_context_set_fill_color(ctx, color_background());
+    graphics_fill_circle(ctx, GPoint(content_x + (content_w / 2), y + 26), 9);
+    draw_text(ctx, "Quota unavailable", GRect(content_x, y + 48, content_w, 34),
+              fonts_get_system_font(FONT_KEY_GOTHIC_28_BOLD), color_text_primary(), GTextAlignmentCenter);
+    draw_text(ctx, s_reset_text, GRect(content_x, y + 86, content_w, 46), fonts_get_system_font(FONT_KEY_GOTHIC_24),
+              color_text_secondary(), GTextAlignmentCenter);
+  } else {
+    for (int i = 0; i < s_row_count && i < MAX_ROWS; i += 1) {
+      QuotaRow *row = &s_rows[i];
+      GColor accent = color_for_status(row->status_rank);
+      int detail_width = 62;
+      draw_text(ctx, row->label, GRect(content_x, y, content_w - detail_width - 8, 22),
+                fonts_get_system_font(FONT_KEY_GOTHIC_18_BOLD), color_text_primary(), GTextAlignmentLeft);
+      draw_text(ctx, row->detail, GRect(content_x + content_w - detail_width, y, detail_width, 22),
+                fonts_get_system_font(FONT_KEY_GOTHIC_18_BOLD), row->has_bar ? color_text_soft() : color_text_primary(),
+                GTextAlignmentRight);
+      y += 22;
+      if (row->has_bar) {
+        draw_progress_bar(ctx, GRect(content_x, y, content_w, 6), row->percent, accent);
+        y += 16;
+      } else {
+        y += 6;
+      }
+      if (i + 1 < s_row_count && i + 1 < MAX_ROWS) {
+        graphics_context_set_stroke_color(ctx, color_card_outline());
+        graphics_draw_line(ctx, GPoint(content_x, y), GPoint(content_x + content_w, y));
+        y += 3;
+      }
+    }
+    if (s_row_count > MAX_ROWS) {
+      draw_text(ctx, "+ more", GRect(content_x, y, content_w, 22), fonts_get_system_font(FONT_KEY_GOTHIC_18),
+                color_text_muted(), GTextAlignmentCenter);
+    }
+  }
+
+  draw_page_dots(ctx, bounds);
+
+  draw_text(ctx, s_reset_text, GRect(content_x, bounds.size.h - 55, content_w, 24),
+            fonts_get_system_font(FONT_KEY_GOTHIC_18), color_text_muted(), GTextAlignmentCenter);
+
+  char footer[32];
+  if (s_page_count > 0 && s_status_text[0] != '\0') {
+    snprintf(footer, sizeof(footer), "%.10s %.12s", s_status_text, s_updated_text);
+  } else if (s_page_count > 0) {
+    snprintf(footer, sizeof(footer), "%.31s", s_updated_text);
+  } else {
+    snprintf(footer, sizeof(footer), "%.31s", s_status_text);
+  }
+  draw_text(ctx, footer, GRect(content_x, bounds.size.h - 33, content_w, 24), fonts_get_system_font(FONT_KEY_GOTHIC_18),
+            color_text_secondary(), GTextAlignmentCenter);
+}
+
+static void copy_string_tuple(DictionaryIterator *iterator, uint32_t key, char *buffer, size_t buffer_size) {
+  Tuple *tuple = dict_find(iterator, key);
+  if (tuple == NULL)
+    return;
+  snprintf(buffer, buffer_size, "%s", tuple->value->cstring);
+}
+
+static void read_row(DictionaryIterator *iterator, int row_index) {
+  QuotaRow *row = &s_rows[row_index];
+  copy_string_tuple(iterator, row_label_key(row_index), row->label, sizeof(row->label));
+  copy_string_tuple(iterator, row_detail_key(row_index), row->detail, sizeof(row->detail));
+
+  Tuple *percent_tuple = dict_find(iterator, row_percent_key(row_index));
+  row->percent = percent_tuple != NULL ? clamp_percent((int)percent_tuple->value->int32) : 0;
+
+  Tuple *status_tuple = dict_find(iterator, row_status_key(row_index));
+  row->status_rank = status_tuple != NULL ? (int)status_tuple->value->int32 : 1;
+
+  Tuple *has_bar_tuple = dict_find(iterator, row_has_bar_key(row_index));
+  row->has_bar = has_bar_tuple != NULL && has_bar_tuple->value->int32 != 0;
+}
+
+static void inbox_received_callback(DictionaryIterator *iterator, void *context) {
+  Tuple *error_tuple = dict_find(iterator, MESSAGE_KEY_ERROR);
+  if (error_tuple != NULL) {
+    s_is_refreshing = false;
+    s_row_count = 0;
+    s_page_index = 0;
+    s_page_count = 0;
+    snprintf(s_title_text, sizeof(s_title_text), "Pebblexus");
+    snprintf(s_reset_text, sizeof(s_reset_text), "%s", error_tuple->value->cstring);
+    snprintf(s_status_text, sizeof(s_status_text), "Select retries");
+    s_updated_text[0] = '\0';
+    s_animation_frame = ANIMATION_FRAMES;
+    mark_card_dirty();
+    return;
+  }
+
+  s_is_refreshing = false;
+  copy_string_tuple(iterator, MESSAGE_KEY_QUOTA_TITLE, s_title_text, sizeof(s_title_text));
+  copy_string_tuple(iterator, MESSAGE_KEY_QUOTA_LABEL, s_group_text, sizeof(s_group_text));
+  copy_string_tuple(iterator, MESSAGE_KEY_QUOTA_RESET, s_reset_text, sizeof(s_reset_text));
+  copy_string_tuple(iterator, MESSAGE_KEY_QUOTA_STATUS, s_status_text, sizeof(s_status_text));
+  copy_string_tuple(iterator, MESSAGE_KEY_UPDATED_AT, s_updated_text, sizeof(s_updated_text));
+
+  Tuple *page_index_tuple = dict_find(iterator, MESSAGE_KEY_PAGE_INDEX);
+  if (page_index_tuple != NULL)
+    s_page_index = (int)page_index_tuple->value->int32;
+
+  Tuple *page_count_tuple = dict_find(iterator, MESSAGE_KEY_PAGE_COUNT);
+  if (page_count_tuple != NULL)
+    s_page_count = (int)page_count_tuple->value->int32;
+  if (s_page_count < 0)
+    s_page_count = 0;
+  if (s_page_index >= s_page_count)
+    s_page_index = s_page_count - 1;
+  if (s_page_index < 0)
+    s_page_index = 0;
+
+  Tuple *row_count_tuple = dict_find(iterator, MESSAGE_KEY_ROW_COUNT);
+  int row_count = row_count_tuple != NULL ? (int)row_count_tuple->value->int32 : 0;
+  if (row_count < 0)
+    row_count = 0;
+  s_row_count = row_count;
+
+  int rows_to_read = row_count > MAX_ROWS ? MAX_ROWS : row_count;
+  for (int i = 0; i < rows_to_read; i += 1) {
+    s_rows[i].label[0] = '\0';
+    s_rows[i].detail[0] = '\0';
+    s_rows[i].percent = 0;
+    s_rows[i].status_rank = 1;
+    s_rows[i].has_bar = false;
+    read_row(iterator, i);
+  }
+
+  start_card_animation(s_animation_direction);
+}
+
+static void inbox_dropped_callback(AppMessageResult reason, void *context) {
+  s_is_refreshing = false;
+  snprintf(s_status_text, sizeof(s_status_text), "Dropped");
+  mark_card_dirty();
+}
+
+static void outbox_failed_callback(DictionaryIterator *iterator, AppMessageResult reason, void *context) {
+  s_is_refreshing = false;
+  snprintf(s_status_text, sizeof(s_status_text), "No phone");
+  mark_card_dirty();
+}
+
+static void main_window_load(Window *window) {
+  Layer *window_layer = window_get_root_layer(window);
+  GRect bounds = layer_get_bounds(window_layer);
+
+  s_card_layer = layer_create(bounds);
+  layer_set_update_proc(s_card_layer, card_update_proc);
+  layer_add_child(window_layer, s_card_layer);
+}
+
+static void main_window_unload(Window *window) {
+  if (s_animation_timer != NULL) {
+    app_timer_cancel(s_animation_timer);
+    s_animation_timer = NULL;
+  }
+  layer_destroy(s_card_layer);
+}
+
+static void init(void) {
+  s_main_window = window_create();
+  window_set_background_color(s_main_window, color_background());
+  window_set_click_config_provider(s_main_window, click_config_provider);
+  window_set_window_handlers(s_main_window, (WindowHandlers){.load = main_window_load, .unload = main_window_unload});
+
+  app_message_register_inbox_received(inbox_received_callback);
+  app_message_register_inbox_dropped(inbox_dropped_callback);
+  app_message_register_outbox_failed(outbox_failed_callback);
+  app_message_open(2048, 256);
+
+  window_stack_push(s_main_window, true);
+  request_quota();
+}
+
+static void deinit(void) { window_destroy(s_main_window); }
+
+int main(void) {
+  init();
+  app_event_loop();
+  deinit();
+}

src/pkjs/index.js 🔗

@@ -0,0 +1,455 @@
+var CONFIG_BASE_URL = "plexusBaseUrl";
+var CONFIG_ADMIN_KEY = "plexusAdminKey";
+var MAX_ROWS_PER_GROUP = 4;
+var quotaGroups = [];
+var currentPageIndex = 0;
+var providerDisplayNames = {};
+
+var ROW_KEYS = [
+  {
+    label: "ROW_0_LABEL",
+    detail: "ROW_0_DETAIL",
+    percent: "ROW_0_PERCENT",
+    status: "ROW_0_STATUS",
+    hasBar: "ROW_0_HAS_BAR"
+  },
+  {
+    label: "ROW_1_LABEL",
+    detail: "ROW_1_DETAIL",
+    percent: "ROW_1_PERCENT",
+    status: "ROW_1_STATUS",
+    hasBar: "ROW_1_HAS_BAR"
+  },
+  {
+    label: "ROW_2_LABEL",
+    detail: "ROW_2_DETAIL",
+    percent: "ROW_2_PERCENT",
+    status: "ROW_2_STATUS",
+    hasBar: "ROW_2_HAS_BAR"
+  },
+  {
+    label: "ROW_3_LABEL",
+    detail: "ROW_3_DETAIL",
+    percent: "ROW_3_PERCENT",
+    status: "ROW_3_STATUS",
+    hasBar: "ROW_3_HAS_BAR"
+  }
+];
+
+function trimSlash(value) {
+  return String(value || "").replace(/\/+$/, "");
+}
+
+function sendError(message) {
+  Pebble.sendAppMessage({ ERROR: String(message).slice(0, 38) });
+}
+
+function xhrRequest(url, adminKey, callback) {
+  var xhr = new XMLHttpRequest();
+  xhr.onload = function () {
+    if (xhr.status < 200 || xhr.status >= 300) {
+      callback(new Error("HTTP " + xhr.status), null);
+      return;
+    }
+    callback(null, xhr.responseText);
+  };
+  xhr.onerror = function () {
+    callback(new Error("Network error"), null);
+  };
+  xhr.open("GET", url);
+  xhr.setRequestHeader("x-admin-key", adminKey);
+  xhr.send();
+}
+
+function asNumber(value) {
+  if (typeof value === "number" && isFinite(value)) return value;
+  if (typeof value === "string" && value !== "" && isFinite(Number(value)))
+    return Number(value);
+  return null;
+}
+
+function statusRank(status) {
+  if (status === "critical" || status === "exhausted") return 4;
+  if (status === "warning") return 3;
+  if (status === "unknown") return 2;
+  if (status === "ok") return 1;
+  return 0;
+}
+
+function meterUrgency(meter) {
+  var rank = statusRank(meter.status);
+  var percent = asNumber(meter.utilizationPercent);
+  if (percent === null) percent = 0;
+  return rank * 1000 + percent;
+}
+
+function groupHasComparableMeter(group) {
+  for (var i = 0; i < group.meters.length; i += 1) {
+    if (group.meters[i].hasBar) return true;
+  }
+  return false;
+}
+
+function groupUrgency(group) {
+  var urgency = 0;
+  for (var i = 0; i < group.meters.length; i += 1) {
+    if (!group.meters[i].hasBar) continue;
+    urgency = Math.max(urgency, meterUrgency(group.meters[i]));
+  }
+  return urgency;
+}
+
+function clampPageIndex(pageIndex, pageCount) {
+  var index = asNumber(pageIndex);
+  if (index === null) index = currentPageIndex;
+  index = Math.round(index);
+  if (pageCount <= 0) return 0;
+  if (index < 0) return pageCount - 1;
+  if (index >= pageCount) return 0;
+  return index;
+}
+
+function formatNumber(value) {
+  var number = asNumber(value);
+  if (number === null) return "?";
+  if (Math.abs(number) >= 1000)
+    return String(Math.round(number).toLocaleString());
+  if (Math.abs(number) >= 100) return String(Math.round(number));
+  if (Math.abs(number) >= 10) return String(Math.round(number * 10) / 10);
+  return String(Math.round(number * 100) / 100);
+}
+
+function formatCompactNumber(value) {
+  var number = asNumber(value);
+  if (number === null) return "?";
+  var absolute = Math.abs(number);
+  if (absolute >= 1000000)
+    return String(Math.round(number / 100000) / 10) + "m";
+  if (absolute >= 1000) return String(Math.round(number / 100) / 10) + "k";
+  return formatNumber(number);
+}
+
+function formatValue(value, unit) {
+  var number = asNumber(value);
+  if (number === null) return "?";
+  if (unit === "usd") return "$" + number.toFixed(2);
+  if (unit === "percent" || unit === "percentage")
+    return formatNumber(number) + "%";
+  if (unit === "hypercredits") return formatCompactNumber(number) + " hc";
+  if (unit === "requests") return formatCompactNumber(number) + " req";
+  if (unit === "kwh") return formatCompactNumber(number) + " kWh";
+  if (!unit) return formatNumber(number);
+  return formatCompactNumber(number) + " " + String(unit).slice(0, 4);
+}
+
+function formatMeterDetail(meter) {
+  var percent = asNumber(meter.utilizationPercent);
+  if (meter.hasBar && percent !== null)
+    return String(Math.round(percent)) + "%";
+
+  var remaining = asNumber(meter.remaining);
+  if (remaining !== null) return formatValue(remaining, meter.unit);
+
+  var limit = asNumber(meter.limit);
+  if (limit !== null) return formatValue(limit, meter.unit);
+
+  return "—";
+}
+
+function shortResetForGroup(group) {
+  var soonest = null;
+  for (var i = 0; i < group.meters.length; i += 1) {
+    if (!group.meters[i].resetsAt) continue;
+    var reset = new Date(group.meters[i].resetsAt);
+    if (!isFinite(reset.getTime())) continue;
+    if (soonest === null || reset.getTime() < soonest.getTime())
+      soonest = reset;
+  }
+  if (soonest === null) return "No reset time";
+
+  var diffMs = soonest.getTime() - Date.now();
+  var prefix = diffMs >= 0 ? "Next reset " : "Reset ";
+  var absMinutes = Math.round(Math.abs(diffMs) / 60000);
+  if (absMinutes < 90) return prefix + absMinutes + "m";
+  var absHours = Math.round(absMinutes / 60);
+  if (absHours < 48) return prefix + absHours + "h";
+  return prefix + Math.round(absHours / 24) + "d";
+}
+
+function hasComparableLimit(meter) {
+  var percent = asNumber(meter.utilizationPercent);
+  var limit = asNumber(meter.limit);
+  if (percent === null) return false;
+  if (meter.kind === "balance") return false;
+  if (meter.unit === "percent" || meter.unit === "percentage") return true;
+  if (limit === null || limit <= 0) return false;
+  return true;
+}
+
+function normalizeMeter(quota, meter) {
+  var percent = asNumber(meter.utilizationPercent);
+  return {
+    checkerId: quota.checkerId || quota.provider || "unknown",
+    provider: quota.provider || quota.checkerId || "unknown",
+    label: meter.label || meter.key || "Quota",
+    kind: meter.kind || "unknown",
+    status: meter.status || "unknown",
+    unit: meter.unit || "",
+    limit: meter.limit,
+    remaining: meter.remaining,
+    utilizationPercent: percent,
+    resetsAt: meter.resetsAt,
+    hasBar: hasComparableLimit(meter)
+  };
+}
+
+function displayNameForQuota(quota, id, displayNames) {
+  return (
+    displayNames[id] ||
+    quota.display_name ||
+    quota.displayName ||
+    quota.name ||
+    quota.providerName ||
+    quota.provider ||
+    id
+  );
+}
+
+function compareText(left, right) {
+  var normalizedLeft = String(left || "").toLowerCase();
+  var normalizedRight = String(right || "").toLowerCase();
+  if (normalizedLeft < normalizedRight) return -1;
+  if (normalizedLeft > normalizedRight) return 1;
+  return 0;
+}
+
+function compareMeters(left, right) {
+  if (left.hasBar !== right.hasBar) return left.hasBar ? -1 : 1;
+  var urgencyDelta = meterUrgency(right) - meterUrgency(left);
+  if (urgencyDelta !== 0) return urgencyDelta;
+  return compareText(left.label, right.label);
+}
+
+function flattenGroups(response, displayNames) {
+  var quotas = JSON.parse(response);
+  var groupsById = {};
+  var groups = [];
+
+  for (var i = 0; i < quotas.length; i += 1) {
+    var quota = quotas[i];
+    if (!quota || quota.success !== true || !quota.meters) continue;
+    var id = quota.checkerId || quota.provider || "unknown";
+    if (!groupsById[id]) {
+      groupsById[id] = {
+        id: id,
+        title: displayNameForQuota(quota, id, displayNames || {}),
+        meters: []
+      };
+      groups.push(groupsById[id]);
+    }
+    for (var j = 0; j < quota.meters.length; j += 1) {
+      if (quota.meters[j])
+        groupsById[id].meters.push(normalizeMeter(quota, quota.meters[j]));
+    }
+  }
+
+  for (var groupIndex = 0; groupIndex < groups.length; groupIndex += 1) {
+    groups[groupIndex].meters.sort(compareMeters);
+  }
+  groups.sort(function (left, right) {
+    var leftHasComparableMeter = groupHasComparableMeter(left);
+    var rightHasComparableMeter = groupHasComparableMeter(right);
+    if (leftHasComparableMeter !== rightHasComparableMeter)
+      return leftHasComparableMeter ? -1 : 1;
+    var urgencyDelta = groupUrgency(right) - groupUrgency(left);
+    if (urgencyDelta !== 0) return urgencyDelta;
+    return compareText(left.id, right.id);
+  });
+
+  return groups;
+}
+
+function sendGroupPage(pageIndex) {
+  if (quotaGroups.length === 0) {
+    sendError("No quota groups found");
+    return;
+  }
+
+  currentPageIndex = clampPageIndex(pageIndex, quotaGroups.length);
+  var group = quotaGroups[currentPageIndex];
+  var dictionary = {
+    QUOTA_TITLE: String(group.title).slice(0, 38),
+    QUOTA_STATUS: "",
+    QUOTA_RESET: shortResetForGroup(group).slice(0, 38),
+    QUOTA_LABEL: "",
+    ROW_COUNT: group.meters.length,
+    PAGE_INDEX: currentPageIndex,
+    PAGE_COUNT: quotaGroups.length,
+    UPDATED_AT: currentTimeText()
+  };
+
+  var worstStatusRank = 1;
+  var pageHasBar = false;
+  var rowsToSend = Math.min(group.meters.length, MAX_ROWS_PER_GROUP);
+  for (var i = 0; i < rowsToSend; i += 1) {
+    var meter = group.meters[i];
+    var keys = ROW_KEYS[i];
+    var rank = statusRank(meter.status);
+    if (meter.hasBar) pageHasBar = true;
+    worstStatusRank = Math.max(worstStatusRank, rank);
+    dictionary[keys.label] = String(meter.label).slice(0, 22);
+    dictionary[keys.detail] = formatMeterDetail(meter).slice(0, 26);
+    dictionary[keys.percent] = Math.max(
+      0,
+      Math.min(100, Math.round(asNumber(meter.utilizationPercent) || 0))
+    );
+    dictionary[keys.status] = rank;
+    dictionary[keys.hasBar] = meter.hasBar ? 1 : 0;
+  }
+
+  if (pageHasBar) {
+    if (worstStatusRank >= 4) dictionary.QUOTA_STATUS = "CRITICAL";
+    else if (worstStatusRank >= 3) dictionary.QUOTA_STATUS = "WARNING";
+    else dictionary.QUOTA_STATUS = "OK";
+  }
+
+  Pebble.sendAppMessage(dictionary);
+}
+
+function currentTimeText() {
+  var now = new Date();
+  var minutes = now.getMinutes();
+  return (
+    "at " + now.getHours() + ":" + (minutes < 10 ? "0" + minutes : minutes)
+  );
+}
+
+function parseProviderDisplayNames(responseText) {
+  var providers = JSON.parse(responseText);
+  var displayNames = {};
+  if (!providers || typeof providers !== "object") return displayNames;
+
+  for (var id in providers) {
+    if (!Object.prototype.hasOwnProperty.call(providers, id)) continue;
+    var provider = providers[id];
+    if (!provider || typeof provider !== "object") continue;
+    var displayName =
+      provider.display_name || provider.displayName || provider.name;
+    if (displayName) displayNames[id] = String(displayName);
+  }
+
+  return displayNames;
+}
+
+function refreshQuotaWithDisplayNames(
+  baseUrl,
+  adminKey,
+  pageIndex,
+  displayNames
+) {
+  xhrRequest(
+    baseUrl + "/v0/management/quotas",
+    adminKey,
+    function (error, responseText) {
+      if (error) {
+        sendError(error.message);
+        return;
+      }
+      try {
+        quotaGroups = flattenGroups(responseText, displayNames);
+        sendGroupPage(pageIndex);
+      } catch (e) {
+        sendError("Bad quota response");
+      }
+    }
+  );
+}
+
+function refreshQuota(pageIndex) {
+  var baseUrl = trimSlash(localStorage.getItem(CONFIG_BASE_URL));
+  var adminKey = localStorage.getItem(CONFIG_ADMIN_KEY);
+  if (!baseUrl || !adminKey) {
+    sendError("Set URL and admin key");
+    return;
+  }
+
+  xhrRequest(
+    baseUrl + "/v0/management/providers",
+    adminKey,
+    function (providerError, providerResponseText) {
+      var displayNames = providerDisplayNames;
+      try {
+        if (!providerError) {
+          displayNames = parseProviderDisplayNames(providerResponseText);
+          providerDisplayNames = displayNames;
+        }
+      } catch (e) {
+        displayNames = providerDisplayNames;
+      }
+      refreshQuotaWithDisplayNames(baseUrl, adminKey, pageIndex, displayNames);
+    }
+  );
+}
+
+function htmlEscape(value) {
+  return String(value || "")
+    .replace(/&/g, "&amp;")
+    .replace(/</g, "&lt;")
+    .replace(/>/g, "&gt;")
+    .replace(/"/g, "&quot;");
+}
+
+function configurationHtml() {
+  var baseUrl = htmlEscape(localStorage.getItem(CONFIG_BASE_URL));
+  var adminKey = htmlEscape(localStorage.getItem(CONFIG_ADMIN_KEY));
+  return (
+    '<!doctype html><html><head><meta name="viewport" content="width=device-width,initial-scale=1">' +
+    "<title>Pebblexus</title><style>body{font:16px sans-serif;margin:1rem;line-height:1.4}" +
+    "label{display:block;margin:1rem 0 .25rem}input{box-sizing:border-box;width:100%;font:inherit;padding:.5rem}" +
+    "button{font:inherit;margin-top:1rem;padding:.7rem 1rem;width:100%}.hint{color:#555;font-size:.9rem}</style></head>" +
+    '<body><h1>Pebblexus</h1><p class="hint">Your admin key stays in PebbleKit JS storage on this phone and is not sent to the watch.</p>' +
+    '<form id="form"><label>Plexus base URL</label><input id="baseUrl" type="url" required value="' +
+    baseUrl +
+    '" placeholder="https://plexus.example">' +
+    '<label>Admin key</label><input id="adminKey" type="password" required value="' +
+    adminKey +
+    '">' +
+    '<button type="submit">Save</button></form><script>document.getElementById("form").addEventListener("submit",function(e){e.preventDefault();' +
+    'var data={plexusBaseUrl:document.getElementById("baseUrl").value,plexusAdminKey:document.getElementById("adminKey").value};' +
+    "var encoded=encodeURIComponent(JSON.stringify(data));var match=location.href.match(/[?&]return_to=([^&#]+)/);" +
+    'if(match){location.href=decodeURIComponent(match[1])+encoded;}else{location.href="pebblejs://close#"+encoded;}});</script></body></html>'
+  );
+}
+
+Pebble.addEventListener("ready", function () {
+  refreshQuota();
+});
+
+Pebble.addEventListener("appmessage", function (e) {
+  if (!e.payload) return;
+  if (e.payload.REQUEST_PAGE !== undefined) {
+    if (quotaGroups.length === 0) {
+      refreshQuota(e.payload.REQUEST_PAGE);
+      return;
+    }
+    sendGroupPage(e.payload.REQUEST_PAGE);
+    return;
+  }
+  if (e.payload.REQUEST_QUOTA) refreshQuota(e.payload.PAGE_INDEX);
+});
+
+Pebble.addEventListener("showConfiguration", function () {
+  Pebble.openURL("data:text/html," + encodeURIComponent(configurationHtml()));
+});
+
+Pebble.addEventListener("webviewclosed", function (e) {
+  if (!e || !e.response) return;
+  try {
+    var response = JSON.parse(decodeURIComponent(e.response));
+    localStorage.setItem(CONFIG_BASE_URL, trimSlash(response.plexusBaseUrl));
+    localStorage.setItem(CONFIG_ADMIN_KEY, response.plexusAdminKey || "");
+    refreshQuota();
+  } catch (error) {
+    sendError("Config save failed");
+  }
+});

wscript 🔗

@@ -0,0 +1,43 @@
+import os.path
+
+top = '.'
+out = 'build'
+
+
+def options(ctx):
+    ctx.load('pebble_sdk')
+
+
+def configure(ctx):
+    ctx.load('pebble_sdk')
+
+
+def build(ctx):
+    ctx.load('pebble_sdk')
+
+    build_worker = os.path.exists('worker_src')
+    binaries = []
+
+    cached_env = ctx.env
+    for platform in ctx.env.TARGET_PLATFORMS:
+        ctx.env = ctx.all_envs[platform]
+        ctx.set_group(ctx.env.PLATFORM_NAME)
+        app_elf = '{}/pebble-app.elf'.format(ctx.env.BUILD_DIR)
+        ctx.pbl_build(source=ctx.path.ant_glob('src/c/**/*.c'),
+                      target=app_elf, bin_type='app')
+
+        if build_worker:
+            worker_elf = '{}/pebble-worker.elf'.format(ctx.env.BUILD_DIR)
+            binaries.append({'platform': platform, 'app_elf': app_elf,
+                             'worker_elf': worker_elf})
+            ctx.pbl_build(source=ctx.path.ant_glob('worker_src/c/**/*.c'),
+                          target=worker_elf, bin_type='worker')
+        else:
+            binaries.append({'platform': platform, 'app_elf': app_elf})
+    ctx.env = cached_env
+
+    ctx.set_group('bundle')
+    ctx.pbl_bundle(binaries=binaries,
+                   js=ctx.path.ant_glob(['src/pkjs/**/*.js',
+                                         'src/pkjs/**/*.json']),
+                   js_entry_file='src/pkjs/index.js')