Compare commits

..
7 Commits
Author SHA1 Message Date
Mark Gregory f9c697619a new git ingnore 2026-10-01 14:47:13 +01:00
Mark Gregory 6313d784fe updates 2026-10-01 11:00:52 +01:00
Mark Gregory ead4483b7c updates 2026-10-01 10:58:07 +01:00
Mark Gregory 3499fe382a new git ingnore 2026-10-01 10:48:59 +01:00
Mark Gregory 5bb4e43665 updates 2026-09-30 13:24:33 +01:00
Mark Gregory bf5357cbcf updated deployer code 2026-09-30 11:27:47 +01:00
Mark Gregory 1a7d026edd updated deployer code 2026-09-30 10:27:14 +01:00
74 changed files with 5483 additions and 360 deletions
+3
View File
@@ -5,6 +5,9 @@
<excludeFolder url="file://$MODULE_DIR$/sonar-desk-licensing/.idea" /> <excludeFolder url="file://$MODULE_DIR$/sonar-desk-licensing/.idea" />
<excludeFolder url="file://$MODULE_DIR$/sonar-desk-licensing/public/build" /> <excludeFolder url="file://$MODULE_DIR$/sonar-desk-licensing/public/build" />
<excludeFolder url="file://$MODULE_DIR$/sonar-desk-licensing/vendor" /> <excludeFolder url="file://$MODULE_DIR$/sonar-desk-licensing/vendor" />
<excludeFolder url="file://$MODULE_DIR$/SonarDesk.xcodeproj/project.xcworkspace/xcuserdata" />
<excludeFolder url="file://$MODULE_DIR$/SonarDesk.xcodeproj/xcuserdata" />
<excludeFolder url="file://$MODULE_DIR$/sonar-desk-licensing/storage/app/licensing" />
</content> </content>
<orderEntry type="inheritedJdk" /> <orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" /> <orderEntry type="sourceFolder" forTests="false" />
+252
View File
@@ -1,19 +1,271 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="BackendCodeEditorSettings"> <component name="BackendCodeEditorSettings">
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CDeclarationWithImplicitIntType/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CommentTypo/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" /> <option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CommentTypo/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=ConstevalIfIsAlwaysConstant/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppAbstractClassWithoutSpecifier/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppAbstractFinalClass/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppAbstractVirtualFunctionCallInCtor/@EntryIndexedValue" value="ERROR" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppAccessSpecifierWithNoDeclarations/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppAwaiterTypeIsNotClass/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppBooleanIncrementExpression/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppBoostFormatBadCode/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppBoostFormatLegacyCode/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppBoostFormatMixedArgs/@EntryIndexedValue" value="ERROR" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppBoostFormatTooFewArgs/@EntryIndexedValue" value="ERROR" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppBoostFormatTooManyArgs/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppCStyleCast/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppCVQualifierCanNotBeAppliedToReference/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppClassCanBeFinal/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppClassIsIncomplete/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppClassNeedsConstructorBecauseOfUninitializedMember/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppClassNeverUsed/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppCompileTimeConstantCanBeReplacedWithBooleanConstant/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppConceptNeverUsed/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppConditionalExpressionCanBeSimplified/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppConstParameterInDeclaration/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppConstValueFunctionReturnType/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppConstexprIfDiscardedBranch/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppCoroutineCallResolveError/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppCudaConflictingMemorySpaceSpecifiers/@EntryIndexedValue" value="ERROR" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppCudaForLoopCanBeReplacedWithIRange/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppCudaFunctionIsNotCallableFromExecutionSpace/@EntryIndexedValue" value="ERROR" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppCudaGotoStatementIsNotAllowedInTileGlobalFunction/@EntryIndexedValue" value="ERROR" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppCudaGridConstantIsOnlyAllowedOnKernelParameter/@EntryIndexedValue" value="ERROR" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppCudaGridConstantParameterMustBeConstNonReference/@EntryIndexedValue" value="ERROR" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppCudaIRangeStepMustBePositive/@EntryIndexedValue" value="ERROR" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppCudaMemorySpaceLocalVariableMustBeExternOrStatic/@EntryIndexedValue" value="ERROR" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppCudaMemorySpaceSpecifierIsNotAllowedOnDataMember/@EntryIndexedValue" value="ERROR" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppCudaMemorySpaceSpecifierIsNotAllowedOnParameter/@EntryIndexedValue" value="ERROR" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppCudaMemorySpaceSpecifierIsNotAllowedOnTypeAlias/@EntryIndexedValue" value="ERROR" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppCudaMemorySpaceVariableIsNotAllowedInHostFunction/@EntryIndexedValue" value="ERROR" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppCudaReturnStatementInsideLoopIsNotAllowedInTileGlobalFunction/@EntryIndexedValue" value="ERROR" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppCudaSharedVariableCannotHaveInitializer/@EntryIndexedValue" value="ERROR" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppCudaTileKernelLaunchMustHaveBlockSizeOne/@EntryIndexedValue" value="ERROR" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppCudaVariableIsNotAccessibleFromExecutionSpace/@EntryIndexedValue" value="ERROR" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAArrayIndexOutOfBounds/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" /> <option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAArrayIndexOutOfBounds/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAConstantConditions/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAConstantFunctionResult/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" /> <option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAConstantFunctionResult/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAConstantParameter/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" /> <option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAConstantParameter/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFADeletedPointer/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAEndlessLoop/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" /> <option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAEndlessLoop/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAInfiniteRecursion/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" /> <option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAInfiniteRecursion/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAInvalidatedMemory/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFALocalValueEscapesFunction/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFALocalValueEscapesScope/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFALoopConditionNotUpdated/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAMemoryLeak/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFANotInitializedField/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFANullDereference/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFATimeOver/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" /> <option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFATimeOver/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAUnreachableCode/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" /> <option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAUnreachableCode/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAUnreachableFunctionCall/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" /> <option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAUnreachableFunctionCall/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAUnreadVariable/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" /> <option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAUnreadVariable/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAUnusedValue/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" /> <option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAUnusedValue/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDeclarationHidesLocal/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDeclarationHidesUncapturedLocal/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDeclarationSpecifierWithoutDeclarators/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDeclaratorDisambiguatedAsFunction/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDeclaratorNeverUsed/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDeclaratorUsedBeforeInitialization/@EntryIndexedValue" value="ERROR" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDefaultCaseNotHandledInSwitchStatement/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDefaultInitializationWithNoUserConstructor/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDefaultIsUsedAsIdentifier/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDefaultedSpecialMemberFunctionIsImplicitlyDeleted/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDefinitionsOrder/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDeletingVoidPointer/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDependentTemplateWithoutTemplateKeyword/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDependentTypeWithoutTypenameKeyword/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDeprecatedEntity/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDeprecatedOverridenMethod/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDeprecatedRegisterStorageClassSpecifier/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDereferenceOperatorLimitExceeded/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDiscardedPostfixOperatorResult/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDoxygenSyntaxError/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDoxygenUndocumentedParameter/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDoxygenUnresolvedReference/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEmptyDeclaration/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEnforceCVQualifiersOrder/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEnforceCVQualifiersPlacement/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEnforceDoStatementBraces/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEnforceForStatementBraces/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEnforceFunctionDeclarationStyle/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEnforceIfStatementBraces/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEnforceNestedNamespacesStyle/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEnforceOverridingDestructorStyle/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEnforceOverridingFunctionStyle/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEnforceTypeAliasCodeStyle/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEnforceWhileStatementBraces/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEntityAssignedButNoRead/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEntityUsedOnlyInUnevaluatedContext/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEnumeratorNeverUsed/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEqualOperandsInBinaryExpression/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEvaluationFailure/@EntryIndexedValue" value="ERROR" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppExplicitSpecializationInNonNamespaceScope/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppExpressionWithoutSideEffects/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppFinalFunctionInFinalClass/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppFinalNonOverridingVirtualFunction/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppForLoopCanBeReplacedWithWhile/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppForwardEnumDeclarationWithoutUnderlyingType/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppFunctionDoesntReturnValue/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppFunctionIsNotImplemented/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppFunctionResultShouldBeUsed/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppFunctionalStyleCast/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppHeaderHasBeenAlreadyIncluded/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppHiddenFunction/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppHidingFunction/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppIdenticalOperandsInBinaryExpression/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppIfCanBeReplacedByConstexprIf/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppImplicitDefaultConstructorNotAvailable/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppIncompatiblePointerConversion/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppIncompleteSwitchStatement/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppInconsistentNaming/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" /> <option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppInconsistentNaming/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppIntegralToPointerConversion/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppInvalidLineContinuation/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppJoinDeclarationAndAssignment/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppLambdaCaptureNeverUsed/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppLocalVariableMayBeConst/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppLocalVariableMightNotBeInitialized/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppLocalVariableWithNonTrivialDtorIsNeverUsed/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppLongFloat/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMemberFunctionMayBeConst/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMemberFunctionMayBeStatic/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMemberInitializersOrder/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMismatchedClassTags/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMissingIncludeGuard/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMissingKeywordThrow/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppModulePartitionWithSeveralPartitionUnits/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMsExtAddressOfClassRValue/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMsExtBindingRValueToLvalueReference/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMsExtCopyElisionInCopyInitDeclarator/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMsExtDoubleUserConversionInCopyInit/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMsExtNotInitializedStaticConstLocalVar/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMsExtReinterpretCastFromNullptr/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMultiCharacterLiteral/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMultiCharacterWideLiteral/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMustBePublicVirtualToImplementInterface/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMutableSpecifierOnReferenceMember/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppNoDiscardExpression/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppNodiscardFunctionWithoutReturnValue/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppNonExceptionSafeResourceAcquisition/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppNonExplicitConversionOperator/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppNonExplicitConvertingConstructor/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppNonInlineFunctionDefinitionInHeaderFile/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppNonInlineVariableDefinitionInHeaderFile/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppNotAllPathsReturnValue/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppObjectMemberMightNotBeInitialized/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppOutParameterMustBeWritten/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppOverrideWithDifferentVisibility/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppParameterMayBeConst/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppParameterMayBeConstPtrOrRef/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppParameterNamesMismatch/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppParameterNeverUsed/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPassValueParameterByConstReference/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPointerConversionDropsQualifiers/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPointerToIntegralConversion/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPolymorphicClassWithNonVirtualPublicDestructor/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPossiblyErroneousEmptyStatements/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPossiblyUninitializedMember/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPossiblyUnintendedObjectSlicing/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPrecompiledHeaderIsNotIncluded/@EntryIndexedValue" value="ERROR" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPrecompiledHeaderNotFound/@EntryIndexedValue" value="ERROR" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPrintfBadFormat/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPrintfExtraArg/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPrintfMissedArg/@EntryIndexedValue" value="ERROR" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPrintfRiskyFormat/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPrivateSpecialMemberFunctionIsNotImplemented/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRangeBasedForIncompatibleReference/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedefinitionOfDefaultArgumentInOverrideFunction/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantAccessSpecifier/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantBaseClassAccessSpecifier/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantBaseClassInitializer/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantBooleanExpressionArgument/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantCastExpression/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantComplexityInComparison/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantConditionalExpression/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantConstSpecifier/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantControlFlowJump/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantDereferencingAndTakingAddress/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantElaboratedTypeSpecifier/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantElseKeyword/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantElseKeywordInsideCompoundStatement/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantEmptyDeclaration/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantEmptyStatement/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantExportKeyword/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantFwdClassOrEnumSpecifier/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantInlineSpecifier/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantLambdaParameterList/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantMemberInitializer/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantNamespaceDefinition/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantParentheses/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantQualifier/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantQualifierADL/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantStaticSpecifierOnMemberAllocationFunction/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantStaticSpecifierOnThreadLocalLocalVariable/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantTemplateArguments/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantTemplateKeyword/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantTypenameKeyword/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantVoidArgumentList/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantZeroInitializerInAggregateInitialization/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppReferenceToOverriddenVirtualFunction/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppReinterpretCastFromVoidPtr/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRemoveRedundantBraces/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppReplaceMemsetWithZeroInitialization/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppReplaceTieWithStructuredBinding/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppReturnNoValueInNonVoidFunction/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppSmartPointerVsMakeFunction/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppSomeObjectMembersMightNotBeInitialized/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppSpecialFunctionWithoutNoexceptSpecification/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppStaticAssertFailure/@EntryIndexedValue" value="ERROR" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppStaticDataMemberInUnnamedStruct/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppStaticSpecifierOnAnonymousNamespaceMember/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppStringLiteralToCharPointerConversion/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppSyntaxWarning/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppTabsAreDisallowed/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppTemplateArgumentsCanBeDeduced/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppTemplateParameterNeverUsed/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppTemplateParameterShadowing/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppThrowExpressionCanBeReplacedWithRethrow/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppTooWideScope/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppTooWideScopeInitStatement/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppTypeAliasNeverUsed/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUninitializedDependentBaseClass/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUninitializedNonStaticDataMember/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUnionMemberOfReferenceType/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUnmatchedPragmaEndRegionDirective/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUnmatchedPragmaRegionDirective/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUnnamedNamespaceInHeaderFile/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUnnecessaryWhitespace/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUnsignedZeroComparison/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUnusedIncludeDirective/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUseAlgorithmWithCount/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUseAssociativeContains/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUseAuto/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUseAutoForNumeric/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUseDesignatedInitializers/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUseElementsView/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUseEraseAlgorithm/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUseFamiliarTemplateSyntaxForGenericLambdas/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUseInternalLinkage/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUseRangeAlgorithm/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUseStdSize/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUseStructuredBinding/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUseTypeTraitAlias/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUserDefinedLiteralSuffixDoesNotStartWithUnderscore/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUsingResultOfAssignmentAsCondition/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppVariableCanBeMadeConstexpr/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppVirtualFunctionCallInsideCtor/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppVirtualFunctionInFinalClass/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppVolatileParameterInDeclaration/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppWarningDirective/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppWrongIncludesOrder/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppWrongSlashesInIncludeDirective/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppZeroConstantCanBeReplacedWithNullptr/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppZeroValuedExpressionUsedAsNullPointer/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=IdentifierTypo/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" /> <option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=IdentifierTypo/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=IfStdIsConstantEvaluatedCanBeReplaced/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=StdIsConstantEvaluatedWillAlwaysEvaluateToConstant/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=StringLiteralTypo/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" /> <option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=StringLiteralTypo/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
<option name="/Default/CodeStyle/CodeFormatting/CppClangFormat/EnableClangFormatSupport/@EntryValue" value="false" type="bool" /> <option name="/Default/CodeStyle/CodeFormatting/CppClangFormat/EnableClangFormatSupport/@EntryValue" value="false" type="bool" />
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/ALIGN_MULTILINE_ARGUMENT/@EntryValue" value="true" type="bool" /> <option name="/Default/CodeStyle/CodeFormatting/CppFormatting/ALIGN_MULTILINE_ARGUMENT/@EntryValue" value="true" type="bool" />
@@ -0,0 +1,16 @@
-----BEGIN CERTIFICATE REQUEST-----
MIICmTCCAYECAQAwVDEuMCwGCSqGSIb3DQEJARYfbWFyay5ncmVnb3J5QGFiYmV5
c29mZnR3YXJlLmNvbTEVMBMGA1UEAwwMTWFyayBHcmVnb3J5MQswCQYDVQQGEwJH
QjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAPSxflrArBG7I5okgb1j
ZpyhzRpX/eA5RdwIQu4YzXAmxzusExOFsTMa0VdpW1KX1JjK13vJU+r2vPp2Pqks
rY5Gq8yIlbcQsbcdAjOP8wG+cA03Zvo7LfXfik9Eoa7kLGDJmzaKwui70YrH1xb7
h2bxutAjwqH8P64YkOQv2RL04+C7TRie5UxmgNz47WPN9sUDjbh1nmgYEc0iZUgL
OsI8aTNwhGueFcGTedNXgiDYnWnTQyUUe0s3vMDhe1JqZrfS/69WTWaKGuHHUugV
GKLURCwdSnRmbHXOaNZnkdxLLcAd+P+yQ+R8G7PRD4zYScOZHg4ihSSybw1Mw/mG
SjkCAwEAAaAAMA0GCSqGSIb3DQEBCwUAA4IBAQA7y55JvSJBOcEPCUHDkvOhJhef
iNn2tZ8abccKZoSf7ZuqsZpMsaLT2dtBlAXGzPiKLhG45KoYnEQE0/INSIJWOKoC
YRvz5SBJotu7DZHIZUGA/AH7vzDpCpP8AYUBXIL0IxnT4MLhErWOfMZcfhrU/HHC
Z6iEymP8P9UxDewH8tb9u//q/lv60wnXqTgyq5AKz0xr1GyYXjf8nyPjcHX2MoH3
tYDtP1FZOlYZnKSvPTDNyHtB2PvfGIbUk3vdsjlThz3rKDVGo0fuS1L0szBDnfYt
QZhPAsfS5n0DS1agPatYWYLxHx+cwwqNGrSgpnXzfyHsFW/GHf4OrEE/bkf3
-----END CERTIFICATE REQUEST-----
+2
View File
@@ -11,6 +11,8 @@
<key>CFBundleShortVersionString</key><string>$(MARKETING_VERSION)</string> <key>CFBundleShortVersionString</key><string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key><string>$(CURRENT_PROJECT_VERSION)</string> <key>CFBundleVersion</key><string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSApplicationCategoryType</key><string>public.app-category.developer-tools</string> <key>LSApplicationCategoryType</key><string>public.app-category.developer-tools</string>
<key>SonarDeskEnvironment</key><string>$(SONARDESK_ENVIRONMENT)</string>
<key>SonarDeskLicenseBaseURL</key><string>$(SONARDESK_LICENSE_BASE_URL)</string>
<key>NSUbiquitousContainers</key> <key>NSUbiquitousContainers</key>
<dict> <dict>
<key>iCloud.net.absdev.SonarDesk</key> <key>iCloud.net.absdev.SonarDesk</key>
+162 -4
View File
@@ -119,7 +119,7 @@
/* End PBXSourcesBuildPhase section */ /* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */ /* Begin XCBuildConfiguration section */
A12 /* Debug */ = { A19 /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
@@ -162,6 +162,93 @@
}; };
name = Debug; name = Debug;
}; };
A12 /* Develop */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ENABLE_MODULES = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 39R6U3437D;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 14.0;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = macosx;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_VERSION = 5.0;
};
name = Develop;
};
A16 /* Stage */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ENABLE_MODULES = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 39R6U3437D;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 14.0;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = macosx;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_VERSION = 5.0;
};
name = Stage;
};
A13 /* Release */ = { A13 /* Release */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
@@ -203,7 +290,7 @@
}; };
name = Release; name = Release;
}; };
A14 /* Debug */ = { A20 /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
@@ -226,12 +313,77 @@
MARKETING_VERSION = 2.2.0; MARKETING_VERSION = 2.2.0;
PRODUCT_BUNDLE_IDENTIFIER = net.absdev.SonarDesk; PRODUCT_BUNDLE_IDENTIFIER = net.absdev.SonarDesk;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SONARDESK_ENVIRONMENT = Develop;
SONARDESK_LICENSE_BASE_URL = "https://sonardesk.absdev.net";
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_STRICT_CONCURRENCY = targeted; SWIFT_STRICT_CONCURRENCY = targeted;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
}; };
name = Debug; name = Debug;
}; };
A14 /* Develop */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CLANG_ENABLE_OBJC_WEAK = YES;
CODE_SIGN_ENTITLEMENTS = SonarDesk/SonarDesk.entitlements;
CODE_SIGN_INJECT_BASE_ENTITLEMENTS = YES;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 6;
DEAD_CODE_STRIPPING = YES;
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = Configuration/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = SonarDesk;
INFOPLIST_KEY_CFBundleIconName = AppIcon;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
MARKETING_VERSION = 2.2.0;
PRODUCT_BUNDLE_IDENTIFIER = net.absdev.SonarDesk;
PRODUCT_NAME = "$(TARGET_NAME)";
SONARDESK_ENVIRONMENT = Develop;
SONARDESK_LICENSE_BASE_URL = "https://sonardesk.absdev.net";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_STRICT_CONCURRENCY = targeted;
SWIFT_VERSION = 5.0;
};
name = Develop;
};
A18 /* Stage */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CLANG_ENABLE_OBJC_WEAK = YES;
CODE_SIGN_ENTITLEMENTS = SonarDesk/SonarDesk.entitlements;
CODE_SIGN_INJECT_BASE_ENTITLEMENTS = YES;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 6;
DEAD_CODE_STRIPPING = YES;
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = Configuration/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = SonarDesk;
INFOPLIST_KEY_CFBundleIconName = AppIcon;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
MARKETING_VERSION = 2.2.0;
PRODUCT_BUNDLE_IDENTIFIER = net.absdev.SonarDesk;
PRODUCT_NAME = "$(TARGET_NAME)";
SONARDESK_ENVIRONMENT = Stage;
SONARDESK_LICENSE_BASE_URL = "https://sonardesk.stage.absdev.net";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_STRICT_CONCURRENCY = targeted;
SWIFT_VERSION = 5.0;
};
name = Stage;
};
A15 /* Release */ = { A15 /* Release */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
@@ -255,6 +407,8 @@
MARKETING_VERSION = 2.2.0; MARKETING_VERSION = 2.2.0;
PRODUCT_BUNDLE_IDENTIFIER = net.absdev.SonarDesk; PRODUCT_BUNDLE_IDENTIFIER = net.absdev.SonarDesk;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SONARDESK_ENVIRONMENT = Production;
SONARDESK_LICENSE_BASE_URL = "https://sonardesk.absdev.net";
SWIFT_COMPILATION_MODE = wholemodule; SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_OPTIMIZATION_LEVEL = "-O";
@@ -270,7 +424,9 @@
A2 /* Build configuration list for PBXProject "SonarDesk" */ = { A2 /* Build configuration list for PBXProject "SonarDesk" */ = {
isa = XCConfigurationList; isa = XCConfigurationList;
buildConfigurations = ( buildConfigurations = (
A12 /* Debug */, A19 /* Debug */,
A12 /* Develop */,
A16 /* Stage */,
A13 /* Release */, A13 /* Release */,
); );
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
@@ -279,7 +435,9 @@
A8 /* Build configuration list for PBXNativeTarget "SonarDesk" */ = { A8 /* Build configuration list for PBXNativeTarget "SonarDesk" */ = {
isa = XCConfigurationList; isa = XCConfigurationList;
buildConfigurations = ( buildConfigurations = (
A14 /* Debug */, A20 /* Debug */,
A14 /* Develop */,
A18 /* Stage */,
A15 /* Release */, A15 /* Release */,
); );
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
+449
View File
@@ -0,0 +1,449 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXFileReference section */
A7 /* SonarDesk.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SonarDesk.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
A6 /* SonarDesk */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = SonarDesk;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
A10 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
A3 = {
isa = PBXGroup;
children = (
A6 /* SonarDesk */,
A4 /* Products */,
);
sourceTree = "<group>";
};
A4 /* Products */ = {
isa = PBXGroup;
children = (
A7 /* SonarDesk.app */,
);
name = Products;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
A5 /* SonarDesk */ = {
isa = PBXNativeTarget;
buildConfigurationList = A8 /* Build configuration list for PBXNativeTarget "SonarDesk" */;
buildPhases = (
A9 /* Sources */,
A10 /* Frameworks */,
A11 /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
A6 /* SonarDesk */,
);
name = SonarDesk;
packageProductDependencies = (
);
productName = SonarDesk;
productReference = A7 /* SonarDesk.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
A1 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 2600;
LastUpgradeCheck = 2700;
};
buildConfigurationList = A2 /* Build configuration list for PBXProject "SonarDesk" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = A3;
preferredProjectObjectVersion = 77;
productRefGroup = A4 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
A5 /* SonarDesk */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
A11 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
A9 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
A19 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ENABLE_MODULES = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 39R6U3437D;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 14.0;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = macosx;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_VERSION = 5.0;
};
name = Debug;
};
A12 /* Develop */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ENABLE_MODULES = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 39R6U3437D;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 14.0;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = macosx;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_VERSION = 5.0;
};
name = Develop;
};
A16 /* Stage */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ENABLE_MODULES = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 39R6U3437D;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 14.0;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = macosx;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_VERSION = 5.0;
};
name = Stage;
};
A13 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ENABLE_MODULES = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 39R6U3437D;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 14.0;
SDKROOT = macosx;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_VERSION = 5.0;
};
name = Release;
};
A20 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CLANG_ENABLE_OBJC_WEAK = YES;
CODE_SIGN_ENTITLEMENTS = SonarDesk/SonarDesk.entitlements;
CODE_SIGN_INJECT_BASE_ENTITLEMENTS = YES;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 6;
DEAD_CODE_STRIPPING = YES;
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = Configuration/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = SonarDesk;
INFOPLIST_KEY_CFBundleIconName = AppIcon;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
MARKETING_VERSION = 2.2.0;
PRODUCT_BUNDLE_IDENTIFIER = net.absdev.SonarDesk;
PRODUCT_NAME = "$(TARGET_NAME)";
SONARDESK_ENVIRONMENT = Develop;
SONARDESK_LICENSE_BASE_URL = "https://sonardesk.vm";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_STRICT_CONCURRENCY = targeted;
SWIFT_VERSION = 5.0;
};
name = Debug;
};
A14 /* Develop */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CLANG_ENABLE_OBJC_WEAK = YES;
CODE_SIGN_ENTITLEMENTS = SonarDesk/SonarDesk.entitlements;
CODE_SIGN_INJECT_BASE_ENTITLEMENTS = YES;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 6;
DEAD_CODE_STRIPPING = YES;
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = Configuration/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = SonarDesk;
INFOPLIST_KEY_CFBundleIconName = AppIcon;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
MARKETING_VERSION = 2.2.0;
PRODUCT_BUNDLE_IDENTIFIER = net.absdev.SonarDesk;
PRODUCT_NAME = "$(TARGET_NAME)";
SONARDESK_ENVIRONMENT = Develop;
SONARDESK_LICENSE_BASE_URL = "https://sonardesk.vm";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_STRICT_CONCURRENCY = targeted;
SWIFT_VERSION = 5.0;
};
name = Develop;
};
A18 /* Stage */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CLANG_ENABLE_OBJC_WEAK = YES;
CODE_SIGN_ENTITLEMENTS = SonarDesk/SonarDesk.entitlements;
CODE_SIGN_INJECT_BASE_ENTITLEMENTS = YES;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 6;
DEAD_CODE_STRIPPING = YES;
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = Configuration/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = SonarDesk;
INFOPLIST_KEY_CFBundleIconName = AppIcon;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
MARKETING_VERSION = 2.2.0;
PRODUCT_BUNDLE_IDENTIFIER = net.absdev.SonarDesk;
PRODUCT_NAME = "$(TARGET_NAME)";
SONARDESK_ENVIRONMENT = Stage;
SONARDESK_LICENSE_BASE_URL = "https://sonardesk.stage.absdev.net";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_STRICT_CONCURRENCY = targeted;
SWIFT_VERSION = 5.0;
};
name = Stage;
};
A15 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CLANG_ENABLE_OBJC_WEAK = YES;
CODE_SIGN_ENTITLEMENTS = SonarDesk/SonarDesk.entitlements;
CODE_SIGN_INJECT_BASE_ENTITLEMENTS = NO;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 6;
DEAD_CODE_STRIPPING = YES;
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = Configuration/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = SonarDesk;
INFOPLIST_KEY_CFBundleIconName = AppIcon;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
MARKETING_VERSION = 2.2.0;
PRODUCT_BUNDLE_IDENTIFIER = net.absdev.SonarDesk;
PRODUCT_NAME = "$(TARGET_NAME)";
SONARDESK_ENVIRONMENT = Production;
SONARDESK_LICENSE_BASE_URL = "https://sonardesk.absdev.net";
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_STRICT_CONCURRENCY = targeted;
SWIFT_VERSION = 5.0;
VALIDATE_PRODUCT = YES;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
A2 /* Build configuration list for PBXProject "SonarDesk" */ = {
isa = XCConfigurationList;
buildConfigurations = (
A19 /* Debug */,
A12 /* Develop */,
A16 /* Stage */,
A13 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
A8 /* Build configuration list for PBXNativeTarget "SonarDesk" */ = {
isa = XCConfigurationList;
buildConfigurations = (
A20 /* Debug */,
A14 /* Develop */,
A18 /* Stage */,
A15 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = A1 /* Project object */;
}
@@ -0,0 +1,76 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2700"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "A5"
BuildableName = "SonarDesk.app"
ReferencedContainer = "container:SonarDesk.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Stage"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES"
queueDebuggingEnabled = "No">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "A5"
BuildableName = "SonarDesk.app"
ReferencedContainer = "container:SonarDesk.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "A5"
BuildableName = "SonarDesk.app"
ReferencedContainer = "container:SonarDesk.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
+18 -7
View File
@@ -13,6 +13,7 @@ struct SonarDeskApp: App {
@NSApplicationDelegateAdaptor(SonarDeskAppDelegate.self) private var appDelegate @NSApplicationDelegateAdaptor(SonarDeskAppDelegate.self) private var appDelegate
@StateObject private var appState = AppState() @StateObject private var appState = AppState()
@StateObject private var reportStore = ReportStore() @StateObject private var reportStore = ReportStore()
@StateObject private var licenseStore = LicenseStore()
@State private var launchPhase: LaunchPhase = .splash @State private var launchPhase: LaunchPhase = .splash
private enum LaunchPhase { private enum LaunchPhase {
@@ -28,10 +29,17 @@ struct SonarDeskApp: App {
LaunchView() LaunchView()
.task { await startApplication() } .task { await startApplication() }
case .application: case .application:
RootView() Group {
.environmentObject(appState) if licenseStore.isLicensed {
.environmentObject(reportStore) RootView()
.transition(.opacity) .environmentObject(appState)
.environmentObject(reportStore)
} else {
LicenseRequiredView()
}
}
.environmentObject(licenseStore)
.transition(.opacity)
} }
} }
.frame(minWidth: 1040, minHeight: 680) .frame(minWidth: 1040, minHeight: 680)
@@ -51,7 +59,8 @@ struct SonarDeskApp: App {
SettingsView() SettingsView()
.environmentObject(appState) .environmentObject(appState)
.environmentObject(reportStore) .environmentObject(reportStore)
.frame(width: 620, height: 430) .environmentObject(licenseStore)
.frame(width: 720, height: 560)
} }
} }
@@ -60,11 +69,13 @@ struct SonarDeskApp: App {
// Keep the branded launch experience visible long enough to be perceptible, // Keep the branded launch experience visible long enough to be perceptible,
// while using the time to perform the first SonarQube refresh. // while using the time to perform the first SonarQube refresh.
async let minimumSplash: Void = Task.sleep(for: .seconds(1.6)) async let minimumSplash: Void = Task.sleep(for: .seconds(1.6))
async let licenceRefresh: Void = licenseStore.refresh()
if appState.projects.isEmpty, appState.selectedServer != nil { _ = await licenceRefresh
if licenseStore.isLicensed, appState.projects.isEmpty, appState.selectedServer != nil {
await appState.refresh() await appState.refresh()
} }
_ = try? await minimumSplash _ = try? await minimumSplash
withAnimation(.easeInOut(duration: 0.32)) { withAnimation(.easeInOut(duration: 0.32)) {
launchPhase = .application launchPhase = .application
@@ -1,7 +1,22 @@
{ {
"images" : [ "images" : [
{ "filename" : "SonarDeskLogo.png", "idiom" : "universal", "scale" : "1x" }, {
{ "filename" : "SonarDeskLogo@2x.png", "idiom" : "universal", "scale" : "2x" } "filename" : "SonarDeskLogo.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "SonarDeskLogo@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
], ],
"info" : { "author" : "xcode", "version" : 1 } "info" : {
"author" : "xcode",
"version" : 1
}
} }
+62 -40
View File
@@ -5,58 +5,23 @@ enum KeychainService {
static let service = "net.absdev.SonarDesk" static let service = "net.absdev.SonarDesk"
static func saveToken(_ token: String, serverID: UUID) throws { static func saveToken(_ token: String, serverID: UUID) throws {
let cleanToken = token.trimmingCharacters(in: .whitespacesAndNewlines) try saveLocal(token, account: serverID.uuidString)
guard !cleanToken.isEmpty else { throw KeychainError.emptyToken }
let account = serverID.uuidString
let data = Data(cleanToken.utf8)
// Remove both legacy local and synchronizable copies before writing one canonical item.
SecItemDelete(query(account: account, synchronizable: kSecAttrSynchronizableAny) as CFDictionary)
var add = query(account: account, synchronizable: kCFBooleanTrue)
add[kSecValueData as String] = data
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
let status = SecItemAdd(add as CFDictionary, nil)
guard status == errSecSuccess else { throw KeychainError.status(status) }
} }
static func token(serverID: UUID) -> String? { static func token(serverID: UUID) -> String? {
let account = serverID.uuidString readWithLegacyMigration(account: serverID.uuidString)
// kSecAttrSynchronizableAny also finds tokens created by pre-iCloud SonarDesk builds.
var lookup = query(account: account, synchronizable: kSecAttrSynchronizableAny)
lookup[kSecReturnData as String] = true
lookup[kSecMatchLimit as String] = kSecMatchLimitOne
var item: CFTypeRef?
guard SecItemCopyMatching(lookup as CFDictionary, &item) == errSecSuccess,
let data = item as? Data else { return nil }
return String(data: data, encoding: .utf8)
} }
static func saveProjectToken(_ token: String, serverID: UUID, projectKey: String) throws { static func saveProjectToken(_ token: String, serverID: UUID, projectKey: String) throws {
let clean = token.trimmingCharacters(in: .whitespacesAndNewlines) try saveLocal(token, account: "project|\(serverID.uuidString)|\(projectKey)")
guard !clean.isEmpty else { throw KeychainError.emptyToken }
let account = "project|\(serverID.uuidString)|\(projectKey)"
let data = Data(clean.utf8)
SecItemDelete(query(account: account, synchronizable: kSecAttrSynchronizableAny) as CFDictionary)
var add = query(account: account, synchronizable: kCFBooleanTrue)
add[kSecValueData as String] = data
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
let status = SecItemAdd(add as CFDictionary, nil)
guard status == errSecSuccess else { throw KeychainError.status(status) }
} }
static func projectToken(serverID: UUID, projectKey: String) -> String? { static func projectToken(serverID: UUID, projectKey: String) -> String? {
let account = "project|\(serverID.uuidString)|\(projectKey)" readWithLegacyMigration(account: "project|\(serverID.uuidString)|\(projectKey)")
var lookup = query(account: account, synchronizable: kSecAttrSynchronizableAny)
lookup[kSecReturnData as String] = true
lookup[kSecMatchLimit as String] = kSecMatchLimitOne
var item: CFTypeRef?
guard SecItemCopyMatching(lookup as CFDictionary, &item) == errSecSuccess, let data = item as? Data else { return nil }
return String(data: data, encoding: .utf8)
} }
static func deleteToken(serverID: UUID) { static func deleteToken(serverID: UUID) {
SecItemDelete(query(account: serverID.uuidString, synchronizable: kSecAttrSynchronizableAny) as CFDictionary) deleteAll(account: serverID.uuidString)
} }
private static func query(account: String, synchronizable: CFTypeRef) -> [String: Any] { private static func query(account: String, synchronizable: CFTypeRef) -> [String: Any] {
@@ -65,6 +30,47 @@ enum KeychainService {
kSecAttrAccount as String: account, kSecAttrAccount as String: account,
kSecAttrSynchronizable as String: synchronizable] kSecAttrSynchronizable as String: synchronizable]
} }
private static func saveLocal(_ value: String, account: String) throws {
let clean = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !clean.isEmpty else { throw KeychainError.emptyToken }
let data = Data(clean.utf8)
let localQuery = query(account: account, synchronizable: kCFBooleanFalse)
let updateStatus = SecItemUpdate(localQuery as CFDictionary,
[kSecValueData as String: data] as CFDictionary)
if updateStatus == errSecSuccess { return }
guard updateStatus == errSecItemNotFound else { throw KeychainError.status(updateStatus) }
var add = localQuery
add[kSecValueData as String] = data
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
let status = SecItemAdd(add as CFDictionary, nil)
guard status == errSecSuccess else { throw KeychainError.status(status) }
}
private static func readWithLegacyMigration(account: String) -> String? {
if let local = read(account: account, synchronizable: kCFBooleanFalse) { return local }
guard let legacy = read(account: account, synchronizable: kSecAttrSynchronizableAny) else { return nil }
// Best-effort migration from older iCloud/synchronizable Keychain entries.
try? saveLocal(legacy, account: account)
return legacy
}
private static func read(account: String, synchronizable: CFTypeRef) -> String? {
var lookup = query(account: account, synchronizable: synchronizable)
lookup[kSecReturnData as String] = true
lookup[kSecMatchLimit as String] = kSecMatchLimitOne
var item: CFTypeRef?
guard SecItemCopyMatching(lookup as CFDictionary, &item) == errSecSuccess,
let data = item as? Data,
let value = String(data: data, encoding: .utf8),
!value.isEmpty else { return nil }
return value
}
private static func deleteAll(account: String) {
SecItemDelete(query(account: account, synchronizable: kSecAttrSynchronizableAny) as CFDictionary)
}
} }
enum KeychainError: LocalizedError { enum KeychainError: LocalizedError {
@@ -79,3 +85,19 @@ enum KeychainError: LocalizedError {
} }
} }
} }
extension KeychainService {
private static let licenseTokenAccount = "sonardesk.license-token"
static func saveLicenseToken(_ token: String) throws {
try saveLocal(token, account: licenseTokenAccount)
}
static func licenseToken() -> String? {
readWithLegacyMigration(account: licenseTokenAccount)
}
static func deleteLicenseToken() {
deleteAll(account: licenseTokenAccount)
}
}
+170
View File
@@ -0,0 +1,170 @@
import Foundation
import IOKit
struct LicenseStatus: Equatable {
let valid: Bool
let edition: String?
let expiresAt: Date?
let message: String?
}
private struct ActivateRequest: Encodable {
let licenseKey: String
let installationID: String
let appVersion: String
let platform = "macOS"
}
private struct ValidateRequest: Encodable {
let licenseToken: String
let installationID: String
let appVersion: String
let platform = "macOS"
}
private struct DeactivateRequest: Encodable {
let licenseToken: String
let installationID: String
}
private struct TokenResponse: Decodable {
let active: Bool?
let licenseToken: String?
}
private struct TokenPayload: Decodable {
let edition: String?
let expiresAt: String?
let installationID: String?
}
enum LicenseEnvironment {
static let baseURL: URL = {
guard let value = Bundle.main.object(forInfoDictionaryKey: "SonarDeskLicenseBaseURL") as? String,
let url = URL(string: value),
url.scheme == "https" else {
fatalError("SonarDeskLicenseBaseURL is missing or invalid for this build configuration.")
}
return url
}()
static let displayName: String = {
Bundle.main.object(forInfoDictionaryKey: "SonarDeskEnvironment") as? String ?? "Unknown"
}()
}
actor LicenseService {
static let shared = LicenseService()
private let session: URLSession
init() {
let configuration = URLSessionConfiguration.ephemeral
configuration.timeoutIntervalForRequest = 15
configuration.timeoutIntervalForResource = 30
configuration.waitsForConnectivity = false
session = URLSession(configuration: configuration)
}
func activate(key: String) async throws -> (LicenseStatus, String) {
let response: TokenResponse = try await post(path: "/api/v1/activate", payload: ActivateRequest(
licenseKey: key,
installationID: MachineIdentity.id,
appVersion: Self.appVersion
))
guard let token = response.licenseToken else { throw LicenseError.invalidResponse }
return (try status(from: token), token)
}
func refresh(token: String) async throws -> (LicenseStatus, String) {
let response: TokenResponse = try await post(path: "/api/v1/validate", payload: ValidateRequest(
licenseToken: token,
installationID: MachineIdentity.id,
appVersion: Self.appVersion
))
guard response.active == true, let refreshed = response.licenseToken else {
throw LicenseError.server("This licence is not active.")
}
return (try status(from: refreshed), refreshed)
}
func deactivate(token: String) async throws {
let _: EmptyResponse = try await post(path: "/api/v1/deactivate", payload: DeactivateRequest(
licenseToken: token,
installationID: MachineIdentity.id
))
}
func cachedStatus(token: String) throws -> LicenseStatus { try status(from: token) }
private func post<T: Encodable, R: Decodable>(path: String, payload: T) async throws -> R {
var request = URLRequest(url: LicenseEnvironment.baseURL.appendingPathComponent(path))
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.httpBody = try JSONEncoder().encode(payload)
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else { throw LicenseError.invalidResponse }
guard (200..<300).contains(http.statusCode) else {
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
let message = object?["message"] as? String
?? ((object?["errors"] as? [String: [String]])?.values.first?.first)
throw LicenseError.server(message ?? "Licence server returned HTTP \(http.statusCode).")
}
do { return try JSONDecoder().decode(R.self, from: data) }
catch { throw LicenseError.invalidResponse }
}
private func status(from token: String) throws -> LicenseStatus {
let parts = token.split(separator: ".", omittingEmptySubsequences: false)
guard parts.count == 3, parts[0] == "RS3", let data = Self.base64URLData(String(parts[1])) else {
throw LicenseError.invalidResponse
}
let payload = try JSONDecoder().decode(TokenPayload.self, from: data)
guard payload.installationID?.lowercased() == MachineIdentity.id.lowercased() else {
throw LicenseError.server("This licence belongs to another Mac installation.")
}
let expires = payload.expiresAt.flatMap { ISO8601DateFormatter().date(from: $0) }
return LicenseStatus(valid: expires.map { $0 > Date() } ?? true, edition: payload.edition, expiresAt: expires, message: nil)
}
private static var appVersion: String {
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0"
}
private static func base64URLData(_ value: String) -> Data? {
var base64 = value.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
base64 += String(repeating: "=", count: (4 - base64.count % 4) % 4)
return Data(base64Encoded: base64)
}
}
private struct EmptyResponse: Decodable {}
enum MachineIdentity {
static let id: String = {
let service = IOServiceGetMatchingService(kIOMainPortDefault, IOServiceMatching("IOPlatformExpertDevice"))
defer { if service != 0 { IOObjectRelease(service) } }
if service != 0,
let value = IORegistryEntryCreateCFProperty(service, "IOPlatformUUID" as CFString, kCFAllocatorDefault, 0)?.takeRetainedValue() as? String,
UUID(uuidString: value) != nil { return value.lowercased() }
// The API requires a UUID. Persisted fallback prevents a transient IOKit failure
// from creating a different installation identity on every launch.
let key = "SonarDeskInstallationID"
if let existing = UserDefaults.standard.string(forKey: key), UUID(uuidString: existing) != nil { return existing.lowercased() }
let generated = UUID().uuidString.lowercased()
UserDefaults.standard.set(generated, forKey: key)
return generated
}()
}
enum LicenseError: LocalizedError {
case invalidResponse
case server(String)
var errorDescription: String? {
switch self {
case .invalidResponse: return "The licence server returned an invalid response."
case .server(let message): return message
}
}
}
@@ -0,0 +1,188 @@
import AppKit
import CoreGraphics
import Foundation
import UniformTypeIdentifiers
@MainActor
enum ProjectPDFReportExporter {
static func export(project: ProjectSnapshot, issues: [Issue], serverURL: String, serverVersion: String?) throws {
let panel = NSSavePanel()
panel.canCreateDirectories = true
panel.allowedContentTypes = [.pdf]
panel.nameFieldStringValue = "SonarDesk-\(safe(project.project.name))-\(dateStamp()).pdf"
guard panel.runModal() == .OK, let url = panel.url else { return }
try makePDF(project: project, issues: issues, serverURL: serverURL, serverVersion: serverVersion).write(to: url, options: .atomic)
}
static func makePDF(project: ProjectSnapshot, issues: [Issue], serverURL: String, serverVersion: String?) throws -> Data {
let data = NSMutableData()
guard let consumer = CGDataConsumer(data: data as CFMutableData) else { throw ExportError.couldNotCreatePDF }
var mediaBox = CGRect(x: 0, y: 0, width: 595, height: 842)
guard let context = CGContext(consumer: consumer, mediaBox: &mediaBox, nil) else { throw ExportError.couldNotCreatePDF }
let writer = Writer(context: context, page: mediaBox)
writer.begin(title: "Project Quality Report", project: project.project.name)
writer.heading("Executive Summary")
writer.paragraph(summary(project: project, issues: issues))
writer.metricGrid([
("Quality Gate", project.qualityGate == "OK" ? "PASSED" : project.qualityGate),
("Bugs", metric(project, "bugs")),
("Vulnerabilities", metric(project, "vulnerabilities")),
("Code Smells", metric(project, "code_smells")),
("Security Hotspots", metric(project, "security_hotspots")),
("Coverage", metric(project, "coverage") + "%"),
("Duplications", metric(project, "duplicated_lines_density") + "%"),
("Lines of Code", metric(project, "ncloc"))
])
writer.heading("Issue Overview")
let severities = ["BLOCKER", "CRITICAL", "MAJOR", "MINOR", "INFO"].map { severity in
(severity.capitalized, issues.filter { ($0.severity ?? "").uppercased() == severity }.count)
}
writer.barChart(severities)
writer.smallText("Open issues exported: \(issues.count). Counts and classifications are supplied by SonarQube.")
writer.heading("Project Information")
writer.keyValues([
("Project", project.project.name), ("Project key", project.project.key),
("SonarQube server", serverURL), ("SonarQube version", serverVersion ?? "Unknown"),
("Last analysis", project.project.lastAnalysisDate ?? "Unknown"),
("Report generated", Date().formatted(date: .long, time: .shortened))
])
writer.heading("Detailed Findings")
if issues.isEmpty {
writer.paragraph("No unresolved issues were returned by SonarQube for this project.")
} else {
for (index, issue) in sorted(issues).enumerated() {
writer.issue(index: index + 1, issue: issue)
}
}
writer.heading("About this report")
writer.paragraph("This report was generated locally by SonarDesk from data returned by the connected SonarQube server. SonarDesk has not independently audited the source code. Findings, ratings and quality-gate results should be interpreted in the context of the SonarQube configuration and analysis used for this project.")
writer.finish()
context.closePDF()
return data as Data
}
private static func metric(_ p: ProjectSnapshot, _ key: String) -> String { p.measures[key] ?? "0" }
private static func safe(_ value: String) -> String { value.replacingOccurrences(of: "/", with: "-").replacingOccurrences(of: ":", with: "-") }
private static func dateStamp() -> String { let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"; return f.string(from: Date()) }
private static func sorted(_ issues: [Issue]) -> [Issue] {
let rank = ["BLOCKER": 0, "CRITICAL": 1, "MAJOR": 2, "MINOR": 3, "INFO": 4]
return issues.sorted { (rank[$0.severity?.uppercased() ?? ""] ?? 99) < (rank[$1.severity?.uppercased() ?? ""] ?? 99) }
}
private static func summary(project: ProjectSnapshot, issues: [Issue]) -> String {
let gate = project.qualityGate == "OK" ? "passed" : project.qualityGate.lowercased()
return "The SonarQube quality gate is \(gate). The current analysis reports \(metric(project, "bugs")) bugs, \(metric(project, "vulnerabilities")) vulnerabilities, \(metric(project, "code_smells")) code smells and \(metric(project, "security_hotspots")) security hotspots. Test coverage is \(metric(project, "coverage"))% and duplicated-lines density is \(metric(project, "duplicated_lines_density"))%. This report contains \(issues.count) unresolved findings returned by SonarQube."
}
enum ExportError: LocalizedError { case couldNotCreatePDF; var errorDescription: String? { "SonarDesk could not create the PDF report." } }
private final class Writer {
private let context: CGContext
private let page: CGRect
private let margin: CGFloat = 42
private var y: CGFloat = 0
private var pageNumber = 0
private var projectName = ""
private let accent = NSColor(calibratedRed: 0.95, green: 0.42, blue: 0.12, alpha: 1)
init(context: CGContext, page: CGRect) { self.context = context; self.page = page }
func begin(title: String, project: String) {
projectName = project; newPage()
text("SONARDESK", x: margin, y: y, width: 220, font: .boldSystemFont(ofSize: 13), color: accent); y += 28
text(title, x: margin, y: y, width: page.width - margin * 2, font: .boldSystemFont(ofSize: 30)); y += 42
text(project, x: margin, y: y, width: page.width - margin * 2, font: .systemFont(ofSize: 17), color: .darkGray); y += 36
rule(); y += 18
}
func finish() {
footer()
endPage()
}
private func newPage() {
if pageNumber > 0 {
footer()
endPage()
}
context.beginPDFPage(nil)
context.saveGState()
// Quartz PDF pages use a bottom-left origin. The report layout below is
// intentionally expressed in normal AppKit top-left coordinates. Flip
// the PDF context once per page so NSString/NSBezierPath drawing lands
// on the page instead of being rendered outside/inverted.
context.translateBy(x: 0, y: page.height)
context.scaleBy(x: 1, y: -1)
NSGraphicsContext.current = NSGraphicsContext(cgContext: context, flipped: true)
pageNumber += 1
y = 52
if pageNumber > 1 {
text("SONARDESK • \(projectName)", x: margin, y: 25, width: page.width - margin * 2, font: .boldSystemFont(ofSize: 8), color: .gray)
}
}
private func endPage() {
NSGraphicsContext.current = nil
context.restoreGState()
context.endPDFPage()
}
private func footer() {
rule(at: page.height - 37)
text("Generated by SonarDesk • SonarQube analysis data", x: margin, y: page.height - 27, width: 360, font: .systemFont(ofSize: 7.5), color: .gray)
text("Page \(pageNumber)", x: page.width - margin - 60, y: page.height - 27, width: 60, font: .systemFont(ofSize: 7.5), color: .gray, align: .right)
}
private func ensure(_ height: CGFloat) { if y + height > page.height - 58 { newPage(); y = 55 } }
func heading(_ value: String) { ensure(44); y += 10; text(value, x: margin, y: y, width: page.width - margin * 2, font: .boldSystemFont(ofSize: 17)); y += 30 }
func paragraph(_ value: String) { let h = measured(value, width: page.width - margin * 2, font: .systemFont(ofSize: 10.5)) + 8; ensure(h); text(value, x: margin, y: y, width: page.width - margin * 2, font: .systemFont(ofSize: 10.5), color: .darkGray); y += h }
func smallText(_ value: String) { ensure(28); text(value, x: margin, y: y, width: page.width - margin * 2, font: .systemFont(ofSize: 8.5), color: .gray); y += 25 }
func metricGrid(_ values: [(String,String)]) {
let w = (page.width - margin * 2 - 12) / 2; let h: CGFloat = 56
for i in stride(from: 0, to: values.count, by: 2) {
ensure(h + 10)
metricBox(values[i], x: margin, width: w, height: h)
if i + 1 < values.count { metricBox(values[i+1], x: margin + w + 12, width: w, height: h) }
y += h + 10
}
}
private func metricBox(_ value: (String,String), x: CGFloat, width: CGFloat, height: CGFloat) {
NSColor(calibratedWhite: 0.96, alpha: 1).setFill(); NSBezierPath(roundedRect: CGRect(x: x, y: y, width: width, height: height), xRadius: 7, yRadius: 7).fill()
text(value.0.uppercased(), x: x + 12, y: y + 10, width: width - 24, font: .boldSystemFont(ofSize: 7.5), color: .gray)
text(value.1, x: x + 12, y: y + 27, width: width - 24, font: .boldSystemFont(ofSize: 16))
}
func barChart(_ values: [(String,Int)]) {
ensure(CGFloat(values.count) * 30 + 15); let maxValue = max(1, values.map(\.1).max() ?? 1); let chartW = page.width - margin * 2 - 120
for (label, value) in values {
text(label, x: margin, y: y + 3, width: 75, font: .systemFont(ofSize: 9))
NSColor(calibratedWhite: 0.92, alpha: 1).setFill(); NSBezierPath(roundedRect: CGRect(x: margin + 82, y: y + 4, width: chartW, height: 12), xRadius: 3, yRadius: 3).fill()
if value > 0 { accent.setFill(); NSBezierPath(roundedRect: CGRect(x: margin + 82, y: y + 4, width: chartW * CGFloat(value) / CGFloat(maxValue), height: 12), xRadius: 3, yRadius: 3).fill() }
text("\(value)", x: page.width - margin - 30, y: y + 2, width: 30, font: .boldSystemFont(ofSize: 9), align: .right); y += 28
}; y += 4
}
func keyValues(_ values: [(String,String)]) {
for value in values { ensure(25); text(value.0, x: margin, y: y, width: 110, font: .boldSystemFont(ofSize: 9), color: .gray); text(value.1, x: margin + 120, y: y, width: page.width - margin * 2 - 120, font: .systemFont(ofSize: 9)); y += 22 }
}
func issue(index: Int, issue: Issue) {
let message = issue.message ?? "Issue"; let details = "\(issue.type ?? "Unknown") • \(issue.severity ?? "Unknown") • \(issue.status ?? "Unknown")"
let location = [issue.component, issue.line.map { "line \($0)" }].compactMap { $0 }.joined(separator: " • ")
let needed = max(88, measured(message, width: page.width - margin * 2 - 24, font: .boldSystemFont(ofSize: 11)) + 68); ensure(needed)
NSColor(calibratedWhite: 0.975, alpha: 1).setFill(); NSBezierPath(roundedRect: CGRect(x: margin, y: y, width: page.width - margin * 2, height: needed - 8), xRadius: 6, yRadius: 6).fill()
text("\(index). \(message)", x: margin + 12, y: y + 10, width: page.width - margin * 2 - 24, font: .boldSystemFont(ofSize: 11))
text(details, x: margin + 12, y: y + 34, width: page.width - margin * 2 - 24, font: .boldSystemFont(ofSize: 8), color: accent)
text(location.isEmpty ? "Location unavailable" : location, x: margin + 12, y: y + 50, width: page.width - margin * 2 - 24, font: .systemFont(ofSize: 8.5), color: .darkGray)
text("Rule: \(issue.rule ?? "Unknown") • Effort: \(issue.effort ?? "Unknown")", x: margin + 12, y: y + 66, width: page.width - margin * 2 - 24, font: .systemFont(ofSize: 8), color: .gray)
y += needed
}
private func rule(at value: CGFloat? = nil) { NSColor(calibratedWhite: 0.85, alpha: 1).setStroke(); let p = NSBezierPath(); p.move(to: CGPoint(x: margin, y: value ?? y)); p.line(to: CGPoint(x: page.width - margin, y: value ?? y)); p.lineWidth = 0.6; p.stroke() }
private func measured(_ value: String, width: CGFloat, font: NSFont) -> CGFloat { (value as NSString).boundingRect(with: CGSize(width: width, height: 10_000), options: [.usesLineFragmentOrigin, .usesFontLeading], attributes: [.font: font]).height }
private func text(_ value: String, x: CGFloat, y: CGFloat, width: CGFloat, font: NSFont, color: NSColor = .black, align: NSTextAlignment = .left) {
let style = NSMutableParagraphStyle(); style.alignment = align; style.lineBreakMode = .byWordWrapping
(value as NSString).draw(with: CGRect(x: x, y: y, width: width, height: 10_000), options: [.usesLineFragmentOrigin, .usesFontLeading], attributes: [.font: font, .foregroundColor: color, .paragraphStyle: style])
}
}
}
+139 -41
View File
@@ -14,12 +14,15 @@ final class AppState: ObservableObject {
@Published var lastRefresh: Date? @Published var lastRefresh: Date?
@Published var serverVersion: String? @Published var serverVersion: String?
private let projectOrderKey = "projectOrder" private let projectOrderKey = "projectOrder"
private let localServersKey = "SonarDesk.servers.v2"
private let localPreferencesKey = "SonarDesk.preferences.v2"
private let driveStore = ICloudDriveStore.shared private let driveStore = ICloudDriveStore.shared
private var isLoadingPersistentData = false private var isLoadingPersistentData = false
private struct PreferencesFile: Codable { private struct PreferencesFile: Codable {
var selectedServerID: UUID? var selectedServerID: UUID?
var projectOrder: [String] var projectOrder: [String]
var projectAliases: [String: String]?
} }
var selectedServer: SonarServer? { servers.first { $0.id == selectedServerID } } var selectedServer: SonarServer? { servers.first { $0.id == selectedServerID } }
@@ -154,6 +157,34 @@ final class AppState: ObservableObject {
} }
func projectDisplayName(for project: Project) -> String {
let key = projectAliasStorageKey(projectKey: project.key)
let alias = localPreferences()?.projectAliases?[key]?.trimmingCharacters(in: .whitespacesAndNewlines)
return alias?.isEmpty == false ? alias! : project.name
}
func setProjectAlias(_ alias: String, for projectKey: String) {
let clean = alias.trimmingCharacters(in: .whitespacesAndNewlines)
var aliases = localPreferences()?.projectAliases ?? [:]
let key = projectAliasStorageKey(projectKey: projectKey)
if clean.isEmpty { aliases.removeValue(forKey: key) } else { aliases[key] = clean }
savePreferences(projectAliases: aliases)
objectWillChange.send()
}
func resetProjectAlias(for projectKey: String) {
setProjectAlias("", for: projectKey)
}
func projectAlias(for projectKey: String) -> String? {
let alias = localPreferences()?.projectAliases?[projectAliasStorageKey(projectKey: projectKey)]
return alias?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? alias : nil
}
private func projectAliasStorageKey(projectKey: String) -> String {
"\(selectedServerID?.uuidString ?? "unknown")|\(projectKey)"
}
func moveProjects(from source: IndexSet, to destination: Int) { func moveProjects(from source: IndexSet, to destination: Int) {
projects.move(fromOffsets: source, toOffset: destination) projects.move(fromOffsets: source, toOffset: destination)
saveProjectOrder() saveProjectOrder()
@@ -211,8 +242,19 @@ final class AppState: ObservableObject {
private func saveServers() { private func saveServers() {
guard !isLoadingPersistentData else { return } guard !isLoadingPersistentData else { return }
do { try driveStore.write(servers, fileName: "servers.json") } do {
catch { errorMessage = error.localizedDescription } let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
UserDefaults.standard.set(try encoder.encode(servers), forKey: localServersKey)
} catch {
errorMessage = error.localizedDescription
return
}
// iCloud is a mirror, not the source of truth required for app startup.
// A temporarily unavailable iCloud container must never make SonarDesk
// appear to have forgotten its configured SonarQube server.
try? driveStore.write(servers, fileName: "servers.json")
} }
private func saveSelectedServer() { private func saveSelectedServer() {
@@ -220,64 +262,120 @@ final class AppState: ObservableObject {
savePreferences() savePreferences()
} }
private func savePreferences(projectOrder: [String]? = nil) { private func savePreferences(projectOrder: [String]? = nil, projectAliases: [String: String]? = nil) {
let existing = localPreferences()
let preferences = PreferencesFile(
selectedServerID: selectedServerID,
projectOrder: projectOrder ?? existing?.projectOrder ?? [],
projectAliases: projectAliases ?? existing?.projectAliases ?? [:]
)
do { do {
let existing = try driveStore.read(PreferencesFile.self, fileName: "preferences.json") let encoder = JSONEncoder()
let preferences = PreferencesFile( UserDefaults.standard.set(try encoder.encode(preferences), forKey: localPreferencesKey)
selectedServerID: selectedServerID, } catch {
projectOrder: projectOrder ?? existing?.projectOrder ?? [] errorMessage = error.localizedDescription
) return
try driveStore.write(preferences, fileName: "preferences.json") }
} catch { errorMessage = error.localizedDescription }
try? driveStore.write(preferences, fileName: "preferences.json")
} }
private func loadDrivePreferences() { private func loadDrivePreferences() {
isLoadingPersistentData = true isLoadingPersistentData = true
defer { isLoadingPersistentData = false } defer { isLoadingPersistentData = false }
// Local data is deliberately authoritative. iCloud is used to seed a new
// installation and as a mirror, but its availability is not required to launch.
var decoded = localServers()
if decoded.isEmpty {
decoded = (try? driveStore.read([SonarServer].self, fileName: "servers.json")) ?? []
}
var seen = Set<String>()
for index in decoded.indices {
decoded[index].baseURL = decoded[index].baseURL.replacingOccurrences(
of: "sonacube.absdev.net", with: "sonarcube.absdev.net", options: [.caseInsensitive]
)
}
servers = decoded.filter { server in
let key = server.baseURL.trimmingCharacters(in: CharacterSet(charactersIn: "/")).lowercased()
return seen.insert(key).inserted
}
let preferences = localPreferences()
?? (try? driveStore.read(PreferencesFile.self, fileName: "preferences.json"))
selectedServerID = preferences?.selectedServerID
if selectedServerID == nil || !servers.contains(where: { $0.id == selectedServerID }) {
selectedServerID = servers.first?.id
}
// Persist the recovered/normalised state locally first, then mirror to iCloud.
do { do {
var decoded = try driveStore.read([SonarServer].self, fileName: "servers.json") ?? [] let encoder = JSONEncoder()
var seen = Set<String>() encoder.dateEncodingStrategy = .iso8601
for index in decoded.indices { UserDefaults.standard.set(try encoder.encode(servers), forKey: localServersKey)
decoded[index].baseURL = decoded[index].baseURL.replacingOccurrences( let repaired = PreferencesFile(selectedServerID: selectedServerID,
of: "sonacube.absdev.net", with: "sonarcube.absdev.net", options: [.caseInsensitive] projectOrder: preferences?.projectOrder ?? [],
) projectAliases: preferences?.projectAliases ?? [:])
} UserDefaults.standard.set(try encoder.encode(repaired), forKey: localPreferencesKey)
servers = decoded.filter { server in try? driveStore.write(servers, fileName: "servers.json")
let key = server.baseURL.trimmingCharacters(in: CharacterSet(charactersIn: "/")).lowercased() try? driveStore.write(repaired, fileName: "preferences.json")
return seen.insert(key).inserted
}
let preferences = try driveStore.read(PreferencesFile.self, fileName: "preferences.json")
selectedServerID = preferences?.selectedServerID
if selectedServerID == nil || !servers.contains(where: { $0.id == selectedServerID }) {
selectedServerID = servers.first?.id
}
// Persist repairs/deduplication back to the visible files.
try driveStore.write(servers, fileName: "servers.json")
try driveStore.write(PreferencesFile(selectedServerID: selectedServerID, projectOrder: preferences?.projectOrder ?? []), fileName: "preferences.json")
} catch { } catch {
errorMessage = error.localizedDescription errorMessage = error.localizedDescription
} }
} }
private func migrateLegacyPreferencesToICloudDriveIfNeeded() { private func migrateLegacyPreferencesToICloudDriveIfNeeded() {
// If the v2 local store already exists there is nothing to migrate.
if UserDefaults.standard.data(forKey: localServersKey) != nil { return }
let cloudStore = NSUbiquitousKeyValueStore.default
cloudStore.synchronize()
let defaults = UserDefaults.standard
var legacyServers: [SonarServer] = []
if let data = cloudStore.data(forKey: "servers") ?? defaults.data(forKey: "servers"),
let decoded = try? JSONDecoder().decode([SonarServer].self, from: data) {
legacyServers = decoded
} else if let driveServers = try? driveStore.read([SonarServer].self, fileName: "servers.json") {
legacyServers = driveServers
}
let selectedRaw = cloudStore.string(forKey: "selectedServer") ?? defaults.string(forKey: "selectedServer")
let selected = selectedRaw.flatMap(UUID.init(uuidString:))
let drivePreferences = try? driveStore.read(PreferencesFile.self, fileName: "preferences.json")
let order = (cloudStore.array(forKey: projectOrderKey) as? [String])
?? defaults.stringArray(forKey: projectOrderKey)
?? drivePreferences?.projectOrder
?? []
let effectiveSelected = selected ?? drivePreferences?.selectedServerID
do { do {
if try driveStore.read([SonarServer].self, fileName: "servers.json") != nil { return } let encoder = JSONEncoder()
let cloudStore = NSUbiquitousKeyValueStore.default encoder.dateEncodingStrategy = .iso8601
cloudStore.synchronize() UserDefaults.standard.set(try encoder.encode(legacyServers), forKey: localServersKey)
let defaults = UserDefaults.standard UserDefaults.standard.set(
var legacyServers: [SonarServer] = [] try encoder.encode(PreferencesFile(selectedServerID: effectiveSelected, projectOrder: order, projectAliases: drivePreferences?.projectAliases ?? [:])),
if let data = cloudStore.data(forKey: "servers") ?? defaults.data(forKey: "servers"), forKey: localPreferencesKey
let decoded = try? JSONDecoder().decode([SonarServer].self, from: data) { legacyServers = decoded } )
let selectedRaw = cloudStore.string(forKey: "selectedServer") ?? defaults.string(forKey: "selectedServer")
let selected = selectedRaw.flatMap(UUID.init(uuidString:))
let order = (cloudStore.array(forKey: projectOrderKey) as? [String]) ?? defaults.stringArray(forKey: projectOrderKey) ?? []
try driveStore.write(legacyServers, fileName: "servers.json")
try driveStore.write(PreferencesFile(selectedServerID: selected, projectOrder: order), fileName: "preferences.json")
} catch { } catch {
errorMessage = error.localizedDescription errorMessage = error.localizedDescription
} }
} }
private func localServers() -> [SonarServer] {
guard let data = UserDefaults.standard.data(forKey: localServersKey) else { return [] }
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return (try? decoder.decode([SonarServer].self, from: data)) ?? []
}
private func localPreferences() -> PreferencesFile? {
guard let data = UserDefaults.standard.data(forKey: localPreferencesKey) else { return nil }
return try? JSONDecoder().decode(PreferencesFile.self, from: data)
}
private func cleanServerName(_ name: String) -> String { private func cleanServerName(_ name: String) -> String {
let clean = name.trimmingCharacters(in: .whitespacesAndNewlines) let clean = name.trimmingCharacters(in: .whitespacesAndNewlines)
+122
View File
@@ -0,0 +1,122 @@
import Foundation
@MainActor
final class LicenseStore: ObservableObject {
@Published private(set) var status: LicenseStatus?
@Published private(set) var isWorking = false
@Published private(set) var hasChecked = false
@Published var errorMessage: String?
var hasStoredLicense: Bool { KeychainService.licenseToken() != nil }
var isLicensed: Bool { status?.valid == true }
func activate(_ key: String) async {
let clean = key.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
guard !clean.isEmpty else {
errorMessage = "Enter your SonarDesk licence key."
return
}
isWorking = true
errorMessage = nil
defer {
isWorking = false
hasChecked = true
}
do {
let (result, token) = try await LicenseService.shared.activate(key: clean)
try KeychainService.saveLicenseToken(token)
status = result
} catch {
errorMessage = error.localizedDescription
}
}
func replaceLicense(with key: String) async -> Bool {
let clean = key.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
guard !clean.isEmpty else {
errorMessage = "Enter your new SonarDesk licence key."
return false
}
let previousToken = KeychainService.licenseToken()
isWorking = true
errorMessage = nil
defer {
isWorking = false
hasChecked = true
}
do {
// Validate the replacement before touching the currently working licence.
let (result, newToken) = try await LicenseService.shared.activate(key: clean)
try KeychainService.saveLicenseToken(newToken)
status = result
// Once the replacement is safely stored, release the old activation.
// Failure here must not undo a successfully installed replacement licence.
if let previousToken, previousToken != newToken {
try? await LicenseService.shared.deactivate(token: previousToken)
}
return true
} catch {
errorMessage = error.localizedDescription
return false
}
}
func refresh() async {
guard let token = KeychainService.licenseToken() else {
status = nil
hasChecked = true
return
}
// Always restore the locally signed status before attempting the network.
// This keeps the application licensed while the validation request is in flight.
if let cached = try? await LicenseService.shared.cachedStatus(token: token) {
status = cached
}
isWorking = true
errorMessage = nil
defer {
isWorking = false
hasChecked = true
}
do {
let (result, refreshedToken) = try await LicenseService.shared.refresh(token: token)
try KeychainService.saveLicenseToken(refreshedToken)
status = result
} catch {
// Do NOT clear status or delete the token here. A failed Check Licence can
// be caused by networking, DNS, TLS, the licensing server, or a transient
// server error. Explicit deactivation is the only path that removes the
// persisted activation.
errorMessage = error.localizedDescription
}
}
func deactivate() async {
guard let token = KeychainService.licenseToken() else { return }
isWorking = true
errorMessage = nil
defer {
isWorking = false
hasChecked = true
}
do {
try await LicenseService.shared.deactivate(token: token)
KeychainService.deleteLicenseToken()
status = nil
} catch {
errorMessage = error.localizedDescription
}
}
}
@@ -15,7 +15,7 @@ struct DashboardView: View {
private var failed: Int { app.projects.filter { $0.qualityGate != "OK" }.count } private var failed: Int { app.projects.filter { $0.qualityGate != "OK" }.count }
private var filtered: [ProjectSnapshot] { private var filtered: [ProjectSnapshot] {
app.projects.filter { p in app.projects.filter { p in
(!failedOnly || p.qualityGate != "OK") && (search.isEmpty || p.project.name.localizedCaseInsensitiveContains(search) || p.project.key.localizedCaseInsensitiveContains(search)) (!failedOnly || p.qualityGate != "OK") && (search.isEmpty || app.projectDisplayName(for: p.project).localizedCaseInsensitiveContains(search) || p.project.key.localizedCaseInsensitiveContains(search))
} }
} }
private var pageCount: Int { max(1, Int(ceil(Double(filtered.count) / Double(pageSize)))) } private var pageCount: Int { max(1, Int(ceil(Double(filtered.count) / Double(pageSize)))) }
@@ -79,7 +79,7 @@ struct DashboardView: View {
SonarPanel { VStack(alignment:.leading,spacing:10) { SonarPanel { VStack(alignment:.leading,spacing:10) {
Text("Project issue load").font(.headline) Text("Project issue load").font(.headline)
Chart(app.projects.prefix(8)) { item in Chart(app.projects.prefix(8)) { item in
BarMark(x:.value("Project",item.project.name), y:.value("Issues", (Int(item.measures["bugs"] ?? "0") ?? 0) + (Int(item.measures["vulnerabilities"] ?? "0") ?? 0) + (Int(item.measures["code_smells"] ?? "0") ?? 0))) BarMark(x:.value("Project",app.projectDisplayName(for: item.project)), y:.value("Issues", (Int(item.measures["bugs"] ?? "0") ?? 0) + (Int(item.measures["vulnerabilities"] ?? "0") ?? 0) + (Int(item.measures["code_smells"] ?? "0") ?? 0)))
.foregroundStyle(SonarTheme.accent.gradient).cornerRadius(4) .foregroundStyle(SonarTheme.accent.gradient).cornerRadius(4)
}.chartXAxis { AxisMarks { value in AxisValueLabel { if let text=value.as(String.self){Text(text).lineLimit(1).font(.caption2)} } } }.frame(height:220) }.chartXAxis { AxisMarks { value in AxisValueLabel { if let text=value.as(String.self){Text(text).lineLimit(1).font(.caption2)} } } }.frame(height:220)
}.padding(16) }.frame(maxWidth:.infinity) }.padding(16) }.frame(maxWidth:.infinity)
@@ -94,7 +94,7 @@ struct DashboardView: View {
SonarPanel { VStack(alignment:.leading,spacing:10) { SonarPanel { VStack(alignment:.leading,spacing:10) {
Text("Coverage by project").font(.headline) Text("Coverage by project").font(.headline)
Chart(app.projects.prefix(8)) { item in Chart(app.projects.prefix(8)) { item in
BarMark(x:.value("Project",item.project.name), y:.value("Coverage",Double(item.measures["coverage"] ?? "0") ?? 0)).foregroundStyle(SonarTheme.success.gradient).cornerRadius(4) BarMark(x:.value("Project",app.projectDisplayName(for: item.project)), y:.value("Coverage",Double(item.measures["coverage"] ?? "0") ?? 0)).foregroundStyle(SonarTheme.success.gradient).cornerRadius(4)
}.chartYScale(domain:0...100).chartYAxis { AxisMarks(position:.leading) { v in AxisGridLine(); AxisValueLabel { if let n=v.as(Double.self){Text("\(Int(n))%")}} } }.frame(height:180) }.chartYScale(domain:0...100).chartYAxis { AxisMarks(position:.leading) { v in AxisGridLine(); AxisValueLabel { if let n=v.as(Double.self){Text("\(Int(n))%")}} } }.frame(height:180)
}.padding(16) }.frame(maxWidth:.infinity) }.padding(16) }.frame(maxWidth:.infinity)
} }
@@ -141,7 +141,7 @@ struct DashboardView: View {
HStack { HStack {
HStack(spacing: 11) { HStack(spacing: 11) {
Image(systemName: "shippingbox.fill").foregroundStyle(.blue) Image(systemName: "shippingbox.fill").foregroundStyle(.blue)
VStack(alignment: .leading, spacing: 1) { Text(item.project.name).fontWeight(.semibold); Text(item.project.key).font(.caption).foregroundStyle(.secondary) } VStack(alignment: .leading, spacing: 1) { Text(app.projectDisplayName(for: item.project)).fontWeight(.semibold); Text(item.project.key).font(.caption).foregroundStyle(.secondary) }
}.frame(maxWidth: .infinity, alignment: .leading) }.frame(maxWidth: .infinity, alignment: .leading)
metricPill(item.measures["bugs"] ?? "0", .red, 65) metricPill(item.measures["bugs"] ?? "0", .red, 65)
metricPill(item.measures["vulnerabilities"] ?? "0", .orange, 65) metricPill(item.measures["vulnerabilities"] ?? "0", .orange, 65)
@@ -12,7 +12,7 @@ struct GlobalMetricView: View {
private var title: String { private var title: String {
switch metric { case .projects: "Projects"; case .failedGates: "Failed Quality Gates"; case .securityHotspots: "Security Hotspots"; case .qualityGates: "Quality Gates"; case .measures: "Measures"; case .coverage: "Coverage"; case .duplication: "Duplication"; case .lines: "Lines of Code"; case .bugs: "Bugs"; case .vulnerabilities: "Vulnerabilities"; case .codeSmells: "Code Smells" } switch metric { case .projects: "Projects"; case .failedGates: "Failed Quality Gates"; case .securityHotspots: "Security Hotspots"; case .qualityGates: "Quality Gates"; case .measures: "Measures"; case .coverage: "Coverage"; case .duplication: "Duplication"; case .lines: "Lines of Code"; case .bugs: "Bugs"; case .vulnerabilities: "Vulnerabilities"; case .codeSmells: "Code Smells" }
} }
private var rows: [ProjectSnapshot] { app.projects.filter { search.isEmpty || $0.project.name.localizedCaseInsensitiveContains(search) || $0.project.key.localizedCaseInsensitiveContains(search) } } private var rows: [ProjectSnapshot] { app.projects.filter { search.isEmpty || app.projectDisplayName(for: $0.project).localizedCaseInsensitiveContains(search) || $0.project.key.localizedCaseInsensitiveContains(search) } }
private var pages: Int { max(1, Int(ceil(Double(rows.count) / Double(pageSize)))) } private var pages: Int { max(1, Int(ceil(Double(rows.count) / Double(pageSize)))) }
private var visible: [ProjectSnapshot] { let s = (min(page,pages)-1)*pageSize; return s < rows.count ? Array(rows[s..<min(s+pageSize,rows.count)]) : [] } private var visible: [ProjectSnapshot] { let s = (min(page,pages)-1)*pageSize; return s < rows.count ? Array(rows[s..<min(s+pageSize,rows.count)]) : [] }
@@ -34,7 +34,7 @@ struct GlobalMetricView: View {
Button { onNavigate(.project(item.project.key)) } label: { Button { onNavigate(.project(item.project.key)) } label: {
HStack(spacing: 14) { HStack(spacing: 14) {
Image(systemName: icon).foregroundStyle(tint).frame(width: 24) Image(systemName: icon).foregroundStyle(tint).frame(width: 24)
VStack(alignment: .leading) { Text(item.project.name).fontWeight(.semibold); Text(item.project.key).font(.caption).foregroundStyle(.secondary) } VStack(alignment: .leading) { Text(app.projectDisplayName(for: item.project)).fontWeight(.semibold); Text(item.project.key).font(.caption).foregroundStyle(.secondary) }
Spacer(); value(for: item).font(.title3.monospacedDigit().weight(.semibold)).foregroundStyle(tint) Spacer(); value(for: item).font(.title3.monospacedDigit().weight(.semibold)).foregroundStyle(tint)
if metric == .qualityGates || metric == .failedGates { GateBadge(status: item.qualityGate) } if metric == .qualityGates || metric == .failedGates { GateBadge(status: item.qualityGate) }
Image(systemName: "chevron.right").foregroundStyle(.tertiary) Image(systemName: "chevron.right").foregroundStyle(.tertiary)
@@ -9,18 +9,37 @@ struct ProjectDetailView: View {
@State private var credentialError: String? @State private var credentialError: String?
@State private var generatingCredentials = false @State private var generatingCredentials = false
@State private var exportingAI = false @State private var exportingAI = false
@State private var exportingPDF = false
@State private var exportError: String? @State private var exportError: String?
@State private var showingAliasEditor = false
@State private var aliasText = ""
private var item: ProjectSnapshot? { app.projects.first { $0.project.key == projectKey } } private var item: ProjectSnapshot? { app.projects.first { $0.project.key == projectKey } }
var body: some View { var body: some View {
if let item { if let item {
VStack(spacing: 0) { VStack(spacing: 0) {
HStack { HStack {
VStack(alignment: .leading, spacing: 4) { Text(item.project.name).font(.system(size: 30, weight: .bold)); Text(item.project.key).foregroundStyle(.secondary) } VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 8) {
Text(app.projectDisplayName(for: item.project)).font(.system(size: 30, weight: .bold))
Button {
aliasText = app.projectAlias(for: item.project.key) ?? item.project.name
showingAliasEditor = true
} label: { Image(systemName: "pencil") }
.buttonStyle(.plain).foregroundStyle(.secondary).help("Edit SonarDesk display name")
}
Text(item.project.key).foregroundStyle(.secondary)
}
Spacer() Spacer()
Button { Task { await exportAIReport(item) } } label: { Menu {
if exportingAI { ProgressView().controlSize(.small) } else { Label("Export for AI Analysis…", systemImage: "brain.head.profile") } Button { Task { await exportPDFReport(item) } } label: { Label("PDF Report…", systemImage: "doc.richtext") }
}.buttonStyle(.bordered).disabled(exportingAI) Button { Task { await exportAIReport(item) } } label: { Label("AI Analysis Export…", systemImage: "brain.head.profile") }
} label: {
if exportingPDF || exportingAI { ProgressView().controlSize(.small) } else { Label("Export Report…", systemImage: "square.and.arrow.up") }
}
.menuStyle(.borderlessButton)
.fixedSize()
.disabled(exportingPDF || exportingAI)
GateBadge(status: item.qualityGate) GateBadge(status: item.qualityGate)
}.padding(.horizontal, 26).padding(.top, 22).padding(.bottom, 14) }.padding(.horizontal, 26).padding(.top, 22).padding(.bottom, 14)
@@ -55,9 +74,29 @@ struct ProjectDetailView: View {
case .scanDetails: scanDetails(item) case .scanDetails: scanDetails(item)
} }
} }
.sheet(isPresented: $showingAliasEditor) { aliasEditor }
} else { ContentUnavailableView("Project unavailable", systemImage: "shippingbox") } } else { ContentUnavailableView("Project unavailable", systemImage: "shippingbox") }
} }
private var aliasEditor: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Project Display Name").font(.title2.bold())
Text("This alias is stored by SonarDesk only. It does not rename the SonarQube project or change its key.")
.foregroundStyle(.secondary)
TextField("Display name", text: $aliasText).textFieldStyle(.roundedBorder)
HStack {
if app.projectAlias(for: projectKey) != nil {
Button("Reset to SonarQube Name") { app.resetProjectAlias(for: projectKey); showingAliasEditor = false }
}
Spacer()
Button("Cancel") { showingAliasEditor = false }
Button("Save") { app.setProjectAlias(aliasText, for: projectKey); showingAliasEditor = false }
.buttonStyle(.borderedProminent)
.disabled(aliasText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}.padding(24).frame(width: 460)
}
private func overview(_ item: ProjectSnapshot) -> some View { private func overview(_ item: ProjectSnapshot) -> some View {
ScrollView { ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 220), spacing: 14)], spacing: 14) { LazyVGrid(columns: [GridItem(.adaptive(minimum: 220), spacing: 14)], spacing: 14) {
@@ -172,6 +211,20 @@ struct ProjectDetailView: View {
catch { credentialError = error.localizedDescription } catch { credentialError = error.localizedDescription }
} }
@MainActor private func exportPDFReport(_ item: ProjectSnapshot) async {
exportingPDF = true; exportError = nil
defer { exportingPDF = false }
do {
let issues = try await app.allIssues(projectKey: item.project.key)
try ProjectPDFReportExporter.export(
project: item,
issues: issues,
serverURL: app.selectedServer?.baseURL ?? "Unknown",
serverVersion: app.serverVersion
)
} catch { exportError = error.localizedDescription }
}
@MainActor private func exportAIReport(_ item: ProjectSnapshot) async { @MainActor private func exportAIReport(_ item: ProjectSnapshot) async {
exportingAI = true; exportError = nil exportingAI = true; exportError = nil
defer { exportingAI = false } defer { exportingAI = false }
+36 -1
View File
@@ -5,6 +5,8 @@ struct RootView: View {
@EnvironmentObject var app: AppState @EnvironmentObject var app: AppState
@EnvironmentObject var reportStore: ReportStore @EnvironmentObject var reportStore: ReportStore
@State private var selection: Destination = .dashboard @State private var selection: Destination = .dashboard
@State private var aliasProjectKey: String?
@State private var aliasText = ""
var body: some View { var body: some View {
HSplitView { HSplitView {
@@ -18,6 +20,9 @@ struct RootView: View {
.background(SonarTheme.canvas) .background(SonarTheme.canvas)
.tint(SonarTheme.accent) .tint(SonarTheme.accent)
.task { if app.projects.isEmpty { await app.refresh() } } .task { if app.projects.isEmpty { await app.refresh() } }
.sheet(isPresented: Binding(get: { aliasProjectKey != nil }, set: { if !$0 { aliasProjectKey = nil } })) {
projectAliasEditor
}
.alert("SonarQube", isPresented: Binding(get: { app.errorMessage != nil }, set: { if !$0 { app.errorMessage = nil } })) { .alert("SonarQube", isPresented: Binding(get: { app.errorMessage != nil }, set: { if !$0 { app.errorMessage = nil } })) {
Button("OK", role: .cancel) { app.errorMessage = nil } Button("OK", role: .cancel) { app.errorMessage = nil }
} message: { Text(app.errorMessage ?? "Unknown error") } } message: { Text(app.errorMessage ?? "Unknown error") }
@@ -65,7 +70,7 @@ struct RootView: View {
sidebarHeading("PROJECTS") sidebarHeading("PROJECTS")
ForEach(app.projects) { snapshot in ForEach(app.projects) { snapshot in
sidebarItem(snapshot.project.name, "shippingbox", .project(snapshot.project.key), SonarTheme.info) sidebarItem(app.projectDisplayName(for: snapshot.project), "shippingbox", .project(snapshot.project.key), SonarTheme.info)
.draggable(snapshot.project.key) .draggable(snapshot.project.key)
.dropDestination(for: String.self) { keys, _ in .dropDestination(for: String.self) { keys, _ in
guard let sourceKey = keys.first else { return false } guard let sourceKey = keys.first else { return false }
@@ -73,6 +78,14 @@ struct RootView: View {
return true return true
} }
.contextMenu { .contextMenu {
Button("Rename Display Name…") {
aliasProjectKey = snapshot.project.key
aliasText = app.projectAlias(for: snapshot.project.key) ?? snapshot.project.name
}
if app.projectAlias(for: snapshot.project.key) != nil {
Button("Reset to SonarQube Name") { app.resetProjectAlias(for: snapshot.project.key) }
Divider()
}
Button("Move Up") { app.moveProject(snapshot.project.key, direction: -1) }.disabled(app.projects.first?.project.key == snapshot.project.key) Button("Move Up") { app.moveProject(snapshot.project.key, direction: -1) }.disabled(app.projects.first?.project.key == snapshot.project.key)
Button("Move Down") { app.moveProject(snapshot.project.key, direction: 1) }.disabled(app.projects.last?.project.key == snapshot.project.key) Button("Move Down") { app.moveProject(snapshot.project.key, direction: 1) }.disabled(app.projects.last?.project.key == snapshot.project.key)
} }
@@ -89,6 +102,28 @@ struct RootView: View {
}.background(SonarTheme.panel.opacity(0.30)) }.background(SonarTheme.panel.opacity(0.30))
} }
private var projectAliasEditor: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Project Display Name").font(.title2.bold())
Text("This changes only the name shown in SonarDesk. The SonarQube project name and project key are not changed.")
.foregroundStyle(.secondary)
TextField("Display name", text: $aliasText)
.textFieldStyle(.roundedBorder)
HStack {
Spacer()
Button("Cancel") { aliasProjectKey = nil }
Button("Save") {
if let key = aliasProjectKey { app.setProjectAlias(aliasText, for: key) }
aliasProjectKey = nil
}
.buttonStyle(.borderedProminent)
.disabled(aliasText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
.padding(24)
.frame(width: 460)
}
private func sidebarHeading(_ title: String) -> some View { private func sidebarHeading(_ title: String) -> some View {
Text(title).font(.caption2.bold()).tracking(1.1).foregroundStyle(.tertiary).padding(.horizontal, 10).padding(.top, 13).padding(.bottom, 5) Text(title).font(.caption2.bold()).tracking(1.1).foregroundStyle(.tertiary).padding(.horizontal, 10).padding(.top, 13).padding(.bottom, 5)
} }
@@ -0,0 +1,36 @@
import SwiftUI
struct LicenseRequiredView: View {
@EnvironmentObject private var license: LicenseStore
@State private var key = ""
var body: some View {
VStack(spacing: 22) {
Image("SonarDeskLogo").resizable().scaledToFit().frame(width: 86, height: 86)
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
VStack(spacing: 8) {
Text("Activate SonarDesk").font(.largeTitle.bold())
Text("Enter the licence key emailed to you after starting your SonarDesk subscription.")
.foregroundStyle(.secondary).multilineTextAlignment(.center)
}
GroupBox {
VStack(spacing: 14) {
SecureField("XXXXX-XXXXX-XXXXX-XXXXX", text: $key)
.textFieldStyle(.roundedBorder)
.frame(width: 390)
.onSubmit { Task { await license.activate(key) } }
Button("Activate This Mac") { Task { await license.activate(key) } }
.buttonStyle(.borderedProminent)
.disabled(key.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || license.isWorking)
if license.isWorking { ProgressView().controlSize(.small) }
if let error = license.errorMessage {
Text(error).font(.callout).foregroundStyle(.red).multilineTextAlignment(.center).textSelection(.enabled)
}
}.padding(12)
}
Text("Licence server: \(LicenseEnvironment.displayName)").font(.caption).foregroundStyle(.tertiary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(40)
}
}
@@ -0,0 +1,195 @@
import SwiftUI
struct LicenseSettingsView: View {
@EnvironmentObject private var license: LicenseStore
@State private var key = ""
@State private var isChangingLicense = false
@State private var showingDeactivateConfirmation = false
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
Text("Licence")
.font(.title2.bold())
Text("Manage the SonarDesk licence used to activate this Mac.")
.foregroundStyle(.secondary)
GroupBox {
VStack(alignment: .leading, spacing: 14) {
if license.isLicensed, let status = license.status {
activatedContent(status)
} else {
activationForm
}
if license.isWorking {
ProgressView()
.controlSize(.small)
}
if let error = license.errorMessage {
Text(error)
.font(.callout)
.foregroundStyle(.red)
.textSelection(.enabled)
}
}
.padding(8)
}
Text("SonarDesk stores the signed activation token in this Mac's Keychain. Licence keys are used only for activation and are not retained by the app.")
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: 620, alignment: .leading)
.padding(32)
}
.confirmationDialog(
"Deactivate SonarDesk on this Mac?",
isPresented: $showingDeactivateConfirmation,
titleVisibility: .visible
) {
Button("Deactivate This Mac", role: .destructive) {
Task {
await license.deactivate()
if !license.isLicensed {
key = ""
isChangingLicense = false
}
}
}
Button("Cancel", role: .cancel) { }
} message: {
Text("This removes the activation from this Mac. You will need a valid licence key to activate SonarDesk again.")
}
}
@ViewBuilder
private func activatedContent(_ status: LicenseStatus) -> some View {
Label("SonarDesk is activated", systemImage: "checkmark.seal.fill")
.font(.headline)
.foregroundStyle(.green)
LabeledContent("Edition", value: status.edition ?? "SonarDesk")
if let expires = status.expiresAt {
LabeledContent(
"Valid until",
value: expires.formatted(date: .abbreviated, time: .omitted)
)
}
LabeledContent("Server", value: LicenseEnvironment.displayName)
if isChangingLicense {
Divider()
VStack(alignment: .leading, spacing: 10) {
Text("Change Licence")
.font(.headline)
Text("Enter the replacement licence key. Your current activation remains in place unless the new licence is successfully validated.")
.font(.callout)
.foregroundStyle(.secondary)
SecureField("XXXXX-XXXXX-XXXXX-XXXXX", text: $key)
.textFieldStyle(.roundedBorder)
.disabled(license.isWorking)
.onSubmit { replaceLicense() }
HStack {
Button("Cancel") {
key = ""
isChangingLicense = false
license.errorMessage = nil
}
.disabled(license.isWorking)
Spacer()
Button("Activate New Licence") {
replaceLicense()
}
.buttonStyle(.borderedProminent)
.disabled(cleanKey.isEmpty || license.isWorking)
}
}
} else {
HStack {
Button("Check Licence") {
Task { await license.refresh() }
}
.disabled(license.isWorking)
Button("Change Licence…") {
key = ""
license.errorMessage = nil
isChangingLicense = true
}
.disabled(license.isWorking)
Spacer()
Button("Deactivate This Mac", role: .destructive) {
showingDeactivateConfirmation = true
}
.disabled(license.isWorking)
}
}
}
private var activationForm: some View {
VStack(alignment: .leading, spacing: 14) {
Text("Activate SonarDesk")
.font(.headline)
Text("Enter the licence key emailed to you after purchase.")
.font(.callout)
.foregroundStyle(.secondary)
SecureField("XXXXX-XXXXX-XXXXX-XXXXX", text: $key)
.textFieldStyle(.roundedBorder)
.disabled(license.isWorking)
.onSubmit { activateLicense() }
HStack {
Text("Server: \(LicenseEnvironment.displayName)")
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
Button("Activate") {
activateLicense()
}
.buttonStyle(.borderedProminent)
.disabled(cleanKey.isEmpty || license.isWorking)
}
}
}
private var cleanKey: String {
key.trimmingCharacters(in: .whitespacesAndNewlines)
}
private func activateLicense() {
guard !cleanKey.isEmpty else { return }
Task {
await license.activate(key)
if license.isLicensed {
key = ""
}
}
}
private func replaceLicense() {
guard !cleanKey.isEmpty else { return }
Task {
if await license.replaceLicense(with: key) {
key = ""
isChangingLicense = false
}
}
}
}
@@ -3,6 +3,7 @@ import SwiftUI
struct SettingsView: View { struct SettingsView: View {
@EnvironmentObject private var app: AppState @EnvironmentObject private var app: AppState
@EnvironmentObject private var reportStore: ReportStore @EnvironmentObject private var reportStore: ReportStore
@EnvironmentObject private var license: LicenseStore
@State private var editingServer: SonarServer? @State private var editingServer: SonarServer?
@State private var showingAddServer = false @State private var showingAddServer = false
@@ -10,6 +11,9 @@ struct SettingsView: View {
TabView { TabView {
serversPage serversPage
.tabItem { Label("Servers", systemImage: "server.rack") } .tabItem { Label("Servers", systemImage: "server.rack") }
LicenseSettingsView()
.environmentObject(license)
.tabItem { Label("Licence", systemImage: "key.fill") }
reportsPage reportsPage
.tabItem { Label("Reports", systemImage: "doc.text") } .tabItem { Label("Reports", systemImage: "doc.text") }
aboutPage aboutPage
+19
View File
@@ -0,0 +1,19 @@
# SonarDesk environments
| Environment | Branch | Licensing/website URL | Paddle | Purpose |
|---|---|---|---|---|
| Develop | develop | https://sonardesk.vm | Sandbox | Local development + Mailpit |
| Stage | stage | https://sonardesk.stage.absdev.net | Sandbox | Release-candidate testing |
| Production | main | https://sonardsk.absdev.net | Live | Paying customers |
## macOS build configurations
The Xcode project contains `Develop`, `Stage`, and `Release`. `Release` is production. The licensing URL is injected into Info.plist at build time; there is no Debug/Release URL heuristic.
## Laravel
Start each environment from the matching template: `.env.develop.example`, `.env.stage.example`, or `.env.production.example`. Never copy Paddle secrets between environments. Develop and Stage use Paddle Sandbox but should use different webhook destinations/secrets. Production uses Paddle Live credentials and its own webhook secret.
## Deployment
Develop runs locally and is not a Deployer target. Deploy Stage with `dep deploy stage`; deploy Production with `dep deploy production`.
Executable
+250
View File
@@ -0,0 +1,250 @@
#!/usr/bin/env bash
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "$0")" && pwd)"
cd "$PROJECT_ROOT"
APP_NAME="SonarDesk"
PROJECT="SonarDesk.xcodeproj"
SCHEME="SonarDesk"
CONFIGURATION="Release"
TEAM_ID="39R6U3437D"
SIGN_IDENTITY="B99D353C88996866C49E4389B375645F76870A3F"
PROFILE_NAME="SonarDesk"
NOTARY_PROFILE="sonardesk-notary"
BUILD_DIR="$PROJECT_ROOT/build"
ARCHIVE="$BUILD_DIR/SonarDesk.xcarchive"
APP="$ARCHIVE/Products/Applications/SonarDesk.app"
STAGE="$BUILD_DIR/dmg-stage"
RELEASE_DIR="$PROJECT_ROOT/release"
echo
echo "========================================"
echo " SonarDesk Release Build"
echo "========================================"
echo
echo "===== CLEAN ====="
rm -rf "$ARCHIVE" "$STAGE"
mkdir -p "$BUILD_DIR" "$RELEASE_DIR"
echo
echo "===== ARCHIVE ====="
xcodebuild \
-project "$PROJECT" \
-scheme "$SCHEME" \
-configuration "$CONFIGURATION" \
-archivePath "$ARCHIVE" \
-destination 'generic/platform=macOS' \
CODE_SIGN_STYLE=Manual \
CODE_SIGN_IDENTITY="$SIGN_IDENTITY" \
DEVELOPMENT_TEAM="$TEAM_ID" \
PROVISIONING_PROFILE_SPECIFIER="$PROFILE_NAME" \
ENABLE_HARDENED_RUNTIME=YES \
clean archive
echo
echo "===== VERIFY APP SIGNATURE ====="
codesign --verify --deep --strict --verbose=2 "$APP"
SIGN_INFO="$(codesign -dv --verbose=4 "$APP" 2>&1)"
echo "$SIGN_INFO" | grep \
"Authority=Developer ID Application: Mark Gregory (39R6U3437D)"
echo "$SIGN_INFO" | grep \
"TeamIdentifier=$TEAM_ID"
echo "$SIGN_INFO" | grep \
"Runtime Version="
echo
echo "===== VERIFY EMBEDDED PROFILE ====="
PROFILE_PLIST="$(mktemp)"
trap 'rm -f "$PROFILE_PLIST"' EXIT
security cms -D \
-i "$APP/Contents/embedded.provisionprofile" \
> "$PROFILE_PLIST"
PROFILE_NAME_ACTUAL=$(
/usr/libexec/PlistBuddy \
-c 'Print :Name' \
"$PROFILE_PLIST"
)
PROFILE_UUID=$(
/usr/libexec/PlistBuddy \
-c 'Print :UUID' \
"$PROFILE_PLIST"
)
ICLOUD_ENV=$(
/usr/libexec/PlistBuddy \
-c 'Print :Entitlements:com.apple.developer.icloud-container-environment' \
"$PROFILE_PLIST"
)
echo "Profile: $PROFILE_NAME_ACTUAL"
echo "UUID: $PROFILE_UUID"
echo "iCloud Environment: $ICLOUD_ENV"
if [[ "$ICLOUD_ENV" != "Production" ]]; then
echo "ERROR: iCloud environment is not Production."
exit 1
fi
echo
echo "===== VERIFY SIGNED ENTITLEMENTS ====="
ENTITLEMENTS="$(codesign -d --entitlements :- "$APP" 2>/dev/null)"
echo "$ENTITLEMENTS" | grep -q \
'iCloud.net.absdev.SonarDesk'
echo "$ENTITLEMENTS" | grep -q \
'CloudDocuments'
echo "$ENTITLEMENTS" | grep -q \
'39R6U3437D.net.absdev.SonarDesk'
echo "iCloud entitlements verified."
echo
echo "===== DETERMINE VERSION ====="
VERSION=$(
/usr/libexec/PlistBuddy \
-c 'Print :CFBundleShortVersionString' \
"$APP/Contents/Info.plist"
)
BUILD=$(
/usr/libexec/PlistBuddy \
-c 'Print :CFBundleVersion' \
"$APP/Contents/Info.plist"
)
DMG="$RELEASE_DIR/SonarDesk-${VERSION}.dmg"
echo "Version: $VERSION"
echo "Build: $BUILD"
echo "DMG: $DMG"
echo
echo "===== PREPARE DMG ====="
rm -rf "$STAGE"
rm -f "$DMG"
mkdir -p "$STAGE"
ditto "$APP" "$STAGE/SonarDesk.app"
ln -s /Applications "$STAGE/Applications"
echo
echo "===== CREATE DMG ====="
hdiutil create \
-volname "SonarDesk" \
-srcfolder "$STAGE" \
-ov \
-format UDZO \
"$DMG"
echo
echo "===== SIGN DMG ====="
codesign \
--force \
--timestamp \
--sign "$SIGN_IDENTITY" \
"$DMG"
echo
echo "===== VERIFY DMG SIGNATURE ====="
codesign --verify --verbose=2 "$DMG"
echo
echo "===== NOTARIZE ====="
xcrun notarytool submit "$DMG" \
--keychain-profile "$NOTARY_PROFILE" \
--wait
echo
echo "===== STAPLE ====="
xcrun stapler staple "$DMG"
echo
echo "===== VALIDATE STAPLE ====="
xcrun stapler validate "$DMG"
echo
echo "===== GATEKEEPER CHECK ====="
spctl \
--assess \
--type open \
--context context:primary-signature \
--verbose=4 \
"$DMG"
echo
echo "===== VERIFY APP INSIDE DMG ====="
MOUNT_DIR="$(mktemp -d)"
cleanup_mount() {
hdiutil detach "$MOUNT_DIR" >/dev/null 2>&1 || true
rmdir "$MOUNT_DIR" >/dev/null 2>&1 || true
}
trap 'cleanup_mount; rm -f "$PROFILE_PLIST"' EXIT
hdiutil attach "$DMG" \
-nobrowse \
-readonly \
-mountpoint "$MOUNT_DIR" \
>/dev/null
codesign \
--verify \
--deep \
--strict \
--verbose=2 \
"$MOUNT_DIR/SonarDesk.app"
spctl \
--assess \
--type execute \
--verbose=4 \
"$MOUNT_DIR/SonarDesk.app"
cleanup_mount
rm -rf "$STAGE"
echo
echo "========================================"
echo " RELEASE COMPLETE"
echo "========================================"
echo
echo "Application: SonarDesk"
echo "Version: $VERSION"
echo "Build: $BUILD"
echo "DMG: $DMG"
echo
echo "Signed: YES"
echo "Notarized: YES"
echo "Stapled: YES"
echo "Gatekeeper: PASSED"
echo
echo "========================================"
@@ -0,0 +1,59 @@
<?php
namespace App\Console\Commands;
use App\Models\License;
use App\Models\User;
use App\Services\Accounts\PurchasedAccountProvisioner;
use Illuminate\Console\Command;
final class SonarDeskAccountsBackfill extends Command
{
protected $signature = 'sonardesk:accounts:backfill {--dry-run : Report missing customer accounts without creating them} {--no-email : Create accounts without sending password setup links}';
protected $description = 'Create portal accounts for existing licence holders that do not already have one.';
public function handle(PurchasedAccountProvisioner $accounts): int
{
$customers = License::query()
->whereNotNull('customer_email')
->where('customer_email', '<>', '')
->select(['customer_email', 'customer_name'])
->orderBy('customer_email')
->get()
->unique(fn (License $license) => strtolower(trim((string) $license->customer_email)));
$missing = 0;
$created = 0;
foreach ($customers as $license) {
$email = strtolower(trim((string) $license->customer_email));
if ($email === '') {
continue;
}
if (User::query()->whereRaw('lower(email) = ?', [$email])->exists()) {
continue;
}
$missing++;
if ($this->option('dry-run')) {
$this->line("MISSING {$email}");
continue;
}
if ($accounts->ensure($email, $license->customer_name, ! $this->option('no-email'))) {
$created++;
$this->info("CREATED {$email}");
}
}
if ($this->option('dry-run')) {
$this->info("{$missing} account(s) would be created.");
} else {
$this->info("{$created} account(s) created.");
}
return self::SUCCESS;
}
}
@@ -0,0 +1,20 @@
<?php
namespace App\Console\Commands;
use App\Services\Licensing\LicenseDeliveryService;
use Illuminate\Console\Command;
final class SonarDeskLicenseDeliver extends Command
{
protected $signature = 'sonardesk:license-deliver {--limit=50 : Maximum pending licence emails to send}';
protected $description = 'Deliver pending SonarDesk licence-key emails';
public function handle(LicenseDeliveryService $delivery): int
{
$limit = max(1, min((int) $this->option('limit'), 100));
$count = $delivery->deliverPending($limit);
$this->info("Delivered {$count} licence email(s).");
return self::SUCCESS;
}
}
@@ -0,0 +1,101 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\SiteSetting;
use App\Models\SonarDeskRelease;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\Rule;
final class ReleaseController extends Controller
{
public function index()
{
return view('admin.releases.index', [
'releases' => SonarDeskRelease::latest()->paginate(30),
'publishedCount' => SonarDeskRelease::where('is_published', true)->count(),
'downloadCount' => SonarDeskRelease::sum('download_count'),
]);
}
public function create() { return view('admin.releases.form', ['release' => new SonarDeskRelease]); }
public function edit(SonarDeskRelease $release) { return view('admin.releases.form', compact('release')); }
public function store(Request $request): RedirectResponse
{
$release = new SonarDeskRelease;
$this->persist($request, $release);
return redirect()->route('admin.releases.index')->with('status', 'Release created.');
}
public function update(Request $request, SonarDeskRelease $release): RedirectResponse
{
$this->persist($request, $release);
return redirect()->route('admin.releases.index')->with('status', 'Release updated.');
}
public function destroy(SonarDeskRelease $release): RedirectResponse
{
if ($release->file_path) Storage::disk('local')->delete($release->file_path);
$release->delete();
return back()->with('status', 'Release deleted.');
}
public function settings()
{
$keys = ['downloads.eyebrow','downloads.title','downloads.intro','downloads.requirements','downloads.support_text'];
$settings = collect($keys)->mapWithKeys(fn ($key) => [$key => SiteSetting::valueFor($key)]);
return view('admin.releases.settings', compact('settings'));
}
public function updateSettings(Request $request): RedirectResponse
{
$data = $request->validate([
'eyebrow' => ['required','string','max:80'], 'title' => ['required','string','max:120'],
'intro' => ['required','string','max:500'], 'requirements' => ['required','string','max:500'],
'support_text' => ['required','string','max:500'],
]);
foreach ($data as $key => $value) SiteSetting::put('downloads.'.$key, $value);
return back()->with('status', 'Download page content updated.');
}
private function persist(Request $request, SonarDeskRelease $release): void
{
$data = $request->validate([
'version' => ['required','string','max:40'], 'build_number' => ['required','integer','min:1'],
'channel' => ['required', Rule::in(['stable','beta'])], 'minimum_macos' => ['nullable','string','max:30'],
'release_notes' => ['nullable','string','max:20000'], 'sparkle_signature' => ['nullable','string','max:2000'],
'package' => [
$release->exists ? 'nullable' : 'required',
'file',
'max:1048576',
function (string $attribute, $value, \Closure $fail): void {
$extension = strtolower($value->getClientOriginalExtension());
if (! in_array($extension, ['dmg', 'zip'], true)) {
$fail('The package must be a DMG or ZIP file.');
}
},
],
'is_published' => ['nullable','boolean'], 'is_featured' => ['nullable','boolean'],
]);
$data['is_published'] = $request->boolean('is_published');
$data['is_featured'] = $request->boolean('is_featured');
if ($data['is_featured']) SonarDeskRelease::query()->update(['is_featured' => false]);
if ($request->hasFile('package')) {
if ($release->file_path) Storage::disk('local')->delete($release->file_path);
$file = $request->file('package');
$path = $file->store('sonardesk/releases', 'local');
$data['file_path'] = $path;
$data['original_filename'] = $file->getClientOriginalName();
$data['file_size'] = $file->getSize();
$data['sha256'] = hash_file('sha256', $file->getRealPath());
}
unset($data['package']);
$data['published_at'] = $data['is_published'] ? ($release->published_at ?: now()) : null;
$release->fill($data)->save();
}
}
@@ -11,68 +11,181 @@ use App\Services\Payments\PurchaseProvisioner;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use RuntimeException;
use Throwable;
final class PaymentWebhookController extends Controller final class PaymentWebhookController extends Controller
{ {
public function __invoke(string $provider, Request $request, PurchaseProvisioner $provisioner, LicenseDeliveryService $delivery): JsonResponse public function __invoke(
{ string $provider,
Request $request,
PurchaseProvisioner $provisioner,
LicenseDeliveryService $delivery
): JsonResponse {
$raw = (string) $request->getContent(); $raw = (string) $request->getContent();
$verifier = match ($provider) { $verifier = match ($provider) {
'paddle' => app(PaddleWebhookVerifier::class),'lemon-squeezy' => app(LemonSqueezyWebhookVerifier::class),default => null 'paddle' => app(PaddleWebhookVerifier::class),
'lemon-squeezy' => app(LemonSqueezyWebhookVerifier::class),
default => null,
}; };
if (! $verifier) { if (! $verifier) {
return response()->json(['message' => 'Unknown webhook provider.'], 404); return response()->json([
'message' => 'Unknown webhook provider.',
], 404);
} }
try { try {
$verifier->verify($request, $raw); $verifier->verify($request, $raw);
} catch (\Throwable $e) { } catch (Throwable $e) {
report($e); report($e);
return response()->json(['message' => 'Invalid webhook signature.'], 400); return response()->json([
'message' => 'Invalid webhook signature.',
], 400);
} }
try { try {
$payload = json_decode($raw, true, flags: JSON_THROW_ON_ERROR); $payload = json_decode(
} catch (\Throwable) { $raw,
return response()->json(['message' => 'Invalid JSON.'], 400); true,
flags: JSON_THROW_ON_ERROR
);
} catch (Throwable) {
return response()->json([
'message' => 'Invalid JSON.',
], 400);
} }
$externalID = (string) ($provider === 'paddle' ? ($payload['event_id'] ?? '') : ($payload['meta']['event_name'] ?? '').':'.($payload['data']['id'] ?? ''));
$eventType = (string) ($provider === 'paddle' ? ($payload['event_type'] ?? 'unknown') : ($payload['meta']['event_name'] ?? 'unknown')); $externalID = (string) (
$provider === 'paddle'
? ($payload['event_id'] ?? '')
: ($payload['meta']['event_name'] ?? '')
.':'
.($payload['data']['id'] ?? '')
);
$eventType = (string) (
$provider === 'paddle'
? ($payload['event_type'] ?? 'unknown')
: ($payload['meta']['event_name'] ?? 'unknown')
);
if ($externalID === '') { if ($externalID === '') {
return response()->json(['message' => 'Webhook event has no stable identifier.'], 400); return response()->json([
'message' => 'Webhook event has no stable identifier.',
], 400);
} }
$payloadHash = hash('sha256', $raw);
try { try {
DB::transaction(function () use ($provider, $externalID, $eventType, $payload, $raw, $provisioner) { /*
$event = LicenseWebhookEvent::firstOrCreate(['provider' => $provider, 'external_event_id' => $externalID], ['event_type' => $eventType, 'payload_sha256' => hash('sha256', $raw), 'payload' => $payload]); * Paddle can deliver several related events concurrently.
if ($event->processed_at) { *
return; * Laravel retries this complete transaction when MySQL reports
} * a deadlock. Five attempts gives short-lived lock contention
if (! hash_equals($event->payload_sha256, hash('sha256', $raw))) { * enough opportunity to resolve without acknowledging an event
throw new \RuntimeException('Webhook ID was reused with a different payload.'); * that was not actually processed.
} */
try { DB::transaction(
$provisioner->provision($provider, $payload); function () use (
$event->update(['processed_at' => now(), 'processing_error' => null]); $provider,
} catch (\Throwable $e) { $externalID,
$event->update(['processing_error' => mb_substr($e->getMessage(), 0, 4000)]); $eventType,
throw $e; $payload,
} $payloadHash,
}); $provisioner
} catch (\Throwable $e) { ): void {
report($e); /*
* Insert the event if this is its first delivery.
*
* Once present, lock the row so duplicate deliveries of
* the same event cannot provision concurrently.
*/
LicenseWebhookEvent::firstOrCreate(
[
'provider' => $provider,
'external_event_id' => $externalID,
],
[
'event_type' => $eventType,
'payload_sha256' => $payloadHash,
'payload' => $payload,
]
);
return response()->json(['message' => 'Webhook could not be processed.'], 500); $event = LicenseWebhookEvent::query()
->where('provider', $provider)
->where('external_event_id', $externalID)
->lockForUpdate()
->firstOrFail();
if ($event->processed_at) {
return;
}
if (! hash_equals(
(string) $event->payload_sha256,
$payloadHash
)) {
throw new RuntimeException(
'Webhook ID was reused with a different payload.'
);
}
try {
$provisioner->provision(
$provider,
$payload
);
$event->processed_at = now();
$event->processing_error = null;
$event->save();
} catch (Throwable $e) {
/*
* This assignment is useful when the surrounding
* transaction eventually succeeds. If processing
* throws, the transaction itself is rolled back and
* Paddle will retry the event.
*/
$event->processing_error = mb_substr(
$e->getMessage(),
0,
4000
);
$event->save();
throw $e;
}
},
5
);
} catch (Throwable $e) {
report($e);
return response()->json([
'message' => 'Webhook could not be processed.',
], 500);
} }
// Delivery is intentionally outside the webhook transaction. A temporary mail /*
// outage must not roll back an otherwise valid Paddle event. Pending outbox * Delivery deliberately happens outside the webhook transaction.
// rows remain available for portal resend or a later webhook delivery attempt. *
* A temporary SMTP failure must not undo a successfully processed
* payment event. The outbox retains anything that still needs
* delivery.
*/
try { try {
$delivery->deliverPending(10); $delivery->deliverPending(10);
} catch (\Throwable $e) { } catch (Throwable $e) {
report($e); report($e);
} }
return response()->json(['ok' => true]); return response()->json([
'ok' => true,
]);
} }
} }
@@ -17,10 +17,10 @@ final class CheckoutController extends Controller
} }
$plans = [ $plans = [
'personal-monthly' => ['name' => 'Personal', 'billing' => 'Monthly', 'display_price' => '£1.99 / month', 'price_id' => $paddle['personal_monthly_price_id'], 'activation_limit' => 1], 'personal-monthly' => ['name' => 'Personal', 'billing' => 'Monthly', 'display_price' => '£4.99 / month', 'price_id' => $paddle['personal_monthly_price_id'], 'activation_limit' => 1],
'personal-annual' => ['name' => 'Personal', 'billing' => 'Annual', 'display_price' => '£14.99 / year', 'price_id' => $paddle['personal_annual_price_id'], 'activation_limit' => 1], 'personal-annual' => ['name' => 'Personal', 'billing' => 'Annual', 'display_price' => '£39.99 / year', 'price_id' => $paddle['personal_annual_price_id'], 'activation_limit' => 1],
'professional-monthly' => ['name' => 'Professional', 'billing' => 'Monthly', 'display_price' => '£3.99 / month', 'price_id' => $paddle['professional_monthly_price_id'], 'activation_limit' => 3], 'professional-monthly' => ['name' => 'Professional', 'billing' => 'Monthly', 'display_price' => '£8.99 / month', 'price_id' => $paddle['professional_monthly_price_id'], 'activation_limit' => 3],
'professional-annual' => ['name' => 'Professional', 'billing' => 'Annual', 'display_price' => '£29.99 / year', 'price_id' => $paddle['professional_annual_price_id'], 'activation_limit' => 3], 'professional-annual' => ['name' => 'Professional', 'billing' => 'Annual', 'display_price' => '£79.99 / year', 'price_id' => $paddle['professional_annual_price_id'], 'activation_limit' => 3],
]; ];
foreach ($plans as $plan) { foreach ($plans as $plan) {
@@ -7,15 +7,14 @@ use App\Models\License;
use App\Models\LicenseDeliveryOutbox; use App\Models\LicenseDeliveryOutbox;
use App\Models\LicenseProduct; use App\Models\LicenseProduct;
use App\Services\Payments\PaddleClient; use App\Services\Payments\PaddleClient;
use App\Services\Licensing\LicenseDeliveryService;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Mail;
use RuntimeException; use RuntimeException;
final class PortalController extends Controller final class PortalController extends Controller
{ {
public function __construct(private PaddleClient $paddle) {} public function __construct(private PaddleClient $paddle, private LicenseDeliveryService $delivery) {}
private function owned(Request $request) private function owned(Request $request)
{ {
@@ -116,9 +115,11 @@ final class PortalController extends Controller
return back()->withErrors(['licence' => 'This licence was issued manually and its full key is not recoverable. Contact support.']); return back()->withErrors(['licence' => 'This licence was issued manually and its full key is not recoverable. Contact support.']);
} }
$plain = Crypt::decryptString($out->encrypted_license_key); $out->update([
Mail::raw("Your SonarDesk licence key is:\n\n{$plain}\n\nKeep this key private.", fn ($m) => $m->to($request->user()->email)->subject('Your SonarDesk licence key')); 'recipient_email' => strtolower($request->user()->email),
$out->update(['delivered_at' => now()]); 'delivered_at' => null,
]);
$this->delivery->deliverPending(1);
return back()->with('status', 'Licence key sent to your verified email address.'); return back()->with('status', 'Licence key sent to your verified email address.');
} }
@@ -0,0 +1,58 @@
<?php
namespace App\Http\Controllers;
use App\Models\SiteSetting;
use App\Models\SonarDeskRelease;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
final class ReleaseController extends Controller
{
public function index()
{
$latest = SonarDeskRelease::published()->where('is_featured', true)->orderByDesc('published_at')->first()
?? SonarDeskRelease::published()->where('channel', 'stable')->orderByDesc('published_at')->orderByDesc('id')->first();
$releases = SonarDeskRelease::published()->orderByDesc('published_at')->paginate(12);
$settings = $this->settings();
return view('downloads.index', compact('latest', 'releases', 'settings'));
}
public function latest(): BinaryFileResponse
{
$release = SonarDeskRelease::published()->where('channel', 'stable')->orderByDesc('published_at')->firstOrFail();
return $this->send($release);
}
public function download(SonarDeskRelease $release): BinaryFileResponse
{
abort_unless($release->is_published && $release->published_at && $release->file_path, 404);
return $this->send($release);
}
public function appcast(): Response
{
$releases = SonarDeskRelease::published()->where('channel', 'stable')->orderByDesc('published_at')->limit(20)->get();
return response()->view('downloads.appcast', compact('releases'), 200, ['Content-Type' => 'application/rss+xml; charset=UTF-8']);
}
private function send(SonarDeskRelease $release): BinaryFileResponse
{
abort_unless(Storage::disk('local')->exists($release->file_path), 404);
$release->increment('download_count');
return response()->download(Storage::disk('local')->path($release->file_path), $release->original_filename ?: 'SonarDesk-'.$release->version.'.dmg');
}
private function settings(): array
{
return [
'eyebrow' => SiteSetting::valueFor('downloads.eyebrow', 'SonarDesk for macOS'),
'title' => SiteSetting::valueFor('downloads.title', 'Download SonarDesk'),
'intro' => SiteSetting::valueFor('downloads.intro', 'A focused native SonarQube workspace, designed for macOS and built for daily engineering work.'),
'requirements' => SiteSetting::valueFor('downloads.requirements', 'Apple silicon and Intel Macs supported. A SonarQube server and valid SonarDesk licence are required.'),
'support_text' => SiteSetting::valueFor('downloads.support_text', 'Need help installing or activating SonarDesk? Our support team can help.'),
];
}
}
@@ -3,6 +3,7 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class LicenseDeliveryOutbox extends Model class LicenseDeliveryOutbox extends Model
{ {
@@ -11,4 +12,9 @@ class LicenseDeliveryOutbox extends Model
protected $fillable = ['license_id', 'encrypted_license_key', 'recipient_email', 'delivered_at']; protected $fillable = ['license_id', 'encrypted_license_key', 'recipient_email', 'delivered_at'];
protected $casts = ['delivered_at' => 'datetime']; protected $casts = ['delivered_at' => 'datetime'];
public function license(): BelongsTo
{
return $this->belongsTo(License::class);
}
} }
@@ -0,0 +1,26 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Cache;
final class SiteSetting extends Model
{
protected $table = 'sonardesk_site_settings';
protected $primaryKey = 'key';
public $incrementing = false;
protected $keyType = 'string';
protected $fillable = ['key', 'value'];
public static function valueFor(string $key, ?string $default = null): ?string
{
return Cache::remember('sonardesk.setting.'.$key, 300, fn () => static::query()->find($key)?->value ?? $default);
}
public static function put(string $key, ?string $value): void
{
static::query()->updateOrCreate(['key' => $key], ['value' => $value]);
Cache::forget('sonardesk.setting.'.$key);
}
}
@@ -0,0 +1,40 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
final class SonarDeskRelease extends Model
{
protected $table = 'sonardesk_releases';
protected $fillable = [
'version', 'build_number', 'channel', 'minimum_macos', 'file_path',
'original_filename', 'file_size', 'sha256', 'sparkle_signature',
'release_notes', 'is_published', 'is_featured', 'published_at', 'download_count',
];
protected function casts(): array
{
return [
'is_published' => 'boolean',
'is_featured' => 'boolean',
'published_at' => 'datetime',
'file_size' => 'integer',
'download_count' => 'integer',
];
}
public function scopePublished($query)
{
return $query->where('is_published', true)->whereNotNull('file_path')->whereNotNull('published_at');
}
public function getFormattedSizeAttribute(): string
{
$bytes = (int) $this->file_size;
if ($bytes < 1024) return $bytes.' B';
if ($bytes < 1048576) return number_format($bytes / 1024, 1).' KB';
if ($bytes < 1073741824) return number_format($bytes / 1048576, 1).' MB';
return number_format($bytes / 1073741824, 2).' GB';
}
}
@@ -0,0 +1,109 @@
<?php
namespace App\Services\Accounts;
use App\Models\User;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str;
use RuntimeException;
final class PurchasedAccountProvisioner
{
public function ensure(
?string $email,
?string $name = null,
bool $sendSetupLink = true
): ?User {
$email = strtolower(trim((string) $email));
if ($email === '' || ! filter_var($email, FILTER_VALIDATE_EMAIL)) {
return null;
}
/*
* Fast path for the normal case.
*
* Paddle can deliver several related webhook events concurrently, so
* this lookup alone cannot guarantee that another request will not
* create the user between this SELECT and our INSERT.
* enure this is pushed
*/
$user = $this->findByEmail($email);
if ($user) {
return $user;
}
$created = false;
try {
$user = User::create([
'name' => $this->displayName($name, $email),
'email' => $email,
'email_verified_at' => now(),
'password' => Str::random(64),
'is_admin' => false,
]);
$created = true;
} catch (UniqueConstraintViolationException $exception) {
/*
* Another webhook may have created this account after our SELECT
* but before our INSERT. In that case the unique email constraint
* correctly rejects our INSERT; simply reuse the account that won
* the race.
*/
$user = $this->findByEmail($email);
if (! $user) {
/*
* The unique violation was not caused by the email account
* appearing concurrently, so preserve the original failure.
*/
throw $exception;
}
}
/*
* Only the request that actually created the account should send the
* account setup/reset link. Concurrent Paddle events must not result
* in several setup emails being sent to the customer.
*/
if ($created && $sendSetupLink) {
DB::afterCommit(static function () use ($email): void {
Password::sendResetLink(['email' => $email]);
});
}
return $user;
}
private function findByEmail(string $email): ?User
{
return User::query()
->whereRaw('LOWER(email) = ?', [$email])
->first();
}
private function displayName(?string $name, string $email): string
{
$name = trim((string) $name);
if ($name !== '') {
return mb_substr($name, 0, 120);
}
$localPart = strstr($email, '@', true) ?: 'SonarDesk Customer';
$localPart = trim(str_replace(['.', '_', '-'], ' ', $localPart));
return mb_substr(
$localPart !== ''
? ucwords($localPart)
: 'SonarDesk Customer',
0,
120
);
}
}
@@ -12,6 +12,7 @@ final class LicenseDeliveryService
{ {
$delivered = 0; $delivered = 0;
$pending = LicenseDeliveryOutbox::query() $pending = LicenseDeliveryOutbox::query()
->with('license')
->whereNull('delivered_at') ->whereNull('delivered_at')
->whereNotNull('recipient_email') ->whereNotNull('recipient_email')
->where('recipient_email', '!=', '') ->where('recipient_email', '!=', '')
@@ -20,16 +21,29 @@ final class LicenseDeliveryService
->get(); ->get();
foreach ($pending as $outbox) { foreach ($pending as $outbox) {
$license = $outbox->license;
if (! $license) {
continue;
}
$plain = Crypt::decryptString($outbox->encrypted_license_key); $plain = Crypt::decryptString($outbox->encrypted_license_key);
$recipient = strtolower(trim((string) $outbox->recipient_email)); $recipient = strtolower(trim((string) $outbox->recipient_email));
if ($recipient === '') { if ($recipient === '') {
continue; continue;
} }
Mail::raw( $data = [
"Thank you for choosing SonarDesk.\n\nYour licence key is:\n\n{$plain}\n\nOpen SonarDesk → Settings → Licence and enter this key to activate your Mac.\n\nKeep this key private. You can manage activations from the SonarDesk licence portal.", 'customerName' => $license->customer_name,
fn ($message) => $message->to($recipient)->subject('Your SonarDesk licence key') 'edition' => $license->edition,
); 'licenseKey' => $plain,
'expiresAt' => $license->expires_at,
'portalUrl' => config('sonardesk.portal_url') ?: url('/portal'),
'downloadUrl' => route('release'),
];
Mail::send(['html' => 'emails.license-key', 'text' => 'emails.license-key-text'], $data, function ($message) use ($recipient): void {
$message->to($recipient)->subject('Your SonarDesk licence key');
});
$outbox->update(['delivered_at' => now()]); $outbox->update(['delivered_at' => now()]);
$delivered++; $delivered++;
@@ -14,10 +14,11 @@ final class LicenseTokenVerifier
} }
$payload = $this->decode($parts[1]); $payload = $this->decode($parts[1]);
$signature = $this->decode($parts[2]); $signature = $this->decode($parts[2]);
$public = base64_decode((string) config('sonardesk.public_key'), true); // Derive the verification key from the same private seed used by LicenseSigner.
if ($public === false || strlen($public) !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES) { // Keeping a separate SONARDESK_LICENSE_PUBLIC_KEY allowed the two keys to drift:
throw new RuntimeException('Server licence public key is invalid.'); // activation would sign a perfectly valid token with the new private key, then
} // /validate would reject that same token using a stale public key.
$public = $this->publicKeyFromPrivateSeed();
if (! sodium_crypto_sign_verify_detached($signature, $payload, $public)) { if (! sodium_crypto_sign_verify_detached($signature, $payload, $public)) {
throw new RuntimeException('Licence token signature is invalid.'); throw new RuntimeException('Licence token signature is invalid.');
} }
@@ -26,6 +27,23 @@ final class LicenseTokenVerifier
return is_array($decoded) ? $decoded : throw new RuntimeException('Licence token payload is invalid.'); return is_array($decoded) ? $decoded : throw new RuntimeException('Licence token payload is invalid.');
} }
private function publicKeyFromPrivateSeed(): string
{
$path = config('sonardesk.private_key_path');
if (! $path || ! is_file($path)) {
throw new RuntimeException('SONARDESK_LICENSE_PRIVATE_KEY_PATH is not configured or does not exist.');
}
$seed = base64_decode(trim((string) file_get_contents($path)), true);
if ($seed === false || strlen($seed) !== SODIUM_CRYPTO_SIGN_SEEDBYTES) {
throw new RuntimeException('The SonarDesk private key must be a Base64-encoded 32-byte Ed25519 seed.');
}
$pair = sodium_crypto_sign_seed_keypair($seed);
return sodium_crypto_sign_publickey($pair);
}
private function decode(string $value): string private function decode(string $value): string
{ {
$value = strtr($value, '-_', '+/'); $value = strtr($value, '-_', '+/');
@@ -0,0 +1,63 @@
<?php
namespace App\Services\Payments;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str;
final class CustomerAccountProvisioner
{
public function ensure(?string $email, ?string $name = null): ?User
{
$email = strtolower(trim((string) $email));
if ($email === '' || ! filter_var($email, FILTER_VALIDATE_EMAIL)) {
return null;
}
$user = User::whereRaw('lower(email) = ?', [$email])->first();
if ($user) {
return $user;
}
$displayName = trim((string) $name);
if ($displayName === '') {
$displayName = Str::headline(Str::before($email, '@')) ?: 'SonarDesk Customer';
}
$user = User::firstOrCreate(
['email' => $email],
[
'name' => $displayName,
// The customer never receives or knows this password. They choose their
// own password through Laravel's signed, single-use reset flow.
'password' => Str::random(64),
// Access still requires possession of the mailbox because account setup
// is completed using the reset link sent to this address.
'email_verified_at' => now(),
],
);
if ($user->wasRecentlyCreated) {
DB::afterCommit(function () use ($email): void {
try {
$status = Password::sendResetLink(['email' => $email]);
if ($status !== Password::RESET_LINK_SENT) {
Log::warning('SonarDesk customer account created but setup email was not sent.', [
'email' => $email,
'status' => $status,
]);
}
} catch (\Throwable $exception) {
// A mail outage must never undo a paid licence. The customer can
// also use Forgot Password once mail is restored.
report($exception);
}
});
}
return $user;
}
}
@@ -2,6 +2,7 @@
namespace App\Services\Payments; namespace App\Services\Payments;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response; use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use RuntimeException; use RuntimeException;
@@ -19,60 +20,147 @@ final class PaddleClient
if (! $response->successful()) { if (! $response->successful()) {
return null; return null;
} }
$data = $response->json('data'); $data = $response->json('data');
return is_array($data) ? $data : null; return is_array($data) ? $data : null;
} }
public function updateSubscriptionPrice(string $subscriptionID, string $priceID): array public function subscription(string $subscriptionID): array
{ {
$this->requireWritableConfiguration(); $this->requireWritableConfiguration();
$response = $this->request()->patch('/subscriptions/'.$subscriptionID, [ $response = $this->request()
'proration_billing_mode' => 'prorated_immediately', ->get('/subscriptions/'.$subscriptionID);
'items' => [[
'price_id' => $priceID,
'quantity' => 1,
]],
]);
return $this->subscriptionData($response, 'Paddle could not update the subscription.'); return $this->subscriptionData(
$response,
'Paddle could not retrieve the subscription.'
);
} }
public function cancelSubscriptionImmediately(string $subscriptionID): array public function updateSubscriptionPrice(
{ string $subscriptionID,
string $priceID
): array {
$this->requireWritableConfiguration(); $this->requireWritableConfiguration();
$response = $this->request()->post('/subscriptions/'.$subscriptionID.'/cancel', [ /*
'effective_from' => 'immediately', * Paddle restricts subscription item changes while a subscription
]); * is trialing. Existing item quantities may only be updated using
* do_not_bill during the trial.
*
* Once the subscription is outside its trial we can use normal
* immediate proration.
*/
$subscription = $this->subscription($subscriptionID);
return $this->subscriptionData($response, 'Paddle could not cancel the subscription.'); $status = (string) ($subscription['status'] ?? '');
$prorationBillingMode = $status === 'trialing'
? 'do_not_bill'
: 'prorated_immediately';
/*
* Preserve the existing subscription item and replace its price.
*
* Paddle treats the submitted items array as the desired
* subscription items, so quantity is retained where possible.
*/
$existingItems = is_array($subscription['items'] ?? null)
? $subscription['items']
: [];
$existingItem = $existingItems[0] ?? [];
$quantity = max(
1,
(int) ($existingItem['quantity'] ?? 1)
);
$response = $this->request()->patch(
'/subscriptions/'.$subscriptionID,
[
'proration_billing_mode' => $prorationBillingMode,
'items' => [
[
'price_id' => $priceID,
'quantity' => $quantity,
],
],
]
);
return $this->subscriptionData(
$response,
'Paddle could not update the subscription.'
);
} }
private function request() public function cancelSubscriptionImmediately(
string $subscriptionID
): array {
$this->requireWritableConfiguration();
$response = $this->request()->post(
'/subscriptions/'.$subscriptionID.'/cancel',
[
'effective_from' => 'immediately',
]
);
return $this->subscriptionData(
$response,
'Paddle could not cancel the subscription.'
);
}
private function request(): PendingRequest
{ {
return Http::baseUrl((string) config('sonardesk.paddle.api_url')) return Http::baseUrl(
->withToken((string) config('sonardesk.paddle.api_key')) (string) config('sonardesk.paddle.api_url')
)
->withToken(
(string) config('sonardesk.paddle.api_key')
)
->acceptJson() ->acceptJson()
->timeout(15); ->timeout(15);
} }
private function requireWritableConfiguration(): void private function requireWritableConfiguration(): void
{ {
if (trim((string) config('sonardesk.paddle.api_key')) === '') { if (
throw new RuntimeException('Paddle subscription management is not configured.'); trim(
(string) config('sonardesk.paddle.api_key')
) === ''
) {
throw new RuntimeException(
'Paddle subscription management is not configured.'
);
} }
} }
private function subscriptionData(Response $response, string $fallback): array private function subscriptionData(
{ Response $response,
string $fallback
): array {
if (! $response->successful()) { if (! $response->successful()) {
$detail = trim((string) ($response->json('error.detail') ?? '')); $detail = trim(
throw new RuntimeException($detail !== '' ? $detail : $fallback); (string) (
$response->json('error.detail')
?? ''
)
);
throw new RuntimeException(
$detail !== ''
? $detail
: $fallback
);
} }
$data = $response->json('data'); $data = $response->json('data');
if (! is_array($data)) { if (! is_array($data)) {
throw new RuntimeException($fallback); throw new RuntimeException($fallback);
} }
@@ -3,36 +3,104 @@
namespace App\Services\Payments; namespace App\Services\Payments;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use RuntimeException; use RuntimeException;
final class PaddleWebhookVerifier implements WebhookVerifier final class PaddleWebhookVerifier implements WebhookVerifier
{ {
public function verify(Request $request, string $rawBody): void public function verify(Request $request, string $rawBody): void
{ {
$secret = (string) config('sonardesk.paddle.webhook_secret'); $secret = trim((string) config('sonardesk.paddle.webhook_secret'));
$header = (string) $request->header('Paddle-Signature'); $header = trim((string) $request->header('Paddle-Signature'));
if ($secret === '' || $header === '') {
throw new RuntimeException('Paddle webhook verification is not configured.'); if ($secret === '') {
throw new RuntimeException('Paddle webhook secret is not configured.');
} }
if ($header === '') {
throw new RuntimeException('Paddle-Signature header is missing.');
}
$parts = []; $parts = [];
foreach (explode(';', $header) as $part) { foreach (explode(';', $header) as $part) {
[$k,$v] = array_pad(explode('=', trim($part), 2), 2, null); [$key, $value] = array_pad(
if ($k && $v) { explode('=', trim($part), 2),
$parts[$k][] = $v; 2,
null
);
if ($key !== null && $value !== null) {
$parts[$key][] = $value;
} }
} }
$ts = isset($parts['ts'][0]) ? (int) $parts['ts'][0] : 0;
$timestamp = isset($parts['ts'][0])
? (int) $parts['ts'][0]
: 0;
$signatures = $parts['h1'] ?? []; $signatures = $parts['h1'] ?? [];
$tolerance = max(1, (int) config('sonardesk.paddle.timestamp_tolerance', 300));
if (! $ts || abs(time() - $ts) > $tolerance) { if ($timestamp === 0) {
throw new RuntimeException('Paddle webhook timestamp is outside the allowed tolerance.'); throw new RuntimeException(
'Paddle webhook signature has no timestamp.'
);
} }
$expected = hash_hmac('sha256', $ts.':'.$rawBody, $secret);
if ($signatures === []) {
throw new RuntimeException(
'Paddle webhook signature has no h1 signature.'
);
}
$tolerance = max(
1,
(int) config('sonardesk.paddle.timestamp_tolerance', 300)
);
$age = abs(time() - $timestamp);
if ($age > $tolerance) {
throw new RuntimeException(
"Paddle webhook timestamp is outside the allowed tolerance ({$age}s)."
);
}
$signedPayload = $timestamp . ':' . $rawBody;
$expected = hash_hmac(
'sha256',
$signedPayload,
$secret
);
Log::debug('Paddle webhook signature diagnostics', [
'timestamp' => $timestamp,
'age_seconds' => $age,
'body_length' => strlen($rawBody),
'body_sha256' => hash('sha256', $rawBody),
// Safe diagnostics — do NOT log the secret itself.
'secret_prefix' => substr($secret, 0, 11),
'secret_length' => strlen($secret),
'received_signature_prefixes' => array_map(
static fn (string $signature): string =>
substr($signature, 0, 12),
$signatures
),
'expected_signature_prefix' => substr($expected, 0, 12),
]);
foreach ($signatures as $signature) { foreach ($signatures as $signature) {
if (hash_equals($expected, $signature)) { if (hash_equals($expected, $signature)) {
return; return;
} }
} }
throw new RuntimeException('Paddle webhook signature is invalid.');
throw new RuntimeException(
'Paddle webhook signature is invalid.'
);
} }
} }
@@ -3,14 +3,22 @@
namespace App\Services\Payments; namespace App\Services\Payments;
use App\Models\License; use App\Models\License;
use App\Models\LicenseDeliveryOutbox;
use App\Models\LicenseProduct; use App\Models\LicenseProduct;
use App\Services\Accounts\PurchasedAccountProvisioner;
use App\Services\Licensing\LicenseIssuer; use App\Services\Licensing\LicenseIssuer;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use RuntimeException; use RuntimeException;
use Throwable;
final class PurchaseProvisioner final class PurchaseProvisioner
{ {
public function __construct(private LicenseIssuer $issuer, private PaddleClient $paddle) {} public function __construct(
private LicenseIssuer $issuer,
private PaddleClient $paddle,
private PurchasedAccountProvisioner $accounts,
) {}
public function provision(string $provider, array $event): void public function provision(string $provider, array $event): void
{ {
@@ -26,16 +34,24 @@ final class PurchaseProvisioner
return; return;
} }
throw new RuntimeException('Unsupported payment provider.'); throw new RuntimeException(
'Unsupported payment provider.'
);
} }
private function provisionPaddle(array $event): void private function provisionPaddle(array $event): void
{ {
$type = (string) ($event['event_type'] ?? ''); $type = (string) ($event['event_type'] ?? '');
$data = $event['data'] ?? [];
$data = is_array($event['data'] ?? null)
? $event['data']
: [];
if (str_starts_with($type, 'subscription.')) { if (str_starts_with($type, 'subscription.')) {
$this->syncPaddleSubscription($type, $data); $this->syncPaddleSubscription(
$type,
$data
);
return; return;
} }
@@ -47,200 +63,724 @@ final class PurchaseProvisioner
} }
if ($type === 'transaction.refunded') { if ($type === 'transaction.refunded') {
$transactionID = (string) ($data['id'] ?? ''); $transactionID = $this->nullableString(
if ($transactionID !== '') { $data['id'] ?? null
License::where('payment_provider', 'paddle') );
->where('external_order_id', $transactionID)
if ($transactionID !== null) {
License::query()
->where('payment_provider', 'paddle')
->where(
'external_order_id',
$transactionID
)
->whereNull('external_subscription_id') ->whereNull('external_subscription_id')
->whereNull('revoked_at') ->whereNull('revoked_at')
->update(['revoked_at' => now(), 'revoke_reason' => 'Payment refunded']); ->update([
'revoked_at' => now(),
'revoke_reason' => 'Payment refunded',
]);
} }
} }
} }
private function syncPaddleSubscription(string $type, array $data): void private function syncPaddleSubscription(
{ string $type,
$subscriptionID = (string) ($data['id'] ?? ''); array $data
if ($subscriptionID === '') { ): void {
throw new RuntimeException('Paddle subscription webhook has no subscription ID.'); $subscriptionID = $this->nullableString(
$data['id'] ?? null
);
if ($subscriptionID === null) {
throw new RuntimeException(
'Paddle subscription webhook has no subscription ID.'
);
} }
$status = (string) ($data['status'] ?? ''); /*
$item = $this->firstRecurringItem($data['items'] ?? []); * Multiple Paddle events for the same subscription can arrive
$price = $item['price'] ?? []; * simultaneously.
$priceID = (string) ($price['id'] ?? $item['price_id'] ?? ''); *
$productID = (string) ($price['product_id'] ?? $item['product_id'] ?? ''); * The unique index protects database integrity, but by itself it
* cannot stop two workers from both observing "no licence" before
* either worker inserts the subscription ID.
*
* A MySQL advisory lock serialises provisioning for one Paddle
* subscription while still allowing unrelated subscriptions to be
* processed concurrently.
*/
$lockName = 'sonardesk:paddle:subscription:'
.$subscriptionID;
$license = License::where('payment_provider', 'paddle') $this->withMysqlLock(
->where('external_subscription_id', $subscriptionID) $lockName,
function () use (
$type,
$data,
$subscriptionID
): void {
$this->syncLockedPaddleSubscription(
$type,
$data,
$subscriptionID
);
}
);
}
private function syncLockedPaddleSubscription(
string $type,
array $data,
string $subscriptionID
): void {
$transactionID = $this->nullableString(
$data['transaction_id'] ?? null
);
$status = (string) ($data['status'] ?? '');
$items = is_array($data['items'] ?? null)
? $data['items']
: [];
$item = $this->firstRecurringItem($items);
$price = is_array($item['price'] ?? null)
? $item['price']
: [];
$priceID = (string) (
$price['id']
?? $item['price_id']
?? ''
);
$productID = (string) (
$price['product_id']
?? $item['product_id']
?? ''
);
/*
* The Paddle subscription ID is the authoritative identity for a
* recurring licence.
*
* lockForUpdate protects an existing licence while we mutate it.
* Creation of a missing licence is serialised by the advisory lock
* acquired by syncPaddleSubscription().
*/
$license = License::query()
->where('payment_provider', 'paddle')
->where(
'external_subscription_id',
$subscriptionID
)
->lockForUpdate()
->first(); ->first();
if (! $license) { if (! $license) {
$product = $this->mappedProduct('paddle', $priceID, $productID); $product = $this->mappedProduct(
$custom = is_array($data['custom_data'] ?? null) ? $data['custom_data'] : []; 'paddle',
$customer = $this->paddle->customer($this->nullableString($data['customer_id'] ?? null)); $priceID,
$productID
);
$custom = is_array($data['custom_data'] ?? null)
? $data['custom_data']
: [];
$customerID = $this->nullableString(
$data['customer_id'] ?? null
);
$customer = $this->paddle->customer(
$customerID
) ?? [];
[$license] = $this->issuer->issue( [$license] = $this->issuer->issue(
$product, $product,
$this->nullableString($custom['customer_name'] ?? $custom['name'] ?? $customer['name'] ?? null), $this->nullableString(
$this->nullableString($custom['customer_email'] ?? $custom['email'] ?? $customer['email'] ?? null), $custom['customer_name']
?? $custom['name']
?? $customer['name']
?? null
),
$this->nullableString(
$custom['customer_email']
?? $custom['email']
?? $customer['email']
?? null
),
'paddle', 'paddle',
$this->nullableString($data['transaction_id'] ?? null), $transactionID,
$this->nullableString($data['customer_id'] ?? null), $customerID,
true, true,
); );
$license->external_subscription_id = $subscriptionID;
} elseif ($priceID !== '' && $priceID !== $license->external_price_id) { /*
// Upgrades/downgrades change the entitlement attached to the existing key. * LicenseIssuer may return an existing licence if the
$product = $this->mappedProduct('paddle', $priceID, $productID); * transaction ID has already been seen.
*
* Re-read it with a row lock before attaching the Paddle
* subscription.
*/
$license = License::query()
->whereKey($license->getKey())
->lockForUpdate()
->firstOrFail();
/*
* Do not attach this subscription to a licence already owned
* by a different subscription.
*/
if (
$license->external_subscription_id !== null
&& $license->external_subscription_id
!== $subscriptionID
) {
throw new RuntimeException(
'Paddle transaction resolved to a licence '
.'belonging to another subscription.'
);
}
$license->external_subscription_id =
$subscriptionID;
} elseif (
$priceID !== ''
&& $priceID !== $license->external_price_id
) {
/*
* Upgrades and downgrades modify the entitlement attached to
* the existing licence key rather than issuing another key.
*/
$product = $this->mappedProduct(
'paddle',
$priceID,
$productID
);
$license->license_product_id = $product->id; $license->license_product_id = $product->id;
$license->edition = $product->edition; $license->edition = $product->edition;
$license->activation_limit = $product->activation_limit; $license->activation_limit =
$license->max_major_version = $product->max_major_version; $product->activation_limit;
$license->max_major_version =
$product->max_major_version;
} }
$periodEnd = $data['current_billing_period']['ends_at'] ?? null; $periodEnd =
$data['current_billing_period']['ends_at']
?? null;
$scheduled = $data['scheduled_change'] ?? null; $scheduled = $data['scheduled_change'] ?? null;
$cancelAt = is_array($scheduled) && ($scheduled['action'] ?? null) === 'cancel'
$cancelAt = is_array($scheduled)
&& ($scheduled['action'] ?? null) === 'cancel'
? ($scheduled['effective_at'] ?? null) ? ($scheduled['effective_at'] ?? null)
: null; : null;
$license->external_customer_id = $this->nullableString($data['customer_id'] ?? null) ?? $license->external_customer_id; $license->external_customer_id =
$license->external_price_id = $priceID !== '' ? $priceID : $license->external_price_id; $this->nullableString(
$license->subscription_status = $status !== '' ? $status : $license->subscription_status; $data['customer_id'] ?? null
$license->current_period_ends_at = $periodEnd ? Carbon::parse($periodEnd) : $license->current_period_ends_at; )
$license->cancel_scheduled_at = $cancelAt ? Carbon::parse($cancelAt) : null; ?? $license->external_customer_id;
if (in_array($status, ['active', 'trialing'], true)) { $this->backfillPaddleCustomer(
$license,
$data
);
/*
* Account provisioning is expected to be idempotent.
*/
$this->accounts->ensure(
$license->customer_email,
$license->customer_name
);
if ($priceID !== '') {
$license->external_price_id = $priceID;
}
if (
$transactionID !== null
&& ! $license->external_order_id
) {
$license->external_order_id =
$transactionID;
}
if ($status !== '') {
$license->subscription_status = $status;
}
if ($periodEnd) {
$license->current_period_ends_at =
Carbon::parse($periodEnd);
}
$license->cancel_scheduled_at = $cancelAt
? Carbon::parse($cancelAt)
: null;
/*
* Subscription entitlement.
*/
if (in_array(
$status,
['active', 'trialing'],
true
)) {
$license->revoked_at = null; $license->revoked_at = null;
$license->revoke_reason = null; $license->revoke_reason = null;
// Trialing/active subscriptions remain valid through the current period.
$license->expires_at = $periodEnd ? Carbon::parse($periodEnd) : null; $license->expires_at = $periodEnd
? Carbon::parse($periodEnd)
: null;
} elseif ($status === 'past_due') { } elseif ($status === 'past_due') {
// Do not instantly kill a licence on the first failed renewal. Preserve the /*
// period Paddle says is current; a later canceled event ends entitlement. * A failed renewal does not immediately destroy the
$license->expires_at = $periodEnd ? Carbon::parse($periodEnd) : ($license->expires_at ?? now()->addDays(7)); * entitlement. Preserve Paddle's current billing period when
} elseif (in_array($status, ['canceled', 'paused'], true)) { * available and otherwise provide the existing grace period.
*/
$license->expires_at = $periodEnd
? Carbon::parse($periodEnd)
: (
$license->expires_at
?? now()->addDays(7)
);
} elseif (in_array(
$status,
['canceled', 'paused'],
true
)) {
$license->expires_at = now(); $license->expires_at = now();
$license->revoked_at = now(); $license->revoked_at = now();
$license->revoke_reason = $status === 'paused' ? 'Subscription paused' : 'Subscription canceled';
$license->revoke_reason =
$status === 'paused'
? 'Subscription paused'
: 'Subscription canceled';
} }
$license->save(); $license->save();
} }
private function syncPaddleTransaction(array $data): void private function syncPaddleTransaction(
{ array $data
$subscriptionID = $this->nullableString($data['subscription_id'] ?? null); ): void {
$transactionID = (string) ($data['id'] ?? ''); $subscriptionID = $this->nullableString(
$data['subscription_id'] ?? null
);
// Recurring purchases are provisioned from subscription events because those $transactionID = $this->nullableString(
// events contain the authoritative trial/status/current-period state. $data['id'] ?? null
if ($subscriptionID) { );
License::where('payment_provider', 'paddle')
->where('external_subscription_id', $subscriptionID) /*
->whereNull('external_order_id') * A recurring transaction belongs to its subscription.
->update(['external_order_id' => $transactionID ?: null]); *
* Serialise this operation using the same lock as subscription
* provisioning. This prevents transaction.completed from updating
* the licence while a subscription event is still creating or
* modifying it.
*/
if ($subscriptionID !== null) {
$lockName = 'sonardesk:paddle:subscription:'
.$subscriptionID;
$this->withMysqlLock(
$lockName,
function () use (
$subscriptionID,
$transactionID
): void {
if ($transactionID === null) {
return;
}
$license = License::query()
->where(
'payment_provider',
'paddle'
)
->where(
'external_subscription_id',
$subscriptionID
)
->lockForUpdate()
->first();
/*
* transaction.completed may legitimately arrive before
* subscription.created. In that case there is nothing
* to update yet. The subscription event will create the
* licence and Paddle's transaction_id can populate the
* order ID.
*/
if (! $license) {
return;
}
if (! $license->external_order_id) {
$license->external_order_id =
$transactionID;
$license->save();
}
}
);
return; return;
} }
// Keep support for any future one-time Paddle licence product. /*
$item = $data['items'][0] ?? []; * No subscription ID means this may be a one-time purchase.
$price = $item['price'] ?? []; */
$priceID = (string) ($price['id'] ?? $item['price_id'] ?? ''); $items = is_array($data['items'] ?? null)
$productID = (string) ($price['product_id'] ?? $item['product_id'] ?? ''); ? $data['items']
$product = $this->mappedProduct('paddle', $priceID, $productID); : [];
$custom = is_array($data['custom_data'] ?? null) ? $data['custom_data'] : [];
$customer = $this->paddle->customer($this->nullableString($data['customer_id'] ?? null));
$this->issuer->issue( $item = $items[0] ?? [];
$price = is_array($item['price'] ?? null)
? $item['price']
: [];
$priceID = (string) (
$price['id']
?? $item['price_id']
?? ''
);
$productID = (string) (
$price['product_id']
?? $item['product_id']
?? ''
);
$product = $this->mappedProduct(
'paddle',
$priceID,
$productID
);
$custom = is_array($data['custom_data'] ?? null)
? $data['custom_data']
: [];
$customerID = $this->nullableString(
$data['customer_id'] ?? null
);
$customer = $this->paddle->customer(
$customerID
) ?? [];
[$license] = $this->issuer->issue(
$product, $product,
$this->nullableString($custom['customer_name'] ?? $custom['name'] ?? $customer['name'] ?? null), $this->nullableString(
$this->nullableString($custom['customer_email'] ?? $custom['email'] ?? $customer['email'] ?? null), $custom['customer_name']
?? $custom['name']
?? $customer['name']
?? null
),
$this->nullableString(
$custom['customer_email']
?? $custom['email']
?? $customer['email']
?? null
),
'paddle', 'paddle',
$transactionID, $transactionID,
$this->nullableString($data['customer_id'] ?? null), $customerID,
true, true,
); );
$this->accounts->ensure(
$license->customer_email,
$license->customer_name
);
} }
private function provisionLemonSqueezy(array $event): void private function provisionLemonSqueezy(
{ array $event
$type = (string) ($event['meta']['event_name'] ?? ''); ): void {
$attributes = $event['data']['attributes'] ?? []; $type = (string) (
$order = (string) ($event['data']['id'] ?? $attributes['order_id'] ?? ''); $event['meta']['event_name']
?? ''
);
if (in_array($type, ['order_refunded', 'subscription_expired'], true)) { $attributes = is_array(
License::where('payment_provider', 'lemon-squeezy') $event['data']['attributes'] ?? null
)
? $event['data']['attributes']
: [];
$order = (string) (
$event['data']['id']
?? $attributes['order_id']
?? ''
);
if (in_array(
$type,
[
'order_refunded',
'subscription_expired',
],
true
)) {
License::query()
->where(
'payment_provider',
'lemon-squeezy'
)
->where('external_order_id', $order) ->where('external_order_id', $order)
->whereNull('revoked_at') ->whereNull('revoked_at')
->update(['revoked_at' => now(), 'revoke_reason' => 'Payment refunded or subscription expired']); ->update([
'revoked_at' => now(),
'revoke_reason' =>
'Payment refunded or subscription expired',
]);
return; return;
} }
if (! in_array($type, ['order_created', 'subscription_created'], true)) { if (! in_array(
$type,
[
'order_created',
'subscription_created',
],
true
)) {
return; return;
} }
$product = $this->mappedProduct( $product = $this->mappedProduct(
'lemon-squeezy', 'lemon-squeezy',
(string) ($attributes['variant_id'] ?? ''), (string) (
(string) ($attributes['product_id'] ?? ''), $attributes['variant_id']
?? ''
),
(string) (
$attributes['product_id']
?? ''
),
); );
$this->issuer->issue( [$license] = $this->issuer->issue(
$product, $product,
$this->nullableString($attributes['user_name'] ?? null), $this->nullableString(
$this->nullableString($attributes['user_email'] ?? null), $attributes['user_name'] ?? null
),
$this->nullableString(
$attributes['user_email'] ?? null
),
'lemon-squeezy', 'lemon-squeezy',
$order, $order,
$this->nullableString($attributes['customer_id'] ?? null), $this->nullableString(
$attributes['customer_id'] ?? null
),
true, true,
); );
$this->accounts->ensure(
$license->customer_email,
$license->customer_name
);
} }
private function mappedProduct(string $provider, string $priceID, string $productID): LicenseProduct private function backfillPaddleCustomer(
{ License $license,
// Price IDs are the authoritative mapping because monthly and annual prices array $data
// intentionally share the same Paddle product ID. ): void {
$custom = is_array(
$data['custom_data'] ?? null
)
? $data['custom_data']
: [];
$customerID = $this->nullableString(
$data['customer_id']
?? $license->external_customer_id
);
$customer = $this->paddle->customer(
$customerID
) ?? [];
$name = $this->nullableString(
$custom['customer_name']
?? $custom['name']
?? $customer['name']
?? null
);
$email = $this->nullableString(
$custom['customer_email']
?? $custom['email']
?? $customer['email']
?? null
);
if (! $license->customer_name && $name) {
$license->customer_name = $name;
}
if (! $license->customer_email && $email) {
$email = strtolower($email);
$license->customer_email = $email;
LicenseDeliveryOutbox::query()
->where(
'license_id',
$license->id
)
->whereNull('delivered_at')
->update([
'recipient_email' => $email,
]);
}
}
private function mappedProduct(
string $provider,
string $priceID,
string $productID
): LicenseProduct {
/*
* Price IDs are authoritative because multiple billing intervals
* can belong to the same Paddle product.
*/
if ($priceID !== '') { if ($priceID !== '') {
$product = LicenseProduct::where('payment_provider', $provider) $product = LicenseProduct::query()
->where('external_price_id', $priceID) ->where(
'payment_provider',
$provider
)
->where(
'external_price_id',
$priceID
)
->where('active', true) ->where('active', true)
->first(); ->first();
} else { } else {
$product = LicenseProduct::where('payment_provider', $provider) $product = LicenseProduct::query()
->where('external_product_id', $productID) ->where(
'payment_provider',
$provider
)
->where(
'external_product_id',
$productID
)
->where('active', true) ->where('active', true)
->first(); ->first();
} }
if (! $product) { if (! $product) {
throw new RuntimeException("No active licence product maps to {$provider} price {$priceID} / product {$productID}."); throw new RuntimeException(
"No active licence product maps to {$provider} "
."price {$priceID} / product {$productID}."
);
} }
return $product; return $product;
} }
private function firstRecurringItem(array $items): array private function firstRecurringItem(
{ array $items
): array {
foreach ($items as $item) { foreach ($items as $item) {
if (! is_array($item)) {
continue;
}
if (($item['recurring'] ?? true) === true) { if (($item['recurring'] ?? true) === true) {
return $item; return $item;
} }
} }
return $items[0] ?? []; $first = $items[0] ?? [];
return is_array($first)
? $first
: [];
} }
private function nullableString(mixed $value): ?string private function nullableString(
{ mixed $value
): ?string {
if (! is_scalar($value)) { if (! is_scalar($value)) {
return null; return null;
} }
$value = trim((string) $value); $value = trim((string) $value);
return $value === '' ? null : $value; return $value === ''
? null
: $value;
}
/**
* Execute a callback while holding a MySQL advisory lock.
*
* The lock is connection-scoped, so it must always be explicitly
* released in finally. A short timeout is sufficient because Paddle
* will retry failed webhook deliveries and the controller also retries
* database deadlocks.
*/
private function withMysqlLock(
string $name,
callable $callback
): mixed {
/*
* MySQL advisory lock names have a finite length. Hashing also
* prevents provider supplied identifiers from creating unusual
* lock names.
*/
$lockName = 'sd:'.substr(hash('sha256', $name), 0, 61);
$row = DB::selectOne(
'SELECT GET_LOCK(?, ?) AS acquired',
[
$lockName,
10,
]
);
$acquired = (int) (
$row->acquired
?? 0
);
if ($acquired !== 1) {
throw new RuntimeException(
'Could not acquire Paddle subscription lock.'
);
}
try {
return $callback();
} finally {
try {
DB::selectOne(
'SELECT RELEASE_LOCK(?) AS released',
[$lockName]
);
} catch (Throwable $e) {
/*
* Do not hide the original processing exception merely
* because releasing an advisory lock failed. MySQL also
* releases connection-owned locks when the connection
* closes.
*/
report($e);
}
}
} }
} }
+6 -2
View File
@@ -1,6 +1,7 @@
<?php <?php
return [ return [
'environment' => env('SONARDESK_ENVIRONMENT', 'develop'),
'product_code' => env('SONARDESK_PRODUCT_CODE', 'sonardesk'), 'product_code' => env('SONARDESK_PRODUCT_CODE', 'sonardesk'),
'private_key_path' => env('SONARDESK_LICENSE_PRIVATE_KEY_PATH'), 'private_key_path' => env('SONARDESK_LICENSE_PRIVATE_KEY_PATH'),
'public_key' => env('SONARDESK_LICENSE_PUBLIC_KEY'), 'public_key' => env('SONARDESK_LICENSE_PUBLIC_KEY'),
@@ -26,6 +27,7 @@ return [
'alert_emails' => array_values(array_filter(array_map('trim', explode(',', (string) env('SONARDESK_SUPPORT_ALERT_EMAILS', ''))))), 'alert_emails' => array_values(array_filter(array_map('trim', explode(',', (string) env('SONARDESK_SUPPORT_ALERT_EMAILS', ''))))),
'notify_admin_users' => filter_var(env('SONARDESK_SUPPORT_NOTIFY_ADMINS', true), FILTER_VALIDATE_BOOL), 'notify_admin_users' => filter_var(env('SONARDESK_SUPPORT_NOTIFY_ADMINS', true), FILTER_VALIDATE_BOOL),
], ],
'paddle' => [ 'paddle' => [
'api_key' => env('PADDLE_API_KEY'), 'api_key' => env('PADDLE_API_KEY'),
'client_side_token' => env('PADDLE_CLIENT_SIDE_TOKEN'), 'client_side_token' => env('PADDLE_CLIENT_SIDE_TOKEN'),
@@ -33,13 +35,15 @@ return [
'api_url' => env('PADDLE_API_URL', env('APP_ENV') === 'production' ? 'https://api.paddle.com' : 'https://sandbox-api.paddle.com'), 'api_url' => env('PADDLE_API_URL', env('APP_ENV') === 'production' ? 'https://api.paddle.com' : 'https://sandbox-api.paddle.com'),
'webhook_secret' => env('PADDLE_WEBHOOK_SECRET'), 'webhook_secret' => env('PADDLE_WEBHOOK_SECRET'),
'timestamp_tolerance' => (int) env('PADDLE_WEBHOOK_TOLERANCE', 300), 'timestamp_tolerance' => (int) env('PADDLE_WEBHOOK_TOLERANCE', 300),
'personal_product_id' => env('PADDLE_PERSONAL_PRODUCT_ID'),
// SonarDesk is one Paddle product with four recurring prices.
'product_id' => env('PADDLE_PRODUCT_ID'),
'personal_monthly_price_id' => env('PADDLE_PERSONAL_MONTHLY_PRICE_ID'), 'personal_monthly_price_id' => env('PADDLE_PERSONAL_MONTHLY_PRICE_ID'),
'personal_annual_price_id' => env('PADDLE_PERSONAL_ANNUAL_PRICE_ID'), 'personal_annual_price_id' => env('PADDLE_PERSONAL_ANNUAL_PRICE_ID'),
'professional_product_id' => env('PADDLE_PROFESSIONAL_PRODUCT_ID'),
'professional_monthly_price_id' => env('PADDLE_PROFESSIONAL_MONTHLY_PRICE_ID'), 'professional_monthly_price_id' => env('PADDLE_PROFESSIONAL_MONTHLY_PRICE_ID'),
'professional_annual_price_id' => env('PADDLE_PROFESSIONAL_ANNUAL_PRICE_ID'), 'professional_annual_price_id' => env('PADDLE_PROFESSIONAL_ANNUAL_PRICE_ID'),
], ],
'lemon_squeezy' => [ 'lemon_squeezy' => [
'webhook_secret' => env('LEMON_SQUEEZY_WEBHOOK_SECRET'), 'webhook_secret' => env('LEMON_SQUEEZY_WEBHOOK_SECRET'),
], ],
Binary file not shown.
@@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('sonardesk_releases', function (Blueprint $table): void {
$table->id();
$table->string('version', 40);
$table->unsignedInteger('build_number')->default(1);
$table->string('channel', 20)->default('stable');
$table->string('minimum_macos', 30)->nullable();
$table->string('file_path')->nullable();
$table->string('original_filename')->nullable();
$table->unsignedBigInteger('file_size')->nullable();
$table->string('sha256', 64)->nullable();
$table->text('sparkle_signature')->nullable();
$table->text('release_notes')->nullable();
$table->boolean('is_published')->default(false)->index();
$table->boolean('is_featured')->default(false)->index();
$table->timestamp('published_at')->nullable()->index();
$table->unsignedBigInteger('download_count')->default(0);
$table->timestamps();
$table->unique(['version', 'build_number', 'channel']);
});
Schema::create('sonardesk_site_settings', function (Blueprint $table): void {
$table->string('key')->primary();
$table->text('value')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('sonardesk_site_settings');
Schema::dropIfExists('sonardesk_releases');
}
};
@@ -10,10 +10,10 @@ class PaddleCatalogSeeder extends Seeder
public function run(): void public function run(): void
{ {
$products = [ $products = [
['code' => 'personal-monthly', 'name' => 'SonarDesk Personal Monthly', 'edition' => 'Personal', 'activation_limit' => 1, 'external_product_id' => config('sonardesk.paddle.personal_product_id'), 'external_price_id' => config('sonardesk.paddle.personal_monthly_price_id')], ['code' => 'personal-monthly', 'name' => 'SonarDesk Personal Monthly', 'edition' => 'Personal', 'activation_limit' => 1, 'external_product_id' => config('sonardesk.paddle.product_id'), 'external_price_id' => config('sonardesk.paddle.personal_monthly_price_id')],
['code' => 'personal-annual', 'name' => 'SonarDesk Personal Annual', 'edition' => 'Personal', 'activation_limit' => 1, 'external_product_id' => config('sonardesk.paddle.personal_product_id'), 'external_price_id' => config('sonardesk.paddle.personal_annual_price_id')], ['code' => 'personal-annual', 'name' => 'SonarDesk Personal Annual', 'edition' => 'Personal', 'activation_limit' => 1, 'external_product_id' => config('sonardesk.paddle.product_id'), 'external_price_id' => config('sonardesk.paddle.personal_annual_price_id')],
['code' => 'professional-monthly', 'name' => 'SonarDesk Professional Monthly', 'edition' => 'Professional', 'activation_limit' => 3, 'external_product_id' => config('sonardesk.paddle.professional_product_id'), 'external_price_id' => config('sonardesk.paddle.professional_monthly_price_id')], ['code' => 'professional-monthly', 'name' => 'SonarDesk Professional Monthly', 'edition' => 'Professional', 'activation_limit' => 3, 'external_product_id' => config('sonardesk.paddle.product_id'), 'external_price_id' => config('sonardesk.paddle.professional_monthly_price_id')],
['code' => 'professional-annual', 'name' => 'SonarDesk Professional Annual', 'edition' => 'Professional', 'activation_limit' => 3, 'external_product_id' => config('sonardesk.paddle.professional_product_id'), 'external_price_id' => config('sonardesk.paddle.professional_annual_price_id')], ['code' => 'professional-annual', 'name' => 'SonarDesk Professional Annual', 'edition' => 'Professional', 'activation_limit' => 3, 'external_product_id' => config('sonardesk.paddle.product_id'), 'external_price_id' => config('sonardesk.paddle.professional_annual_price_id')],
]; ];
foreach ($products as $data) { foreach ($products as $data) {
+1 -1
View File
@@ -25,7 +25,7 @@ host('main')
->set('deploy_path', '/var/www/sonardesk/main'); ->set('deploy_path', '/var/www/sonardesk/main');
host('stage') host('stage')
->set('hostname', 'sonardesk.absdev.net') ->set('hostname', 'sonardesk.stage.absdev.net')
->set('remote_user', 'mag') ->set('remote_user', 'mag')
->set('branch', 'stage') ->set('branch', 'stage')
->set('deploy_path', '/var/www/sonardesk/stage'); ->set('deploy_path', '/var/www/sonardesk/stage');
@@ -0,0 +1,415 @@
# SonarDesk Commercial Launch & Selling Guide
## 1. Recommended commercial model
Launch SonarDesk as a **direct-download macOS product with recurring licensing**. Use **Paddle as the production Merchant of Record (MoR)** for the initial launch because this codebase already has Paddle checkout, subscription lifecycle handling, customer lookup, upgrades/cancellation, webhook verification and product mapping implemented.
Do **not** operate Paddle and Lemon Squeezy as two public checkout systems at launch. Pick one system of record. Running two providers doubles reconciliation, refund, subscription-state and support complexity.
### Launch pricing
| Plan | Monthly | Annual | Activations | Positioning |
|---|---:|---:|---:|---|
| Personal | £2.99/month | £24.99/year | 1 Mac | Individual developer |
| Professional | £4.99/month | £44.99/year | 3 Macs | Multi-Mac / professional workflow |
Both plans should include the complete feature set and a **7-day free trial**. Do not artificially cripple Personal. The upgrade is based on activation allowance, which is simple to understand and easy to support.
Annual should be the visual default. At these prices it produces better unit economics than very low monthly pricing because both Paddle and Lemon Squeezy publish a fixed transaction component in addition to their percentage fee.
### Launch offer
For the first 30 days, use a single promotion rather than permanently reducing the list price:
- 20% off the first annual term using a launch coupon such as `LAUNCH20`.
- Do not discount monthly plans.
- Do not promise “lifetime” access while the licensing model is subscription-based.
- Keep the public list price stable so the product does not look disposable or permanently on sale.
## 2. Why Paddle is the recommended launch provider
Paddle's public pay-as-you-go pricing is 5% + $0.50 per checkout transaction and includes payments/billing, tax compliance and related Merchant of Record services. Paddle supports a sandbox, checkout, subscription lifecycle events and signed webhooks.
This project is already designed around Paddle:
- `CheckoutController` supplies Paddle price IDs to Paddle.js.
- `/api/v1/webhooks/paddle` accepts Paddle events.
- `PaddleWebhookVerifier` verifies signed webhook requests.
- `PurchaseProvisioner` provisions from `subscription.*` and `transaction.completed` events.
- `PaddleCatalogSeeder` maps Paddle products/prices to SonarDesk licence products.
- The portal can upgrade Personal to Professional and cancel Paddle subscriptions.
That makes Paddle the lowest-risk route to a release.
Official references:
- Paddle pricing: https://www.paddle.com/pricing
- Paddle quickstart: https://developer.paddle.com/get-started/quickstart/
- Paddle webhooks: https://developer.paddle.com/webhooks/
- Paddle webhook simulator: https://developer.paddle.com/webhooks/simulator/
## 3. Paddle setup — complete production checklist
### A. Create/complete the Paddle account
1. Create the Paddle account and complete business verification.
2. Add the legal/business details that Paddle requests.
3. Configure payout details.
4. Add the production website/domain.
5. Complete Paddle's domain/default payment link approval before launch.
6. Keep the account in sandbox while integrating. Switch the application to production only after the full purchase flow has passed end-to-end testing.
### B. Create the catalog
Create two Paddle products:
**SonarDesk Personal**
- Monthly recurring price: **£3.99 GBP / month**
- Annual recurring price: **£29.99 GBP / year**
- Trial: **7 days**
**SonarDesk Professional**
- Monthly recurring price: **£6.99 GBP / month**
- Annual recurring price: **£59.99 GBP / year**
- Trial: **7 days**
Use tax-inclusive/exclusive behaviour consistently with the checkout experience you want Paddle to present. Paddle, as MoR, calculates the applicable transaction taxes in checkout.
Copy the resulting two product IDs and four price IDs.
### C. Create application credentials
In Paddle Developer Tools:
1. Create a server API key for the licensing application.
2. Create a client-side token for Paddle.js.
3. Never put the server API key into browser JavaScript or source control.
4. Keep sandbox and production credentials separate.
### D. Configure the webhook
Create a Paddle notification destination:
`https://YOUR-DOMAIN/api/v1/webhooks/paddle`
Subscribe at minimum to:
- `transaction.completed`
- `subscription.created`
- `subscription.updated`
- `subscription.activated`
- `subscription.canceled`
- `subscription.paused`
- `subscription.resumed`
- `subscription.past_due`
The current code uses subscription events as the authoritative state for recurring licences. Paddle documents `transaction.completed` as the point at which transaction processing is complete. Paddle also documents at-least-once webhook delivery, so webhook handlers must remain idempotent; this project stores webhook event IDs for that purpose.
Copy the webhook endpoint secret.
### E. Production `../.env`
Set the following values on the production server. Do not commit them.
```dotenv
APP_ENV=production
APP_DEBUG=false
APP_URL=https://YOUR-DOMAIN
PADDLE_ENVIRONMENT=production
PADDLE_API_URL=https://api.paddle.com
PADDLE_API_KEY=YOUR_PRODUCTION_API_KEY
PADDLE_CLIENT_SIDE_TOKEN=YOUR_PRODUCTION_CLIENT_TOKEN
PADDLE_WEBHOOK_SECRET=YOUR_PRODUCTION_WEBHOOK_SECRET
PADDLE_WEBHOOK_TOLERANCE=300
PADDLE_PERSONAL_PRODUCT_ID=pro_...
PADDLE_PERSONAL_MONTHLY_PRICE_ID=pri_...
PADDLE_PERSONAL_ANNUAL_PRICE_ID=pri_...
PADDLE_PROFESSIONAL_PRODUCT_ID=pro_...
PADDLE_PROFESSIONAL_MONTHLY_PRICE_ID=pri_...
PADDLE_PROFESSIONAL_ANNUAL_PRICE_ID=pri_...
```
Also configure the existing SonarDesk licence signing keys, mailer, support email and application database.
### F. Seed the price mapping
After the production IDs are in `../.env`:
```bash
php artisan config:clear
php artisan db:seed --class=PaddleCatalogSeeder
php artisan config:cache
```
Check the `license_products` table and confirm there are four active Paddle rows with the correct product/price IDs and activation limits.
### G. Test in sandbox before production
Test every one of these scenarios:
1. Personal monthly trial signup.
2. Personal annual trial signup.
3. Professional monthly trial signup.
4. Professional annual trial signup.
5. Licence email is delivered.
6. Licence activates in SonarDesk.
7. Second activation is rejected for Personal.
8. Up to three activations work for Professional.
9. Portal displays the licence.
10. Deactivate a Mac and activate a replacement Mac.
11. Upgrade Personal to Professional.
12. Cancel a subscription.
13. Simulate a failed payment / past-due subscription.
14. Simulate cancellation/expiry and confirm entitlement behaviour.
15. Replay the same webhook and confirm a duplicate licence is not created.
16. Refund test transaction and verify the intended entitlement state.
17. Verify support and transactional email deliverability.
18. Verify checkout on Safari and Chrome, including a private window.
Use Paddle's webhook simulator for lifecycle testing as well as a real sandbox checkout.
### H. Go live
1. Create the production catalog (do not reuse sandbox IDs).
2. Add production API key/client token/webhook secret.
3. Seed the production catalog mapping.
4. Change `PADDLE_ENVIRONMENT=production` and `PADDLE_API_URL=https://api.paddle.com`.
5. Run `php artisan config:cache`.
6. Make one real low-value purchase with a controlled account.
7. Confirm the webhook, licence issuance, email, activation, portal and cancellation path.
8. Refund the test purchase in Paddle after validation if appropriate.
## 4. Lemon Squeezy — alternative setup
Lemon Squeezy is also a Merchant of Record and publishes base ecommerce pricing of 5% + $0.50 per transaction, with additional fees in some cases (for example certain international, PayPal and subscription payments). It also provides software licence keys and a License API.
Official references:
- Pricing: https://www.lemonsqueezy.com/pricing
- Fees: https://docs.lemonsqueezy.com/help/getting-started/fees
- Webhooks: https://docs.lemonsqueezy.com/help/webhooks
- License API: https://docs.lemonsqueezy.com/api/license-api
### Important for this codebase
The application has a Lemon Squeezy webhook verifier and basic purchase provisioning, but the **public checkout and customer portal are currently Paddle-first**. Lemon Squeezy should therefore be treated as an alternative migration path, not a second launch checkout, until its full subscription lifecycle and portal actions are implemented and tested.
If switching to Lemon Squeezy:
1. Create a store and complete activation/verification.
2. Create Personal and Professional products/variants with the same public pricing.
3. Decide whether SonarDesk's own signed licence system remains authoritative (recommended for consistency with the Mac app) or whether you migrate fully to Lemon Squeezy's License API. Do not mix both without a clear source of truth.
4. Create a webhook at:
`https://YOUR-DOMAIN/api/v1/webhooks/lemon-squeezy`
5. Generate a strong signing secret and set:
```dotenv
LEMON_SQUEEZY_WEBHOOK_SECRET=YOUR_SECRET
```
6. Subscribe to the events the application needs, including order creation/refund and all relevant subscription state events.
7. Add `license_products` mappings where `payment_provider=lemon-squeezy`, mapping Lemon Squeezy variant IDs to `external_price_id`.
8. Implement/verify checkout links or overlay checkout on `/buy` before exposing Lemon Squeezy publicly.
9. Extend `PurchaseProvisioner::provisionLemonSqueezy()` so subscription updates, cancellations, pauses, payment failures/recovery and upgrades are handled with the same completeness as Paddle.
10. Add customer-portal actions for cancellation/change-of-plan or route customers to Lemon Squeezy's supported customer management flow.
11. Run the same end-to-end test matrix used for Paddle.
Lemon Squeezy webhooks are signed and should always be verified. Their documentation states that unsuccessful webhook deliveries are retried; keep processing idempotent and persist event identity/state.
## 5. Website conversion strategy
The updated site now has a clearer commercial funnel:
`Home → Features → Pricing → Paddle checkout → licence email → app activation → customer portal`
### Home page
The home page should answer these questions in order:
1. What is it? A native macOS companion for SonarQube.
2. Why should I care? Faster access to project health and issues without browser-tab friction.
3. What does it do? Projects, quality gates, measures, issues, multiple servers and secure token storage.
4. Is it safe? SonarQube tokens are stored in macOS Keychain and server permissions remain authoritative.
5. What does it cost? Clear Personal and Professional pricing.
6. What should I do next? Start the 7-day free trial.
### Product language to keep consistent
**Primary one-line pitch**
> SonarDesk is a native macOS companion for SonarQube that puts project health, quality gates and issues in one focused desktop workspace.
**Short description**
> Monitor SonarQube projects, quality gates, bugs, vulnerabilities, code smells and measures without living in a browser.
**Positioning rule**
Never imply SonarDesk replaces SonarQube. The stronger and more credible position is: **SonarQube remains the source of truth; SonarDesk makes everyday monitoring faster on macOS.**
## 6. Launch channels
### Phase 1 — pre-launch (7–14 days)
- Finish notarised/signable release build and direct-download hosting.
- Finish Paddle production approval.
- Record a 30–60 second clean screen capture showing: connect server → project dashboard → issues → issue details.
- Produce 4–6 high-resolution screenshots.
- Create the launch coupon.
- Test website analytics and conversion events.
- Prepare support macros for activation, lost key, machine replacement, refund and SonarQube connection problems.
- Recruit 5–10 testers from existing developer contacts for final feedback and testimonials you have permission to publish.
### Phase 2 — launch day
Publish in this order:
1. SonarDesk release page on your own site.
2. LinkedIn launch post.
3. Medium article.
4. Relevant developer communities where self-promotion is allowed and useful.
5. Existing customer/user mailing list only if recipients have consented to product marketing.
Do not spam generic communities. Show the product solving a concrete SonarQube workflow problem.
### Phase 3 — first 30 days
Publish practical content rather than repeating sales copy:
- “A faster way to review SonarQube issues on macOS”
- “How I use SonarQube quality gates in a desktop workflow”
- “What SonarDesk stores locally and how tokens are protected”
- “Personal vs Professional: which SonarDesk licence do I need?”
- Short release notes for every update
Track: website → pricing clicks, checkout starts, trial starts, trial-to-paid conversion, cancellations, refunds, activation failures and support topics.
## 7. LinkedIn release post
**SonarDesk 1.0 is now available for macOS.**
If SonarQube is part of your development workflow, you probably know the routine: open the browser, find the project, check the quality gate, scan the latest issues, then switch back to your editor.
I built SonarDesk to make that recurring workflow faster on the Mac.
SonarDesk is a native macOS companion for SonarQube. It brings project health, quality gates, bugs, vulnerabilities, code smells and measures into one focused desktop workspace.
Version 1.0 includes:
- Project dashboards and quality gate status
- Bugs, vulnerabilities and code smells
- Detailed issue views and filtering
- Server-side pagination for larger issue sets
- Multiple SonarQube server configurations
- SonarQube token storage in macOS Keychain
- Direct licensing and a self-service customer portal
SonarQube remains the source of truth. SonarDesk is designed to make the information you check throughout the day quicker to reach and easier to keep visible.
Personal is £24.99/year for one active Mac. Professional is £44.99/year for up to three active Macs. Monthly options are also available, and every plan starts with a 7-day free trial.
Learn more and try SonarDesk: **YOUR_RELEASE_URL**
#macOS #SonarQube #SoftwareDevelopment #CodeQuality #DeveloperTools
## 8. Medium launch article
# Introducing SonarDesk: a native SonarQube companion for macOS
SonarQube is a valuable part of the software-development toolchain. It gives teams a central place to understand code quality, security findings and the state of quality gates. But the information developers need throughout the day often lives one browser context-switch away from the work itself.
That is the problem I wanted SonarDesk to solve.
## A focused Mac workspace for SonarQube
SonarDesk is a native macOS companion for SonarQube. It is not a replacement for SonarQube, and it does not try to become another analysis engine. Your SonarQube server remains the source of truth.
Instead, SonarDesk focuses on the recurring desktop workflow: open a project, see its health, check the quality gate, browse bugs, vulnerabilities and code smells, inspect an issue, and get back to the code.
## What SonarDesk 1.0 includes
The first release includes project dashboards, quality gate status, important measures, issue browsing and detailed issue views. Issue lists support practical filtering and server-side pagination, which helps keep the workflow manageable as result sets grow.
SonarDesk can also keep multiple SonarQube server configurations available from the same Mac application. SonarQube access tokens are stored using macOS Keychain, and access remains governed by the permissions of the credentials you provide.
## Why a native app?
The browser is excellent for deep investigation and administration, and SonarDesk is not intended to remove it from the workflow. The benefit of a dedicated desktop app is focus. The project-health information you check repeatedly can have a consistent place on the Mac, separate from documentation, tickets, dashboards and all the other tabs competing for attention.
## Direct distribution and licensing
SonarDesk is distributed directly for macOS. Customers receive a licence and can manage activations through the SonarDesk customer portal.
There are two plans. Personal supports one active Mac and costs £24.99 per year or £2.99 per month. Professional supports up to three active Macs and costs £44.99 per year or £4.99 per month. Both include the complete feature set and start with a seven-day free trial.
## What comes next
Version 1.0 establishes the core SonarQube monitoring workflow on macOS. Future releases will continue to focus on making project and issue review faster, clearer and more useful without turning SonarDesk into a replacement for the platform it connects to.
If SonarQube is part of your day-to-day development workflow and you work on a Mac, you can learn more and start a free trial here:
**YOUR_RELEASE_URL**
## 9. Short announcement copy
**SonarDesk 1.0 for macOS is available now.** Monitor SonarQube project health, quality gates, bugs, vulnerabilities and code smells from a focused native Mac app. Personal and Professional plans include a 7-day free trial. **YOUR_RELEASE_URL**
## 10. Product listing copy
### Name
SonarDesk
### Tagline
Native SonarQube monitoring for macOS.
### 80-character style description
Monitor SonarQube projects, quality gates and issues from your Mac.
### Full product description
SonarDesk is a native macOS companion for developers and engineering teams using SonarQube. It provides a focused desktop view of project health, quality gates, measures, bugs, vulnerabilities and code smells, with detailed issue views, filtering, server-side pagination and support for multiple SonarQube server configurations. SonarQube tokens are stored in macOS Keychain, and your existing SonarQube permissions remain authoritative.
## 11. Release readiness checklist
Do not announce the public release until all items below are true:
- Production app is signed with Developer ID and notarised.
- DMG/ZIP download is served over HTTPS.
- Version and build number are final.
- Sparkle/update mechanism, if used, is production-configured and signed.
- Privacy policy, terms and cookie information match actual behaviour.
- Support address is monitored.
- Paddle production checkout is approved and working.
- Webhook signature verification is enabled.
- Production webhook endpoint returns successful responses.
- Production product and price IDs are mapped in the database.
- Licence signing private key is backed up securely and is not in source control.
- Licence activation/validation/deactivation work from a clean Mac.
- Purchase email and support email pass SPF/DKIM/DMARC checks.
- Trial, paid renewal, cancellation, failed payment and refund paths are tested.
- Website has working Home, Features, Pricing, Release, Support, Privacy and Terms pages.
- Open Graph image and favicon render correctly.
- A real production purchase has been tested end-to-end.
- Download analytics and checkout conversion events are visible.
- Launch screenshots/video are ready.
- LinkedIn and Medium copy has been proofread and final URLs inserted.
## 12. First commercial targets
For the first 90 days, optimize for learning rather than vanity traffic. Review weekly:
- Unique visitors to the product page
- Pricing-page visits
- Checkout starts
- Trial starts
- Trial-to-paid conversion
- Annual vs monthly mix
- Personal vs Professional mix
- Cancellation/refund reasons
- Activation failure rate
- Top support issues
Do not change pricing every week. Collect enough data to understand whether the problem is traffic, positioning, checkout friction, onboarding or product value before changing the commercial model.
@@ -0,0 +1,11 @@
## Licensing/email release checks
- Production app endpoint: `https://sonardsk.absdev.net`.
- Stage app endpoint: `https://sonardesk.stage.absdev.net`.
- Production must use Paddle production credentials and the production webhook destination.
- `MAIL_MAILER` must be a real delivery transport in production; never `log`.
- Run the Laravel scheduler continuously so pending licence-key emails retry automatically.
- Verify `php artisan sonardesk:license-deliver` reports no unexpected pending delivery failures.
- Verify a real Paddle purchase creates one licence, sends one key email, activates a clean Mac, validates after relaunch, and deactivates from the portal.
- Never deploy `.env`, signing seeds, Paddle secrets, or webhook secrets from source control.
@@ -0,0 +1,28 @@
# SonarDesk release distribution
The website now owns SonarDesk release distribution from the admin area.
## Deploy
1. Deploy the code normally.
2. Run `php artisan migrate --force`.
3. Ensure Laravel's `storage` directory remains shared/persistent between releases (the existing Deployer configuration already shares `storage`).
4. Ensure the web/PHP upload limits are large enough for the DMG. For a 1 GB application limit, configure nginx `client_max_body_size` and PHP `upload_max_filesize` / `post_max_size` accordingly.
## Publishing a build
Open **Admin → Releases → New release**. Enter the marketing version, build number, minimum macOS version and release notes, upload the signed/notarized DMG or ZIP, and paste the Sparkle EdDSA signature when Sparkle is enabled. Save as a draft until the package is ready, then publish it.
Published stable releases automatically feed:
- `/release` — customer-facing distribution centre
- `/download` — latest stable package
- `/updates/appcast.xml` — Sparkle feed
- the licence portal download call-to-action
- licence delivery email download call-to-action
The SHA-256 checksum and file size are calculated on upload. Download counts are tracked per release.
## Sparkle
Generate the EdDSA signature with the Sparkle release tooling and paste it into the release editor. Configure the macOS app's feed URL to the production `/updates/appcast.xml` endpoint. Do not place Sparkle private signing keys on the public web server.
View File
@@ -2,5 +2,5 @@
@section('content') @section('content')
<section class="portal-header"><div><span class="eyebrow">Admin</span><h1>Licensing administration</h1><p class="lead compact">Manage licences, activations and customer support.</p></div></section> <section class="portal-header"><div><span class="eyebrow">Admin</span><h1>Licensing administration</h1><p class="lead compact">Manage licences, activations and customer support.</p></div></section>
<div class="grid"><div class="card"><strong>{{ $licenseCount }}</strong><p>Licences</p></div><div class="card"><strong>{{ $activeCount }}</strong><p>Not revoked</p></div><div class="card"><strong>{{ $activationCount }}</strong><p>Active installations</p></div><div class="card"><strong>{{ $userCount }}</strong><p>Portal users</p></div></div> <div class="grid"><div class="card"><strong>{{ $licenseCount }}</strong><p>Licences</p></div><div class="card"><strong>{{ $activeCount }}</strong><p>Not revoked</p></div><div class="card"><strong>{{ $activationCount }}</strong><p>Active installations</p></div><div class="card"><strong>{{ $userCount }}</strong><p>Portal users</p></div></div>
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:18px"><a class="btn" href="{{ route('admin.licenses') }}">Manage licences</a><a class="btn btn-secondary" href="{{ route('admin.cms.index') }}">Manage content</a><a class="btn btn-secondary" href="{{ route('admin.support.index') }}">Support inbox</a></div> <div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:18px"><a class="btn btn-primary" href="{{ route('admin.releases.index') }}">Manage releases</a><a class="btn" href="{{ route('admin.licenses') }}">Manage licences</a><a class="btn btn-secondary" href="{{ route('admin.cms.index') }}">Manage content</a><a class="btn btn-secondary" href="{{ route('admin.support.index') }}">Support inbox</a></div>
@endsection @endsection
@@ -33,8 +33,8 @@
<div class="card checkout-status" id="checkout-status" hidden> <div class="card checkout-status" id="checkout-status" hidden>
<h2>Purchase received</h2> <h2>Purchase received</h2>
<p>Paddle is confirming your subscription. Your SonarDesk licence key will be sent to the email used at checkout. You can then activate it in <strong>SonarDesk → Settings → Licence</strong>.</p> <p>Paddle is confirming your subscription. Your SonarDesk licence key is generated by our licensing server and emailed to the address used at checkout. You can then activate it in <strong>SonarDesk → Settings → Licence</strong>.</p>
<p class="muted">If the email takes a moment, wait for the Paddle confirmation and check your junk folder. You can also create a portal account with the same email address.</p> <p class="muted">Delivery normally follows the subscription webhook within moments. Check your junk folder if needed. You can also create a portal account with the same email address and resend the key from My licences.</p>
<a class="btn" href="{{ route('register') }}">Create portal account</a> <a class="btn" href="{{ route('register') }}">Create portal account</a>
</div> </div>
@@ -61,7 +61,7 @@
Paddle.PricePreview({items: previewItems}).then(result => { Paddle.PricePreview({items: previewItems}).then(result => {
for (const item of (result?.data?.details?.lineItems ?? [])) { for (const item of (result?.data?.details?.lineItems ?? [])) {
const element = priceElements.get(item?.price?.id); const element = priceElements.get(item?.price?.id);
const formatted = item?.formattedTotals?.subtotal ?? item?.formattedUnitTotals?.subtotal; const formatted = item?.formattedTotals?.total ?? item?.formattedUnitTotals?.total;
if (!element || !formatted) continue; if (!element || !formatted) continue;
const suffix = element.dataset.billing === 'annual' ? ' / year' : ' / month'; const suffix = element.dataset.billing === 'annual' ? ' / year' : ' / month';
element.textContent = formatted + suffix; element.textContent = formatted + suffix;
@@ -0,0 +1,2 @@
{!! '<?xml version="1.0" encoding="utf-8"?>' !!}
<rss version="2.0" xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle"><channel><title>SonarDesk Updates</title><link>{{ route('updates.appcast') }}</link><description>SonarDesk stable release feed</description>@foreach($releases as $release)<item><title>SonarDesk {{ $release->version }}</title><pubDate>{{ $release->published_at?->toRfc2822String() }}</pubDate><sparkle:version>{{ $release->build_number }}</sparkle:version><sparkle:shortVersionString>{{ $release->version }}</sparkle:shortVersionString>@if($release->minimum_macos)<sparkle:minimumSystemVersion>{{ $release->minimum_macos }}</sparkle:minimumSystemVersion>@endif<description><![CDATA[{!! nl2br(e($release->release_notes ?: 'SonarDesk update')) !!}]]></description><enclosure url="{{ route('download.release',$release) }}" length="{{ $release->file_size }}" type="application/octet-stream"@if($release->sparkle_signature) sparkle:edSignature="{{ $release->sparkle_signature }}"@endif /></item>@endforeach</channel></rss>
@@ -0,0 +1,8 @@
@extends('layouts.app', ['title' => 'Download SonarDesk — Native SonarQube monitoring for macOS', 'description' => 'Download the latest signed SonarDesk release for macOS, review release notes and system requirements.'])
@section('fullbleed')
<main class="download-page"><section class="download-hero"><div class="download-shell"><div class="download-copy"><span class="eyebrow">{{ $settings['eyebrow'] }}</span><h1>{{ $settings['title'] }}</h1><p>{{ $settings['intro'] }}</p>@if($latest)<div class="download-actions"><a class="btn download-primary" href="{{ route('download.latest') }}"><span class="download-icon">↓</span><span><strong>Download SonarDesk {{ $latest->version }}</strong><small>{{ $latest->formatted_size }} · macOS {{ $latest->minimum_macos ?: 'supported versions' }}</small></span></a><a class="btn download-secondary" href="#release-notes">View release notes</a></div><div class="release-verification"><span>✓ Signed distribution</span><span>✓ SHA-256 verified</span><span>✓ Secure licence activation</span></div>@else<div class="no-release"><strong>The next SonarDesk release is being prepared.</strong><span>Check back shortly or contact support.</span></div>@endif</div><div class="product-visual"><div class="mac-window"><div class="mac-titlebar"><i></i><i></i><i></i><span>SonarDesk</span></div><div class="mac-content"><div class="mini-sidebar"><img src="{{ asset('icons/sonardesk-icon-64.png') }}" alt=""><b>SonarDesk</b><span class="active"></span><span></span><span></span><span></span><span></span></div><div class="mini-dashboard"><div class="mini-head"><b>Project health</b><em>All systems monitored</em></div><div class="mini-stats"><i></i><i></i><i></i><i></i></div><div class="mini-chart"><span></span><span></span><span></span><span></span><span></span><span></span></div></div></div></div><div class="visual-badge"><span>Native</span><strong>Built for macOS</strong></div></div></div></section>
<section class="download-confidence"><div class="download-shell confidence-grid"><div><span>Native macOS</span><strong>Fast, focused desktop workflow</strong></div><div><span>SonarQube</span><strong>Your server remains the source of truth</strong></div><div><span>Security</span><strong>Tokens protected by macOS Keychain</strong></div><div><span>Updates</span><strong>Professional in-app release delivery</strong></div></div></section>
<div class="download-shell download-content"><section id="release-notes" class="release-notes-panel">@if($latest)<div class="release-heading"><div><span class="eyebrow">Latest stable release</span><h2>SonarDesk {{ $latest->version }}</h2><p>Build {{ $latest->build_number }} · Released {{ $latest->published_at?->format('j F Y') }}</p></div><a class="btn btn-primary" href="{{ route('download.release',$latest) }}">Download {{ $latest->version }}</a></div><div class="release-note-copy">{!! nl2br(e($latest->release_notes ?: 'This release contains the latest SonarDesk improvements and maintenance updates.')) !!}</div><div class="checksum-box"><span>SHA-256</span><code>{{ $latest->sha256 }}</code></div>@endif</section><aside class="requirements-panel"><span class="eyebrow">Before you install</span><h2>System requirements</h2><p>{{ $settings['requirements'] }}</p><ol><li><span>1</span><div><strong>Download</strong><small>Get the latest signed SonarDesk package.</small></div></li><li><span>2</span><div><strong>Install</strong><small>Open the DMG and move SonarDesk to Applications.</small></div></li><li><span>3</span><div><strong>Activate</strong><small>Enter the licence key delivered after purchase.</small></div></li></ol><a href="{{ route('buy') }}">Need a licence? View pricing →</a></aside></div>
<section class="release-history"><div class="download-shell"><div class="section-heading"><span class="eyebrow">Release archive</span><h2>Previous versions</h2><p>Published SonarDesk builds remain available here for customers who need a previous release.</p></div><div class="release-history-list">@forelse($releases as $release)<article><div><strong>SonarDesk {{ $release->version }}</strong><span>Build {{ $release->build_number }} · {{ ucfirst($release->channel) }} · {{ $release->published_at?->format('j M Y') }}</span></div><div><span>{{ $release->formatted_size }}</span><a href="{{ route('download.release',$release) }}">Download</a></div></article>@empty<p>No published releases are available yet.</p>@endforelse</div>{{ $releases->links() }}</div></section>
<section class="download-support"><div class="download-shell"><div><span class="eyebrow">Support</span><h2>Installation or activation help?</h2><p>{{ $settings['support_text'] }}</p></div><a class="btn btn-secondary" href="{{ route('support') }}">Contact SonarDesk support</a></div></section></main>
@endsection
@@ -0,0 +1,23 @@
SonarDesk — Your licence is ready
{{ $customerName ? 'Hello '.$customerName.',' : 'Hello,' }}
Thank you for choosing SonarDesk. Your {{ $edition }} licence key is:
{{ $licenseKey }}
To activate:
1. Open SonarDesk.
2. Open Settings → Licence.
3. Paste the key above and select Activate.
@if($expiresAt)Current entitlement period: {{ $expiresAt->format('j M Y') }}.
@endif
@if($portalUrl)Manage your licence: {{ $portalUrl }}
@endif
Keep this key private.
DOWNLOAD SONARDESK
{{ $downloadUrl }}
@@ -0,0 +1,20 @@
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Your SonarDesk licence key</title></head>
<body style="margin:0;background:#f4f6f8;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#172033">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f4f6f8;padding:32px 12px"><tr><td align="center">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:620px;background:#fff;border:1px solid #e4e8ee;border-radius:16px;overflow:hidden">
<tr><td style="padding:30px 34px;background:#111827;color:#fff"><div style="font-size:24px;font-weight:700">SonarDesk</div><div style="margin-top:5px;color:#cbd5e1">Your licence is ready</div></td></tr>
<tr><td style="padding:34px">
<p style="margin:0 0 18px">{{ $customerName ? 'Hello '.$customerName.',' : 'Hello,' }}</p>
<p style="margin:0 0 22px;line-height:1.6">Thank you for choosing SonarDesk. Your {{ $edition }} licence key is:</p>
<div style="padding:18px;border:1px solid #d7dde5;border-radius:10px;background:#f8fafc;text-align:center;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:21px;font-weight:700;letter-spacing:1px">{{ $licenseKey }}</div>
<p style="margin:24px 0 10px;line-height:1.6"><strong>Activate SonarDesk</strong></p>
<ol style="padding-left:22px;line-height:1.8;margin-top:0"><li>Open SonarDesk on your Mac.</li><li>Open <strong>Settings → Licence</strong>.</li><li>Paste the licence key above and select <strong>Activate</strong>.</li></ol>
@if($expiresAt)<p style="line-height:1.6">Current entitlement period: <strong>{{ $expiresAt->format('j M Y') }}</strong>.</p>@endif
@if($portalUrl)<p style="margin:24px 0"><a href="{{ $portalUrl }}" style="display:inline-block;background:#2563eb;color:#fff;text-decoration:none;padding:12px 18px;border-radius:8px;font-weight:600">Manage your licence</a></p>@endif
<p style="margin:24px 0"><a href="{{ $downloadUrl }}" style="display:inline-block;background:#0f172a;color:#ffffff;text-decoration:none;font-weight:700;padding:12px 18px;border-radius:8px">Download SonarDesk</a></p>
<p style="margin:22px 0 0;color:#64748b;font-size:13px;line-height:1.6">Keep this key private. If you did not make this purchase, contact SonarDesk support.</p>
</td></tr></table>
</td></tr></table>
</body></html>
@@ -0,0 +1,15 @@
@extends('layouts.app', ['title' => 'Features — SonarDesk', 'description' => 'Explore SonarDesk features for monitoring SonarQube projects, quality gates, issues and measures from macOS.'])
@section('content')
<section class="hero-card marketing-hero compact-hero"><span class="eyebrow">Features</span><h1>A focused desktop companion for SonarQube.</h1><p class="lead">Built around the checks developers make every day: project status, quality gates, issues, security findings and measures.</p><div class="support-actions marketing-actions"><a class="btn btn-primary" href="{{ route('buy') }}">Start free trial</a><a class="btn btn-secondary" href="{{ route('release') }}">Read the release notes</a></div></section>
<div class="feature-detail-grid">
<article class="feature-detail"><span>Projects</span><h2>Project dashboard</h2><p>Keep configured SonarQube projects together and scan project health without opening a collection of browser tabs.</p></article>
<article class="feature-detail"><span>Quality</span><h2>Quality gates and measures</h2><p>Surface the status and metrics that tell you whether a project needs attention, in a layout designed for quick review.</p></article>
<article class="feature-detail"><span>Issues</span><h2>Bugs, vulnerabilities and code smells</h2><p>Browse the major SonarQube issue categories, filter the list and open an issue to inspect its details and context.</p></article>
<article class="feature-detail"><span>Scale</span><h2>Server-side pagination</h2><p>Issue browsing is designed to remain practical on projects with larger result sets by paging data from the server.</p></article>
<article class="feature-detail"><span>Connections</span><h2>Multiple SonarQube servers</h2><p>Configure the environments you work with and keep them available from the same SonarDesk workspace.</p></article>
<article class="feature-detail"><span>Security</span><h2>macOS Keychain</h2><p>SonarQube tokens are stored using macOS Keychain rather than being treated as ordinary application preferences.</p></article>
<article class="feature-detail"><span>Licensing</span><h2>Email delivery and self-service portal</h2><p>After checkout, the licence key is emailed to the customer. The SonarDesk portal can resend the key and manage licence information and Mac activations.</p></article>
<article class="feature-detail"><span>Distribution</span><h2>Built for direct Mac distribution</h2><p>SonarDesk is distributed directly for macOS, giving the product a focused release and support channel outside the Mac App Store.</p></article>
</div>
<section class="cta-panel"><div><span class="eyebrow">7-day free trial</span><h2>Try the complete app.</h2><p>Choose Personal for one active Mac or Professional for up to three.</p></div><a class="btn btn-primary" href="{{ route('buy') }}">View pricing</a></section>
@endsection
File diff suppressed because one or more lines are too long
@@ -0,0 +1,102 @@
@extends('layouts.app', ['title' => 'Refund Policy — SonarDesk'])
@section('content')
@php($legal = config('sonardesk.legal'))
<article class="legal-page">
<header class="legal-head">
<span class="eyebrow">Legal</span>
<h1>Refund Policy</h1>
<p>Last updated 1 October 2026</p>
</header>
<div class="legal-intro">
This policy explains cancellation, refund and statutory consumer rights for SonarDesk subscriptions.
SonarDesk payments are processed by Paddle, our Merchant of Record. Nothing in this policy limits any
rights or remedies that cannot lawfully be limited.
</div>
<section>
<h2>1. Seven-day free trial</h2>
<p>Where shown at checkout, SonarDesk includes a seven-day free trial. You can cancel before the trial
ends to prevent the subscription converting to a paid subscription. If you cancel during the trial
before a charge is made, there is no subscription payment to refund.</p>
</section>
<section>
<h2>2. Cancelling a paid subscription</h2>
<p>You may cancel your subscription through the SonarDesk licence portal or the subscription-management
facilities provided through Paddle. Cancellation stops future automatic renewals. Unless stated
otherwise during cancellation, your licence normally remains available until the end of the billing
period for which you have already paid.</p>
<p>Cancellation does not itself create a refund for an earlier payment.</p>
</section>
<section>
<h2>3. Refund requests</h2>
<p>If you believe a payment should be refunded, contact
<a href="{{ route('support') }}">SonarDesk Support</a> as soon as possible. Please provide the email
address used for the purchase and enough information for us to identify the transaction. Do not send
full payment-card details.</p>
<p>Because Paddle is the Merchant of Record, a refund may be processed by Paddle and may also be
requested through Paddle's buyer-support facilities. Approved refunds are returned to the original
payment method in accordance with Paddle's processing arrangements and the payment provider's timing.</p>
</section>
<section>
<h2>4. UK and EEA consumer withdrawal rights</h2>
<p>If you purchase as a consumer, applicable law may provide a cooling-off or withdrawal period for
distance contracts. For UK consumers this is generally 14 days, subject to the statutory rules and
exceptions that apply to digital content and services.</p>
<p>If you expressly request or consent to digital supply or performance beginning during a statutory
withdrawal period, the law may affect the right to withdraw once supply or performance has begun.
Nothing on this page removes a statutory right that applies to your purchase.</p>
</section>
<section>
<h2>5. Faulty or misdescribed digital content</h2>
<p>This refund policy does not replace your statutory remedies. If SonarDesk is faulty, materially not
as described, or otherwise fails to meet rights that apply to digital content or services under
consumer law, you may be entitled to a repair, replacement, price reduction, refund or another remedy
depending on the circumstances.</p>
</section>
<section>
<h2>6. Duplicate or incorrect charges</h2>
<p>If you believe you have been charged twice, charged after a valid cancellation, charged an incorrect
amount, or charged for the wrong plan, contact us promptly. We will investigate the subscription and
transaction records and, where appropriate, arrange correction or refund through Paddle.</p>
</section>
<section>
<h2>7. Renewal payments</h2>
<p>Subscriptions renew automatically until cancelled. A renewal charge is not automatically refundable
solely because the subscription was not cancelled before renewal. We will nevertheless consider refund
requests fairly, taking account of the circumstances, applicable law, the timing of the request and
whether the renewed service has been used.</p>
</section>
<section>
<h2>8. Chargebacks</h2>
<p>Please contact SonarDesk Support or Paddle about a disputed payment before initiating a chargeback
where practical. This gives us an opportunity to investigate duplicate, fraudulent or incorrect charges
promptly. This does not restrict any right you have to dispute a payment with your payment provider.</p>
</section>
<section>
<h2>9. How refunds affect your licence</h2>
<p>A full refund or reversal of a subscription payment may cause the associated subscription and
SonarDesk licence to be cancelled or revoked. Where only part of a payment is refunded, any effect on
the subscription will depend on the reason for the adjustment and the resulting Paddle subscription
status.</p>
</section>
<section>
<h2>10. Contact</h2>
<p>For refund or billing enquiries, contact
<a href="mailto:{{ $legal['support_email'] }}">{{ $legal['support_email'] }}</a> or use
<a href="{{ route('support') }}">SonarDesk Support</a>. Include your purchase email address and relevant
transaction details, but never send complete card details.</p>
<p>See also our <a href="{{ route('terms') }}">Terms of Service</a>.</p>
</section>
</article>
@endsection
@@ -3,17 +3,144 @@
@section('content') @section('content')
@php($legal = config('sonardesk.legal')) @php($legal = config('sonardesk.legal'))
<article class="legal-page"> <article class="legal-page">
<header class="legal-head"><span class="eyebrow">Legal</span><h1>Terms of Service</h1><p>Last updated 9 September 2026</p></header> <header class="legal-head">
<div class="legal-intro">These terms govern your use of the SonarDesk website, licence portal, subscription service and macOS application.</div> <span class="eyebrow">Legal</span>
<section><h2>1. Licence plans</h2><p>Personal permits the number of active Mac installations shown for that plan at purchase. Professional permits the number shown for Professional. A licence is for your use and may not be shared, resold or used to bypass activation limits.</p></section> <h1>Terms of Service</h1>
<section><h2>2. Trials and subscriptions</h2><p>Where offered, the trial length and renewal terms are shown at checkout. Subscription billing, cancellation, tax and payment processing are handled through Paddle. Your subscription continues until cancelled in accordance with the checkout terms. Changes between Personal and Professional may be prorated by Paddle.</p></section> <p>Last updated 1 October 2026</p>
<section><h2>3. Activation</h2><p>SonarDesk uses a licence service to activate permitted Macs and periodically confirm that an activation remains valid. A reasonable offline grace period may be provided, but continued use after a subscription or activation is revoked is not authorised.</p></section> </header>
<section><h2>4. Your SonarQube connections</h2><p>You are responsible for the SonarQube servers, credentials, tokens and projects you connect to SonarDesk. SonarDesk displays information made available by your SonarQube account and does not grant additional server permissions.</p></section>
<section><h2>5. Acceptable use</h2><p>You must not attempt to defeat licensing controls, interfere with the service, probe accounts you do not own, distribute malware, or use SonarDesk in violation of applicable law or third-party rights.</p></section> <div class="legal-intro">
<section><h2>6. Updates and availability</h2><p>We may update the application, website and licensing service to improve security, compatibility or functionality. We aim to provide a reliable service but do not guarantee uninterrupted availability.</p></section> These Terms of Service govern your use of the SonarDesk website, licence portal,
<section><h2>7. Consumer rights</h2><p>Nothing in these terms excludes rights that cannot lawfully be excluded, including applicable UK consumer rights. Where mandatory law gives you stronger protections, that law prevails.</p></section> subscription service and macOS application. By purchasing, activating or using SonarDesk,
<section><h2>8. Liability</h2><p>To the extent permitted by law, we are not responsible for indirect or consequential loss arising from incorrect sync configuration, deleted destination data, unavailable third-party systems or failures outside our reasonable control. This does not limit liability that cannot lawfully be limited.</p></section> you agree to these terms. Your statutory rights are not affected.
<section><h2>9. Privacy</h2><p>Our processing of personal data is described in the <a href="{{ route('privacy') }}">Privacy Policy</a> and <a href="{{ route('cookies') }}">Cookie Policy</a>.</p></section> </div>
<section><h2>10. Contact</h2><p>These terms are provided by {{ $legal['controller_name'] }} in {{ $legal['country'] }}. Questions can be submitted through <a href="{{ route('support') }}">SonarDesk Support</a>.</p></section>
<section>
<h2>1. SonarDesk licences</h2>
<p>SonarDesk is licensed, not sold. A Personal subscription permits the number of active Mac
installations stated for that plan at the time of purchase. A Professional subscription permits
the number stated for the Professional plan. Licences are for the subscriber's use and must not
be shared, resold, sublicensed or used to circumvent activation limits.</p>
</section>
<section>
<h2>2. Free trials</h2>
<p>Where a free trial is offered, its duration and the price that will apply afterwards are shown
before checkout. Unless you cancel before the trial ends, the subscription will automatically
convert to the selected paid plan and Paddle will charge the payment method provided at checkout.</p>
</section>
<section>
<h2>3. Subscriptions, billing and renewal</h2>
<p>SonarDesk subscriptions renew automatically at the billing interval selected at checkout until
cancelled. The renewal price, billing period, applicable taxes and other checkout information are
presented before you complete the transaction. Payment processing, tax collection and subscription
billing are handled by Paddle, our Merchant of Record.</p>
<p>Changes between Personal and Professional plans may result in a prorated charge or credit as
calculated by Paddle and displayed as part of the subscription change.</p>
</section>
<section>
<h2>4. Cancellation</h2>
<p>You may cancel a subscription through the SonarDesk licence portal or the subscription-management
facilities made available through Paddle. Cancellation prevents future renewals. Unless the
cancellation flow expressly states otherwise, access normally continues until the end of the
already-paid billing period.</p>
<p>Cancellation and refund requests are separate. Cancelling a subscription does not automatically
refund charges already paid. See our <a href="{{ route('refund') }}">Refund Policy</a>.</p>
</section>
<section>
<h2>5. Activation and licence checks</h2>
<p>SonarDesk uses a licence service to activate permitted Macs and periodically confirm that an
activation remains valid. A reasonable offline grace period may be provided. Continued use after a
licence or subscription has expired or been revoked is not authorised.</p>
</section>
<section>
<h2>6. Your SonarQube connections</h2>
<p>You are responsible for the SonarQube servers, credentials, tokens and projects you connect to
SonarDesk. SonarDesk accesses information made available by the permissions of the credentials you
provide. It does not grant additional permissions on your SonarQube server.</p>
<p>You are responsible for ensuring that you are authorised to access and process the projects and
information you connect to SonarDesk.</p>
</section>
<section>
<h2>7. Acceptable use</h2>
<p>You must not attempt to defeat or circumvent licensing controls, interfere with the SonarDesk
service, gain unauthorised access to accounts or systems, distribute malware through the service,
reverse engineer the service except where applicable law expressly permits it, or use SonarDesk in
violation of applicable law or third-party rights.</p>
</section>
<section>
<h2>8. Software updates and service availability</h2>
<p>We may update SonarDesk, the website and licensing service to improve security, compatibility,
reliability or functionality. We may also discontinue obsolete versions where reasonably necessary.
We aim to provide a reliable service but do not guarantee uninterrupted or error-free availability.</p>
</section>
<section>
<h2>9. Third-party services</h2>
<p>SonarDesk works with third-party services including SonarQube and Paddle. Those services are
operated independently and may have their own terms and policies. We are not responsible for changes,
outages or restrictions imposed by third-party services outside our reasonable control.</p>
</section>
<section>
<h2>10. Consumer rights</h2>
<p>Nothing in these terms excludes or restricts rights that cannot lawfully be excluded or restricted.
If you are a consumer, this includes applicable rights relating to digital content and services under
UK consumer law. Where mandatory law gives you stronger protection than these terms, the mandatory
protection applies.</p>
<p>Information about refunds, statutory remedies and withdrawal rights is set out in our
<a href="{{ route('refund') }}">Refund Policy</a>.</p>
</section>
<section>
<h2>11. Liability</h2>
<p>Nothing in these terms excludes or limits liability where doing so would be unlawful, including
liability for death or personal injury caused by negligence, fraud or fraudulent misrepresentation.</p>
<p>Subject to those protections, we are not responsible for indirect or consequential losses, loss
caused by unauthorised or incorrect configuration, unavailable third-party systems, or events outside
our reasonable control. If you use SonarDesk for business purposes, you are responsible for maintaining
appropriate backups and operational safeguards.</p>
</section>
<section>
<h2>12. Suspension and termination</h2>
<p>We may suspend or terminate access where reasonably necessary to address fraud, security threats,
material breach of these terms, chargebacks, or an invalid or expired subscription. Where appropriate,
we will give reasonable notice and an opportunity to resolve the issue.</p>
</section>
<section>
<h2>13. Changes to these terms</h2>
<p>We may update these terms when the service, law or our commercial arrangements change. The current
version will be published on this page with its revision date. Material changes affecting an existing
subscription will be communicated where required by law.</p>
</section>
<section>
<h2>14. Privacy</h2>
<p>Our processing of personal data is described in the
<a href="{{ route('privacy') }}">Privacy Policy</a> and
<a href="{{ route('cookies') }}">Cookie Policy</a>.</p>
</section>
<section>
<h2>15. Governing law</h2>
<p>These terms are governed by the laws applicable in {{ $legal['country'] }}, subject to any mandatory
consumer protections and rights you have under the law of the country in which you live.</p>
</section>
<section>
<h2>16. Contact</h2>
<p>These terms are provided by <strong>{{ $legal['controller_name'] }}</strong> in
{{ $legal['country'] }}. For licensing, billing or contractual questions, contact
<a href="mailto:{{ $legal['support_email'] }}">{{ $legal['support_email'] }}</a> or use
<a href="{{ route('support') }}">SonarDesk Support</a>.</p>
</section>
</article> </article>
@endsection @endsection
@@ -1,6 +1,7 @@
@extends('layouts.app') @extends('layouts.app')
@section('content') @section('content')
<div class="portal-download-banner"><div><span class="eyebrow">Latest SonarDesk</span><strong>Need the Mac app?</strong><small>Download the latest published, signed release from the SonarDesk distribution centre.</small></div><a class="btn btn-primary" href="{{ route('release') }}">Download SonarDesk</a></div>
<section class="portal-header"> <section class="portal-header">
<div> <div>
<span class="eyebrow">Account</span> <span class="eyebrow">Account</span>
@@ -1,6 +1,7 @@
@extends('layouts.app') @extends('layouts.app')
@section('content') @section('content')
<div class="portal-download-banner"><div><span class="eyebrow">Latest SonarDesk</span><strong>Need the Mac app?</strong><small>Download the latest published, signed release from the SonarDesk distribution centre.</small></div><a class="btn btn-primary" href="{{ route('release') }}">Download SonarDesk</a></div>
@php @php
$activeActivations = $license->activations->whereNull('deactivated_at'); $activeActivations = $license->activations->whereNull('deactivated_at');
$billing = str_contains(strtolower((string) optional($license->product)->code), 'annual') ? 'Annual' : 'Monthly'; $billing = str_contains(strtolower((string) optional($license->product)->code), 'annual') ? 'Annual' : 'Monthly';
@@ -1,19 +1,50 @@
@extends('layouts.app', [ @extends('layouts.app', [
'title' => 'SonarDesk — SonarQube monitoring for macOS', 'title' => 'SonarDesk — Native SonarQube monitoring for macOS',
'description' => 'A professional native macOS dashboard for SonarQube projects, quality gates, bugs, vulnerabilities and code smells.' 'description' => 'SonarDesk is a native macOS companion for SonarQube: monitor quality gates, issues, measures and project health from one focused desktop workspace.'
]) ])
@section('content') @section('content')
<section class="hero-card" style="padding:64px 0 38px"> <section class="hero-card marketing-hero">
<span class="eyebrow">Native SonarQube monitor for macOS</span> <span class="eyebrow">SonarQube, without the browser tab sprawl</span>
<h1 style="max-width:850px;font-size:58px">Your SonarQube projects.<br>One focused Mac workspace.</h1> <h1>Your code quality.<br>At home on the Mac.</h1>
<p class="lead">SonarDesk gives development teams a fast native view of project health, quality gates, bugs, vulnerabilities, code smells and measures without living in a browser.</p> <p class="lead">SonarDesk is a focused native macOS companion for developers and teams using SonarQube. See project health, quality gates, bugs, vulnerabilities, code smells and measures in one fast desktop workspace.</p>
<div class="support-actions" style="margin-top:24px"><a class="btn btn-primary" href="{{ route('buy') }}">Start free trial</a><a class="btn btn-secondary" href="{{ route('support') }}">Contact support</a></div> <div class="support-actions marketing-actions">
<a class="btn btn-primary" href="{{ route('buy') }}">Start 7-day free trial</a>
<a class="btn btn-secondary" href="{{ route('features') }}">Explore features</a>
</div>
<p class="microcopy">Direct download for macOS · Secure licence activation · Cancel anytime</p>
</section> </section>
<div class="grid" style="grid-template-columns:repeat(3,minmax(0,1fr));margin:22px 0 44px">
<div class="card"><span class="eyebrow">Projects</span><h2>Everything at a glance</h2><p>Review quality gates and core measures across every configured SonarQube project.</p></div> <section class="trust-strip" aria-label="SonarDesk highlights">
<div class="card"><span class="eyebrow">Issues</span><h2>Find what needs work</h2><p>Browse bugs, vulnerabilities and code smells with filtering and server-side pagination.</p></div> <span>Native macOS app</span><span>Multiple SonarQube servers</span><span>Keychain token storage</span><span>Issue detail workflow</span>
<div class="card"><span class="eyebrow">Security</span><h2>Credentials stay protected</h2><p>SonarQube tokens are stored in the macOS Keychain and API access respects your server permissions.</p></div> </section>
</div>
<section class="panel" style="padding:34px"><div class="portal-header"><div><span class="eyebrow">Built for engineering teams</span><h2 style="font-size:34px;margin:7px 0">A professional desktop companion for SonarQube.</h2><p class="lead compact">Multiple servers, project dashboards, quality gates, issue views, secure token storage and a native macOS experience.</p></div></div></section> <section class="section-block">
<section style="padding:54px 0 10px"><div class="portal-header"><div><span class="eyebrow">Licensing</span><h2 style="font-size:34px;margin:7px 0">Simple licensing for SonarDesk.</h2><p class="lead compact">Start with a free trial, then manage your licence, activations and subscription from the customer portal.</p></div><a class="btn" href="{{ route('buy') }}">View pricing</a></div></section> <div class="section-heading"><span class="eyebrow">Built for daily engineering work</span><h2>See the signal. Open the issue. Get back to code.</h2><p>SonarDesk keeps the information you check most often close at hand, without replacing SonarQube or changing your existing workflow.</p></div>
<div class="feature-grid">
<article class="feature-card"><div class="feature-icon">01</div><h3>Project health at a glance</h3><p>Review configured projects, quality gate state and important measures from a clean Mac dashboard.</p></article>
<article class="feature-card"><div class="feature-icon">02</div><h3>Issue details that are useful</h3><p>Open bugs, vulnerabilities and code smells to see the context you need before switching back to the codebase.</p></article>
<article class="feature-card"><div class="feature-icon">03</div><h3>Focused filtering</h3><p>Work through issue lists with practical filters and server-side pagination rather than fighting a giant browser view.</p></article>
<article class="feature-card"><div class="feature-icon">04</div><h3>Multiple servers</h3><p>Keep the SonarQube environments you work with available from one desktop application.</p></article>
<article class="feature-card"><div class="feature-icon">05</div><h3>macOS security</h3><p>SonarQube access tokens are stored in the macOS Keychain. SonarDesk respects the permissions of the token you provide.</p></article>
<article class="feature-card"><div class="feature-icon">06</div><h3>Native workflow</h3><p>A purpose-built macOS interface with familiar navigation, desktop behaviour and a focused experience for engineering teams.</p></article>
</div>
</section>
<section class="section-block split-panel">
<div><span class="eyebrow">Designed to complement SonarQube</span><h2>Keep SonarQube as the source of truth.</h2><p class="lead compact">SonarDesk is a desktop companion, not a replacement. It connects to your SonarQube server using your credentials and presents the project and issue information you need for everyday monitoring.</p></div>
<div class="check-panel"><div>Quality gates and measures</div><div>Bugs, vulnerabilities and code smells</div><div>Project and issue navigation</div><div>Secure token storage in Keychain</div><div>Customer portal for licences and activations</div></div>
</section>
<section class="section-block pricing-preview">
<div class="section-heading"><span class="eyebrow">Straightforward pricing</span><h2>Start small. Upgrade when you need more Macs.</h2><p>Every plan includes the full SonarDesk application. Professional increases the activation allowance for people working across multiple Macs.</p></div>
<div class="pricing-preview-grid">
<article class="price-card"><span class="eyebrow">Personal</span><h3>For one Mac</h3><div class="marketing-price">£24.99 <small>/ year</small></div><p>or £2.99 monthly</p><ul class="features"><li>Full SonarDesk feature set</li><li>1 active Mac</li><li>7-day free trial</li><li>Licence key delivered by email</li><li>Customer licence portal</li></ul></article>
<article class="price-card featured"><span class="pill">Most popular</span><span class="eyebrow">Professional</span><h3>For a multi-Mac workflow</h3><div class="marketing-price">£44.99 <small>/ year</small></div><p>or £4.99 monthly</p><ul class="features"><li>Full SonarDesk feature set</li><li>Up to 3 active Macs</li><li>7-day free trial</li><li>Licence key delivered by email</li><li>Customer licence portal</li></ul></article>
</div>
<div class="center-action"><a class="btn btn-primary" href="{{ route('buy') }}">See pricing and start trial</a></div>
</section>
<section class="home-download-showcase"><div class="home-download-copy"><span class="eyebrow">Desktop distribution</span><h2>Ready when you are.</h2><p>Download the latest signed SonarDesk release for macOS, review what changed, verify the package checksum and browse previous versions from one dedicated release centre.</p><div class="support-actions">@if($latestRelease)<a class="btn btn-primary" href="{{ route('download.latest') }}">Download SonarDesk {{ $latestRelease->version }}</a>@else<a class="btn btn-primary" href="{{ route('release') }}">Visit download centre</a>@endif<a class="btn btn-secondary" href="{{ route('release') }}">Release notes</a></div></div><div class="home-download-meta">@if($latestRelease)<span>Latest stable</span><strong>Version {{ $latestRelease->version }}</strong><small>Build {{ $latestRelease->build_number }} · {{ $latestRelease->formatted_size }} · macOS {{ $latestRelease->minimum_macos ?: 'supported versions' }}</small><div><b>SHA-256</b><code>{{ Str::limit($latestRelease->sha256, 34) }}</code></div>@else<span>SonarDesk releases</span><strong>Distribution centre</strong><small>Published builds will appear here automatically.</small>@endif</div></section>
<section class="cta-panel"><div><span class="eyebrow">SonarDesk for macOS</span><h2>Make SonarQube easier to keep an eye on.</h2><p>Try SonarDesk for seven days and connect it to your existing SonarQube environment.</p></div><a class="btn btn-primary" href="{{ route('buy') }}">Start free trial</a></section>
@endsection @endsection
@@ -0,0 +1,16 @@
@extends('layouts.app', ['title' => 'SonarDesk 1.0 — Release', 'description' => 'SonarDesk 1.0 for macOS: a native desktop companion for SonarQube project health, quality gates and issues.'])
@section('content')
<section class="hero-card marketing-hero compact-hero"><span class="eyebrow">Introducing SonarDesk 1.0</span><h1>SonarQube monitoring, built for the Mac.</h1><p class="lead">SonarDesk brings project health, quality gates, bugs, vulnerabilities, code smells and measures into a dedicated native macOS workspace.</p><div class="support-actions marketing-actions"><a class="btn btn-primary" href="{{ route('buy') }}">Start free trial</a><a class="btn btn-secondary" href="{{ route('features') }}">Explore features</a></div></section>
<section class="release-body">
<div class="release-main">
<span class="eyebrow">Version 1.0</span><h2>Why SonarDesk</h2>
<p>SonarQube is an essential part of many development workflows, but checking it throughout the day often means returning to another browser tab, navigating back to a project and rebuilding context. SonarDesk is designed to make that recurring check faster and more focused on macOS.</p>
<p>It does not replace SonarQube. Your SonarQube server remains the source of truth. SonarDesk gives you a dedicated desktop view of the information you monitor most often.</p>
<h2>What is included</h2>
<ul class="release-list"><li>Project dashboards and project health views</li><li>Quality gate status and important measures</li><li>Issue browsing for bugs, vulnerabilities and code smells</li><li>Detailed issue views and practical filtering</li><li>Server-side pagination for larger issue sets</li><li>Support for multiple SonarQube server configurations</li><li>SonarQube token storage using macOS Keychain</li><li>Direct licensing with a self-service customer portal</li></ul>
<h2>Who it is for</h2><p>SonarDesk is for developers, technical leads and engineering teams who already use SonarQube and want a faster, dedicated way to keep project quality visible while working on a Mac.</p>
<h2>Launch pricing</h2><p>Personal is £24.99/year or £2.99/month for one active Mac. Professional is £44.99/year or £4.99/month for up to three active Macs. Both include a 7-day free trial and the complete SonarDesk feature set.</p>
</div>
<aside class="release-aside"><div class="card"><span class="eyebrow">At a glance</span><h3>SonarDesk 1.0</h3><p>Native macOS application</p><p>Direct distribution</p><p>7-day free trial</p><p>Personal + Professional plans</p><a class="btn btn-primary full-width" href="{{ route('buy') }}">Try SonarDesk</a></div></aside>
</section>
@endsection
+4
View File
@@ -4,3 +4,7 @@ use Illuminate\Support\Facades\Schedule;
// Removes resolved support requests after the configured retention period. // Removes resolved support requests after the configured retention period.
Schedule::command('model:prune')->dailyAt('03:20')->withoutOverlapping(); Schedule::command('model:prune')->dailyAt('03:20')->withoutOverlapping();
Schedule::call(function (): void {
app(\App\Services\Licensing\LicenseDeliveryService::class)->deliverPending(50);
})->name('license-delivery')->everyMinute()->withoutOverlapping();
+17 -1
View File
@@ -2,6 +2,7 @@
use App\Http\Controllers\Admin\AdminController; use App\Http\Controllers\Admin\AdminController;
use App\Http\Controllers\Admin\SupportTicketController; use App\Http\Controllers\Admin\SupportTicketController;
use App\Http\Controllers\Admin\ReleaseController as AdminReleaseController;
use App\Http\Controllers\Admin\CmsPageController as AdminCmsPageController; use App\Http\Controllers\Admin\CmsPageController as AdminCmsPageController;
use App\Http\Controllers\CmsPageController; use App\Http\Controllers\CmsPageController;
use App\Http\Controllers\Auth\AuthenticatedSessionController; use App\Http\Controllers\Auth\AuthenticatedSessionController;
@@ -9,17 +10,24 @@ use App\Http\Controllers\Auth\EmailVerificationController;
use App\Http\Controllers\Auth\PasswordController; use App\Http\Controllers\Auth\PasswordController;
use App\Http\Controllers\Auth\RegisteredUserController; use App\Http\Controllers\Auth\RegisteredUserController;
use App\Http\Controllers\CheckoutController; use App\Http\Controllers\CheckoutController;
use App\Http\Controllers\ReleaseController;
use App\Http\Controllers\Portal\PortalController; use App\Http\Controllers\Portal\PortalController;
use App\Http\Controllers\Support\SupportController; use App\Http\Controllers\Support\SupportController;
use App\Http\Middleware\AdminOnly; use App\Http\Middleware\AdminOnly;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::get('/', fn () => view('portal.welcome'))->name('home'); Route::get('/', fn () => view('portal.welcome', ['latestRelease' => \App\Models\SonarDeskRelease::published()->where('channel', 'stable')->latest('published_at')->first()]))->name('home');
Route::get('/buy', CheckoutController::class)->name('buy'); Route::get('/buy', CheckoutController::class)->name('buy');
Route::view('/features', 'features')->name('features');
Route::get('/release', [ReleaseController::class, 'index'])->name('release');
Route::get('/download', [ReleaseController::class, 'latest'])->name('download.latest');
Route::get('/downloads/{release}', [ReleaseController::class, 'download'])->name('download.release');
Route::get('/updates/appcast.xml', [ReleaseController::class, 'appcast'])->name('updates.appcast');
Route::get('/support', [SupportController::class, 'create'])->name('support'); Route::get('/support', [SupportController::class, 'create'])->name('support');
Route::post('/support', [SupportController::class, 'store'])->middleware('throttle:5,10')->name('support.store'); Route::post('/support', [SupportController::class, 'store'])->middleware('throttle:5,10')->name('support.store');
Route::view('/privacy', 'legal.privacy')->name('privacy'); Route::view('/privacy', 'legal.privacy')->name('privacy');
Route::view('/terms', 'legal.terms')->name('terms'); Route::view('/terms', 'legal.terms')->name('terms');
Route::view('/refund-policy', 'legal.refund')->name('refund');
Route::view('/cookies', 'legal.cookies')->name('cookies'); Route::view('/cookies', 'legal.cookies')->name('cookies');
Route::middleware('guest')->group(function () { Route::middleware('guest')->group(function () {
Route::get('/login', [AuthenticatedSessionController::class, 'create'])->name('login'); Route::get('/login', [AuthenticatedSessionController::class, 'create'])->name('login');
@@ -57,6 +65,14 @@ Route::middleware(['auth', 'verified', AdminOnly::class])->prefix('admin')->name
Route::get('/cms/{page}/edit', [AdminCmsPageController::class, 'edit'])->name('cms.edit'); Route::get('/cms/{page}/edit', [AdminCmsPageController::class, 'edit'])->name('cms.edit');
Route::put('/cms/{page}', [AdminCmsPageController::class, 'update'])->name('cms.update'); Route::put('/cms/{page}', [AdminCmsPageController::class, 'update'])->name('cms.update');
Route::delete('/cms/{page}', [AdminCmsPageController::class, 'destroy'])->name('cms.destroy'); Route::delete('/cms/{page}', [AdminCmsPageController::class, 'destroy'])->name('cms.destroy');
Route::get('/releases', [AdminReleaseController::class, 'index'])->name('releases.index');
Route::get('/releases/create', [AdminReleaseController::class, 'create'])->name('releases.create');
Route::post('/releases', [AdminReleaseController::class, 'store'])->name('releases.store');
Route::get('/releases/settings', [AdminReleaseController::class, 'settings'])->name('releases.settings');
Route::put('/releases/settings', [AdminReleaseController::class, 'updateSettings'])->name('releases.settings.update');
Route::get('/releases/{release}/edit', [AdminReleaseController::class, 'edit'])->name('releases.edit');
Route::put('/releases/{release}', [AdminReleaseController::class, 'update'])->name('releases.update');
Route::delete('/releases/{release}', [AdminReleaseController::class, 'destroy'])->name('releases.destroy');
Route::get('/support', [SupportTicketController::class, 'index'])->name('support.index'); Route::get('/support', [SupportTicketController::class, 'index'])->name('support.index');
Route::get('/support/{ticket}', [SupportTicketController::class, 'show'])->name('support.show'); Route::get('/support/{ticket}', [SupportTicketController::class, 'show'])->name('support.show');
Route::patch('/support/{ticket}',[SupportTicketController::class, 'update'])->name('support.update'); Route::patch('/support/{ticket}',[SupportTicketController::class, 'update'])->name('support.update');
@@ -134,10 +134,10 @@ PERSONAL_MONTHLY=$(create_price \
"Personal Monthly" \ "Personal Monthly" \
"SonarDesk Personal — monthly subscription with 7-day free trial." \ "SonarDesk Personal — monthly subscription with 7-day free trial." \
"month" \ "month" \
"199" \ "299" \
"199" \ "299" \
"199" \ "349" \
"299") "499")
PERSONAL_MONTHLY_ID=$(echo "$PERSONAL_MONTHLY" | jq -r '.data.id') PERSONAL_MONTHLY_ID=$(echo "$PERSONAL_MONTHLY" | jq -r '.data.id')
@@ -148,10 +148,10 @@ PERSONAL_ANNUAL=$(create_price \
"Personal Annual" \ "Personal Annual" \
"SonarDesk Personal — annual subscription with 7-day free trial." \ "SonarDesk Personal — annual subscription with 7-day free trial." \
"year" \ "year" \
"1499" \ "2499" \
"1499" \ "2499" \
"1699" \ "2899" \
"2499") "4199")
PERSONAL_ANNUAL_ID=$(echo "$PERSONAL_ANNUAL" | jq -r '.data.id') PERSONAL_ANNUAL_ID=$(echo "$PERSONAL_ANNUAL" | jq -r '.data.id')
@@ -174,10 +174,10 @@ PRO_MONTHLY=$(create_price \
"Professional Monthly" \ "Professional Monthly" \
"SonarDesk Professional — monthly subscription with 7-day free trial." \ "SonarDesk Professional — monthly subscription with 7-day free trial." \
"month" \ "month" \
"399" \ "499" \
"399" \ "499" \
"449" \ "599" \
"699") "849")
PRO_MONTHLY_ID=$(echo "$PRO_MONTHLY" | jq -r '.data.id') PRO_MONTHLY_ID=$(echo "$PRO_MONTHLY" | jq -r '.data.id')
@@ -188,10 +188,10 @@ PRO_ANNUAL=$(create_price \
"Professional Annual" \ "Professional Annual" \
"SonarDesk Professional — annual subscription with 7-day free trial." \ "SonarDesk Professional — annual subscription with 7-day free trial." \
"year" \ "year" \
"2999" \ "4499" \
"2999" \ "4499" \
"3499" \ "5199" \
"4999") "7499")
PRO_ANNUAL_ID=$(echo "$PRO_ANNUAL" | jq -r '.data.id') PRO_ANNUAL_ID=$(echo "$PRO_ANNUAL" | jq -r '.data.id')
@@ -219,13 +219,13 @@ echo
printf "%-24s %-12s %-12s %-12s %-12s\n" \ printf "%-24s %-12s %-12s %-12s %-12s\n" \
"Plan" "USD" "UK" "Ireland" "Australia" "Plan" "USD" "UK" "Ireland" "Australia"
printf "%-24s %-12s %-12s %-12s %-12s\n" \ printf "%-24s %-12s %-12s %-12s %-12s\n" \
"Personal Monthly" '$1.99' '£1.99' '€1.99' 'A$2.99' "Personal Monthly" '$2.99' '£2.99' '€3.49' 'A$4.99'
printf "%-24s %-12s %-12s %-12s %-12s\n" \ printf "%-24s %-12s %-12s %-12s %-12s\n" \
"Personal Annual" '$14.99' '£14.99' '€16.99' 'A$24.99' "Personal Annual" '$24.99' '£24.99' '€28.99' 'A$41.99'
printf "%-24s %-12s %-12s %-12s %-12s\n" \ printf "%-24s %-12s %-12s %-12s %-12s\n" \
"Professional Monthly" '$3.99' '£3.99' '€4.49' 'A$6.99' "Professional Monthly" '$4.99' '£4.99' '€5.99' 'A$8.49'
printf "%-24s %-12s %-12s %-12s %-12s\n" \ printf "%-24s %-12s %-12s %-12s %-12s\n" \
"Professional Annual" '$29.99' '£29.99' '€34.99' 'A$49.99' "Professional Annual" '$44.99' '£44.99' '€51.99' 'A$74.99'
echo echo
echo "All four prices include a 7-day FREE trial." echo "All four prices include a 7-day FREE trial."
echo echo
+257
View File
@@ -0,0 +1,257 @@
#!/usr/bin/env bash
set -euo pipefail
PADDLE_API_URL="https://api.paddle.com"
: "${PADDLE_API_KEY:?PADDLE_API_KEY is not set}"
: "${PADDLE_PRODUCT_ID:?PADDLE_PRODUCT_ID is not set}"
command -v curl >/dev/null || {
echo "ERROR: curl is required."
exit 1
}
command -v jq >/dev/null || {
echo "ERROR: jq is required."
exit 1
}
if [[ "$PADDLE_API_KEY" != pdl_live_apikey_* ]]; then
echo "ERROR: This script requires a Paddle LIVE API key."
exit 1
fi
if [[ "$PADDLE_PRODUCT_ID" != pro_* ]]; then
echo "ERROR: Invalid Paddle product ID."
exit 1
fi
echo "============================================"
echo " SonarDesk Paddle Production Price Setup"
echo "============================================"
echo
echo "Product: $PADDLE_PRODUCT_ID"
echo
# ------------------------------------------------------------
# Verify product
# ------------------------------------------------------------
PRODUCT_RESPONSE="$(
curl -fsS \
-H "Authorization: Bearer $PADDLE_API_KEY" \
-H "Accept: application/json" \
"$PADDLE_API_URL/products/$PADDLE_PRODUCT_ID"
)"
PRODUCT_NAME="$(echo "$PRODUCT_RESPONSE" | jq -r '.data.name // empty')"
if [[ -z "$PRODUCT_NAME" ]]; then
echo "ERROR: Could not retrieve Paddle product."
exit 1
fi
echo "Found product: $PRODUCT_NAME"
echo
# ------------------------------------------------------------
# Existing prices
# ------------------------------------------------------------
get_prices() {
curl -fsS \
-H "Authorization: Bearer $PADDLE_API_KEY" \
-H "Accept: application/json" \
"$PADDLE_API_URL/prices?product_id=$PADDLE_PRODUCT_ID&status=active&per_page=100"
}
find_price_by_description() {
local description="$1"
get_prices |
jq -r \
--arg description "$description" \
'.data[]
| select(.description == $description)
| .id' |
head -n 1
}
create_price() {
local description="$1"
local amount="$2"
local interval="$3"
echo "Creating: $description"
curl -fsS \
-X POST \
"$PADDLE_API_URL/prices" \
-H "Authorization: Bearer $PADDLE_API_KEY" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
--data "$(
jq -n \
--arg product "$PADDLE_PRODUCT_ID" \
--arg description "$description" \
--arg amount "$amount" \
--arg interval "$interval" \
'{
product_id: $product,
description: $description,
unit_price: {
amount: $amount,
currency_code: "GBP"
},
billing_cycle: {
interval: $interval,
frequency: 1
},
quantity: {
minimum: 1,
maximum: 1
},
tax_mode: "account_setting"
}'
)" |
jq -r '.data.id'
}
update_price_amount() {
local price_id="$1"
local description="$2"
local amount="$3"
echo "Updating: $description ($price_id)"
curl -fsS \
-X PATCH \
"$PADDLE_API_URL/prices/$price_id" \
-H "Authorization: Bearer $PADDLE_API_KEY" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
--data "$(
jq -n \
--arg description "$description" \
--arg amount "$amount" \
'{
description: $description,
unit_price: {
amount: $amount,
currency_code: "GBP"
}
}'
)" >/dev/null
}
ensure_price() {
local description="$1"
local amount="$2"
local interval="$3"
local existing
existing="$(find_price_by_description "$description")"
if [[ -n "$existing" ]]; then
local current_amount
current_amount="$(
get_prices |
jq -r \
--arg id "$existing" \
'.data[]
| select(.id == $id)
| .unit_price.amount'
)"
if [[ "$current_amount" != "$amount" ]]; then
echo "Existing price $existing has amount $current_amount."
echo "Required amount is $amount."
update_price_amount \
"$existing" \
"$description" \
"$amount"
else
echo "Exists: $description ($existing)"
fi
echo "$existing"
return
fi
create_price \
"$description" \
"$amount" \
"$interval"
}
# ------------------------------------------------------------
# SonarDesk production prices
#
# Paddle amounts are in pennies:
#
# £2.99 = 299
# £24.99 = 2499
# £4.99 = 499
# £44.99 = 4499
# ------------------------------------------------------------
echo "Configuring prices..."
echo
PERSONAL_MONTHLY="$(
ensure_price \
"SonarDesk Personal Monthly" \
"299" \
"month" |
tail -n 1
)"
PERSONAL_ANNUAL="$(
ensure_price \
"SonarDesk Personal Annual" \
"2499" \
"year" |
tail -n 1
)"
PROFESSIONAL_MONTHLY="$(
ensure_price \
"SonarDesk Professional Monthly" \
"499" \
"month" |
tail -n 1
)"
PROFESSIONAL_ANNUAL="$(
ensure_price \
"SonarDesk Professional Annual" \
"4499" \
"year" |
tail -n 1
)"
echo
echo "============================================"
echo " Paddle Production Prices"
echo "============================================"
echo
echo "Personal Monthly: $PERSONAL_MONTHLY"
echo "Personal Annual: $PERSONAL_ANNUAL"
echo "Professional Monthly: $PROFESSIONAL_MONTHLY"
echo "Professional Annual: $PROFESSIONAL_ANNUAL"
echo
echo "Add these to the production .env:"
echo
echo "PADDLE_ENVIRONMENT=production"
echo "PADDLE_API_URL=https://api.paddle.com"
echo "PADDLE_PRODUCT_ID=$PADDLE_PRODUCT_ID"
echo "PADDLE_PERSONAL_MONTHLY_PRICE_ID=$PERSONAL_MONTHLY"
echo "PADDLE_PERSONAL_ANNUAL_PRICE_ID=$PERSONAL_ANNUAL"
echo "PADDLE_PROFESSIONAL_MONTHLY_PRICE_ID=$PROFESSIONAL_MONTHLY"
echo "PADDLE_PROFESSIONAL_ANNUAL_PRICE_ID=$PROFESSIONAL_ANNUAL"
echo
echo "IMPORTANT:"
echo "PADDLE_API_KEY and PADDLE_CLIENT_SIDE_TOKEN must also be LIVE values."
echo "PADDLE_WEBHOOK_SECRET must come from the LIVE webhook destination."
echo
+296
View File
@@ -0,0 +1,296 @@
# pdl_sdbx_apikey_01m3pv5nbpjq94nh2hzqwhzbv5_TW7kG6xyGMcbxFAnEAtKqc_ALK
#client side
# pdl_sdbx_apikey_01m3pveweeskjzz1rc8f12a7x3_G9qWxtDBxGZJWa3f7VGKz6_A15
#!/usr/bin/env bash
set -euo pipefail
PADDLE_API="https://sandbox-api.paddle.com"
PRODUCT_ID="pro_01m3ptwmqy8npywsq4h4q27p0w"
OUTPUT_FILE=".env.paddle"
echo "============================================================"
echo " SonarDesk Paddle SANDBOX Price Setup"
echo "============================================================"
echo
echo "Product: $PRODUCT_ID"
echo
command -v curl >/dev/null || {
echo "ERROR: curl is required"
exit 1
}
command -v jq >/dev/null || {
echo "ERROR: jq is required"
echo "Install with: brew install jq"
exit 1
}
read -rsp "Enter Paddle SANDBOX API key: " PADDLE_API_KEY
echo
echo
if [[ -z "$PADDLE_API_KEY" ]]; then
echo "ERROR: No API key supplied."
exit 1
fi
# ------------------------------------------------------------
# API request helper
# ------------------------------------------------------------
api_request() {
local method="$1"
local endpoint="$2"
local data="${3:-}"
local tmp
tmp="$(mktemp)"
if [[ "$method" == "GET" ]]; then
HTTP_CODE=$(curl -sS \
-o "$tmp" \
-w "%{http_code}" \
-H "Authorization: Bearer ${PADDLE_API_KEY}" \
-H "Content-Type: application/json" \
"${PADDLE_API}${endpoint}")
else
HTTP_CODE=$(curl -sS \
-o "$tmp" \
-w "%{http_code}" \
-X "$method" \
-H "Authorization: Bearer ${PADDLE_API_KEY}" \
-H "Content-Type: application/json" \
-d "$data" \
"${PADDLE_API}${endpoint}")
fi
RESPONSE="$(cat "$tmp")"
rm -f "$tmp"
}
# ------------------------------------------------------------
# Verify API key
# ------------------------------------------------------------
echo "1. Testing Paddle API key..."
api_request GET "/event-types"
if [[ "$HTTP_CODE" != "200" ]]; then
echo
echo "ERROR: Paddle rejected the API key."
echo "HTTP: $HTTP_CODE"
echo
echo "$RESPONSE" | jq . 2>/dev/null || echo "$RESPONSE"
exit 1
fi
echo " ✓ API key works"
echo
# ------------------------------------------------------------
# Verify product
# ------------------------------------------------------------
echo "2. Checking SonarDesk product..."
api_request GET "/products/${PRODUCT_ID}"
if [[ "$HTTP_CODE" != "200" ]]; then
echo
echo "ERROR: Cannot access SonarDesk product."
echo "HTTP: $HTTP_CODE"
echo
echo "$RESPONSE" | jq . 2>/dev/null || echo "$RESPONSE"
exit 1
fi
PRODUCT_NAME="$(echo "$RESPONSE" | jq -r '.data.name')"
echo " ✓ Product found: $PRODUCT_NAME"
echo " ✓ $PRODUCT_ID"
echo
# ------------------------------------------------------------
# Create price
# ------------------------------------------------------------
create_price() {
local variable="$1"
local name="$2"
local description="$3"
local amount="$4"
local interval="$5"
local display="$6"
echo "Creating: $name"
echo " $display"
PAYLOAD=$(jq -n \
--arg product "$PRODUCT_ID" \
--arg name "$name" \
--arg description "$description" \
--arg amount "$amount" \
--arg interval "$interval" \
'{
product_id: $product,
name: $name,
description: $description,
unit_price: {
amount: $amount,
currency_code: "GBP"
},
billing_cycle: {
interval: $interval,
frequency: 1
},
trial_period: {
interval: "day",
frequency: 7,
requires_payment_method: true
},
tax_mode: "account_setting",
quantity: {
minimum: 1,
maximum: 1
}
}')
echo
echo "Request:"
echo "$PAYLOAD" | jq .
echo
api_request POST "/prices" "$PAYLOAD"
if [[ "$HTTP_CODE" != "201" ]]; then
echo "============================================================"
echo " ERROR CREATING PRICE"
echo "============================================================"
echo
echo "Price: $name"
echo "HTTP: $HTTP_CODE"
echo
echo "Paddle response:"
echo
echo "$RESPONSE" | jq . 2>/dev/null || echo "$RESPONSE"
echo
exit 1
fi
PRICE_ID="$(echo "$RESPONSE" | jq -r '.data.id')"
if [[ -z "$PRICE_ID" || "$PRICE_ID" == "null" ]]; then
echo "ERROR: Paddle returned no price ID."
echo "$RESPONSE" | jq .
exit 1
fi
printf -v "$variable" '%s' "$PRICE_ID"
echo " ✓ CREATED"
echo " ✓ $PRICE_ID"
echo
}
# ------------------------------------------------------------
# Create SonarDesk prices
# ------------------------------------------------------------
echo "============================================================"
echo " Creating SonarDesk Prices"
echo "============================================================"
echo
create_price \
PERSONAL_MONTHLY \
"Personal Monthly" \
"SonarDesk Personal - Monthly subscription" \
"299" \
"month" \
"£2.99/month"
create_price \
PERSONAL_ANNUAL \
"Personal Annual" \
"SonarDesk Personal - Annual subscription" \
"2499" \
"year" \
"£24.99/year"
create_price \
PROFESSIONAL_MONTHLY \
"Professional Monthly" \
"SonarDesk Professional - Monthly subscription" \
"499" \
"month" \
"£4.99/month"
create_price \
PROFESSIONAL_ANNUAL \
"Professional Annual" \
"SonarDesk Professional - Annual subscription" \
"4499" \
"year" \
"£44.99/year"
# ------------------------------------------------------------
# Generate Laravel configuration
# ------------------------------------------------------------
cat > "$OUTPUT_FILE" <<EOF
# ============================================================
# SonarDesk Paddle SANDBOX
# ============================================================
PADDLE_ENV=sandbox
PADDLE_PRODUCT_ID=${PRODUCT_ID}
PADDLE_PERSONAL_MONTHLY_PRICE_ID=${PERSONAL_MONTHLY}
PADDLE_PERSONAL_YEARLY_PRICE_ID=${PERSONAL_ANNUAL}
PADDLE_PROFESSIONAL_MONTHLY_PRICE_ID=${PROFESSIONAL_MONTHLY}
PADDLE_PROFESSIONAL_YEARLY_PRICE_ID=${PROFESSIONAL_ANNUAL}
EOF
echo
echo "============================================================"
echo " SUCCESS"
echo "============================================================"
echo
echo "SonarDesk Paddle prices:"
echo
echo "Personal Monthly"
echo " £2.99/month"
echo " $PERSONAL_MONTHLY"
echo
echo "Personal Annual"
echo " £24.99/year"
echo " $PERSONAL_ANNUAL"
echo
echo "Professional Monthly"
echo " £4.99/month"
echo " $PROFESSIONAL_MONTHLY"
echo
echo "Professional Annual"
echo " £44.99/year"
echo " $PROFESSIONAL_ANNUAL"
echo
echo "Configuration:"
echo " $OUTPUT_FILE"
echo
echo "Now check:"
echo " Paddle Sandbox -> Catalog -> Products -> SonarDesk"
echo
+4
View File
@@ -0,0 +1,4 @@
cloudflared tunnel \
--url https://sonardesk.vm \
--no-tls-verify \
--http-host-header sonardesk.vm
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env bash
set -euo pipefail
ENV_FILE="${1:-.env}"
if [[ ! -f "$ENV_FILE" ]]; then
echo "ERROR: Cannot find $ENV_FILE"
exit 1
fi
# Load only the Paddle values we need.
get_env() {
grep -E "^${1}=" "$ENV_FILE" | head -1 | cut -d= -f2-
}
PADDLE_ENVIRONMENT="$(get_env PADDLE_ENVIRONMENT)"
PADDLE_API_URL="$(get_env PADDLE_API_URL)"
PADDLE_API_KEY="$(get_env PADDLE_API_KEY)"
PERSONAL_MONTHLY="$(get_env PADDLE_PERSONAL_MONTHLY_PRICE_ID)"
PERSONAL_ANNUAL="$(get_env PADDLE_PERSONAL_ANNUAL_PRICE_ID)"
PRO_MONTHLY="$(get_env PADDLE_PROFESSIONAL_MONTHLY_PRICE_ID)"
PRO_ANNUAL="$(get_env PADDLE_PROFESSIONAL_ANNUAL_PRICE_ID)"
if [[ -z "$PADDLE_API_KEY" ]]; then
echo "ERROR: PADDLE_API_KEY is missing"
exit 1
fi
echo
echo "Paddle environment: $PADDLE_ENVIRONMENT"
echo "API URL: $PADDLE_API_URL"
echo
echo "Prices to update:"
echo " Personal monthly -> £4.99"
echo " Personal annual -> £39.99"
echo " Professional monthly -> £8.99"
echo " Professional annual -> £79.99"
echo
if [[ "$PADDLE_ENVIRONMENT" == "production" ]]; then
echo "WARNING: THIS IS THE LIVE PADDLE ACCOUNT."
printf "Type LIVE to continue: "
read -r confirmation
if [[ "$confirmation" != "LIVE" ]]; then
echo "Cancelled."
exit 1
fi
else
printf "Type YES to update the sandbox prices: "
read -r confirmation
if [[ "$confirmation" != "YES" ]]; then
echo "Cancelled."
exit 1
fi
fi
update_price() {
local id="$1"
local amount="$2"
local description="$3"
if [[ -z "$id" ]]; then
echo "ERROR: Missing price ID for $description"
exit 1
fi
echo
echo "Updating $description ($id)..."
response="$(
curl --silent --show-error --fail-with-body \
--request PATCH \
"${PADDLE_API_URL}/prices/${id}" \
--header "Authorization: Bearer ${PADDLE_API_KEY}" \
--header "Content-Type: application/json" \
--data "{
\"unit_price\": {
\"amount\": \"${amount}\",
\"currency_code\": \"GBP\"
}
}"
)"
if command -v jq >/dev/null 2>&1; then
echo "$response" | jq '{
id: .data.id,
description: .data.description,
price: .data.unit_price,
billing_cycle: .data.billing_cycle,
status: .data.status
}'
else
echo "$response"
fi
}
# Paddle amounts are in pennies:
# £4.99 = 499
# £39.99 = 3999
# £8.99 = 899
# £79.99 = 7999
update_price "$PERSONAL_MONTHLY" 499 "Personal Monthly"
update_price "$PERSONAL_ANNUAL" 3999 "Personal Annual"
update_price "$PRO_MONTHLY" 899 "Professional Monthly"
update_price "$PRO_ANNUAL" 7999 "Professional Annual"
echo
echo "----------------------------------------"
echo "All Paddle prices updated successfully."
echo "----------------------------------------"
View File
@@ -1,2 +0,0 @@
*
!.gitignore
@@ -4,6 +4,7 @@ namespace Tests\Feature;
use App\Models\License; use App\Models\License;
use App\Models\LicenseProduct; use App\Models\LicenseProduct;
use App\Models\User;
use App\Services\Payments\PurchaseProvisioner; use App\Services\Payments\PurchaseProvisioner;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase; use Tests\TestCase;
@@ -102,4 +103,30 @@ class PaddleSubscriptionProvisioningTest extends TestCase
$this->assertNotNull($license->revoked_at); $this->assertNotNull($license->revoked_at);
$this->assertFalse($license->is_usable); $this->assertFalse($license->is_usable);
} }
public function test_new_purchase_creates_a_portal_user_for_the_customer(): void
{
$this->product('pri_personal_monthly', 'Personal', 1);
app(PurchaseProvisioner::class)->provision('paddle', $this->subscription(
'subscription.created', 'active', 'pri_personal_monthly', 'pro_personal'
));
$user = User::where('email', 'mark@example.com')->firstOrFail();
$this->assertSame('Mark', $user->name);
$this->assertNotNull($user->email_verified_at);
}
public function test_existing_customer_account_is_reused(): void
{
User::factory()->create(['name' => 'Existing Mark', 'email' => 'mark@example.com']);
$this->product('pri_personal_monthly', 'Personal', 1);
app(PurchaseProvisioner::class)->provision('paddle', $this->subscription(
'subscription.created', 'active', 'pri_personal_monthly', 'pro_personal'
));
$this->assertSame(1, User::where('email', 'mark@example.com')->count());
$this->assertSame('Existing Mark', User::where('email', 'mark@example.com')->firstOrFail()->name);
}
} }
@@ -0,0 +1,45 @@
<?php
namespace Tests\Feature;
use App\Models\SonarDeskRelease;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
final class ReleaseDistributionTest extends TestCase
{
use RefreshDatabase;
public function test_download_page_is_public(): void
{
$this->get('/release')->assertOk()->assertSee('Download SonarDesk');
}
public function test_latest_download_returns_published_package(): void
{
Storage::fake('local');
Storage::disk('local')->put('sonardesk/releases/SonarDesk.dmg', 'package');
SonarDeskRelease::create([
'version' => '1.2.0', 'build_number' => 120, 'channel' => 'stable',
'file_path' => 'sonardesk/releases/SonarDesk.dmg', 'original_filename' => 'SonarDesk-1.2.0.dmg',
'file_size' => 7, 'sha256' => hash('sha256', 'package'), 'is_published' => true, 'published_at' => now(),
]);
$this->get('/download')->assertOk()->assertDownload('SonarDesk-1.2.0.dmg');
}
public function test_draft_release_is_not_downloadable(): void
{
$release = SonarDeskRelease::create(['version'=>'1.0.0','build_number'=>1,'channel'=>'stable','is_published'=>false]);
$this->get('/downloads/'.$release->id)->assertNotFound();
}
public function test_appcast_contains_published_release(): void
{
SonarDeskRelease::create([
'version'=>'2.0.0','build_number'=>200,'channel'=>'stable','file_path'=>'sonardesk/releases/a.dmg',
'original_filename'=>'a.dmg','file_size'=>10,'sha256'=>str_repeat('a',64),'is_published'=>true,'published_at'=>now(),
]);
$this->get('/updates/appcast.xml')->assertOk()->assertSee('SonarDesk 2.0.0')->assertSee('sparkle:version', false);
}
}