Add support for Productivity Pro tier, v2.9.0

This commit is contained in:
wheaney 2026-03-03 10:56:05 -08:00
parent 7d73625920
commit 1171b68d2d
12 changed files with 170 additions and 85 deletions

View File

@ -72,23 +72,44 @@ After setup, you'll have an application called `Breezy Desktop` installed. Launc
For a double-wide screen, enable "widescreen mode" using the toggle in the Breezy Desktop application. **Note: this can be significantly more resource intensive than non-widescreen, you may notice performance dips on older hardware.**
### Breezy Desktop Pricing (Productivity Tier)
### Breezy Desktop Pricing
Breezy Desktop for GNOME and KDE comes with 2 free trial months. After that, it requires an active Productivity Tier license. Payments are currently only accepted via [Ko-fi](https://ko-fi.com/wheaney). Here's the pricing structure:
Breezy Desktop for GNOME and KDE comes with 2 free trial months. Trial access is granted automatically after setup (no token entry required), and you only need to be online once after setup so your device can activate/refresh access.
After the trial period ends, you must have an active Productivity license (Basic or Pro) to keep Productivity features enabled.
Productivity licenses apply to **Breezy Desktop (GNOME/KDE)**. **Breezy Vulkan** uses a separate Supporter Tier (see below).
Paid plans are currently only available via [Ko-fi](https://ko-fi.com/wheaney).
#### Productivity Basic
The Basic plan includes everything needed for multi-screen productivity using only device orientation (3DoF). For device position (6DoF) — e.g. for the VITURE Luma Ultra glasses or supplementing with OpenTrack+NeuralNet — see the Productivity Pro plan.
| Payment period | Price | Upgrade window \* |
| -------------- | ------------------ | ------------------------------------- |
| Monthly | $5 USD, recurring | Within 7 days to upgrade to yearly |
| Yearly | $50 USD, recurring | Within 90 days to upgrade to lifetime |
| Lifetime | $125 USD, one-time | — |
| Yearly | $10 USD, recurring | Within 90 days to upgrade to lifetime |
| Lifetime | $25 USD, one-time | — |
\* If you pay for a plan and decide to upgrade to a longer-term plan, you may pay the difference within this window.
If you have enough funds, your license will renew automatically within 7 days of expiration so you never experience an unexpected outage. Your device is never required to be online to continue using Productivity Tier features when enabled, but if your access expires while offline (even if you have enough funds), the features will be disabled until the next time your device goes online and the license can be refreshed. Be sure to check for expiration warnings prior to travel.
#### Productivity Pro
#### Free Productivity Tier
The Pro plan provides all the same multi-screen productivity features as Basic, plus device position (6DoF) allowing you to lean in and move more naturally about the displays.
To make Breezy widely accessible, Productivity Tier is currently free of charge for qualified individuals using it for non-commercial purposes. Eligible groups include:
| Payment period | Price | Upgrade window \* |
| -------------- | ------------------ | ------------------------------------- |
| Monthly | $2 USD, recurring | Within 7 days to upgrade to yearly |
| Yearly | $20 USD, recurring | Within 90 days to upgrade to lifetime |
| Lifetime | $50 USD, one-time | — |
\* If you pay for a plan and decide to upgrade to a longer-term plan, you may pay the difference within this window.
For recurring plans (monthly/yearly), renewals are processed automatically during the renewal window (7 days prior to expiration). Your device does not need to stay online during normal use, but it must go online at least once to refresh your license. If you remain offline past your expiration time, features will pause until the next time you go online and refresh.
#### Free Productivity Basic
To make Breezy widely accessible, Productivity Basic is currently free of charge for qualified individuals using it for non-commercial purposes. Eligible groups include:
* Students
* Public school educators
@ -98,9 +119,9 @@ To make Breezy widely accessible, Productivity Tier is currently free of charge
If you believe you qualify, please email wayne@xronlinux.com. You may be asked to provide documentation to verify your eligibility.
#### Unlocking Productivity Tier
#### Unlocking a Productivity License
After your first payment, you should immediately receive an email (to your Ko-fi email address) with a verification token. Once you receive that, enter it in the `License Details` view of the `Breezy Desktop` application, available from the menu in the top window bar.
After your trial ends, purchase (or being granted free eligibility) will result in an email with a verification token. Once you receive that, enter it in the `License Details` view of the `Breezy Desktop` application, available from the menu in the top window bar.
If you don't receive a token, you can request one in the `License Details` view by entering your email address.
@ -138,7 +159,7 @@ I've implemented an experimental multi-tap detection feature for screen **re-cen
### Supporter Tier
Breezy Vulkan's Supporter Tier features are enhancments to core functionality, offered as a way to reward those who have [supported the project](https://ko-fi.com/wheaney). Core features -- like Virtual Display mode, VR-Lite mouse/joystick modes, and Follow mode's display positioning/resizing settings -- will always remain available to everyone regardless of supporter status. Here's the pricing structure:
Breezy Vulkan's Supporter Tier features are enhancements to core functionality, offered as a way to reward those who have [supported the project](https://ko-fi.com/wheaney). Core features — like Virtual Display mode, VR-Lite mouse/joystick modes, and Follow mode's display positioning/resizing settings — will always remain available to everyone regardless of supporter status. Here's the pricing structure:
| Payment period | Price | Upgrade window \* |
| -------------- | ------------------ | ------------------------------------- |
@ -147,7 +168,7 @@ Breezy Vulkan's Supporter Tier features are enhancments to core functionality, o
\* If you pay for a plan and decide to upgrade to a longer-term plan, you may pay the difference within this window.
If you have enough funds, your access will renew automatically within 7 days of expiration so you never experience an unexpected outage. Your device is never required to be online to continue using Supporter Tier features when enabled, but if your access expires while offline (even if you have enough funds), the features will be disabled until the next time your device goes online and the license can be refreshed. Be sure to check for expiration warnings prior to travel.
For recurring plans, access renews automatically during the renewal window (7 days prior to expiration). Your device does not need to stay online during normal use, but it must go online at least once to refresh your license. If you remain offline past your expiration time, features will pause until the next time you go online and refresh.
Features currently offered:
* Smooth Follow (in Follow mode)
@ -194,7 +215,7 @@ In order to provide you with Supporter Tier features, this application and its b
* Your email address is sent to this application's backend server from either the payment vendor (Ko-fi) or from your device (at your request). Your email address may be used immediately upon receipt in its unaltered form to send you a transactional email, but it is then hashed prior to storage. The unaltered form of your email address is never stored and can no longer be referenced. The hashed value is stored for later reference.
* Other personal data may be sent from the payment vendor, but is never utilized nor stored.
* Your device's MAC address is hashed on your device. It never leaves your device in its original, unaltered form. The hashed value is sent to this application's backend server and stored for later reference, and -- up to version 0.8.7 -- to Google Analytics.
* Your device's MAC address is hashed on your device. It never leaves your device in its original, unaltered form. The hashed value is sent to this application's backend server and stored for later reference.
Hashing functions are a one-way process that serve to anonymize your personal data by irreversibly changing them. Once hashed, they can never be unhashed or traced back to their original values.

View File

@ -1 +1 @@
2.8.11
2.9.0

View File

@ -181,4 +181,4 @@ fi
echo "END - xr_driver_setup"
echo "Setting up productivity features"
printf "request_features=productivity_basic\n" >> /dev/shm/xr_driver_control 2>/dev/null || true
printf "request_features=productivity,productivity_pro\n" >> /dev/shm/xr_driver_control 2>/dev/null || true

View File

@ -68,7 +68,7 @@ export default class BreezyDesktopExtension extends Extension {
Globals.extension_dir = this.path;
// safe to request on each load, acts as a no-op if already present
this._write_control('request_features', 'productivity_basic');
this._write_control('request_features', 'productivity,productivity_pro');
Globals.data_stream.start();

View File

@ -259,6 +259,6 @@ echo "END - xr_driver_setup"
fix_xr_gaming_plugin
echo "Setting up productivity features"
printf "request_features=productivity_basic\n" >> /dev/shm/xr_driver_control 2>/dev/null || true
printf "request_features=productivity,productivity_pro\n" >> /dev/shm/xr_driver_control 2>/dev/null || true
printf "\n\033[1;33m!!! IMPORTANT !!!\033[0m You must log out and back in, then enable Breezy Desktop from the Desktop Effects in System Settings\n\n"

View File

@ -126,7 +126,8 @@ BreezyDesktopEffect::BreezyDesktopEffect()
{
QJsonObject flags;
QJsonArray requested;
requested.append(QStringLiteral("productivity_basic"));
requested.append(QStringLiteral("productivity"));
requested.append(QStringLiteral("productivity_pro"));
flags.insert(QStringLiteral("request_features"), requested);
XRDriverIPC::instance().writeControlFlags(flags);
}

View File

@ -210,7 +210,8 @@ BreezyDesktopEffectConfig::BreezyDesktopEffectConfig(QObject *parent, const KPlu
{
QJsonObject flags;
QJsonArray requested;
requested.append(QStringLiteral("productivity_basic"));
requested.append(QStringLiteral("productivity"));
requested.append(QStringLiteral("productivity_pro"));
flags.insert(QStringLiteral("request_features"), requested);
XRDriverIPC::instance().writeControlFlags(flags);
}
@ -1170,79 +1171,142 @@ void BreezyDesktopEffectConfig::refreshLicenseUi(const QJsonObject &rootObj) {
auto donate = tab->findChild<QLabel*>("labelDonateLink");
auto globalWarn = widget()->findChild<QLabel*>("labelGlobalWarning");
QString status = tr("disabled");
QString renewalDescriptor = QStringLiteral("");
auto uiView = rootObj.value(QStringLiteral("ui_view")).toObject();
auto license = uiView.value(QStringLiteral("license")).toObject();
bool warningState = false;
bool expired = false;
if (!license.isEmpty()) {
auto tiers = license.value(QStringLiteral("tiers")).toObject();
QJsonValue prodTier = tiers.value(QStringLiteral("subscriber"));
QJsonObject prodTierObj = prodTier.isUndefined() ? QJsonObject() : prodTier.toObject();
struct TierUiState {
QString status = BreezyDesktopEffectConfig::tr("disabled");
QString renewalDescriptor;
bool warningState = false;
bool isActive = false;
bool isTrial = false;
bool entitled = false;
};
auto features = license.value(QStringLiteral("features")).toObject();
QJsonValue prodFeature = features.value(QStringLiteral("productivity_basic"));
QJsonObject prodFeatureObj = prodFeature.isUndefined() ? QJsonObject() : prodFeature.toObject();
if (!prodTierObj.isEmpty() && !prodFeatureObj.isEmpty()) {
const QString activePeriod = prodTierObj.value(QStringLiteral("active_period")).toString();
const bool isActive = !activePeriod.isEmpty();
if (isActive) {
status = tr("active");
auto computeTierState = [](const QJsonObject &tierObj, const QJsonObject &featureObj) -> TierUiState {
TierUiState out;
QString periodDescriptor = activePeriod.contains(QStringLiteral("lifetime"), Qt::CaseInsensitive) ?
tr("lifetime") :
tr("%1 license").arg(activePeriod);
// Active if both tier+feature exist and the tier reports an active period.
const QString activePeriod = tierObj.value(QStringLiteral("active_period")).toString();
const bool isActive = !tierObj.isEmpty() && !featureObj.isEmpty() && !activePeriod.isEmpty();
QString timeDescriptor;
auto secsVal = prodTierObj.value(QStringLiteral("funds_needed_in_seconds"));
if (secsVal.isDouble()) {
qint64 secs = static_cast<qint64>(secsVal.toDouble());
QString remaining = secondsToRemainingString(secs);
if (!remaining.isEmpty()) {
timeDescriptor = tr("%1 remaining").arg(remaining);
}
if (isActive) {
out.status = BreezyDesktopEffectConfig::tr("active");
out.isActive = true;
out.entitled = true;
const QString periodDescriptor = activePeriod.contains(QStringLiteral("lifetime"), Qt::CaseInsensitive)
? BreezyDesktopEffectConfig::tr("lifetime")
: BreezyDesktopEffectConfig::tr("%1 license").arg(activePeriod);
QString timeDescriptor;
const QJsonValue secsVal = tierObj.value(QStringLiteral("funds_needed_in_seconds"));
if (secsVal.isDouble()) {
const qint64 secs = static_cast<qint64>(secsVal.toDouble());
const QString remaining = secondsToRemainingString(secs);
if (!remaining.isEmpty()) {
timeDescriptor = BreezyDesktopEffectConfig::tr("%1 remaining").arg(remaining);
}
renewalDescriptor = tr(" (%1)").arg(periodDescriptor);
warningState = !timeDescriptor.isEmpty();
if (warningState) {
auto fundsNeeded = prodTierObj.value(QStringLiteral("funds_needed_by_period")).toObject().value(activePeriod).toDouble();
if (fundsNeeded > 0.0) {
QString fundsNeededDescriptor = tr("$%1 USD to renew").arg(fundsNeeded);
renewalDescriptor = tr(" (%1, %2, %3)").arg(periodDescriptor, fundsNeededDescriptor, timeDescriptor);
}
}
out.renewalDescriptor = BreezyDesktopEffectConfig::tr(" (%1)").arg(periodDescriptor);
out.warningState = !timeDescriptor.isEmpty();
if (out.warningState) {
const double fundsNeeded = tierObj.value(QStringLiteral("funds_needed_by_period"))
.toObject()
.value(activePeriod)
.toDouble();
if (fundsNeeded > 0.0) {
const QString fundsNeededDescriptor = BreezyDesktopEffectConfig::tr("$%1 USD to renew").arg(fundsNeeded);
out.renewalDescriptor = BreezyDesktopEffectConfig::tr(" (%1, %2, %3)").arg(periodDescriptor, fundsNeededDescriptor, timeDescriptor);
}
} else {
QJsonValue isEnabled = prodFeatureObj.value(QStringLiteral("is_enabled"));
QJsonValue isTrial = prodFeatureObj.value(QStringLiteral("is_trial"));
if (isEnabled.toBool()) {
if (isTrial.toBool()) {
status = tr("in trial");
auto secsVal = prodFeatureObj.value(QStringLiteral("funds_needed_in_seconds"));
if (secsVal.isDouble()) {
qint64 secs = static_cast<qint64>(secsVal.toDouble());
QString remaining = secondsToRemainingString(secs);
warningState = !remaining.isEmpty();
if (warningState) {
QString timeDescriptor = tr("%1 remaining").arg(remaining);
renewalDescriptor = tr(" (%1)").arg(timeDescriptor);
}
}
return out;
}
// Not active: interpret feature flags.
if (!featureObj.isEmpty()) {
const bool isEnabled = featureObj.value(QStringLiteral("is_enabled")).toBool();
const bool isTrial = featureObj.value(QStringLiteral("is_trial")).toBool();
if (isEnabled) {
if (isTrial) {
out.status = BreezyDesktopEffectConfig::tr("in trial");
out.isTrial = true;
out.entitled = true;
const QJsonValue secsVal = featureObj.value(QStringLiteral("funds_needed_in_seconds"));
if (secsVal.isDouble()) {
const qint64 secs = static_cast<qint64>(secsVal.toDouble());
const QString remaining = secondsToRemainingString(secs);
out.warningState = !remaining.isEmpty();
if (out.warningState) {
const QString timeDescriptor = BreezyDesktopEffectConfig::tr("%1 remaining").arg(remaining);
out.renewalDescriptor = BreezyDesktopEffectConfig::tr(" (%1)").arg(timeDescriptor);
}
}
} else {
expired = true;
out.status = BreezyDesktopEffectConfig::tr("enabled");
out.entitled = true;
}
}
}
return out;
};
auto uiView = rootObj.value(QStringLiteral("ui_view")).toObject();
auto license = uiView.value(QStringLiteral("license")).toObject();
const QJsonObject tiers = license.value(QStringLiteral("tiers")).toObject();
const QJsonObject features = license.value(QStringLiteral("features")).toObject();
const TierUiState baseState = computeTierState(
tiers.value(QStringLiteral("productivity")).toObject(),
features.value(QStringLiteral("productivity")).toObject());
const QString baseLine = tr("Productivity Basic features are %1%2").arg(baseState.status, baseState.renewalDescriptor);
const TierUiState proState = computeTierState(
tiers.value(QStringLiteral("productivity_pro")).toObject(),
features.value(QStringLiteral("productivity_pro")).toObject());
const QString proLine = tr("Productivity Pro features are %1%2").arg(proState.status, proState.renewalDescriptor);
// Display rules:
// - Only Pro if it has an active period or both are in trial
// - Both if Pro is in trial and base is active
// - Only base if Pro is disabled or expired
// - Otherwise show both (fallback)
const bool baseEntitled = baseState.entitled;
const bool proEntitled = proState.entitled;
const bool bothInTrial = proState.isTrial && baseState.isTrial;
const bool showProOnly = proEntitled && (proState.isActive || bothInTrial);
const bool showBaseOnly = !proEntitled;
const bool showBoth = !showProOnly && !showBaseOnly;
QStringList lines;
if (showProOnly) {
lines << proLine;
} else if (showBoth) {
lines << baseLine;
lines << proLine;
} else {
lines << baseLine;
}
const QString message = tr("Productivity Tier features are %1%2").arg(status, renewalDescriptor);
const bool showBase = !showProOnly;
const bool showPro = showProOnly || showBoth;
const bool warningState = (showBase && baseState.warningState) || (showPro && proState.warningState);
// Only disable the effect if neither tier grants access.
const bool effectDisabled = !baseEntitled && !proEntitled;
// Show donate link when the currently relevant tier(s) are warning/disabled.
const bool shownTierNotEntitled = (showBase && !baseEntitled) || (showPro && !proEntitled);
const bool donateVisible = warningState || effectDisabled || shownTierNotEntitled;
const QString message = lines.join(QStringLiteral("\n"));
labelSummary->setText(message);
if (donate) donate->setVisible(warningState || expired);
if (donate) donate->setVisible(donateVisible);
if (globalWarn && !globalWarn->isVisible()) {
if (warningState || expired) {
globalWarn->setText(message + (expired ? tr(" — effect disabled") : QString()));
if (donateVisible) {
globalWarn->setText(message + (effectDisabled ? tr(" — effect disabled") : QString()));
globalWarn->setVisible(true);
} else {
globalWarn->clear();
@ -1250,7 +1314,7 @@ void BreezyDesktopEffectConfig::refreshLicenseUi(const QJsonObject &rootObj) {
}
}
if (expired) {
if (effectDisabled) {
ui.EffectEnabled->setChecked(false);
ui.EffectEnabled->setEnabled(false);
} else {

@ -1 +1 @@
Subproject commit a78350fd326251844624b681aa966144595274e8
Subproject commit 1ce341879704c065e4233c31aa6a4082c35b2948

View File

@ -1 +1 @@
BREEZY_GNOME_FEATURES = ['productivity_basic', 'productivity_pro']
BREEZY_GNOME_FEATURES = ['productivity', 'productivity_pro']

View File

@ -27,8 +27,7 @@ class LicenseFeatureRow(Adw.ActionRow):
def _feature_name(self, feature):
feature_names = {
'sbs': lambda: _('Side-by-side mode (gaming)'),
'smooth_follow': lambda: _('Smooth Follow (gaming)'),
'productivity_basic': lambda: _('Breezy Desktop (productivity)')
'productivity': lambda: _('Productivity Basic'),
'productivity_pro': lambda: _('Productivity Pro'),
}
return feature_names[feature]()

View File

@ -53,8 +53,8 @@ class LicenseTierRow(Adw.ExpanderRow):
def _tier_name(self, tier):
tier_names = {
'supporter': _('Gaming'),
'subscriber': _('Productivity')
'productivity': _('Productivity Basic'),
'productivity_pro': _('Productivity Pro')
}
return tier_names.get(tier) or ""

View File

@ -61,7 +61,7 @@ class BreezydesktopApplication(Adw.Application):
# always do this on start-up since the driver sometimes fails to update the license on boot,
# prevent showing a license warning unnecessarily
XRDriverIPC.get_instance().write_control_flags({'request_features': ['productivity_basic']})
XRDriverIPC.get_instance().write_control_flags({'request_features': ['productivity', 'productivity_pro']})
def do_activate(self):
"""Called when the application is activated.