Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions site/src/content/docs/licence.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ check allows up to 30 days offline. The Licence page shows the last successful
check and days remaining. When 7 days or fewer remain, the app shows a reminder,
including at 3 days and 1 day. Choose **Check licence now** after reconnecting.
A failed check leaves the existing allowance intact; it does not add extra days.
If the app says it could not verify the request, check that you used the complete
purchase key and that your computer's date and time are correct. Try again, or
contact info@theswitchify.app if the message continues. This message does not
revoke an existing allowance. If the app instead asks you to connect, check your
internet connection before retrying.

After restarting the app, Switchify PC checks the saved licence against this
computer's protected installation credentials. If your credential store is
Expand Down
59 changes: 55 additions & 4 deletions src-tauri/src/licence_activation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use std::{future::Future, pin::Pin, time::Duration};

const SERVICE: &str = "com.enaboapps.switchify.pc.licence";
pub const NETWORK_ERROR: &str = "Could not check your licence. Connect to the internet and try again. Your existing offline allowance is unchanged.";
pub const INVALID_REQUEST_ERROR: &str = "Could not verify this licence request. Check your key and your computer's date and time, or contact support. Your existing offline allowance is unchanged.";
pub const STORAGE_ERROR: &str = "Your installation key is unavailable. Unlock your keychain and try again. Your existing offline allowance is unchanged.";

#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)]
Expand Down Expand Up @@ -169,6 +170,24 @@ pub trait Transport: Send + Sync {
fn post(&self, config: &Config, body: Value) -> ResponseFuture;
}
pub struct HttpTransport;
fn decode_response(status: reqwest::StatusCode, bytes: &[u8]) -> Result<Value, String> {
let value: Value = serde_json::from_slice(bytes).map_err(|_| NETWORK_ERROR)?;
if status == reqwest::StatusCode::BAD_REQUEST {
// A narrowly classified unsigned error gives guidance, never authority
// to change an entitlement or accept a proof in an error response.
return if value.as_object().is_some_and(|object| {
object.len() == 1 && object.get("outcome").is_some_and(|v| v == "invalid")
}) {
Ok(value)
} else {
Err(NETWORK_ERROR.into())
};
}
if !status.is_success() && status.as_u16() != 409 && status.as_u16() != 429 {
return Err(NETWORK_ERROR.into());
}
Ok(value)
}
impl Transport for HttpTransport {
fn post(&self, config: &Config, body: Value) -> ResponseFuture {
let config = config.clone();
Expand All @@ -193,10 +212,7 @@ impl Transport for HttpTransport {
}
bytes.extend_from_slice(&chunk);
}
if !status.is_success() && status.as_u16() != 409 && status.as_u16() != 429 {
return Err(NETWORK_ERROR.into());
}
serde_json::from_slice(&bytes).map_err(|_| NETWORK_ERROR.into())
decode_response(status, &bytes)
})
}
}
Expand All @@ -216,6 +232,41 @@ pub fn request(
#[cfg(test)]
pub mod tests {
use super::*;
#[test]
fn only_the_exact_bad_request_outcome_gets_invalid_request_guidance() {
use reqwest::StatusCode;
let value = decode_response(StatusCode::BAD_REQUEST, br#"{"outcome":"invalid"}"#).unwrap();
assert_eq!(value["outcome"], "invalid");
for body in [
b"not json".as_slice(),
br#"{"outcome":"invalid","proof":"untrusted"}"#,
br#"{"proof":"untrusted"}"#,
br#"{"outcome":"revoked"}"#,
br#"{"outcome":"unknown"}"#,
] {
assert!(decode_response(StatusCode::BAD_REQUEST, body).is_err());
}
for status in [
StatusCode::UNAUTHORIZED,
StatusCode::NOT_FOUND,
StatusCode::INTERNAL_SERVER_ERROR,
] {
assert!(decode_response(status, br#"{"outcome":"invalid"}"#).is_err());
}
assert_eq!(
decode_response(StatusCode::CONFLICT, br#"{"outcome":"seat_taken"}"#).unwrap()
["outcome"],
"seat_taken"
);
assert_eq!(
decode_response(
StatusCode::TOO_MANY_REQUESTS,
br#"{"outcome":"rate_limited"}"#
)
.unwrap()["outcome"],
"rate_limited"
);
}
pub struct FakeIdentity;
pub struct OtherIdentity;
impl IdentityStore for OtherIdentity {
Expand Down
45 changes: 44 additions & 1 deletion src-tauri/src/licensing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -630,6 +630,9 @@ impl Licensing {
);
let response = self.transport.post(config, body).await?;
if let Some(outcome) = response.get("outcome").and_then(|v| v.as_str()) {
if outcome == "invalid" {
return Err(activation::INVALID_REQUEST_ERROR.into());
}
if matches!(outcome, "seat_taken" | "takeover_limited") {
{
let mut data = self.data.lock().unwrap_or_else(|p| p.into_inner());
Expand Down Expand Up @@ -1192,7 +1195,7 @@ mod tests {
if outcome == "outage" {
return Err(activation::NETWORK_ERROR.into());
}
if matches!(outcome, "seat_taken" | "takeover_limited") {
if matches!(outcome, "seat_taken" | "takeover_limited" | "invalid") {
return Ok(json!({"outcome":outcome}));
}
let request = &body["request"];
Expand Down Expand Up @@ -1266,6 +1269,46 @@ mod tests {
service.transport = transport.clone();
(service, transport, dir)
}
#[tokio::test]
async fn unverifiable_request_guidance_does_not_replace_or_revoke_cached_access() {
let (service, transport, dir) = online_service();
service
.check("activate", Some(&purchase()), false)
.await
.unwrap();
let before = service.data.lock().unwrap().record.clone();
*transport.outcome.lock().unwrap() = "invalid";
assert_eq!(
service.check("check", None, false).await.unwrap_err(),
activation::INVALID_REQUEST_ERROR
);
assert_eq!(service.view().status, "licensed");
assert!(service.data.lock().unwrap().record == before);
std::fs::remove_dir_all(dir).unwrap();
}
#[tokio::test]
async fn rejected_initial_activation_preserves_trial_and_suggests_key_and_clock_checks() {
let (service, transport, dir) = online_service();
*transport.outcome.lock().unwrap() = "invalid";
assert_eq!(
service
.check("activate", Some(&purchase()), false)
.await
.unwrap_err(),
activation::INVALID_REQUEST_ERROR
);
assert_eq!(service.view().status, "trial");
assert!(service
.data
.lock()
.unwrap()
.record
.as_ref()
.unwrap()
.key
.is_none());
std::fs::remove_dir_all(dir).ok();
}
fn restart(source: &Licensing, record: Record) -> Licensing {
let mut service = configured_service(record);
service.keys = source.keys.clone();
Expand Down
Loading