commit 9d75fbb828c4dc98f05e8f39a93699cf6072e760 Author: Amolith Date: Wed Jun 10 13:36:33 2026 -0600 Add Pebblexus watch app diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000000000000000000000000000000000000..99509f37dd6ba85a7838b555e2e11d2877aa5ead --- /dev/null +++ b/.clang-format @@ -0,0 +1,6 @@ +BasedOnStyle: LLVM +ColumnLimit: 120 +IndentWidth: 2 +ContinuationIndentWidth: 4 +PointerAlignment: Right +SortIncludes: false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..2c09fea51e7a74533c991b1e153759749285cfcb --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +build/ +node_modules/ +.lock-waf_*_build +*.pbw +*.png +*.gif +*.log diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000000000000000000000000000000000..a8a4538dbaac38c3435e4675568e1efdc93e055d --- /dev/null +++ b/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. diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 0000000000000000000000000000000000000000..94add03908f87a94f19814a9fef7419332e9b2f1 --- /dev/null +++ b/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. diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..bdaeaf4a7ab7803848f55162182dc0b03a4152a5 --- /dev/null +++ b/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 +``` diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000000000000000000000000000000000000..7e6a2e739d70954469fad4073260c823cb3591ef --- /dev/null +++ b/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" diff --git a/package.json b/package.json new file mode 100644 index 0000000000000000000000000000000000000000..162faa5563c2cfe764c6876f610a0ef173423539 --- /dev/null +++ b/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": [] + } + } +} diff --git a/src/c/main.c b/src/c/main.c new file mode 100644 index 0000000000000000000000000000000000000000..ff68fcd311e5a2c8052458fb28c195f19e9f567f --- /dev/null +++ b/src/c/main.c @@ -0,0 +1,547 @@ +#include + +#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(); +} diff --git a/src/pkjs/index.js b/src/pkjs/index.js new file mode 100644 index 0000000000000000000000000000000000000000..a3471d261336d09ec2379369095b51c37a38c107 --- /dev/null +++ b/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, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function configurationHtml() { + var baseUrl = htmlEscape(localStorage.getItem(CONFIG_BASE_URL)); + var adminKey = htmlEscape(localStorage.getItem(CONFIG_ADMIN_KEY)); + return ( + '' + + "Pebblexus" + + '

Pebblexus

Your admin key stays in PebbleKit JS storage on this phone and is not sent to the watch.

' + + '
' + + '' + + '
' + ); +} + +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"); + } +}); diff --git a/wscript b/wscript new file mode 100644 index 0000000000000000000000000000000000000000..7228b40df153e66a09875319a925b5e070f6c1a0 --- /dev/null +++ b/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')