Stripping KiezelPay (and other network calls) from Pebble Watch Faces

Stripping KiezelPay (and other network calls) from Pebble Watch Faces

How to modify Pebble watch faces to your heart’s content

⚠️ This post is for educational purposes only. I encourage you to support the creators of great watch faces. All faces used here were paid for in full before stripping payment verification. Following the steps here may violate usage agreements and could be unlawful in your jurisdiction.

This week I got my first Pebble watch in the mail after pre-ordering 15 months ago!

So far I’m extremely impressed with the quality and openness of the hardware and software. If you’re looking for a basic smartwatch with multi-day battery life, then I can wholeheartedly recommend the Pebble Time 2.

The one thing that caused me to raise an eyebrow was seeing that many of the watch faces call out to third parties for things like weather and payment info. Watch face scripts can send information like my location to arbitrary web resources along with a unique device and account identifier.

Visibility and tooling around network calls in the app itself would be nice to have, but for now I’ve resorted to stripping out the network calls myself thanks to how easy Pebble makes it to modify watch faces. The payment service most watch faces use, KiezelPay, gave me more trouble than I was expecting!

Pebble Watch Face Architecture

There are two components to most watch faces:

  1. C code that compiles down to a binary that runs on the watch hardware
  2. JavaScript that runs on the connected mobile device

Only the mobile device JS is able to make network calls.

This diagram from the Pebble developer docs illustrates how the components communicate:

┌─────────────┐          ┌──────────────────┐          ┌──────────────┐
│   Watch     │          │  Phone (PKJS)    │          │   Internet   │
│             │          │                  │          │              │
│  C code   ──┼── msg ──>│  index.js      ──┼── HTTP ─>│  API server  │
│             │          │                  │          │              │
│  <── msg ───┼──────────┤  <── response ───┼──────────┤              │
│             │          │                  │          │              │
│  AppMessage │          │  geolocation     │          │              │
│  request  ──┼── msg ──>│  → GPS lookup    │          │              │
│  <── msg ───┼──────────┤  → sends coords  │          │              │
└─────────────┘          └──────────────────┘          └──────────────┘

Let’s take a look at my current watch face Weather Land:

Weather Land

The app store links to a Download PBW button that lets us download the watch face bundle directly. A .pbw file is just a ZIP archive, so we can extract and view its contents:

unzip 4.0.pbw
Archive:  4.0.pbw
 extracting: appinfo.json
 extracting: pebble-js-app.js
 extracting: chalk/pebble-app.bin
 extracting: chalk/app_resources.pbpack
 extracting: chalk/manifest.json
 extracting: basalt/pebble-app.bin
 extracting: basalt/app_resources.pbpack
 extracting: basalt/manifest.json
 extracting: aplite/pebble-app.bin
 extracting: aplite/app_resources.pbpack
 extracting: aplite/manifest.json
  • appinfo.json - Metadata about the application including name, UUID, resources, capabilities, and target platforms
  • pebble-js-app.js - Phone-side script that handles fetching network resources and communicating weather data back to the watch
  • chalk/, basalt/, aplite/ - Each Pebble platform has its own name. See https://developer.rebble.io/guides/tools-and-resources/hardware-information/ for the list of platforms and their differences
  • pebble-app.bin - The watch face written in C compiles down to this binary. It’s a custom executable format for Pebble watches, starting with magic header PBLA
  • app_resources.pbpack - Resource bundle that contains fonts and images
  • manifest.json - Timestamps, checksums, and sizes of the resource pack and pebble app binary

Modifying Pebble JS

Let’s open up the pebble-js-app.js file for the Weather Land application and see how it works!

It’s a simple script at just 200 lines and handles:

  • Logic to choose which background to display based on current wind, time, and temperature
  • Network call to api.openweathermap.org with latitude/longitude
  • Network call to api.openweathermap.org with hardcoded location
  • Configuration using reno.watch/pebble/settings.html

I’ve turned off precise location for the Pebble app and am okay with this app fetching my approximate location for the purposes of updating weather icons.

It’s a small complaint, but I’d really prefer the settings page to not be hosted externally and just live all within the app itself. I’m also not a fan of my unique account identifier being sent to the third party settings page.

Pebble.addEventListener("showConfiguration", function (e) {
  var uri =
    "https://reno.watch/pebble/settings.html?" +
    "use_gps=" +
    encodeURIComponent(options.use_gps) +
    "&location=" +
    encodeURIComponent(options.location) +
    "&apikey=" +
    encodeURIComponent(options.apikey) +
    "&units=" +
    encodeURIComponent(options.units) +
    "&invert_color=" +
    encodeURIComponent(options.invert_color) +
    "&account_token=" +
    encodeURIComponent(Pebble.getAccountToken());
  //console.log('showing configuration at uri: ' + uri);

  Pebble.openURL(uri);
});

Pebble.getAccountToken() is documented at https://developer.rebble.io/docs/pebblekit-js/Pebble/:

Pebble.getAccountToken()

Returns a unique account token that is associated with the Pebble account of the current user.

Returns

A string that is guaranteed to be identical across devices if the user owns several Pebble or several mobile devices. From the developer’s perspective, the account token of a user is identical across platforms and across all the developer’s watchapps. If the user is not logged in, this function will return an empty string (‘’).

To get the app in a state I’m comfortable with, I’ll hardcode my desired settings and remove the call to the remote settings page. Pebble does offer a native, local configuration service Clay that could be used to keep the settings menu on-device, but I will leave that as an exercise to the reader.

To prove that our change actually takes effect, let’s also change the weather to always show rain, the weather I hope for every day.

diff -U 3 pebble-js-app.js.bak pebble-js-app.js
--- pebble-js-app.js.bak        2021-04-28 21:39:46
+++ pebble-js-app.js    2026-06-13 15:51:30
@@ -25,6 +25,8 @@
 var NA = 13;

 function getIcon(id, dayBool, temp, wind) {
+  return RAIN;
+
   var category = id[0];

   switch (category) {
@@ -162,16 +164,15 @@
 }

 Pebble.addEventListener('showConfiguration', function (e) {
-  var uri = 'https://reno.watch/pebble/settings.html?' +
-    'use_gps=' + encodeURIComponent(options.use_gps) +
-    '&location=' + encodeURIComponent(options.location) +
-    '&apikey=' + encodeURIComponent(options.apikey) +
-    '&units=' + encodeURIComponent(options.units) +
-    '&invert_color=' + encodeURIComponent(options.invert_color) +
-    '&account_token=' + encodeURIComponent(Pebble.getAccountToken());
-  //console.log('showing configuration at uri: ' + uri);
+  // var uri = 'https://reno.watch/pebble/settings.html?' +
+  //   'use_gps=' + encodeURIComponent(options.use_gps) +
+  //   '&location=' + encodeURIComponent(options.location) +
+  //   '&apikey=' + encodeURIComponent(options.apikey) +
+  //   '&units=' + encodeURIComponent(options.units) +
+  //   '&invert_color=' + encodeURIComponent(options.invert_color) +
+  console.log('skipping configuration request');

-  Pebble.openURL(uri);
+  // Pebble.openURL(uri);
 });

 Pebble.addEventListener('webviewclosed', function (e) {

Now we can zip up the files again and load the app on our device!

zip -r weather_land_patched.pbw appinfo.json pebble-js-app.js aplite basalt chalk
  adding: appinfo.json (deflated 88%)
  adding: pebble-js-app.js (deflated 69%)
  adding: aplite/ (stored 0%)
  adding: aplite/pebble-app.bin (deflated 34%)
  adding: aplite/app_resources.pbpack (deflated 81%)
  adding: aplite/manifest.json (deflated 42%)
  adding: basalt/ (stored 0%)
  adding: basalt/pebble-app.bin (deflated 34%)
  adding: basalt/app_resources.pbpack (deflated 23%)
  adding: basalt/manifest.json (deflated 42%)
  adding: chalk/ (stored 0%)
  adding: chalk/pebble-app.bin (deflated 34%)
  adding: chalk/app_resources.pbpack (deflated 19%)
  adding: chalk/manifest.json (deflated 42%)

Share the .pbw to your mobile device and then sideload:

Sideload

The face should start automatically and now shows rain!

Rain

KiezelPay Watch Faces

Okay the Weather Land example was a bit silly, let’s go through an example of something I actually care about: stripping network calls that check payment status. Forgive me, but I won’t be linking to a real watch face for this so I avoid singling out any app. The principles here should apply to all KiezelPay apps.

KiezelPay is the de facto payment handler for Pebble watch faces. It takes care of free trials, payment processing, and account recovery for a 27% fee at time of writing. My understanding is that the maintainer is pretty involved in the Pebble community and also offers the service for Garmin and Fitbit applications.

When you download a watch face from the Pebble store, after a trial period, you will get a notification from the app that looks like this (name of the face and my code redacted):

KiezelPay Prompt

In every paid app I’ve seen, the pebble-js-app.js contains a section that calls out to www.kiezelpay.com with parameters that identify the device and account:

https://www.kiezelpay.com/api/v1/status?appid=123456&devicetoken=deadbeef&rand=15124&accounttoken=0123456789abcdef0123456789abcdef&platform=basalt&flags=9 HTTP/1.1

The KiezelPay service responds with a status and status-dependent information about trial status, purchase codes, etc.:

{
  "status": "unlicensed",
  "paymentCode": 1337,
  "purchaseStatus": "waitForUser",
  "checksum": "54d626e08c1c802b305dad30b7e54a82f102390cc92c7d4db112048935236e9c"
}

Again, I am not opposed to paying for watch faces but I would prefer that once paid for, the app does not reach out to any third party service to verify this status with my unique device and account identifiers. So, let’s figure out how to remove it!

Failed Attempts

Removing /api/v1/status call

Let’s start with the most basic test: removing the network call. The watch face requests status updates from the script running on the phone. Every time this happens we can just ignore it and see what happens.

As an example, an app might listen for status requests from the Pebble app binary like this:

Pebble.addEventListener("appmessage", function (e) {
  if (e.payload.KIEZELPAY_STATUS_MESSAGE > 0) {
    handleKiezelPayAppMessage(e);
  }
});

If we comment out this handler and reload the app it might work for a bit, but in most cases the app will throw a fit after some timeout period:

KiezelPay Internet Error

Spoofing Response

The error above suggests that the watch requires a response, so let’s forge a response. Based on some hints in various apps, we can try returning a fake licensed response.

{
  "status": "licensed",
  "validityPeriodInDays": 9999,
  "checksum": "54d626e08c1c802b305dad30b7e54a82f102390cc92c7d4db112048935236e9c"
}

Wherever the app makes the network request, replace the network call with this static response. The checksum here is just a random SHA-256 that likely won’t pass validation if it exists, but we can see if we at least get a different error message.

And we do!

KiezelPay Unknown Error

Okay now we have a good lead that something in the binary is validating a checksum, but we don’t know how the checksum is generated. At this point I sought both better debugging strategies and more info about how a developer sets up KiezelPay to see if it would help me identify what’s going on here.

Debugging Pebble Apps

The Pebble SDK allows for emulating Pebble devices and connecting to hardware to view logs. I opted for the emulator when debugging my apps.

Using the .pbw from the previous forged response test after installing the SDK we get the same error in the emulated window but a dump of what looks like the client spamming status requests because it encounters errors:

...
DEBUG:pypkjs.javascript.console:pebble-js-app.js:332 KiezelPay - kiezelPayOnAppMessage() called
DEBUG:pypkjs.javascript.console:pebble-js-app.js:332 https://www.kiezelpay.com/api/v1/status?appid=123456&devicetoken=deadbeef&rand=15124&accounttoken=0123456789abcdef0123456789abcdef&platform=basalt&flags=9
DEBUG:pypkjs.javascript.console:pebble-js-app.js:332 KiezelPay - Status response: {"status":"licensed","validityPeriodInDays": 9999,"checksum": "54d626e08c1c802b305dad30b7e54a82f102390cc92c7d4db112048935236e9c"}
DEBUG:pypkjs.javascript.console:pebble-js-app.js:332 KiezelPay - Watch status msg: {"KIEZELPAY_STATUS_RESULT":2,"KIEZELPAY_STATUS_TRIAL_DURATION":0,"KIEZELPAY_PURCHASE_CODE":0,"KIEZELPAY_PURCHASE_STATUS":0,"KIEZELPAY_STATUS_VALIDITY_PERIOD":9999,"KIEZELPAY_STATUS_CHECKSUM":[84,214,38,224,140,28,128,43,48,93,173,48,183,229,74,130,241,2,57,12,201,44,125,77,177,18,4,137,53,35,110,156]}
DEBUG:libpebble2.communication:-> AppMessage(command=None, transaction_id=236, data=AppMessagePush(uuid=c4c60c62-2c22-4ad7-aef4-cad9481da580, count=None, dictionary=[AppMessageTuple(key=10034, type=3, length=None, data=02000000), AppMessageTuple(key=10035, type=3, length=None, data=00000000), AppMessageTuple(key=10028, type=3, length=None, data=00000000), AppMessageTuple(key=10029, type=3, length=None, data=00000000), AppMessageTuple(key=10036, type=3, length=None, data=0f270000), AppMessageTuple(key=10033, type=0, length=None, data=54d626e08c1c802b305dad30b7e54a82f1...)]))
...

Unfortunately there are no errors from the watch binary that tell us why the response failed. At the very least this confirms that we’re sending what looks like a valid response to the watch and allows us to iterate faster.

KiezelPay Status Verification

I signed up for a developer account with KiezelPay to see if I could learn more about how the checksum works.

In the developer dashboard there is an option to create a new “product” with fields including name, description, and price. When a product is created, KiezelPay generates a bundle that includes .c files containing a unique checksum verification function!

#define KP_GENERATED_MAJOR 2

#define KIEZELPAY_APPID 12345678

#if KIEZELPAY_LOW_MEMORY_MODE == 0
static uint8_t kiezelpay_secret[16] = {14, 8, 7, 19, 100, 237, 68, 113, 221, 58, 99, 1, 11, 213, 47, 224};
#else
static uint32_t kiezelpay_secret = 1243866482;
#endif

#include <kiezelpay-core/kiezelpay-core.h>


static bool kiezelpay_validate_msg(kiezelpay_msg_data *msg) {
	LOG("%s", __func__);
	//before checking the hash, do some sanity checks
	#if KIEZELPAY_LOW_MEMORY_MODE == 0
	bool valid_format = (msg != NULL && msg->checksum != NULL);
	#else
	bool valid_format = (msg != NULL && msg->checksum != 0);
	#endif
	if (!valid_format) {
		return false;
	}

	#if KIEZELPAY_LOW_MEMORY_MODE == 0
	//prepare sha-256 context
	SHA256_CTX ctx_msg_check;
	sha256_init(&ctx_msg_check);
	uint32_t int_for_bytes;
	if (msg->status == 0) {          //unlicensed
	int_for_bytes = msg->purchase_code;
	sha256_update(&ctx_msg_check, (uint8_t*)&int_for_bytes + 3, 1);
	}
	int_for_bytes = kiezelpay_get_status_flags();
	sha256_update(&ctx_msg_check, (uint8_t*)&int_for_bytes + 3, 1);
	sha256_update(&ctx_msg_check, kiezelpay_secret + 11, 1);
	sha256_update(&ctx_msg_check, (uint8_t*)&kiezelpay_msg_random + 0, 1);
	sha256_update(&ctx_msg_check, (uint8_t*)&kiezelpay_msg_random + 1, 1);
	#if KIEZELPAY_DISABLE_TIME_TRIAL == 0
	if (msg->status == 1) {      //trial
	sha256_update(&ctx_msg_check, (uint8_t*)&(msg->trial_duration) + 3, 1);
	}
	#endif
	sha256_update(&ctx_msg_check, kiezelpay_secret + 7, 1);
	sha256_update(&ctx_msg_check, kiezelpay_secret + 8, 1);
	sha256_update(&ctx_msg_check, (uint8_t*)&(kiezelpay_current_state.device_id) + 2, 1);
	sha256_update(&ctx_msg_check, kiezelpay_secret + 6, 1);
	sha256_update(&ctx_msg_check, kiezelpay_secret + 1, 1);
	sha256_update(&ctx_msg_check, (uint8_t*)&(kiezelpay_current_state.device_id) + 1, 1);
	sha256_update(&ctx_msg_check, kiezelpay_secret + 4, 1);
	sha256_update(&ctx_msg_check, kiezelpay_secret + 5, 1);
	int_for_bytes = kiezelpay_get_status_flags();
	sha256_update(&ctx_msg_check, (uint8_t*)&int_for_bytes + 0, 1);
	#if KIEZELPAY_DISABLE_TIME_TRIAL == 0
	if (msg->status == 1) {      //trial
	sha256_update(&ctx_msg_check, (uint8_t*)&(msg->trial_duration) + 2, 1);
	}
	#endif
	int_for_bytes = kiezelpay_get_status_flags();
	sha256_update(&ctx_msg_check, (uint8_t*)&int_for_bytes + 2, 1);
	sha256_update(&ctx_msg_check, kiezelpay_secret + 10, 1);
	sha256_update(&ctx_msg_check, kiezelpay_secret + 2, 1);
	sha256_update(&ctx_msg_check, kiezelpay_secret + 9, 1);
	sha256_update(&ctx_msg_check, kiezelpay_secret + 0, 1);
	#if KIEZELPAY_DISABLE_TIME_TRIAL == 0
	if (msg->status == 1) {      //trial
	sha256_update(&ctx_msg_check, (uint8_t*)&(msg->trial_duration) + 1, 1);
	}
	#endif
	sha256_update(&ctx_msg_check, (uint8_t*)&(kiezelpay_current_state.device_id) + 3, 1);
	sha256_update(&ctx_msg_check, kiezelpay_secret + 3, 1);
	sha256_update(&ctx_msg_check, kiezelpay_secret + 13, 1);
	int_for_bytes = kiezelpay_get_status_flags();
	sha256_update(&ctx_msg_check, (uint8_t*)&int_for_bytes + 1, 1);
	#if KIEZELPAY_DISABLE_TIME_TRIAL == 0
	if (msg->status == 1) {      //trial
	sha256_update(&ctx_msg_check, (uint8_t*)&(msg->trial_duration) + 0, 1);
	}
	#endif
	sha256_update(&ctx_msg_check, kiezelpay_secret + 12, 1);
	if (msg->status == 0) {          //unlicensed
	int_for_bytes = msg->purchase_code;
	sha256_update(&ctx_msg_check, (uint8_t*)&int_for_bytes + 1, 1);
	}
	if (msg->status == 1 || msg->status == 2) {      //trial or licensed
	sha256_update(&ctx_msg_check, (uint8_t*)&(msg->validity_period), 1);
	}
	if (msg->status == 0) {          //unlicensed
	int_for_bytes = msg->purchase_code;
	sha256_update(&ctx_msg_check, (uint8_t*)&int_for_bytes + 2, 1);
	}
	sha256_update(&ctx_msg_check, kiezelpay_secret + 14, 1);
	sha256_update(&ctx_msg_check, (uint8_t*)&(kiezelpay_current_state.device_id) + 0, 1);
	if (msg->status == 0) {          //unlicensed
	int_for_bytes = msg->purchase_code;
	sha256_update(&ctx_msg_check, (uint8_t*)&int_for_bytes + 0, 1);
	}
	sha256_update(&ctx_msg_check, kiezelpay_secret + 15, 1);
	int_for_bytes = msg->status;
	sha256_update(&ctx_msg_check, (uint8_t*)&int_for_bytes, 1);
	//calculate sha-256 hash
	uint8_t hash[SHA256_BLOCK_SIZE];
	sha256_final(&ctx_msg_check, hash);

	//compare this hash with the checksum returned by the server
	for (uint32_t i = 0; i < SHA256_BLOCK_SIZE; i++) {
		if (msg->checksum[i] != hash[i]) return false;
	}
	return true;
	#else
	uint32_t hash = ((kiezelpay_current_state.device_id + kiezelpay_secret) * 1788393421 + 40820) & 0x7fffffff;
	return hash == msg->checksum;
	#endif
}

We have a path forward! The verification here tells us how the checksum is recreated client side to ensure the status response from the server matches. There are many ways we could get around this, here are the two I considered, each with its own pros and cons:

1: Extract the key from the existing binary to be able to reproduce the checksums.

As I later learned by creating a second product in the KiezelPay developer portal, the offsets into the secret are also randomized, which can be annoying to extract. It also involves including a sha256 implementation that runs in the app’s JS.

I threw this at Opus 4.8 just for fun and it did successfully extract the secret from a couple of apps but got stuck extracting the offsets. The loop eventually hit my usage limits and I finished option #2 the old fashioned way before the limit renewed. I’m sure with a bit more time or better harnessing it would’ve been able to complete the task.

2: Patch the verification function to always return that the checksum is valid.

This one involves patching the binary itself which comes with its own complications (you’ll see), but is the same change in nearly all apps and only requires a spoofed “licensed” response from the JS app.

Patching Pebble Binary

As an occasional reverse engineering dabbler, I reached for Ghidra to decompile the Pebble binary.

If you’re not super with patching binaries, the high-level overview is that we can change the program by modifying the CPU instructions encoded in the binary itself. To identify which instructions to change, we can use a disassembler/decompiler like Ghidra to figure out exactly which instructions to change such that the behavior of the program is different without needing to recompile from the source code.

After creating a new Ghidra project, import the pebble-app.bin from the basalt/ directory. Basalt corresponds to the original Pebble Time and is what runs on my Pebble Time 2.

Import Pebble Binary

Pebble Basalt runs a Cortex-M4 processor, so select the appropriate architecture:

Pebble Binary Architecture

Then run Analysis to decompile.

Something I’ve always struggled with is finding or tracking logic in a decompiled binary. My strategy is usually to find some crib that I search for within the binary and then go from there.

There are a couple of cribs in the verification function we can look for:

  1. %s string in the LOG() function. I didn’t have any luck with this one, none of the %s strings Ghidra found looked relevant.
  2. The sha256_update function is called A LOT. If a function has 20+ references then there’s a good chance that’s our function.

This function has a suspicious number of references:

SHA256 Update References

Right clicking on the function > References > Show References To… reveals what looks a lot like the update function we’re hunting down. Admittedly I counted the number of calls to the update function in both places to make sure they matched…

SHA256 Update Disassembled

Now that we’ve found the verification function in the binary it’s straightforward to extract the secret and offsets OR to patch the binary. Up to you. I took the patching approach.

The very bottom of the function has the logic for validating that the client-side checksum matches the checksum response from the server. The goal is to make all checksum comparisons return true even if they don’t match, so we can make a change like this:

 //compare this hash with the checksum returned by the server
 for (uint32_t i = 0; i < SHA256_BLOCK_SIZE; i++) {
-    if (msg->checksum[i] != hash[i]) return false;
+    if (msg->checksum[i] != hash[i]) return true;
 }

In the Ghidra decompilation the equivalent decompiled code looks like this, returning 0 when any element of the array does not match:

    do {
      if (*(char *)(param_1[1] + iVar2) != local_a8[iVar2]) {
        return 0;
      }
      iVar2 = iVar2 + 1;
    } while (iVar2 != 0x20);

We can click on the return 0 to jump to the instructions in the instruction view, then Right Click > Patch Instruction to change the movs r0, #0x0 to movs r0, #0x1. This will update the decompilation view to match our desired state:

Patched Instruction

Now we can hit File > Export Program to save the patched program and attempt to run the watch face again! Make sure you still have the forged API response in the JS so the watch face still gets a licensed response.

After zipping and sideloading the new .pbw… it doesn’t load! On my hardware it crashes back to the settings menu and reverts to the previously running watch face.

Trying with the emulator also crashes:

Emulator Crash

All we did was change a single instruction!

Patching Pebble Checksums

I didn’t see any useful crash logs in the emulator, but while searching around for how other people patch their Pebble apps I found https://github.com/cpfair/rockgarden. This library allows for patching the Pebble SDK functions.

It patches 2 CRCs, one for the manifest and one in the binary itself that I did not account for:

if os.path.exists(asset_path):
	bin_crc = stm32crc(asset_path)
	manifest_obj[manifest_key]["crc"] = bin_crc
	manifest_obj[manifest_key]["size"] = os.stat(asset_path).st_size
final_crc = crc32(mod_binary + main_binary)
logger.debug("Final CRC: %d" % final_crc)

self._write_value_at_offset(CRC_ADDR, "<L", final_crc)

I pulled in rockgarden to create a custom patcher that will build a new .pbw from the unpacked app in the current directory, fixing the CRC of both the manifest and the binary:

Now we can run repack.py to build a new .pbw and test that!

python3 repack.py
INFO: patching Aplite binary CRC
INFO: patching Basalt binary CRC
INFO: Patching Basalt binary
INFO: Main binary:
        Load size       3a30
        Virt size       3bf9
        Entry pt        21ac
        Jump tbl        b8
INFO: patching Chalk binary CRC
INFO: Patching Chalk binary
INFO: Main binary:
        Load size       3858
        Virt size       3a2e
        Entry pt        2170
        Jump tbl        b8
INFO: patching Diorite binary CRC
INFO: Patching Diorite binary
INFO: Main binary:
        Load size       3788
        Virt size       3951
        Entry pt        20a4
        Jump tbl        b8
INFO: patching Emery binary CRC
INFO: Adding file pebble-js-app.js.map to zip
INFO: Adding file appinfo.json to zip
INFO: Adding file pebble-js-app.js to zip
INFO: Adding file pebble-app.bin to zip
INFO: Adding file app_resources.pbpack to zip
INFO: Adding file manifest.json to zip
INFO: Adding file pebble-app.bin to zip
INFO: Adding file app_resources.pbpack to zip
INFO: Adding file manifest.json to zip
INFO: Adding file pebble-app.bin to zip
INFO: Adding file app_resources.pbpack to zip
INFO: Adding file manifest.json to zip
INFO: Adding file pebble-app.bin to zip
INFO: Adding file app_resources.pbpack to zip
INFO: Adding file manifest.json to zip

Success! This newly packed watch face with the corrected CRCs works in both the emulator and the hardware without any KiezelPay network callouts!

Conclusion

This experience highlights one of the biggest selling points of Pebble: complete ownership of hardware and software. Because sideloading has first-class support in the Pebble app it’s incredibly easy to customize watch faces and reupload them. Now I don’t have to worry about the watch faces I use regularly reaching out to third party services with my account and device identifiers.

To recap the process:

  • Download the watch face .pbw from the Pebble store
  • Unzip the bundle and identify what you want to change
  • Change pebble-js-app.js to modify network calls and anything else that runs phone-side
  • Patch pebble-app.bin to modify the code that runs on the watch itself
  • Run repack.py to correct the checksums and repack the .pbw
  • Sideload on phone through the native Pebble app

Of course this level of ownership is a double-edged sword: having the ability to strip out the mechanism that enforces payment for software makes the Pebble ecosystem less attractive for creators depending on payment for their work. This has been a struggle since the beginning of computing and partially why we have things like closed platforms and DRM. My hope is that the tradeoff still favors openness and the incentives still align enough to keep platforms like Pebble appealing to creators and users.