use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use tauri::Emitter;
use tauri::Manager;
use tauri::{AppHandle, Runtime, plugin::PluginApi};
use windows::core::{HSTRING, Interface};
use windows::{
Foundation::DateTime,
Services::Store::{
StoreConsumableStatus, StoreContext, StoreLicense, StoreProduct, StorePurchaseProperties,
StorePurchaseStatus,
},
Win32::UI::Shell::IInitializeWithWindow,
};
use windows_collections::IIterable;
use crate::error::{ErrorResponse, PluginInvokeError};
use crate::models::{
GetProductsResponse, PricingPhase, Product, ProductStatus, Purchase, PurchaseRequest,
PurchaseStateValue, RestorePurchasesResponse, SubscriptionOffer,
};
use std::sync::{Arc, RwLock};
#[derive(Serialize, Deserialize)]
struct WindowsPurchaseTokenV1 {
v: u8,
product_id: String,
purchase_time: i64,
nonce: u32,
}
impl WindowsPurchaseTokenV1 {
fn encode(&self) -> crate::Result<String> {
let bytes = serde_json::to_vec(self).map_err(|e| {
crate::Error::PluginInvoke(PluginInvokeError::InvokeRejected(ErrorResponse {
code: Some("internalError".to_string()),
message: Some(format!("Failed to encode purchase token: {e}")),
data: (),
}))
})?;
Ok(URL_SAFE_NO_PAD.encode(&bytes))
}
fn decode(s: &str) -> crate::Result<Self> {
let invalid = || {
crate::Error::PluginInvoke(PluginInvokeError::InvokeRejected(ErrorResponse {
code: Some("invalidPurchaseToken".to_string()),
message: Some("Invalid Windows purchase token".to_string()),
data: (),
}))
};
let bytes = URL_SAFE_NO_PAD.decode(s).map_err(|_| invalid())?;
let env: Self = serde_json::from_slice(&bytes).map_err(|_| invalid())?;
if env.v != 1 {
return Err(invalid());
}
Ok(env)
}
}
#[allow(clippy::unnecessary_wraps)]
pub fn init<R: Runtime, C: DeserializeOwned>(
app: &AppHandle<R>,
_api: &PluginApi<R, C>,
) -> crate::Result<Iap<R>> {
Ok(Iap {
app_handle: app.clone(),
store_context: Arc::new(RwLock::new(None)),
})
}
pub struct Iap<R: Runtime> {
app_handle: AppHandle<R>,
store_context: Arc<RwLock<Option<StoreContext>>>,
}
impl<R: Runtime> Iap<R> {
fn get_store_context(&self) -> crate::Result<StoreContext> {
let mut context_guard = self.store_context.write().map_err(|e| {
crate::Error::PluginInvoke(PluginInvokeError::InvokeRejected(ErrorResponse {
code: Some("internalError".to_string()),
message: Some(format!("Failed to acquire write lock: {e:?}")),
data: (),
}))
})?;
if context_guard.is_none() {
let context = StoreContext::GetDefault()?;
let window = self.app_handle.get_webview_window("main").ok_or_else(|| {
crate::Error::PluginInvoke(PluginInvokeError::InvokeRejected(ErrorResponse {
code: Some("windowError".to_string()),
message: Some("Failed to get main window".to_string()),
data: (),
}))
})?;
let hwnd = window.hwnd().map_err(|e| {
crate::Error::PluginInvoke(PluginInvokeError::InvokeRejected(ErrorResponse {
code: Some("windowError".to_string()),
message: Some(format!("Failed to get window handle: {e:?}")),
data: (),
}))
})?;
let init = context.cast::<IInitializeWithWindow>()?;
unsafe {
init.Initialize(hwnd)?;
}
*context_guard = Some(context);
}
Ok(context_guard
.as_ref()
.ok_or_else(|| {
crate::Error::PluginInvoke(PluginInvokeError::InvokeRejected(ErrorResponse {
code: Some("storeNotInitialized".to_string()),
message: Some("Store context not initialized".to_string()),
data: (),
}))
})?
.clone())
}
const fn datetime_to_unix_millis(datetime: DateTime) -> i64 {
const WINDOWS_TICK: i64 = 10_000_000;
const SEC_TO_UNIX_EPOCH: i64 = 11_644_473_600;
let windows_ticks = datetime.UniversalTime;
let seconds_since_1601 = windows_ticks / WINDOWS_TICK;
let unix_seconds = seconds_since_1601 - SEC_TO_UNIX_EPOCH;
unix_seconds * 1000 }
fn trigger<S: serde::Serialize + Clone>(&self, event: &str, payload: S) {
let _ = self.app_handle.emit(event, payload);
}
#[allow(clippy::unused_async)]
pub async fn get_products(
&self,
product_ids: Vec<String>,
product_type: String,
) -> crate::Result<GetProductsResponse> {
let context = self.get_store_context()?;
let store_ids: Vec<HSTRING> = product_ids
.iter()
.map(|id| HSTRING::from(id.as_str()))
.collect();
let product_kinds: Vec<HSTRING> = match product_type.as_str() {
"inapp" => vec![
HSTRING::from("Consumable"),
HSTRING::from("UnmanagedConsumable"),
],
"subs" => vec![HSTRING::from("Subscription"), HSTRING::from("Durable")],
_ => vec![
HSTRING::from("Consumable"),
HSTRING::from("UnmanagedConsumable"),
HSTRING::from("Durable"),
HSTRING::from("Subscription"),
],
};
let store_ids: IIterable<HSTRING> = store_ids.into();
let product_kinds: IIterable<HSTRING> = product_kinds.into();
let query_result = context
.GetStoreProductsAsync(&product_kinds, &store_ids)
.and_then(|async_op| async_op.get())?;
let extended_error = query_result.ExtendedError()?;
if extended_error.is_err() {
return Err(crate::Error::PluginInvoke(
PluginInvokeError::InvokeRejected(ErrorResponse {
code: Some("storeQueryFailed".to_string()),
message: Some(format!(
"Store query failed with error: {:?}",
extended_error.message()
)),
data: (),
}),
));
}
let products_map = query_result.Products()?;
let mut products = Vec::new();
let iterator = products_map.First()?;
while iterator.HasCurrent()? {
let item = iterator.Current()?;
let store_product = item.Value()?;
let product = Self::convert_store_product_to_product(&store_product, &product_type)?;
products.push(product);
iterator.MoveNext()?;
}
Ok(GetProductsResponse { products })
}
fn convert_store_product_to_product(
store_product: &StoreProduct,
product_type: &str,
) -> crate::Result<Product> {
let product_id = store_product.StoreId()?.to_string();
let title = store_product.Title()?.to_string();
let description = store_product.Description()?.to_string();
let price = store_product.Price()?;
let formatted_price = price.FormattedPrice()?.to_string();
let currency_code = price.CurrencyCode()?.to_string();
let formatted_base_price = price.FormattedBasePrice()?.to_string();
let price_value = formatted_base_price
.chars()
.filter(|c| c.is_numeric() || *c == '.')
.collect::<String>()
.parse::<f64>()
.unwrap_or(0.0);
#[allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss
)]
let price_amount_micros = (price_value * 1_000_000.0) as i64;
let subscription_offer_details = if product_type == "subs" {
let mut offers = Vec::new();
let skus = store_product.Skus()?;
let sku_count = skus.Size()?;
for i in 0..sku_count {
let sku = skus.GetAt(i)?;
let sku_id = sku.StoreId()?.to_string();
sku.StoreId()?.to_string();
let sku_price = sku.Price()?;
let subscription_info = sku.SubscriptionInfo();
if let Ok(info) = subscription_info {
let billing_period = info.BillingPeriod()?;
let billing_period_unit = info.BillingPeriodUnit()?;
let billing_period_str = format!(
"P{}{}",
billing_period,
match billing_period_unit.0 {
0 => "D", 1 => "W", 3 => "Y", _ => "M", }
);
let pricing_phase = PricingPhase {
formatted_price: sku_price.FormattedPrice()?.to_string(),
price_currency_code: currency_code.clone(),
price_amount_micros,
billing_period: billing_period_str,
billing_cycle_count: 0, recurrence_mode: 1, };
let offer = SubscriptionOffer {
offer_token: sku_id.clone(),
base_plan_id: sku_id,
offer_id: None,
pricing_phases: vec![pricing_phase],
};
offers.push(offer);
}
}
if offers.is_empty() {
None
} else {
Some(offers)
}
} else {
None
};
Ok(Product {
product_id,
title,
description,
product_type: product_type.to_string(),
formatted_price: Some(formatted_price),
price_currency_code: Some(currency_code),
price_amount_micros: Some(price_amount_micros),
subscription_offer_details,
})
}
#[allow(clippy::too_many_lines)]
pub async fn purchase(&self, payload: PurchaseRequest) -> crate::Result<Purchase> {
let context = self.get_store_context()?;
let products_response = self
.get_products(
vec![payload.product_id.clone()],
payload.product_type.clone(),
)
.await?;
if products_response.products.is_empty() {
return Err(crate::Error::PluginInvoke(
PluginInvokeError::InvokeRejected(ErrorResponse {
code: Some("productNotFound".to_string()),
message: Some("Product not found".to_string()),
data: (),
}),
));
}
let product = &products_response.products[0];
let product_title = product.title.clone();
let store_id = HSTRING::from(&payload.product_id);
let offer_token = payload.options.and_then(|opts| opts.offer_token);
let purchase_result = if let Some(token) = offer_token {
let properties = StorePurchaseProperties::Create(&HSTRING::from(&payload.product_id))?;
properties.SetExtendedJsonData(&HSTRING::from(format!(r#"{{"skuId":"{token}"}}"#)))?;
context
.RequestPurchaseWithPurchasePropertiesAsync(&store_id, &properties)
.and_then(|async_op| async_op.get())?
} else {
context
.RequestPurchaseAsync(&store_id)
.and_then(|async_op| async_op.get())?
};
let status = purchase_result.Status()?;
let purchase_state = match status {
StorePurchaseStatus::Succeeded | StorePurchaseStatus::AlreadyPurchased => {
PurchaseStateValue::Purchased
}
StorePurchaseStatus::NotPurchased => {
return Err(crate::Error::PluginInvoke(
PluginInvokeError::InvokeRejected(ErrorResponse {
code: Some("purchaseNotCompleted".to_string()),
message: Some("Purchase was not completed".to_string()),
data: (),
}),
));
}
StorePurchaseStatus::NetworkError => {
return Err(crate::Error::PluginInvoke(
PluginInvokeError::InvokeRejected(ErrorResponse {
code: Some("networkError".to_string()),
message: Some("Network error during purchase".to_string()),
data: (),
}),
));
}
StorePurchaseStatus::ServerError => {
return Err(crate::Error::PluginInvoke(
PluginInvokeError::InvokeRejected(ErrorResponse {
code: Some("serverError".to_string()),
message: Some("Server error during purchase".to_string()),
data: (),
}),
));
}
_ => {
return Err(crate::Error::PluginInvoke(
PluginInvokeError::InvokeRejected(ErrorResponse {
code: Some("purchaseFailed".to_string()),
message: Some("Purchase failed".to_string()),
data: (),
}),
));
}
};
let error_message = purchase_result
.ExtendedError()
.ok()
.map_or_else(String::new, windows::core::HRESULT::message);
let purchase_time_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_err(|e| {
crate::Error::PluginInvoke(PluginInvokeError::InvokeRejected(ErrorResponse {
code: Some("systemTimeError".to_string()),
message: Some(format!("Failed to get system time: {e:?}")),
data: (),
}))
})?
.as_millis();
let purchase_time = i64::try_from(purchase_time_ms).map_err(|e| {
crate::Error::PluginInvoke(PluginInvokeError::InvokeRejected(ErrorResponse {
code: Some("systemTimeError".to_string()),
message: Some(format!("System time out of i64 range: {e}")),
data: (),
}))
})?;
let nonce = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.subsec_nanos());
let purchase_token = WindowsPurchaseTokenV1 {
v: 1,
product_id: product.product_id.clone(),
purchase_time,
nonce,
}
.encode()?;
let purchase = Purchase {
order_id: Some(purchase_token.clone()),
package_name: product_title,
product_id: product.product_id.clone(),
purchase_time,
purchase_token,
purchase_state,
is_auto_renewing: product.product_type == "subs",
is_acknowledged: true, original_json: format!(
r#"{{"status":{},"message":"{}","productId":"{}"}}"#,
status.0, error_message, product.product_id
),
signature: String::new(), original_id: None, jws_representation: None, };
self.trigger("purchaseUpdated", purchase.clone());
Ok(purchase)
}
#[allow(clippy::unused_async)]
pub async fn restore_purchases(
&self,
product_type: String,
) -> crate::Result<RestorePurchasesResponse> {
let context = self.get_store_context()?;
let app_license = context
.GetAppLicenseAsync()
.and_then(|async_op| async_op.get())?;
let mut purchases = Vec::new();
let addon_licenses = app_license.AddOnLicenses()?;
let iterator = addon_licenses.First()?;
while iterator.HasCurrent()? {
let item = iterator.Current()?;
let license = item.Value()?;
let purchase = self.convert_license_to_purchase(&license, &product_type)?;
if purchase.purchase_state == PurchaseStateValue::Purchased {
purchases.push(purchase);
}
iterator.MoveNext()?;
}
Ok(RestorePurchasesResponse { purchases })
}
fn convert_license_to_purchase(
&self,
license: &StoreLicense,
product_type: &str,
) -> crate::Result<Purchase> {
let product_id = license.InAppOfferToken()?.to_string();
let sku_store_id = license.SkuStoreId()?.to_string();
let is_active = license.IsActive()?;
let expiration_date = license.ExpirationDate()?;
let expiration_millis = Self::datetime_to_unix_millis(expiration_date);
let purchase_time = if product_type == "subs" && expiration_millis > 0 {
expiration_millis - (30 * 24 * 60 * 60 * 1000)
} else {
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_err(|e| {
crate::Error::PluginInvoke(PluginInvokeError::InvokeRejected(ErrorResponse {
code: Some("systemTimeError".to_string()),
message: Some(format!("Failed to get system time: {e:?}")),
data: (),
}))
})?
.as_millis();
i64::try_from(now_ms).map_err(|e| {
crate::Error::PluginInvoke(PluginInvokeError::InvokeRejected(ErrorResponse {
code: Some("systemTimeError".to_string()),
message: Some(format!("System time out of i64 range: {e}")),
data: (),
}))
})?
};
let purchase_state = if is_active {
PurchaseStateValue::Purchased
} else {
PurchaseStateValue::Canceled
};
Ok(Purchase {
order_id: Some(sku_store_id.clone()),
package_name: self.app_handle.package_info().name.clone(),
product_id,
purchase_time,
purchase_token: sku_store_id,
purchase_state,
is_auto_renewing: product_type == "subs" && is_active,
is_acknowledged: true,
original_json: format!(
r#"{{"isActive":{is_active},"expirationDate":{expiration_millis}}}"#
),
signature: String::new(),
original_id: None,
jws_representation: None, })
}
#[allow(clippy::unused_async, clippy::unused_self)]
pub async fn acknowledge_purchase(&self, _purchase_token: String) -> crate::Result<()> {
Ok(())
}
#[allow(clippy::unused_async)]
pub async fn consume_purchase(&self, purchase_token: String) -> crate::Result<()> {
let envelope = WindowsPurchaseTokenV1::decode(&purchase_token)?;
let context = self.get_store_context()?;
let store_id = HSTRING::from(&envelope.product_id);
let tracking_id = windows::core::GUID::new()?;
let result = context
.ReportConsumableFulfillmentAsync(&store_id, 1u32, tracking_id)
.and_then(|async_op| async_op.get())?;
let status = result.Status()?;
match status {
StoreConsumableStatus::Succeeded => Ok(()),
StoreConsumableStatus::InsufficentQuantity => Err(crate::Error::PluginInvoke(
PluginInvokeError::InvokeRejected(ErrorResponse {
code: Some("insufficientQuantity".to_string()),
message: Some("Not enough balance remaining to consume".to_string()),
data: (),
}),
)),
StoreConsumableStatus::NetworkError => Err(crate::Error::PluginInvoke(
PluginInvokeError::InvokeRejected(ErrorResponse {
code: Some("networkError".to_string()),
message: Some("Network error during consume".to_string()),
data: (),
}),
)),
StoreConsumableStatus::ServerError => Err(crate::Error::PluginInvoke(
PluginInvokeError::InvokeRejected(ErrorResponse {
code: Some("serverError".to_string()),
message: Some("Server error during consume".to_string()),
data: (),
}),
)),
_ => Err(crate::Error::PluginInvoke(
PluginInvokeError::InvokeRejected(ErrorResponse {
code: Some("consumeFailed".to_string()),
message: Some("Failed to consume purchase".to_string()),
data: (),
}),
)),
}
}
#[allow(clippy::unused_async)]
pub async fn get_product_status(
&self,
product_id: String,
product_type: String,
) -> crate::Result<ProductStatus> {
let context = self.get_store_context()?;
let app_license = context
.GetAppLicenseAsync()
.and_then(|async_op| async_op.get())?;
let addon_licenses = app_license.AddOnLicenses()?;
let product_key = HSTRING::from(&product_id);
let has_license = addon_licenses.HasKey(&product_key)?;
if has_license {
let license = addon_licenses.Lookup(&product_key)?;
let is_active = license.IsActive()?;
let expiration_date = license.ExpirationDate()?;
let expiration_time = Self::datetime_to_unix_millis(expiration_date);
let purchase_time = if product_type == "subs" && expiration_time > 0 {
expiration_time - (30 * 24 * 60 * 60 * 1000)
} else {
expiration_time
};
let purchase_state = if is_active {
Some(PurchaseStateValue::Purchased)
} else {
Some(PurchaseStateValue::Canceled)
};
let sku_store_id = license.SkuStoreId()?.to_string();
Ok(ProductStatus {
product_id,
is_owned: is_active,
purchase_state,
purchase_time: Some(purchase_time),
expiration_time: if expiration_time > 0 {
Some(expiration_time)
} else {
None
},
is_auto_renewing: Some(product_type == "subs" && is_active),
is_acknowledged: Some(true),
purchase_token: Some(sku_store_id),
})
} else {
Ok(ProductStatus {
product_id,
is_owned: false,
purchase_state: None,
purchase_time: None,
expiration_time: None,
is_auto_renewing: None,
is_acknowledged: None,
purchase_token: None,
})
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_datetime_to_unix_millis_epoch() {
let datetime = DateTime {
UniversalTime: 116_444_736_000_000_000,
};
let result = Iap::<tauri::Wry>::datetime_to_unix_millis(datetime);
assert_eq!(result, 0);
}
#[test]
fn test_datetime_to_unix_millis_known_date() {
let datetime = DateTime {
UniversalTime: 133_445_856_000_000_000,
};
let result = Iap::<tauri::Wry>::datetime_to_unix_millis(datetime);
assert_eq!(result, 1_699_920_000_000);
}
#[test]
fn test_datetime_to_unix_millis_before_epoch() {
let datetime = DateTime {
UniversalTime: 116_413_200_000_000_000,
};
let result = Iap::<tauri::Wry>::datetime_to_unix_millis(datetime);
assert!(result < 0);
}
#[test]
fn test_datetime_to_unix_millis_year_2000() {
let datetime = DateTime {
UniversalTime: 125_911_584_000_000_000,
};
let result = Iap::<tauri::Wry>::datetime_to_unix_millis(datetime);
assert_eq!(result, 946_684_800_000);
}
#[test]
fn test_datetime_to_unix_millis_precision() {
let datetime = DateTime {
UniversalTime: 116_444_736_000_000_000 + 5_000_000, };
let result = Iap::<tauri::Wry>::datetime_to_unix_millis(datetime);
assert_eq!(result, 0);
}
#[test]
fn test_datetime_to_unix_millis_one_second_after_epoch() {
let datetime = DateTime {
UniversalTime: 116_444_736_000_000_000 + 10_000_000, };
let result = Iap::<tauri::Wry>::datetime_to_unix_millis(datetime);
assert_eq!(result, 1000);
}
#[test]
fn test_datetime_to_unix_millis_far_future() {
let datetime = DateTime {
UniversalTime: 157_766_880_000_000_000,
};
let result = Iap::<tauri::Wry>::datetime_to_unix_millis(datetime);
assert!(result > 4_000_000_000_000);
}
fn sample_envelope(product_id: &str) -> WindowsPurchaseTokenV1 {
WindowsPurchaseTokenV1 {
v: 1,
product_id: product_id.to_string(),
purchase_time: 1_714_387_200_000,
nonce: 42,
}
}
fn assert_envelope_eq(a: &WindowsPurchaseTokenV1, b: &WindowsPurchaseTokenV1) {
assert_eq!(a.v, b.v);
assert_eq!(a.product_id, b.product_id);
assert_eq!(a.purchase_time, b.purchase_time);
assert_eq!(a.nonce, b.nonce);
}
#[test]
fn test_envelope_round_trip_typical() {
let original = sample_envelope("9MSPC6MP8FM4");
let encoded = original.encode().expect("encode must succeed");
let decoded =
WindowsPurchaseTokenV1::decode(&encoded).expect("just-encoded token must decode");
assert_envelope_eq(&original, &decoded);
}
#[test]
fn test_envelope_round_trip_empty_product_id() {
let original = sample_envelope("");
let encoded = original.encode().expect("encode must succeed");
let decoded =
WindowsPurchaseTokenV1::decode(&encoded).expect("just-encoded token must decode");
assert_envelope_eq(&original, &decoded);
}
#[test]
fn test_envelope_round_trip_long_product_id() {
let original = sample_envelope(&"x".repeat(512));
let encoded = original.encode().expect("encode must succeed");
let decoded =
WindowsPurchaseTokenV1::decode(&encoded).expect("just-encoded token must decode");
assert_envelope_eq(&original, &decoded);
}
#[test]
fn test_envelope_encoded_uses_url_safe_alphabet() {
let encoded = sample_envelope("9MSPC6MP8FM4")
.encode()
.expect("encode must succeed");
for ch in encoded.chars() {
assert!(
ch.is_ascii_alphanumeric() || ch == '-' || ch == '_',
"encoded token contains non-URL-safe char: {ch:?}"
);
}
}
#[test]
fn test_envelope_encoding_is_stable() {
let env = sample_envelope("9MSPC6MP8FM4");
let a = env.encode().expect("encode must succeed");
let b = env.encode().expect("encode must succeed");
assert_eq!(a, b);
}
#[test]
fn test_envelope_decode_rejects_malformed_base64() {
assert!(WindowsPurchaseTokenV1::decode("!!!not-base64!!!").is_err());
}
#[test]
fn test_envelope_decode_rejects_non_json_payload() {
let bogus = URL_SAFE_NO_PAD.encode(b"hello world");
assert!(WindowsPurchaseTokenV1::decode(&bogus).is_err());
}
#[test]
fn test_envelope_decode_rejects_unknown_version() {
let bytes = serde_json::to_vec(&serde_json::json!({
"v": 99,
"product_id": "9MSPC6MP8FM4",
"purchase_time": 1_714_387_200_000_i64,
"nonce": 42,
}))
.expect("static JSON must serialize");
let encoded = URL_SAFE_NO_PAD.encode(&bytes);
assert!(WindowsPurchaseTokenV1::decode(&encoded).is_err());
}
#[test]
fn test_envelope_decode_rejects_missing_product_id() {
let bytes = serde_json::to_vec(&serde_json::json!({
"v": 1,
"purchase_time": 1_714_387_200_000_i64,
"nonce": 42,
}))
.expect("static JSON must serialize");
let encoded = URL_SAFE_NO_PAD.encode(&bytes);
assert!(WindowsPurchaseTokenV1::decode(&encoded).is_err());
}
}