Compare commits

...
Author SHA1 Message Date
Mark Gregory eac5910dbb IOS updates 2026-10-06 09:13:11 +01:00
Mark Gregory 736765180c phase two 2026-10-06 08:58:01 +01:00
Mark Gregory 6bb62ebf69 added shared support 2026-10-06 08:55:19 +01:00
Mark Gregory 211115c9bc site updates 2026-10-06 08:47:30 +01:00
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
223 changed files with 9356 additions and 919 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-----
@@ -0,0 +1,25 @@
import Foundation
/// Cross-platform issue filter used by the shared SonarDesk application state.
/// UI-specific colours/icons belong in the platform view layer.
enum IssueFilter: String, CaseIterable, Identifiable, Hashable {
case all = "All Issues"
case bugs = "Bugs"
case vulnerabilities = "Vulnerabilities"
case codeSmells = "Code Smells"
var id: String { rawValue }
var apiType: String? {
switch self {
case .all:
return nil
case .bugs:
return "BUG"
case .vulnerabilities:
return "VULNERABILITY"
case .codeSmells:
return "CODE_SMELL"
}
}
}
+80
View File
@@ -0,0 +1,80 @@
import Foundation
struct SonarServer: Codable, Identifiable, Hashable {
var id = UUID()
var name: String
var baseURL: String
}
struct Project: Codable, Identifiable, Hashable {
var id: String { key }
let key: String
let name: String
let qualifier: String?
let visibility: String?
let lastAnalysisDate: String?
}
struct ProjectsResponse: Codable {
let components: [Project]
let paging: Paging?
}
struct Paging: Codable { let pageIndex: Int?; let pageSize: Int?; let total: Int? }
struct Measure: Codable, Hashable { let metric: String; let value: String?; let bestValue: Bool? }
struct MeasuresComponent: Codable { let key: String; let name: String; let measures: [Measure] }
struct MeasuresResponse: Codable { let component: MeasuresComponent }
struct QualityGateResponse: Codable { let projectStatus: ProjectStatus }
struct ProjectStatus: Codable { let status: String; let conditions: [GateCondition]? }
struct GateCondition: Codable, Hashable {
let status: String
let metricKey: String
let comparator: String?
let errorThreshold: String?
let actualValue: String?
}
struct Issue: Codable, Identifiable, Hashable {
var id: String { key }
let key: String
let rule: String?
let severity: String?
let component: String?
let project: String?
let line: Int?
let message: String?
let type: String?
let status: String?
let creationDate: String?
let effort: String?
}
struct IssuesResponse: Codable { let total: Int?; let p: Int?; let ps: Int?; let issues: [Issue] }
struct ServerStatusResponse: Codable { let id: String?; let version: String?; let status: String? }
struct ProjectSnapshot: Identifiable {
var id: String { project.key }
let project: Project
var measures: [String: String] = [:]
var qualityGate: String = "UNKNOWN"
}
struct TokenGenerateResponse: Codable {
let login: String?
let name: String?
let token: String
let createdAt: String?
let type: String?
}
struct CreatedProjectCredentials: Identifiable {
let id = UUID()
let projectKey: String
let projectName: String
let tokenName: String
let token: String
let serverURL: String
}
@@ -0,0 +1,171 @@
import Foundation
#if os(macOS)
import AppKit
#endif
/// SonarDesk's user-visible iCloud Drive document store.
/// Tokens remain in iCloud Keychain and are never written to this folder.
final class ICloudDriveStore {
static let shared = ICloudDriveStore()
static let containerIdentifier = "iCloud.net.absdev.SonarDesk"
enum StoreError: LocalizedError {
case unavailable
case notDownloaded
var errorDescription: String? {
switch self {
case .unavailable:
return "SonarDesk cannot access its iCloud Drive container. Check iCloud Drive and the SonarDesk iCloud capability/signing."
case .notDownloaded:
return "The SonarDesk iCloud Drive folder is not currently available on this Mac."
}
}
}
struct Status {
let available: Bool
let containerIdentifier: String
let documentsURL: URL?
let message: String
}
private let fm = FileManager.default
private init() {}
/// Returns the public Documents scope of the SonarDesk ubiquity container.
/// No local fallback is permitted: persistent SonarDesk files are iCloud-only.
func root(create: Bool = true) throws -> URL {
guard let container = fm.url(forUbiquityContainerIdentifier: Self.containerIdentifier) else {
throw StoreError.unavailable
}
let documents = container.appendingPathComponent("Documents", isDirectory: true)
if create {
try createHierarchy(at: documents)
// Touch a harmless marker so the public document scope is materialised immediately.
let marker = documents.appendingPathComponent(".sonardesk", isDirectory: false)
if !fm.fileExists(atPath: marker.path) {
try Data("SonarDesk iCloud Drive data store\n".utf8).write(to: marker, options: .atomic)
}
}
return documents
}
func status() -> Status {
guard let container = fm.url(forUbiquityContainerIdentifier: Self.containerIdentifier) else {
return Status(available: false,
containerIdentifier: Self.containerIdentifier,
documentsURL: nil,
message: "Unavailable — the signed app has no access to its iCloud Drive container.")
}
let documents = container.appendingPathComponent("Documents", isDirectory: true)
do {
try createHierarchy(at: documents)
return Status(available: true,
containerIdentifier: Self.containerIdentifier,
documentsURL: documents,
message: "Connected")
} catch {
return Status(available: false,
containerIdentifier: Self.containerIdentifier,
documentsURL: documents,
message: error.localizedDescription)
}
}
func dataDirectory(create: Bool = true) throws -> URL { try directory("Data", create: create) }
func reportsDirectory(create: Bool = true) throws -> URL { try directory("Reports", create: create) }
func backupsDirectory(create: Bool = true) throws -> URL { try directory("Backups", create: create) }
func historyDirectory(create: Bool = true) throws -> URL { try directory("Data/history", create: create) }
func exportsDirectory(_ name: String, create: Bool = true) throws -> URL { try directory("Reports/\(name)", create: create) }
private func directory(_ relative: String, create: Bool) throws -> URL {
let url = try root(create: create).appendingPathComponent(relative, isDirectory: true)
if create { try fm.createDirectory(at: url, withIntermediateDirectories: true) }
return url
}
func read<T: Decodable>(_ type: T.Type, fileName: String) throws -> T? {
let url = try dataDirectory().appendingPathComponent(fileName)
guard fm.fileExists(atPath: url.path) else { return nil }
try ensureDownloaded(url)
let coordinator = NSFileCoordinator(filePresenter: nil)
var coordinationError: NSError?
var result: Result<T, Error>?
coordinator.coordinate(readingItemAt: url, options: [], error: &coordinationError) { coordinatedURL in
result = Result {
let data = try Data(contentsOf: coordinatedURL, options: [.mappedIfSafe])
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return try decoder.decode(type, from: data)
}
}
if let coordinationError { throw coordinationError }
return try result?.get()
}
func write<T: Encodable>(_ value: T, fileName: String) throws {
let url = try dataDirectory().appendingPathComponent(fileName)
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
encoder.dateEncodingStrategy = .iso8601
let data = try encoder.encode(value)
try coordinatedAtomicWrite(data, to: url, backupName: fileName)
}
func coordinatedAtomicWrite(_ data: Data, to url: URL, backupName: String? = nil) throws {
let coordinator = NSFileCoordinator(filePresenter: nil)
var coordinationError: NSError?
var writeError: Error?
coordinator.coordinate(writingItemAt: url, options: .forReplacing, error: &coordinationError) { coordinatedURL in
do {
if fm.fileExists(atPath: coordinatedURL.path), let backupName {
let backupDir = try backupsDirectory()
let stamp = ISO8601DateFormatter().string(from: Date()).replacingOccurrences(of: ":", with: "-")
let backup = backupDir.appendingPathComponent("\(stamp)-\(backupName)")
try? fm.copyItem(at: coordinatedURL, to: backup)
pruneBackups(named: backupName, in: backupDir)
}
try data.write(to: coordinatedURL, options: [.atomic, .completeFileProtectionUnlessOpen])
} catch { writeError = error }
}
if let coordinationError { throw coordinationError }
if let writeError { throw writeError }
}
private func ensureDownloaded(_ url: URL) throws {
let values = try url.resourceValues(forKeys: [.isUbiquitousItemKey, .ubiquitousItemDownloadingStatusKey])
guard values.isUbiquitousItem == true else { return }
let status = values.ubiquitousItemDownloadingStatus
if status != .current && status != .downloaded {
try fm.startDownloadingUbiquitousItem(at: url)
throw StoreError.notDownloaded
}
}
private func pruneBackups(named fileName: String, in directory: URL) {
guard let files = try? fm.contentsOfDirectory(at: directory, includingPropertiesForKeys: [.contentModificationDateKey]) else { return }
let matching = files.filter { $0.lastPathComponent.hasSuffix("-\(fileName)") }.sorted {
let l = (try? $0.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
let r = (try? $1.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
return l > r
}
for old in matching.dropFirst(10) { try? fm.removeItem(at: old) }
}
func revealRoot() throws {
#if os(macOS)
let url = try root()
NSWorkspace.shared.open(url)
#endif
}
private func createHierarchy(at root: URL) throws {
try fm.createDirectory(at: root, withIntermediateDirectories: true)
for relative in ["Data", "Data/history", "Reports", "Reports/PDF", "Reports/CSV", "Reports/JSON", "Backups"] {
try fm.createDirectory(at: root.appendingPathComponent(relative, isDirectory: true), withIntermediateDirectories: true)
}
}
}
@@ -0,0 +1,103 @@
import Foundation
import Security
enum KeychainService {
static let service = "net.absdev.SonarDesk"
static func saveToken(_ token: String, serverID: UUID) throws {
try saveLocal(token, account: serverID.uuidString)
}
static func token(serverID: UUID) -> String? {
readWithLegacyMigration(account: serverID.uuidString)
}
static func saveProjectToken(_ token: String, serverID: UUID, projectKey: String) throws {
try saveLocal(token, account: "project|\(serverID.uuidString)|\(projectKey)")
}
static func projectToken(serverID: UUID, projectKey: String) -> String? {
readWithLegacyMigration(account: "project|\(serverID.uuidString)|\(projectKey)")
}
static func deleteToken(serverID: UUID) {
deleteAll(account: serverID.uuidString)
}
private static func query(account: String, synchronizable: CFTypeRef) -> [String: Any] {
[kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
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 {
case emptyToken
case status(OSStatus)
var errorDescription: String? {
switch self {
case .emptyToken: return "The SonarQube token cannot be empty."
case .status(let status):
return SecCopyErrorMessageString(status, nil) as String? ?? "Keychain error (\(status))."
}
}
}
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)
}
}
@@ -0,0 +1,156 @@
import Foundation
actor SonarQubeClient {
let server: SonarServer
private let token: String
private let session: URLSession
init(server: SonarServer, token: String) {
self.server = server
self.token = token
let config = URLSessionConfiguration.ephemeral
config.timeoutIntervalForRequest = 30
config.timeoutIntervalForResource = 60
config.requestCachePolicy = .reloadIgnoringLocalCacheData
config.urlCache = nil
config.httpCookieStorage = nil
config.httpShouldSetCookies = false
config.httpAdditionalHeaders = ["Accept": "application/json"]
self.session = URLSession(configuration: config)
}
func serverStatus() async throws -> ServerStatusResponse { try await get("api/system/status") }
func projects(page: Int = 1, pageSize: Int = 100) async throws -> ProjectsResponse {
try await get("api/components/search", query: ["qualifiers":"TRK", "p":"\(page)", "ps":"\(pageSize)"])
}
func measures(projectKey: String) async throws -> MeasuresResponse {
let metrics = ["bugs","vulnerabilities","code_smells","security_hotspots","coverage","duplicated_lines_density","ncloc","complexity","reliability_rating","security_rating","sqale_rating"].joined(separator: ",")
return try await get("api/measures/component", query: ["component": projectKey, "metricKeys": metrics])
}
func qualityGate(projectKey: String) async throws -> QualityGateResponse {
try await get("api/qualitygates/project_status", query: ["projectKey": projectKey])
}
func createProject(name: String, key: String, visibility: String? = nil) async throws {
var fields = ["name": name, "project": key]
if let visibility, !visibility.isEmpty { fields["visibility"] = visibility }
try await postForm("api/projects/create", fields: fields)
}
func generateProjectAnalysisToken(projectKey: String, tokenName: String) async throws -> TokenGenerateResponse {
try await postForm("api/user_tokens/generate", fields: [
"name": tokenName,
"type": "PROJECT_ANALYSIS_TOKEN",
"projectKey": projectKey
])
}
func issues(projectKey: String? = nil, type: String? = nil, page: Int = 1, pageSize: Int = 25) async throws -> IssuesResponse {
var q = ["p":"\(page)", "ps":"\(pageSize)", "resolved":"false", "s":"CREATION_DATE", "asc":"false"]
if let projectKey { q["componentKeys"] = projectKey }
if let type { q["types"] = type }
return try await get("api/issues/search", query: q)
}
private func postForm<T: Decodable>(_ path: String, fields: [String: String]) async throws -> T {
let data = try await postFormData(path, fields: fields)
do { return try JSONDecoder().decode(T.self, from: data) }
catch { throw APIError.decoding(error.localizedDescription) }
}
private func postForm(_ path: String, fields: [String: String]) async throws {
_ = try await postFormData(path, fields: fields)
}
private func postFormData(_ path: String, fields: [String: String]) async throws -> Data {
let base = server.baseURL.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
guard let url = URL(string: base + "/" + path) else { throw APIError.invalidURL }
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
var components = URLComponents()
components.queryItems = fields.sorted(by: { $0.key < $1.key }).map { URLQueryItem(name: $0.key, value: $0.value) }
request.httpBody = components.percentEncodedQuery?.data(using: .utf8)
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse }
if (200...299).contains(http.statusCode) { return data }
if http.statusCode == 401 || http.statusCode == 403 { throw APIError.authentication }
if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let errors = object["errors"] as? [[String: Any]],
let message = errors.first?["msg"] as? String { throw APIError.serverMessage(message) }
throw APIError.http(http.statusCode)
}
private func get<T: Decodable>(_ path: String, query: [String:String] = [:]) async throws -> T {
let base = server.baseURL.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
guard var components = URLComponents(string: base + "/" + path),
let scheme = components.scheme?.lowercased(),
scheme == "https" || (scheme == "http" && Self.isLocalDevelopmentHost(components.host)) else {
throw APIError.insecureURL
}
if !query.isEmpty { components.queryItems = query.sorted(by: { $0.key < $1.key }).map { URLQueryItem(name: $0.key, value: $0.value) } }
guard let url = components.url else { throw APIError.invalidURL }
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.cachePolicy = .reloadIgnoringLocalCacheData
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Accept")
var lastError: Error?
for attempt in 0..<3 {
do {
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse }
if (200...299).contains(http.statusCode) {
do { return try JSONDecoder().decode(T.self, from: data) }
catch { throw APIError.decoding(error.localizedDescription) }
}
if http.statusCode == 401 || http.statusCode == 403 { throw APIError.authentication }
guard (http.statusCode == 429 || (500...599).contains(http.statusCode)), attempt < 2 else {
throw APIError.http(http.statusCode)
}
let delay = UInt64(500_000_000 * (attempt + 1))
try await Task.sleep(nanoseconds: delay)
} catch is CancellationError {
throw CancellationError()
} catch let error as APIError {
switch error {
case .authentication, .decoding, .invalidResponse, .invalidURL, .insecureURL: throw error
default: lastError = error
}
} catch {
lastError = error
guard attempt < 2 else { break }
try await Task.sleep(nanoseconds: UInt64(500_000_000 * (attempt + 1)))
}
}
throw lastError ?? APIError.invalidResponse
}
private static func isLocalDevelopmentHost(_ host: String?) -> Bool {
guard let host = host?.lowercased() else { return false }
return host == "localhost" || host == "127.0.0.1" || host == "::1"
}
}
enum APIError: LocalizedError {
case invalidURL, insecureURL, invalidResponse, authentication, http(Int), decoding(String), notConfigured, serverMessage(String)
var errorDescription: String? {
switch self {
case .invalidURL: return "The server URL is invalid."
case .insecureURL: return "Use an HTTPS SonarQube URL. HTTP is allowed only for localhost development."
case .invalidResponse: return "The server returned an invalid response."
case .authentication: return "SonarQube rejected the saved token. Edit the server and enter a valid token."
case .http(let code): return "SonarQube returned HTTP \(code)."
case .decoding(let text): return "Could not read the SonarQube response: \(text)"
case .notConfigured: return "Configure a SonarQube server in Settings."
case .serverMessage(let message): return message
}
}
}
+436
View File
@@ -0,0 +1,436 @@
import Foundation
import SwiftUI
@MainActor
final class AppState: ObservableObject {
@Published var servers: [SonarServer] = [] { didSet { saveServers() } }
@Published var selectedServerID: UUID? { didSet { saveSelectedServer() } }
@Published var projects: [ProjectSnapshot] = []
@Published var issues: [Issue] = []
@Published var issueTotal = 0
@Published var selectedProjectKey: String?
@Published var isLoading = false
@Published var errorMessage: String?
@Published var lastRefresh: Date?
@Published var serverVersion: String?
private let projectOrderKey = "projectOrder"
private let localServersKey = "SonarDesk.servers.v2"
private let localPreferencesKey = "SonarDesk.preferences.v2"
private let driveStore = ICloudDriveStore.shared
private var isLoadingPersistentData = false
private struct PreferencesFile: Codable {
var selectedServerID: UUID?
var projectOrder: [String]
var projectAliases: [String: String]?
}
var selectedServer: SonarServer? { servers.first { $0.id == selectedServerID } }
init() {
migrateLegacyPreferencesToICloudDriveIfNeeded()
loadDrivePreferences()
}
func client() throws -> SonarQubeClient {
guard let server = selectedServer, let token = KeychainService.token(serverID: server.id), !token.isEmpty else { throw APIError.notConfigured }
return SonarQubeClient(server: server, token: token)
}
func testServerConfiguration(name: String, url: String, token: String) async throws -> String {
let normalized = try normalizedServerURL(url)
let cleanToken = token.trimmingCharacters(in: .whitespacesAndNewlines)
guard !cleanToken.isEmpty else { throw KeychainError.emptyToken }
let temporary = SonarServer(name: cleanServerName(name), baseURL: normalized)
let status = try await SonarQubeClient(server: temporary, token: cleanToken).serverStatus()
return status.version ?? "Connected"
}
func testServer(id: UUID, replacementToken: String? = nil) async throws -> String {
guard let server = servers.first(where: { $0.id == id }) else { throw APIError.notConfigured }
let token = replacementToken?.trimmingCharacters(in: .whitespacesAndNewlines)
let effectiveToken = (token?.isEmpty == false ? token : KeychainService.token(serverID: id))
guard let effectiveToken, !effectiveToken.isEmpty else { throw APIError.notConfigured }
let status = try await SonarQubeClient(server: server, token: effectiveToken).serverStatus()
return status.version ?? "Connected"
}
func refresh() async {
isLoading = true; errorMessage = nil
defer { isLoading = false }
do {
let client = try client()
async let status = client.serverStatus()
async let response = client.projects()
let (serverStatus, projectResponse) = try await (status, response)
serverVersion = serverStatus.version
var snapshots = projectResponse.components.map { ProjectSnapshot(project: $0) }
await withTaskGroup(of: (Int, [String:String], String).self) { group in
for (index, project) in projectResponse.components.enumerated() {
group.addTask {
async let m = try? client.measures(projectKey: project.key)
async let g = try? client.qualityGate(projectKey: project.key)
let (measures, gate) = await (m, g)
let dict = Dictionary(uniqueKeysWithValues: (measures?.component.measures ?? []).compactMap { x in x.value.map { (x.metric, $0) } })
return (index, dict, gate?.projectStatus.status ?? "UNKNOWN")
}
}
for await result in group { snapshots[result.0].measures = result.1; snapshots[result.0].qualityGate = result.2 }
}
projects = applySavedProjectOrder(to: snapshots)
lastRefresh = Date()
} catch { errorMessage = error.localizedDescription }
}
func createSonarQubeProject(name: String, key: String, visibility: String, tokenName: String) async throws -> CreatedProjectCredentials {
guard selectedServer != nil else { throw APIError.notConfigured }
let cleanName = name.trimmingCharacters(in: .whitespacesAndNewlines)
let cleanKey = key.trimmingCharacters(in: .whitespacesAndNewlines)
guard !cleanName.isEmpty, !cleanKey.isEmpty else { throw ProjectCreationError.missingNameOrKey }
let client = try client()
try await client.createProject(name: cleanName, key: cleanKey, visibility: visibility)
await refresh()
return try await generateScanCredentials(projectKey: cleanKey, projectName: cleanName, tokenName: tokenName)
}
func generateScanCredentials(projectKey: String, projectName: String? = nil, tokenName: String = "") async throws -> CreatedProjectCredentials {
guard let server = selectedServer else { throw APIError.notConfigured }
let cleanKey = projectKey.trimmingCharacters(in: .whitespacesAndNewlines)
let resolvedName = projectName ?? projects.first(where: { $0.project.key == cleanKey })?.project.name ?? cleanKey
let suppliedName = tokenName.trimmingCharacters(in: .whitespacesAndNewlines)
let effectiveTokenName = suppliedName.isEmpty ? "SonarDesk - \(resolvedName) - \(Int(Date().timeIntervalSince1970))" : suppliedName
let generated = try await client().generateProjectAnalysisToken(projectKey: cleanKey, tokenName: effectiveTokenName)
try KeychainService.saveProjectToken(generated.token, serverID: server.id, projectKey: cleanKey)
return CreatedProjectCredentials(projectKey: cleanKey, projectName: resolvedName, tokenName: effectiveTokenName, token: generated.token, serverURL: server.baseURL)
}
func storedProjectToken(projectKey: String) -> String? {
guard let server = selectedServer else { return nil }
return KeychainService.projectToken(serverID: server.id, projectKey: projectKey)
}
func loadIssues(projectKey: String? = nil, filter: IssueFilter = .all, page: Int = 1, pageSize: Int = 25) async {
isLoading = true; errorMessage = nil
defer { isLoading = false }
do {
let response = try await client().issues(projectKey: projectKey, type: filter.apiType, page: page, pageSize: pageSize)
issues = response.issues
issueTotal = response.total ?? response.issues.count
} catch { errorMessage = error.localizedDescription }
}
func addServer(name: String, url: String, token: String) throws {
let normalized = try normalizedServerURL(url)
guard !servers.contains(where: { canonicalServerURL($0.baseURL) == canonicalServerURL(normalized) }) else {
throw ServerConfigurationError.duplicateServer
}
let server = SonarServer(name: cleanServerName(name), baseURL: normalized)
try KeychainService.saveToken(token, serverID: server.id)
servers.append(server)
selectedServerID = server.id
}
func updateServer(id: UUID, name: String, url: String, token: String?) throws {
guard let index = servers.firstIndex(where: { $0.id == id }) else { return }
let normalized = try normalizedServerURL(url)
guard !servers.contains(where: { $0.id != id && canonicalServerURL($0.baseURL) == canonicalServerURL(normalized) }) else {
throw ServerConfigurationError.duplicateServer
}
servers[index].name = cleanServerName(name)
servers[index].baseURL = normalized
if let token, !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
try KeychainService.saveToken(token, serverID: id)
}
saveServers()
if selectedServerID == id {
projects = []; issues = []; issueTotal = 0; serverVersion = nil; errorMessage = nil
Task { await refresh() }
}
}
func selectServer(_ id: UUID?) {
guard selectedServerID != id else { return }
selectedServerID = id
projects = []
issues = []
issueTotal = 0
serverVersion = nil
errorMessage = nil
if id != nil { Task { await refresh() } }
}
func removeServer(_ server: SonarServer) {
KeychainService.deleteToken(serverID: server.id); servers.removeAll { $0.id == server.id }
if selectedServerID == server.id { selectedServerID = servers.first?.id }
}
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) {
projects.move(fromOffsets: source, toOffset: destination)
saveProjectOrder()
}
func moveProject(_ key: String, direction: Int) {
guard let index = projects.firstIndex(where: { $0.project.key == key }) else { return }
let target = index + direction
guard projects.indices.contains(target) else { return }
projects.swapAt(index, target)
saveProjectOrder()
}
func moveProject(_ sourceKey: String, before targetKey: String) {
guard sourceKey != targetKey,
let sourceIndex = projects.firstIndex(where: { $0.project.key == sourceKey }),
let targetIndex = projects.firstIndex(where: { $0.project.key == targetKey }) else { return }
let item = projects.remove(at: sourceIndex)
let adjustedTarget = sourceIndex < targetIndex ? targetIndex - 1 : targetIndex
projects.insert(item, at: max(0, min(adjustedTarget, projects.count)))
saveProjectOrder()
}
func allIssues(projectKey: String) async throws -> [Issue] {
let api = try client()
let pageSize = 500
var page = 1
var result: [Issue] = []
while true {
let response = try await api.issues(projectKey: projectKey, page: page, pageSize: pageSize)
result.append(contentsOf: response.issues)
let total = response.total ?? result.count
if result.count >= total || response.issues.isEmpty { break }
page += 1
}
return result
}
private func applySavedProjectOrder(to snapshots: [ProjectSnapshot]) -> [ProjectSnapshot] {
let alphabetical = snapshots.sorted { $0.project.name.localizedCaseInsensitiveCompare($1.project.name) == .orderedAscending }
let keys = (try? driveStore.read(PreferencesFile.self, fileName: "preferences.json"))?.projectOrder ?? []
guard !keys.isEmpty else { return alphabetical }
let positions = Dictionary(uniqueKeysWithValues: keys.enumerated().map { ($0.element, $0.offset) })
return alphabetical.sorted { lhs, rhs in
let li = positions[lhs.project.key] ?? Int.max
let ri = positions[rhs.project.key] ?? Int.max
if li == ri { return lhs.project.name.localizedCaseInsensitiveCompare(rhs.project.name) == .orderedAscending }
return li < ri
}
}
private func saveProjectOrder() {
savePreferences(projectOrder: projects.map { $0.project.key })
}
private func saveServers() {
guard !isLoadingPersistentData else { return }
do {
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() {
guard !isLoadingPersistentData else { return }
savePreferences()
}
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 {
let encoder = JSONEncoder()
UserDefaults.standard.set(try encoder.encode(preferences), forKey: localPreferencesKey)
} catch {
errorMessage = error.localizedDescription
return
}
try? driveStore.write(preferences, fileName: "preferences.json")
}
private func loadDrivePreferences() {
isLoadingPersistentData = true
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 {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
UserDefaults.standard.set(try encoder.encode(servers), forKey: localServersKey)
let repaired = PreferencesFile(selectedServerID: selectedServerID,
projectOrder: preferences?.projectOrder ?? [],
projectAliases: preferences?.projectAliases ?? [:])
UserDefaults.standard.set(try encoder.encode(repaired), forKey: localPreferencesKey)
try? driveStore.write(servers, fileName: "servers.json")
try? driveStore.write(repaired, fileName: "preferences.json")
} catch {
errorMessage = error.localizedDescription
}
}
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 {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
UserDefaults.standard.set(try encoder.encode(legacyServers), forKey: localServersKey)
UserDefaults.standard.set(
try encoder.encode(PreferencesFile(selectedServerID: effectiveSelected, projectOrder: order, projectAliases: drivePreferences?.projectAliases ?? [:])),
forKey: localPreferencesKey
)
} catch {
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 {
let clean = name.trimmingCharacters(in: .whitespacesAndNewlines)
return clean.isEmpty ? "SonarQube" : clean
}
private func canonicalServerURL(_ value: String) -> String {
value.trimmingCharacters(in: CharacterSet(charactersIn: "/")).lowercased()
}
private func normalizedServerURL(_ value: String) throws -> String {
let clean = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard var components = URLComponents(string: clean),
let scheme = components.scheme?.lowercased(),
let host = components.host?.lowercased(), !host.isEmpty else { throw APIError.invalidURL }
let localHost = ["localhost", "127.0.0.1", "::1"].contains(host)
guard scheme == "https" || (scheme == "http" && localHost) else { throw APIError.insecureURL }
guard components.user == nil, components.password == nil, components.query == nil, components.fragment == nil else {
throw ServerConfigurationError.invalidServerURL
}
components.scheme = scheme
components.host = host
var path = components.path
while path.count > 1 && path.hasSuffix("/") { path.removeLast() }
components.path = path == "/" ? "" : path
guard let result = components.url?.absoluteString else { throw APIError.invalidURL }
return result
}
}
enum ServerConfigurationError: LocalizedError {
case duplicateServer
case invalidServerURL
var errorDescription: String? {
switch self {
case .duplicateServer: return "That SonarQube server is already configured."
case .invalidServerURL: return "Enter only the SonarQube server base URL, without credentials, a query, or a fragment."
}
}
}
enum ProjectCreationError: LocalizedError {
case missingNameOrKey
var errorDescription: String? { "Enter both a project name and project key." }
}
@@ -0,0 +1,49 @@
import Foundation
enum WorkspaceKind: String, Codable, CaseIterable, Sendable {
case personal
case team
}
enum WorkspaceRole: String, Codable, CaseIterable, Sendable {
case owner
case admin
case developer
case viewer
}
struct SonarWorkspace: Codable, Identifiable, Hashable, Sendable {
var id: UUID
var name: String
var kind: WorkspaceKind
var role: WorkspaceRole
var serverIDs: [UUID]
var createdAt: Date
var updatedAt: Date
init(id: UUID = UUID(), name: String, kind: WorkspaceKind, role: WorkspaceRole,
serverIDs: [UUID] = [], createdAt: Date = Date(), updatedAt: Date = Date()) {
self.id = id
self.name = name
self.kind = kind
self.role = role
self.serverIDs = serverIDs
self.createdAt = createdAt
self.updatedAt = updatedAt
}
static let personal = SonarWorkspace(
id: UUID(uuidString: "00000000-0000-0000-0000-000000000001")!,
name: "Personal",
kind: .personal,
role: .owner
)
}
struct WorkspaceMember: Codable, Identifiable, Hashable, Sendable {
var id: UUID
var workspaceID: UUID
var displayName: String
var role: WorkspaceRole
var joinedAt: Date
}
@@ -0,0 +1,52 @@
import Foundation
import Combine
@MainActor
final class WorkspaceStore: ObservableObject {
@Published private(set) var workspaces: [SonarWorkspace] = [.personal]
@Published var selectedWorkspaceID: UUID = SonarWorkspace.personal.id
private let driveStore = ICloudDriveStore.shared
private let fileName = "workspaces.json"
var selectedWorkspace: SonarWorkspace {
workspaces.first(where: { $0.id == selectedWorkspaceID }) ?? .personal
}
init() {
load()
}
func load() {
do {
guard let saved = try driveStore.read([SonarWorkspace].self, fileName: fileName), !saved.isEmpty else {
persist()
return
}
workspaces = saved
if !workspaces.contains(where: { $0.id == selectedWorkspaceID }) {
selectedWorkspaceID = workspaces.first?.id ?? SonarWorkspace.personal.id
}
} catch {
// Keep Personal usable if iCloud is temporarily unavailable.
workspaces = [.personal]
}
}
func createTeam(name: String) {
let clean = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !clean.isEmpty else { return }
let team = SonarWorkspace(name: clean, kind: .team, role: .owner)
workspaces.append(team)
selectedWorkspaceID = team.id
persist()
}
func select(_ workspace: SonarWorkspace) {
selectedWorkspaceID = workspace.id
}
private func persist() {
try? driveStore.write(workspaces, fileName: fileName)
}
}
+14
View File
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.icloud-container-identifiers</key>
<array><string>iCloud.net.absdev.SonarDesk</string></array>
<key>com.apple.developer.ubiquity-container-identifiers</key>
<array><string>iCloud.net.absdev.SonarDesk</string></array>
<key>com.apple.developer.icloud-services</key>
<array><string>CloudDocuments</string><string>CloudKit</string></array>
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
<string>$(TeamIdentifierPrefix)net.absdev.SonarDesk</string>
</dict>
</plist>
+15
View File
@@ -0,0 +1,15 @@
import SwiftUI
@main
struct SonarDeskIOSApp: App {
@StateObject private var appState = AppState()
@StateObject private var workspaceStore = WorkspaceStore()
var body: some Scene {
WindowGroup {
IOSRootView()
.environmentObject(appState)
.environmentObject(workspaceStore)
}
}
}
@@ -0,0 +1,86 @@
import SwiftUI
struct IOSDashboardView: View {
@EnvironmentObject private var appState: AppState
private var failedProjects: [ProjectSnapshot] {
appState.projects.filter { $0.qualityGate.uppercased() != "OK" }
}
private var bugs: Int { total("bugs") }
private var vulnerabilities: Int { total("vulnerabilities") }
private var codeSmells: Int { total("code_smells") }
var body: some View {
NavigationStack {
ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 155), spacing: 12)], spacing: 12) {
metricCard("Projects", value: appState.projects.count, icon: "square.stack.3d.up")
metricCard("Failed", value: failedProjects.count, icon: "xmark.octagon.fill", isFailure: !failedProjects.isEmpty)
metricCard("Bugs", value: bugs, icon: "ladybug")
metricCard("Vulnerabilities", value: vulnerabilities, icon: "lock.trianglebadge.exclamationmark")
metricCard("Code Smells", value: codeSmells, icon: "exclamationmark.bubble")
}
.padding()
if !failedProjects.isEmpty {
VStack(alignment: .leading, spacing: 10) {
Text("Needs attention")
.font(.headline)
ForEach(failedProjects) { snapshot in
NavigationLink {
IOSProjectDetailView(snapshot: snapshot)
} label: {
HStack {
Image(systemName: "xmark.octagon.fill")
.foregroundStyle(.red)
VStack(alignment: .leading) {
Text(snapshot.project.name).fontWeight(.semibold)
Text(snapshot.qualityGate).font(.caption)
}
Spacer()
Image(systemName: "chevron.right")
.foregroundStyle(.secondary)
}
.padding()
.background(.red.opacity(0.10), in: RoundedRectangle(cornerRadius: 14))
}
.buttonStyle(.plain)
}
}
.padding(.horizontal)
}
}
.navigationTitle("Dashboard")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button { Task { await appState.refresh() } } label: {
if appState.isLoading { ProgressView() } else { Image(systemName: "arrow.clockwise") }
}
.disabled(appState.isLoading || appState.selectedServer == nil)
}
}
.overlay {
if appState.selectedServer == nil {
ContentUnavailableView("No SonarQube Server", systemImage: "server.rack", description: Text("Add or sync a server to start using SonarDesk."))
}
}
}
}
private func total(_ key: String) -> Int {
appState.projects.reduce(0) { $0 + (Int($1.measures[key] ?? "0") ?? 0) }
}
@ViewBuilder
private func metricCard(_ title: String, value: Int, icon: String, isFailure: Bool = false) -> some View {
VStack(alignment: .leading, spacing: 12) {
Image(systemName: icon).font(.title2).foregroundStyle(isFailure ? .red : .primary)
Text("\(value)").font(.system(.title, design: .rounded, weight: .bold))
Text(title).font(.subheadline).foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.background(isFailure ? .red.opacity(0.10) : Color.secondary.opacity(0.08), in: RoundedRectangle(cornerRadius: 16))
}
}
@@ -0,0 +1,54 @@
import SwiftUI
struct IOSProjectDetailView: View {
let snapshot: ProjectSnapshot
private let metrics: [(String, String, String)] = [
("bugs", "Bugs", "ladybug"),
("vulnerabilities", "Vulnerabilities", "lock.trianglebadge.exclamationmark"),
("code_smells", "Code Smells", "exclamationmark.bubble"),
("coverage", "Coverage", "chart.bar"),
("duplicated_lines_density", "Duplications", "doc.on.doc")
]
var body: some View {
List {
Section("Quality Gate") {
HStack {
Label(snapshot.qualityGate.uppercased() == "OK" ? "Passed" : "Failed",
systemImage: snapshot.qualityGate.uppercased() == "OK" ? "checkmark.circle.fill" : "xmark.octagon.fill")
.foregroundStyle(snapshot.qualityGate.uppercased() == "OK" ? .green : .red)
Spacer()
Text(snapshot.qualityGate)
.foregroundStyle(.secondary)
}
}
Section("Metrics") {
ForEach(metrics, id: \.0) { metric in
HStack {
Label(metric.1, systemImage: metric.2)
Spacer()
Text(displayValue(metric.0))
.foregroundStyle(.secondary)
}
}
}
Section("Project") {
LabeledContent("Key", value: snapshot.project.key)
if let date = snapshot.project.lastAnalysisDate {
LabeledContent("Last analysis", value: date)
}
}
}
.navigationTitle(snapshot.project.name)
.navigationBarTitleDisplayMode(.inline)
}
private func displayValue(_ metric: String) -> String {
guard let value = snapshot.measures[metric] else { return "—" }
if metric == "coverage" || metric == "duplicated_lines_density" { return "\(value)%" }
return value
}
}
+41
View File
@@ -0,0 +1,41 @@
import SwiftUI
struct IOSProjectsView: View {
@EnvironmentObject private var appState: AppState
@State private var searchText = ""
private var filtered: [ProjectSnapshot] {
guard !searchText.isEmpty else { return appState.projects }
return appState.projects.filter {
$0.project.name.localizedCaseInsensitiveContains(searchText) ||
$0.project.key.localizedCaseInsensitiveContains(searchText)
}
}
var body: some View {
NavigationStack {
List(filtered) { snapshot in
NavigationLink {
IOSProjectDetailView(snapshot: snapshot)
} label: {
HStack(spacing: 12) {
Image(systemName: snapshot.qualityGate.uppercased() == "OK" ? "checkmark.circle.fill" : "xmark.octagon.fill")
.foregroundStyle(snapshot.qualityGate.uppercased() == "OK" ? .green : .red)
VStack(alignment: .leading, spacing: 3) {
Text(snapshot.project.name).fontWeight(.semibold)
Text(snapshot.project.key).font(.caption).foregroundStyle(.secondary)
}
}
}
}
.navigationTitle("Projects")
.searchable(text: $searchText, prompt: "Search projects")
.refreshable { await appState.refresh() }
.overlay {
if appState.projects.isEmpty && !appState.isLoading {
ContentUnavailableView("No Projects", systemImage: "square.stack.3d.up", description: Text(appState.errorMessage ?? "No SonarQube projects are available."))
}
}
}
}
}
+54
View File
@@ -0,0 +1,54 @@
import SwiftUI
struct IOSRootView: View {
@EnvironmentObject private var appState: AppState
@EnvironmentObject private var workspaceStore: WorkspaceStore
@State private var selection: IOSSection? = .dashboard
enum IOSSection: Hashable {
case dashboard
case projects
case settings
}
var body: some View {
NavigationSplitView {
List(selection: $selection) {
Section {
Label("Dashboard", systemImage: "gauge.with.dots.needle.50percent")
.tag(IOSSection.dashboard)
Label("Projects", systemImage: "square.stack.3d.up")
.tag(IOSSection.projects)
}
Section("Workspace") {
Picker("Workspace", selection: $workspaceStore.selectedWorkspaceID) {
ForEach(workspaceStore.workspaces) { workspace in
Text(workspace.name).tag(workspace.id)
}
}
}
Section {
Label("Settings", systemImage: "gearshape")
.tag(IOSSection.settings)
}
}
.navigationTitle("SonarDesk")
} detail: {
switch selection ?? .dashboard {
case .dashboard:
IOSDashboardView()
case .projects:
IOSProjectsView()
case .settings:
IOSSettingsView()
}
}
.task {
if appState.selectedServer != nil && appState.projects.isEmpty {
await appState.refresh()
}
}
}
}
+154
View File
@@ -0,0 +1,154 @@
import SwiftUI
struct IOSSettingsView: View {
@EnvironmentObject private var appState: AppState
@EnvironmentObject private var workspaceStore: WorkspaceStore
@State private var editor: ServerEditorItem?
@State private var serverToDelete: SonarServer?
var body: some View {
NavigationStack {
Form {
Section("Workspace") {
LabeledContent("Name", value: workspaceStore.selectedWorkspace.name)
LabeledContent("Type", value: workspaceStore.selectedWorkspace.kind == .team ? "Team" : "Personal")
LabeledContent("Role", value: workspaceStore.selectedWorkspace.role.rawValue.capitalized)
}
Section {
if appState.servers.isEmpty {
ContentUnavailableView("No SonarQube Servers", systemImage: "server.rack", description: Text("Add a server to load your SonarQube projects."))
} else {
ForEach(appState.servers) { server in
Button {
appState.selectServer(server.id)
} label: {
HStack(spacing: 12) {
Image(systemName: appState.selectedServerID == server.id ? "checkmark.circle.fill" : "circle")
.foregroundStyle(appState.selectedServerID == server.id ? Color.accentColor : Color.secondary)
VStack(alignment: .leading, spacing: 3) {
Text(server.name).foregroundStyle(.primary)
Text(server.baseURL).font(.caption).foregroundStyle(.secondary).lineLimit(1)
}
Spacer()
Button { editor = ServerEditorItem(server: server) } label: { Image(systemName: "pencil") }
.buttonStyle(.borderless)
}
}
.swipeActions(edge: .trailing) {
Button(role: .destructive) { serverToDelete = server } label: { Label("Delete", systemImage: "trash") }
Button { editor = ServerEditorItem(server: server) } label: { Label("Edit", systemImage: "pencil") }.tint(.blue)
}
}
}
Button { editor = ServerEditorItem(server: nil) } label: {
Label("Add SonarQube Server", systemImage: "plus.circle.fill")
}
} header: { Text("SonarQube Servers") } footer: {
Text("Access tokens are stored only in this device's Keychain and are not written to iCloud.")
}
if let server = appState.selectedServer {
Section("Active Server") {
LabeledContent("Server", value: server.name)
LabeledContent("Address", value: server.baseURL)
if let version = appState.serverVersion { LabeledContent("Version", value: version) }
Button("Refresh Projects") { Task { await appState.refresh() } }
}
}
}
.navigationTitle("Settings")
.sheet(item: $editor) { item in
IOSServerEditorView(server: item.server)
.environmentObject(appState)
}
.confirmationDialog("Delete this server?", isPresented: Binding(get: { serverToDelete != nil }, set: { if !$0 { serverToDelete = nil } }), titleVisibility: .visible) {
if let server = serverToDelete {
Button("Delete \(server.name)", role: .destructive) { appState.removeServer(server); serverToDelete = nil }
}
Button("Cancel", role: .cancel) { serverToDelete = nil }
} message: { Text("The server configuration and its token will be removed from this device.") }
}
}
}
private struct ServerEditorItem: Identifiable {
let id = UUID()
let server: SonarServer?
}
private struct IOSServerEditorView: View {
@EnvironmentObject private var appState: AppState
@Environment(\.dismiss) private var dismiss
let server: SonarServer?
@State private var name: String
@State private var url: String
@State private var token = ""
@State private var isTesting = false
@State private var isSaving = false
@State private var testResult: String?
@State private var errorMessage: String?
init(server: SonarServer?) {
self.server = server
_name = State(initialValue: server?.name ?? "")
_url = State(initialValue: server?.baseURL ?? "https://")
}
var body: some View {
NavigationStack {
Form {
Section("Server") {
TextField("Name", text: $name).textContentType(.organizationName)
TextField("https://sonarqube.example.com", text: $url)
.textInputAutocapitalization(.never).autocorrectionDisabled()
.keyboardType(.URL).textContentType(.URL)
}
Section("Authentication") {
SecureField(server == nil ? "SonarQube API token" : "New token (leave blank to keep current)", text: $token)
.textInputAutocapitalization(.never).autocorrectionDisabled()
Text(server == nil ? "Create a SonarQube user token and paste it here." : "For security the existing token cannot be displayed. Leave this blank unless you want to replace it.")
.font(.footnote).foregroundStyle(.secondary)
}
Section {
Button { Task { await testConnection() } } label: {
HStack { if isTesting { ProgressView() }; Label("Test Connection", systemImage: "network") }
}.disabled(isTesting || url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || (server == nil && token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty))
if let testResult { Label(testResult, systemImage: "checkmark.circle.fill").foregroundStyle(.green) }
if let errorMessage { Text(errorMessage).foregroundStyle(.red).font(.footnote) }
}
}
.navigationTitle(server == nil ? "Add Server" : "Edit Server")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } }
ToolbarItem(placement: .confirmationAction) { Button("Save") { save() }.disabled(isSaving || url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || (server == nil && token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)) }
}
}
}
@MainActor private func testConnection() async {
isTesting = true; errorMessage = nil; testResult = nil
defer { isTesting = false }
do {
let result: String
if let server, token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
result = try await appState.testServer(id: server.id)
} else {
result = try await appState.testServerConfiguration(name: name, url: url, token: token)
}
testResult = "Connected — \(result)"
} catch { errorMessage = error.localizedDescription }
}
private func save() {
isSaving = true; errorMessage = nil
do {
if let server { try appState.updateServer(id: server.id, name: name, url: url, token: token.isEmpty ? nil : token) }
else { try appState.addServer(name: name, url: url, token: token) }
dismiss()
} catch { errorMessage = error.localizedDescription; isSaving = false }
}
}
+66 -2
View File
@@ -8,6 +8,7 @@
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
A7 /* SonarDesk.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SonarDesk.app; sourceTree = BUILT_PRODUCTS_DIR; }; A7 /* SonarDesk.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SonarDesk.app; sourceTree = BUILT_PRODUCTS_DIR; };
B7 /* SonarDesk iOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "SonarDesk iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFileSystemSynchronizedRootGroup section */
@@ -16,9 +17,21 @@
path = SonarDesk; path = SonarDesk;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
B6 /* SonarDesk-iOS */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = "SonarDesk-iOS";
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */ /* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
B10 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
A10 /* Frameworks */ = { A10 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
@@ -33,6 +46,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
A6 /* SonarDesk */, A6 /* SonarDesk */,
B6 /* SonarDesk-iOS */,
A4 /* Products */, A4 /* Products */,
); );
sourceTree = "<group>"; sourceTree = "<group>";
@@ -41,6 +55,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
A7 /* SonarDesk.app */, A7 /* SonarDesk.app */,
B7 /* SonarDesk iOS.app */,
); );
name = Products; name = Products;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -70,6 +85,28 @@
productReference = A7 /* SonarDesk.app */; productReference = A7 /* SonarDesk.app */;
productType = "com.apple.product-type.application"; productType = "com.apple.product-type.application";
}; };
B5 /* SonarDesk-iOS */ = {
isa = PBXNativeTarget;
buildConfigurationList = B8 /* Build configuration list for PBXNativeTarget "SonarDesk-iOS" */;
buildPhases = (
B9 /* Sources */,
B10 /* Frameworks */,
B11 /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
B6 /* SonarDesk-iOS */,
);
name = "SonarDesk-iOS";
packageProductDependencies = (
);
productName = "SonarDesk iOS";
productReference = B7 /* SonarDesk iOS.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */ /* End PBXNativeTarget section */
/* Begin PBXProject section */ /* Begin PBXProject section */
@@ -94,6 +131,7 @@
projectRoot = ""; projectRoot = "";
targets = ( targets = (
A5 /* SonarDesk */, A5 /* SonarDesk */,
B5 /* SonarDesk-iOS */,
); );
}; };
/* End PBXProject section */ /* End PBXProject section */
@@ -106,6 +144,13 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
B11 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */ /* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */
@@ -116,6 +161,13 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
B9 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */ /* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */ /* Begin XCBuildConfiguration section */
@@ -314,7 +366,7 @@
PRODUCT_BUNDLE_IDENTIFIER = net.absdev.SonarDesk; PRODUCT_BUNDLE_IDENTIFIER = net.absdev.SonarDesk;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SONARDESK_ENVIRONMENT = Develop; SONARDESK_ENVIRONMENT = Develop;
SONARDESK_LICENSE_BASE_URL = "https://sonardesk.vm"; 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;
@@ -345,7 +397,7 @@
PRODUCT_BUNDLE_IDENTIFIER = net.absdev.SonarDesk; PRODUCT_BUNDLE_IDENTIFIER = net.absdev.SonarDesk;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SONARDESK_ENVIRONMENT = Develop; SONARDESK_ENVIRONMENT = Develop;
SONARDESK_LICENSE_BASE_URL = "https://sonardesk.vm"; 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;
@@ -418,6 +470,11 @@
}; };
name = Release; name = Release;
}; };
B20 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_ENTITLEMENTS = "SonarDesk-iOS/SonarDesk-iOS.entitlements"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 7; DEVELOPMENT_TEAM = 39R6U3437D; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = SonarDesk; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.0; MARKETING_VERSION = 2.3.0; PRODUCT_BUNDLE_IDENTIFIER = net.absdev.SonarDesk.iOS; PRODUCT_NAME = "SonarDesk"; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_STRICT_CONCURRENCY = targeted; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; };
B14 /* Develop */ = { isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_ENTITLEMENTS = "SonarDesk-iOS/SonarDesk-iOS.entitlements"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 7; DEVELOPMENT_TEAM = 39R6U3437D; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = SonarDesk; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.0; MARKETING_VERSION = 2.3.0; PRODUCT_BUNDLE_IDENTIFIER = net.absdev.SonarDesk.iOS; PRODUCT_NAME = "SonarDesk"; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_STRICT_CONCURRENCY = targeted; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Develop; };
B18 /* Stage */ = { isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_ENTITLEMENTS = "SonarDesk-iOS/SonarDesk-iOS.entitlements"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 7; DEVELOPMENT_TEAM = 39R6U3437D; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = SonarDesk; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.0; MARKETING_VERSION = 2.3.0; PRODUCT_BUNDLE_IDENTIFIER = net.absdev.SonarDesk.iOS; PRODUCT_NAME = "SonarDesk"; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_STRICT_CONCURRENCY = targeted; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Stage; };
B15 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_ENTITLEMENTS = "SonarDesk-iOS/SonarDesk-iOS.entitlements"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 7; DEVELOPMENT_TEAM = 39R6U3437D; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = SonarDesk; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.0; MARKETING_VERSION = 2.3.0; PRODUCT_BUNDLE_IDENTIFIER = net.absdev.SonarDesk.iOS; PRODUCT_NAME = "SonarDesk"; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_STRICT_CONCURRENCY = targeted; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; VALIDATE_PRODUCT = YES; }; name = Release; };
/* End XCBuildConfiguration section */ /* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */ /* Begin XCConfigurationList section */
@@ -443,6 +500,13 @@
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
B8 /* Build configuration list for PBXNativeTarget "SonarDesk-iOS" */ = {
isa = XCConfigurationList;
buildConfigurations = ( B20 /* Debug */, B14 /* Develop */, B18 /* Stage */, B15 /* Release */, );
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */ /* End XCConfigurationList section */
}; };
rootObject = A1 /* Project object */; rootObject = A1 /* Project object */;
+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>
+49 -57
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 {
@@ -84,28 +90,14 @@ extension KeychainService {
private static let licenseTokenAccount = "sonardesk.license-token" private static let licenseTokenAccount = "sonardesk.license-token"
static func saveLicenseToken(_ token: String) throws { static func saveLicenseToken(_ token: String) throws {
let clean = token.trimmingCharacters(in: .whitespacesAndNewlines) try saveLocal(token, account: licenseTokenAccount)
guard !clean.isEmpty else { throw KeychainError.emptyToken }
let data = Data(clean.utf8)
SecItemDelete(query(account: licenseTokenAccount, synchronizable: kSecAttrSynchronizableAny) as CFDictionary)
var add = query(account: licenseTokenAccount, synchronizable: kCFBooleanFalse)
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) }
} }
static func licenseToken() -> String? { static func licenseToken() -> String? {
var lookup = query(account: licenseTokenAccount, synchronizable: kSecAttrSynchronizableAny) readWithLegacyMigration(account: licenseTokenAccount)
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 deleteLicenseToken() { static func deleteLicenseToken() {
SecItemDelete(query(account: licenseTokenAccount, synchronizable: kSecAttrSynchronizableAny) as CFDictionary) deleteAll(account: licenseTokenAccount)
} }
} }
@@ -97,11 +97,40 @@ enum ProjectPDFReportExporter {
text(project, x: margin, y: y, width: page.width - margin * 2, font: .systemFont(ofSize: 17), color: .darkGray); y += 36 text(project, x: margin, y: y, width: page.width - margin * 2, font: .systemFont(ofSize: 17), color: .darkGray); y += 36
rule(); y += 18 rule(); y += 18
} }
func finish() { footer(); context.endPDFPage() } func finish() {
footer()
endPage()
}
private func newPage() { private func newPage() {
if pageNumber > 0 { footer(); context.endPDFPage() } if pageNumber > 0 {
context.beginPDFPage(nil); pageNumber += 1; y = 52 footer()
if pageNumber > 1 { text("SONARDESK • \(projectName)", x: margin, y: 25, width: page.width - margin * 2, font: .boldSystemFont(ofSize: 8), color: .gray) } 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() { private func footer() {
rule(at: page.height - 37) rule(at: page.height - 37)
@@ -0,0 +1,32 @@
# SonarDesk shared workspace foundation
This is the first compatibility-safe step toward macOS + iOS + Team.
## Storage boundaries
- iCloud Drive currently remains the persistence mechanism for existing macOS data.
- Workspace metadata is stored in `Data/workspaces.json` so current releases can adopt the model without a destructive migration.
- SonarQube credentials remain in Keychain and are **not** written to iCloud Drive.
- `ThisDeviceOnly` remains the default credential policy. An iPhone therefore authenticates independently.
## Next migration
Move workspace/team records to CloudKit private/shared databases:
- Personal workspace -> private database
- Team workspace -> CKShare/shared database
- Team membership and roles -> shared records
- Reports -> iCloud Drive assets/CloudKit asset metadata
The existing JSON workspace model is intentionally Codable so it can be migrated to CloudKit records without changing UI-facing domain types.
## iOS target
The iOS target should share:
- Models/Models.swift
- Services/SonarQubeClient.swift
- Services/KeychainService.swift
- Shared/Workspaces/*
macOS-only UI and exporters stay out of the iOS target. The first iOS screens are Dashboard, Projects and Project Detail.
@@ -0,0 +1,49 @@
import Foundation
enum WorkspaceKind: String, Codable, CaseIterable, Sendable {
case personal
case team
}
enum WorkspaceRole: String, Codable, CaseIterable, Sendable {
case owner
case admin
case developer
case viewer
}
struct SonarWorkspace: Codable, Identifiable, Hashable, Sendable {
var id: UUID
var name: String
var kind: WorkspaceKind
var role: WorkspaceRole
var serverIDs: [UUID]
var createdAt: Date
var updatedAt: Date
init(id: UUID = UUID(), name: String, kind: WorkspaceKind, role: WorkspaceRole,
serverIDs: [UUID] = [], createdAt: Date = Date(), updatedAt: Date = Date()) {
self.id = id
self.name = name
self.kind = kind
self.role = role
self.serverIDs = serverIDs
self.createdAt = createdAt
self.updatedAt = updatedAt
}
static let personal = SonarWorkspace(
id: UUID(uuidString: "00000000-0000-0000-0000-000000000001")!,
name: "Personal",
kind: .personal,
role: .owner
)
}
struct WorkspaceMember: Codable, Identifiable, Hashable, Sendable {
var id: UUID
var workspaceID: UUID
var displayName: String
var role: WorkspaceRole
var joinedAt: Date
}
@@ -0,0 +1,52 @@
import Foundation
import Combine
@MainActor
final class WorkspaceStore: ObservableObject {
@Published private(set) var workspaces: [SonarWorkspace] = [.personal]
@Published var selectedWorkspaceID: UUID = SonarWorkspace.personal.id
private let driveStore = ICloudDriveStore.shared
private let fileName = "workspaces.json"
var selectedWorkspace: SonarWorkspace {
workspaces.first(where: { $0.id == selectedWorkspaceID }) ?? .personal
}
init() {
load()
}
func load() {
do {
guard let saved = try driveStore.read([SonarWorkspace].self, fileName: fileName), !saved.isEmpty else {
persist()
return
}
workspaces = saved
if !workspaces.contains(where: { $0.id == selectedWorkspaceID }) {
selectedWorkspaceID = workspaces.first?.id ?? SonarWorkspace.personal.id
}
} catch {
// Keep Personal usable if iCloud is temporarily unavailable.
workspaces = [.personal]
}
}
func createTeam(name: String) {
let clean = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !clean.isEmpty else { return }
let team = SonarWorkspace(name: clean, kind: .team, role: .owner)
workspaces.append(team)
selectedWorkspaceID = team.id
persist()
}
func select(_ workspace: SonarWorkspace) {
selectedWorkspaceID = workspace.id
}
private func persist() {
try? driveStore.write(workspaces, fileName: fileName)
}
}
+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)
+84 -12
View File
@@ -12,39 +12,111 @@ final class LicenseStore: ObservableObject {
func activate(_ key: String) async { func activate(_ key: String) async {
let clean = key.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() let clean = key.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
guard !clean.isEmpty else { errorMessage = "Enter your SonarDesk licence key."; return } guard !clean.isEmpty else {
isWorking = true; errorMessage = nil errorMessage = "Enter your SonarDesk licence key."
defer { isWorking = false; hasChecked = true } return
}
isWorking = true
errorMessage = nil
defer {
isWorking = false
hasChecked = true
}
do { do {
let (result, token) = try await LicenseService.shared.activate(key: clean) let (result, token) = try await LicenseService.shared.activate(key: clean)
try KeychainService.saveLicenseToken(token) try KeychainService.saveLicenseToken(token)
status = result status = result
} catch { errorMessage = error.localizedDescription } } 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 { func refresh() async {
guard let token = KeychainService.licenseToken() else { status = nil; hasChecked = true; return } guard let token = KeychainService.licenseToken() else {
if status == nil { status = try? await LicenseService.shared.cachedStatus(token: token) } status = nil
isWorking = true; errorMessage = nil hasChecked = true
defer { isWorking = false; 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 { do {
let (result, refreshedToken) = try await LicenseService.shared.refresh(token: token) let (result, refreshedToken) = try await LicenseService.shared.refresh(token: token)
try KeychainService.saveLicenseToken(refreshedToken) try KeychainService.saveLicenseToken(refreshedToken)
status = result status = result
} catch { } catch {
status = nil // 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 errorMessage = error.localizedDescription
} }
} }
func deactivate() async { func deactivate() async {
guard let token = KeychainService.licenseToken() else { return } guard let token = KeychainService.licenseToken() else { return }
isWorking = true; errorMessage = nil
defer { isWorking = false; hasChecked = true } isWorking = true
errorMessage = nil
defer {
isWorking = false
hasChecked = true
}
do { do {
try await LicenseService.shared.deactivate(token: token) try await LicenseService.shared.deactivate(token: token)
KeychainService.deleteLicenseToken() KeychainService.deleteLicenseToken()
status = nil status = nil
} catch { errorMessage = error.localizedDescription } } 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)
@@ -11,13 +11,25 @@ struct ProjectDetailView: View {
@State private var exportingAI = false @State private var exportingAI = false
@State private var exportingPDF = 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()
Menu { Menu {
Button { Task { await exportPDFReport(item) } } label: { Label("PDF Report…", systemImage: "doc.richtext") } Button { Task { await exportPDFReport(item) } } label: { Label("PDF Report…", systemImage: "doc.richtext") }
@@ -62,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) {
+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)
} }
@@ -3,54 +3,193 @@ import SwiftUI
struct LicenseSettingsView: View { struct LicenseSettingsView: View {
@EnvironmentObject private var license: LicenseStore @EnvironmentObject private var license: LicenseStore
@State private var key = "" @State private var key = ""
@State private var isChangingLicense = false
@State private var showingDeactivateConfirmation = false
var body: some View { var body: some View {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 20) { VStack(alignment: .leading, spacing: 20) {
Text("Licence").font(.title2.bold()) Text("Licence")
Text("Activate this Mac with the SonarDesk licence key sent to you after purchase.") .font(.title2.bold())
Text("Manage the SonarDesk licence used to activate this Mac.")
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
GroupBox { GroupBox {
VStack(alignment: .leading, spacing: 14) { VStack(alignment: .leading, spacing: 14) {
if license.isLicensed, let status = license.status { if license.isLicensed, let status = license.status {
Label("SonarDesk is activated", systemImage: "checkmark.seal.fill") activatedContent(status)
.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)
HStack {
Button("Check Licence") { Task { await license.refresh() } }
Spacer()
Button("Deactivate This Mac", role: .destructive) { Task { await license.deactivate() } }
}
} else { } else {
SecureField("XXXXX-XXXXX-XXXXX-XXXXX", text: $key) activationForm
.textFieldStyle(.roundedBorder)
.onSubmit { Task { await license.activate(key) } }
HStack {
Text("Server: \(LicenseEnvironment.displayName)")
.font(.caption).foregroundStyle(.secondary)
Spacer()
Button("Activate") { Task { await license.activate(key) } }
.buttonStyle(.borderedProminent)
.disabled(key.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || license.isWorking)
}
} }
if license.isWorking { ProgressView().controlSize(.small) }
if license.isWorking {
ProgressView()
.controlSize(.small)
}
if let error = license.errorMessage { if let error = license.errorMessage {
Text(error).font(.callout).foregroundStyle(.red).textSelection(.enabled) Text(error)
.font(.callout)
.foregroundStyle(.red)
.textSelection(.enabled)
} }
}.padding(8) }
.padding(8)
} }
Text("Your signed activation token is stored only in this Mac's Keychain. Your licence key is used to activate the Mac and is not retained by SonarDesk.") 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) .font(.caption)
.foregroundStyle(.secondary)
} }
.frame(maxWidth: 620, alignment: .leading) .frame(maxWidth: 620, alignment: .leading)
.padding(32) .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
}
}
} }
} }
+22
View File
@@ -0,0 +1,22 @@
# SonarDesk iOS + Team — Phase 1
Added the non-destructive shared workspace domain foundation.
## Added
- `WorkspaceKind`: personal/team
- `WorkspaceRole`: owner/admin/developer/viewer
- `SonarWorkspace`
- `WorkspaceMember`
- `WorkspaceStore`
- iCloud Drive persistence for workspace metadata
- Explicit credential boundary: SonarQube tokens stay device-local in Keychain
## Next in Xcode
1. Add a SonarDesk iOS app target (`net.absdev.SonarDesk.iOS`).
2. Enable the existing iCloud container `iCloud.net.absdev.SonarDesk` plus CloudKit.
3. Share API/models/workspace files with both targets.
4. Add an iOS-specific app entry point and `NavigationSplitView`.
5. Implement Dashboard, Projects and Project Detail.
6. Introduce CloudKit private/shared workspace repository and migrate the workspace JSON records.
No existing macOS source was modified in Phase 1; the new synchronized-folder files are automatically visible to the current Xcode project.
+34
View File
@@ -0,0 +1,34 @@
# SonarDesk iOS + Team — Phase 2
Phase 2 adds the first native iPhone/iPad client source while leaving the existing macOS target and licensing implementation untouched.
## Added
- Native iOS app entry point (`SonarDeskIOSApp`)
- Adaptive `NavigationSplitView` root interface
- iOS Dashboard
- Failed Quality Gate projects highlighted in red
- Project browser with search and pull-to-refresh
- Project detail metrics
- Workspace selector using the Phase 1 Personal/Team model
- iOS settings showing workspace/server and the device-local credential boundary
- Separate iOS entitlements file with the existing iCloud container and CloudKit capability
## Shared files for the iOS target
Add these existing source groups to iOS target membership:
- `SonarDesk/Models/Models.swift`
- `SonarDesk/Services/SonarQubeClient.swift`
- `SonarDesk/Services/KeychainService.swift`
- `SonarDesk/Services/ICloudDriveStore.swift`
- `SonarDesk/Stores/AppState.swift`
- `SonarDesk/Shared/Workspaces/WorkspaceModels.swift`
- `SonarDesk/Shared/Workspaces/WorkspaceStore.swift`
Do not add the macOS `SonarDesk/App/SonarDeskApp.swift` to the iOS target. Use `SonarDesk-iOS/SonarDeskIOSApp.swift` as the iOS app entry point.
## Xcode target
Create an iOS App target named `SonarDesk-iOS` with bundle identifier `net.absdev.SonarDesk.iOS`, iPhone + iPad destinations, and the deployment target you want to support. Assign `SonarDesk-iOS/SonarDesk-iOS.entitlements` to that target.
The existing macOS entitlements, licensing files, bundle identifier, and licence verification code are unchanged.
## Phase 3
Replace workspace JSON mirroring with a CloudKit repository, implement CKShare-backed team workspaces/invitations, member roles, and server metadata synchronization. Credentials remain in each member's Keychain.
+19
View File
@@ -0,0 +1,19 @@
# SonarDesk Phase 3 — Buildable iOS/iPadOS target
This phase fixes the missing Xcode target from Phase 2.
## Added
- `SonarDesk-iOS` native application target in `SonarDesk.xcodeproj`
- iPhone + iPad destinations (`TARGETED_DEVICE_FAMILY = 1,2`)
- iOS 17 deployment target
- bundle identifier `net.absdev.SonarDesk.iOS`
- iOS-specific iCloud/CloudKit entitlements
- self-contained iOS shared source copies so AppKit-only macOS files are not compiled into iOS
- Dashboard, Projects, Project Detail, Settings and workspace selector
## Important
The macOS `SonarDesk` target is retained separately. Its licensing source/configuration was not intentionally changed by this phase.
Open `SonarDesk.xcodeproj`, choose the `SonarDesk-iOS` scheme/target and an iPhone/iPad simulator or device.
If Xcode reports a provisioning error for `net.absdev.SonarDesk.iOS` or the iCloud container, enable the App ID/capabilities for your Apple Developer team in Signing & Capabilities. This cannot be provisioned from the Linux build environment used to prepare this archive.
+3
View File
@@ -0,0 +1,3 @@
# Phase 3.2 — iOS SonarQube Server Configuration
Adds full on-device server management to the iPhone/iPad target: add, edit, delete, select, test connection, multiple servers, and secure Keychain token storage. Existing tokens are never displayed. HTTPS is required except localhost loopback development URLs.
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 "========================================"
+1
View File
@@ -25,3 +25,4 @@ _ide_helper.php
Homestead.json Homestead.json
Homestead.yaml Homestead.yaml
Thumbs.db Thumbs.db
/.scannerwork/
@@ -0,0 +1,45 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\CmsPage;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
final class CmsPageController extends Controller
{
public function index() { return view('admin.cms.index', ['pages' => CmsPage::latest()->paginate(25)]); }
public function create() { return view('admin.cms.form', ['page' => new CmsPage()]); }
public function store(Request $request) { $page = CmsPage::create($this->validated($request)); return redirect()->route('admin.cms.edit', $page)->with('status', 'Page created.'); }
public function edit(CmsPage $page) { return view('admin.cms.form', compact('page')); }
public function update(Request $request, CmsPage $page) { $page->update($this->validated($request, $page)); return back()->with('status', 'Page updated.'); }
public function destroy(CmsPage $page) { $page->delete(); return redirect()->route('admin.cms.index')->with('status', 'Page deleted.'); }
private function validated(Request $request, ?CmsPage $page = null): array
{
$request->merge(['slug' => Str::slug($request->input('slug') ?: $request->input('title', ''))]);
$data = $request->validate([
'title' => ['required','string','max:160'],
'slug' => ['required','string','max:180', Rule::unique('cms_pages','slug')->ignore($page?->id)],
'excerpt' => ['nullable','string','max:1000'],
'body' => ['required','string'],
'meta_title' => ['nullable','string','max:160'],
'meta_description' => ['nullable','string','max:320'],
'meta_keywords' => ['nullable','string','max:500'],
'canonical_url' => ['nullable','url:http,https','max:2048'],
'og_title' => ['nullable','string','max:160'],
'og_description' => ['nullable','string','max:320'],
'og_image' => ['nullable','url:http,https','max:2048'],
'sitemap_priority' => ['required','numeric','min:0','max:1'],
'sitemap_change_frequency' => ['required', Rule::in(['always','hourly','daily','weekly','monthly','yearly','never'])],
]);
$data['robots_index'] = $request->boolean('robots_index');
$data['robots_follow'] = $request->boolean('robots_follow');
$data['include_in_sitemap'] = $request->boolean('include_in_sitemap');
$data['is_published'] = $request->boolean('is_published');
$data['published_at'] = $data['is_published'] ? ($page?->published_at ?? now()) : null;
return $data;
}
}
@@ -0,0 +1,50 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\SiteSetting;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
final class SeoSettingsController extends Controller
{
private const KEYS = [
'seo_site_name', 'seo_title_suffix', 'seo_default_title', 'seo_default_description',
'seo_default_keywords', 'seo_canonical_base_url', 'seo_default_og_image', 'seo_twitter_site',
'seo_organization_name', 'seo_organization_logo', 'seo_google_site_verification',
];
public function edit(): View
{
return view('admin.seo.edit', [
'settings' => collect(self::KEYS)->mapWithKeys(fn (string $key) => [$key => SiteSetting::valueFor($key, '')]),
'robotsEnabled' => SiteSetting::booleanFor('seo_robots_enabled', true),
]);
}
public function update(Request $request): RedirectResponse
{
$data = $request->validate([
'seo_site_name' => ['required', 'string', 'max:80'],
'seo_title_suffix' => ['nullable', 'string', 'max:80'],
'seo_default_title' => ['required', 'string', 'max:160'],
'seo_default_description' => ['required', 'string', 'max:320'],
'seo_default_keywords' => ['nullable', 'string', 'max:500'],
'seo_canonical_base_url' => ['nullable', 'url:http,https', 'max:2048'],
'seo_default_og_image' => ['nullable', 'url:http,https', 'max:2048'],
'seo_twitter_site' => ['nullable', 'string', 'max:40', 'regex:/^@?[A-Za-z0-9_]{1,15}$/'],
'seo_organization_name' => ['nullable', 'string', 'max:120'],
'seo_organization_logo' => ['nullable', 'url:http,https', 'max:2048'],
'seo_google_site_verification' => ['nullable', 'string', 'max:255'],
]);
foreach (self::KEYS as $key) {
SiteSetting::put($key, isset($data[$key]) ? trim((string) $data[$key]) : null);
}
SiteSetting::put('seo_robots_enabled', $request->boolean('seo_robots_enabled') ? '1' : '0');
return back()->with('status', 'SEO settings updated.');
}
}
@@ -0,0 +1,53 @@
<?php
namespace App\Http\Controllers;
use App\Models\CmsPage;
use App\Models\SiteSetting;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Route;
final class SeoController extends Controller
{
public function sitemap(): Response
{
$static = collect([
['route' => 'home', 'priority' => '1.0', 'frequency' => 'weekly'],
['route' => 'features', 'priority' => '0.9', 'frequency' => 'monthly'],
['route' => 'buy', 'priority' => '0.9', 'frequency' => 'monthly'],
['route' => 'release', 'priority' => '0.8', 'frequency' => 'weekly'],
['route' => 'support', 'priority' => '0.6', 'frequency' => 'monthly'],
['route' => 'privacy', 'priority' => '0.3', 'frequency' => 'yearly'],
['route' => 'terms', 'priority' => '0.3', 'frequency' => 'yearly'],
['route' => 'refund', 'priority' => '0.3', 'frequency' => 'yearly'],
['route' => 'cookies', 'priority' => '0.3', 'frequency' => 'yearly'],
])->filter(fn (array $item) => Route::has($item['route']))->values();
$pages = CmsPage::query()
->where('is_published', true)
->where('include_in_sitemap', true)
->where('robots_index', true)
->where(fn ($query) => $query->whereNull('published_at')->orWhere('published_at', '<=', now()))
->orderBy('slug')
->get();
return response()
->view('seo.sitemap', compact('static', 'pages'))
->header('Content-Type', 'application/xml; charset=UTF-8')
->header('Cache-Control', 'public, max-age=3600');
}
public function robots(): Response
{
$indexable = app()->environment('production') && SiteSetting::booleanFor('seo_robots_enabled', true);
$body = $indexable
? "User-agent: *\nAllow: /\nDisallow: /admin/\nDisallow: /portal/\nDisallow: /login\nDisallow: /register\n\nSitemap: ".route('sitemap')."\n"
: "User-agent: *\nDisallow: /\n";
return response($body, 200, [
'Content-Type' => 'text/plain; charset=UTF-8',
'Cache-Control' => 'public, max-age=3600',
]);
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
final class CmsPage extends Model
{
protected $fillable = [
'title', 'slug', 'excerpt', 'body', 'meta_title', 'meta_description', 'meta_keywords',
'canonical_url', 'og_title', 'og_description', 'og_image', 'robots_index', 'robots_follow',
'include_in_sitemap', 'sitemap_priority', 'sitemap_change_frequency', 'is_published', 'published_at',
];
protected function casts(): array
{
return [
'is_published' => 'boolean', 'published_at' => 'datetime', 'robots_index' => 'boolean',
'robots_follow' => 'boolean', 'include_in_sitemap' => 'boolean', 'sitemap_priority' => 'decimal:1',
];
}
}
@@ -0,0 +1,32 @@
<?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 booleanFor(string $key, bool $default = false): bool
{
$value = static::valueFor($key);
return $value === null ? $default : filter_var($value, FILTER_VALIDATE_BOOLEAN);
}
public static function put(string $key, ?string $value): void
{
static::query()->updateOrCreate(['key' => $key], ['value' => $value]);
Cache::forget('sonardesk.setting.'.$key);
}
}
@@ -0,0 +1,59 @@
<?php
namespace App\Console\Commands;
use Modules\Licensing\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;
}
}
@@ -3,8 +3,8 @@
namespace App\Http\Controllers\Admin; namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\License; use Modules\Licensing\Models\License;
use App\Models\LicenseActivation; use Modules\Licensing\Models\LicenseActivation;
use App\Models\User; use App\Models\User;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -27,7 +27,17 @@ final class CmsPageController extends Controller
'body' => ['required','string'], 'body' => ['required','string'],
'meta_title' => ['nullable','string','max:160'], 'meta_title' => ['nullable','string','max:160'],
'meta_description' => ['nullable','string','max:320'], 'meta_description' => ['nullable','string','max:320'],
'meta_keywords' => ['nullable','string','max:500'],
'canonical_url' => ['nullable','url:http,https','max:2048'],
'og_title' => ['nullable','string','max:160'],
'og_description' => ['nullable','string','max:320'],
'og_image' => ['nullable','url:http,https','max:2048'],
'sitemap_priority' => ['required','numeric','min:0','max:1'],
'sitemap_change_frequency' => ['required', Rule::in(['always','hourly','daily','weekly','monthly','yearly','never'])],
]); ]);
$data['robots_index'] = $request->boolean('robots_index');
$data['robots_follow'] = $request->boolean('robots_follow');
$data['include_in_sitemap'] = $request->boolean('include_in_sitemap');
$data['is_published'] = $request->boolean('is_published'); $data['is_published'] = $request->boolean('is_published');
$data['published_at'] = $data['is_published'] ? ($page?->published_at ?? now()) : null; $data['published_at'] = $data['is_published'] ? ($page?->published_at ?? now()) : null;
return $data; return $data;
@@ -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();
}
}
@@ -0,0 +1,50 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\SiteSetting;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
final class SeoSettingsController extends Controller
{
private const KEYS = [
'seo_site_name', 'seo_title_suffix', 'seo_default_title', 'seo_default_description',
'seo_default_keywords', 'seo_canonical_base_url', 'seo_default_og_image', 'seo_twitter_site',
'seo_organization_name', 'seo_organization_logo', 'seo_google_site_verification',
];
public function edit(): View
{
return view('admin.seo.edit', [
'settings' => collect(self::KEYS)->mapWithKeys(fn (string $key) => [$key => SiteSetting::valueFor($key, '')]),
'robotsEnabled' => SiteSetting::booleanFor('seo_robots_enabled', true),
]);
}
public function update(Request $request): RedirectResponse
{
$data = $request->validate([
'seo_site_name' => ['required', 'string', 'max:80'],
'seo_title_suffix' => ['nullable', 'string', 'max:80'],
'seo_default_title' => ['required', 'string', 'max:160'],
'seo_default_description' => ['required', 'string', 'max:320'],
'seo_default_keywords' => ['nullable', 'string', 'max:500'],
'seo_canonical_base_url' => ['nullable', 'url:http,https', 'max:2048'],
'seo_default_og_image' => ['nullable', 'url:http,https', 'max:2048'],
'seo_twitter_site' => ['nullable', 'string', 'max:40', 'regex:/^@?[A-Za-z0-9_]{1,15}$/'],
'seo_organization_name' => ['nullable', 'string', 'max:120'],
'seo_organization_logo' => ['nullable', 'url:http,https', 'max:2048'],
'seo_google_site_verification' => ['nullable', 'string', 'max:255'],
]);
foreach (self::KEYS as $key) {
SiteSetting::put($key, isset($data[$key]) ? trim((string) $data[$key]) : null);
}
SiteSetting::put('seo_robots_enabled', $request->boolean('seo_robots_enabled') ? '1' : '0');
return back()->with('status', 'SEO settings updated.');
}
}
@@ -0,0 +1,156 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use Modules\Licensing\Models\License;
use Modules\Licensing\Models\LicenseActivation;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Password;
use Illuminate\Validation\Rule;
final class UserController extends Controller
{
public function index(Request $request)
{
$query = User::query()->latest();
if ($search = trim((string) $request->query('q'))) {
$query->where(fn ($q) => $q
->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%"));
}
if ($request->query('status') === 'admin') {
$query->where('is_admin', true);
} elseif ($request->query('status') === 'suspended') {
$query->whereNotNull('disabled_at');
} elseif ($request->query('status') === 'unverified') {
$query->whereNull('email_verified_at');
}
$users = $query->paginate(50)->withQueryString();
$emails = $users->getCollection()->pluck('email')->map(fn ($email) => strtolower($email))->all();
$licenseCounts = License::query()
->selectRaw('LOWER(customer_email) as email_key, COUNT(*) as aggregate')
->whereIn(DB::raw('LOWER(customer_email)'), $emails)
->groupBy(DB::raw('LOWER(customer_email)'))
->pluck('aggregate', 'email_key');
return view('admin.users.index', compact('users', 'licenseCounts'));
}
public function show(User $user)
{
$licenses = License::with(['product', 'activations'])
->whereRaw('LOWER(customer_email) = ?', [strtolower($user->email)])
->latest()
->get();
return view('admin.users.show', compact('user', 'licenses'));
}
public function update(Request $request, User $user)
{
$data = $request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255', Rule::unique('users', 'email')->ignore($user->id)],
'is_admin' => ['nullable', 'boolean'],
]);
$oldEmail = strtolower($user->email);
$newEmail = strtolower(trim($data['email']));
$isSelf = $request->user()->is($user);
DB::transaction(function () use ($user, $data, $oldEmail, $newEmail, $isSelf): void {
$user->name = trim($data['name']);
$user->email = $newEmail;
if (! $isSelf) {
$user->is_admin = (bool) ($data['is_admin'] ?? false);
}
$user->save();
if ($oldEmail !== $newEmail) {
License::whereRaw('LOWER(customer_email) = ?', [$oldEmail])
->update(['customer_email' => $newEmail]);
}
});
return redirect()->route('admin.users.show', $user)->with('status', 'User updated.');
}
public function verify(User $user)
{
if (! $user->hasVerifiedEmail()) {
$user->markEmailAsVerified();
}
return back()->with('status', 'Email marked as verified.');
}
public function resendVerification(User $user)
{
if (! $user->hasVerifiedEmail()) {
$user->sendEmailVerificationNotification();
}
return back()->with('status', 'Verification email sent.');
}
public function sendPasswordReset(User $user)
{
$status = Password::sendResetLink(['email' => $user->email]);
return $status === Password::RESET_LINK_SENT
? back()->with('status', 'Password reset link sent.')
: back()->withErrors(['email' => __($status)]);
}
public function suspend(Request $request, User $user)
{
abort_if($request->user()->is($user), 422, 'You cannot suspend your own administrator account.');
$user->forceFill(['disabled_at' => now(), 'remember_token' => null])->save();
return back()->with('status', 'User suspended.');
}
public function restore(User $user)
{
$user->forceFill(['disabled_at' => null])->save();
return back()->with('status', 'User restored.');
}
public function destroy(Request $request, User $user)
{
abort_if($request->user()->is($user), 422, 'You cannot delete your own administrator account.');
$user->delete();
return redirect()->route('admin.users.index')->with('status', 'User deleted. Licences were retained.');
}
public function updateLicense(Request $request, User $user, License $license)
{
$this->ensureOwnsLicense($user, $license);
$data = $request->validate(['activation_limit' => ['required', 'integer', 'min:1', 'max:1000']]);
$license->update(['activation_limit' => $data['activation_limit']]);
return back()->with('status', 'Activation limit updated.');
}
public function deactivate(User $user, License $license, LicenseActivation $activation)
{
$this->ensureOwnsLicense($user, $license);
abort_unless((string) $activation->license_id === (string) $license->id, 404);
$activation->update(['deactivated_at' => now()]);
return back()->with('status', 'Device activation released.');
}
private function ensureOwnsLicense(User $user, License $license): void
{
abort_unless(strtolower((string) $license->customer_email) === strtolower($user->email), 404);
}
}
@@ -1,78 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\LicenseWebhookEvent;
use App\Services\Licensing\LicenseDeliveryService;
use App\Services\Payments\LemonSqueezyWebhookVerifier;
use App\Services\Payments\PaddleWebhookVerifier;
use App\Services\Payments\PurchaseProvisioner;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
final class PaymentWebhookController extends Controller
{
public function __invoke(string $provider, Request $request, PurchaseProvisioner $provisioner, LicenseDeliveryService $delivery): JsonResponse
{
$raw = (string) $request->getContent();
$verifier = match ($provider) {
'paddle' => app(PaddleWebhookVerifier::class),'lemon-squeezy' => app(LemonSqueezyWebhookVerifier::class),default => null
};
if (! $verifier) {
return response()->json(['message' => 'Unknown webhook provider.'], 404);
}
try {
$verifier->verify($request, $raw);
} catch (\Throwable $e) {
report($e);
return response()->json(['message' => 'Invalid webhook signature.'], 400);
}
try {
$payload = json_decode($raw, 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'));
if ($externalID === '') {
return response()->json(['message' => 'Webhook event has no stable identifier.'], 400);
}
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]);
if ($event->processed_at) {
return;
}
if (! hash_equals($event->payload_sha256, hash('sha256', $raw))) {
throw new \RuntimeException('Webhook ID was reused with a different payload.');
}
try {
$provisioner->provision($provider, $payload);
$event->update(['processed_at' => now(), 'processing_error' => null]);
} catch (\Throwable $e) {
$event->update(['processing_error' => mb_substr($e->getMessage(), 0, 4000)]);
throw $e;
}
});
} 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
// rows remain available for portal resend or a later webhook delivery attempt.
try {
$delivery->deliverPending(10);
} catch (\Throwable $e) {
report($e);
}
return response()->json(['ok' => true]);
}
}
@@ -16,7 +16,7 @@ final class AuthenticatedSessionController extends Controller
public function store(Request $request) public function store(Request $request)
{ {
$credentials = $request->validate(['email' => ['required', 'email'], 'password' => ['required', 'string']]); $credentials = $request->validate(['email' => ['required', 'email'], 'password' => ['required', 'string']]);
if (! Auth::attempt(['email' => strtolower($credentials['email']), 'password' => $credentials['password']], $request->boolean('remember'))) { if (! Auth::attempt(['email' => strtolower($credentials['email']), 'password' => $credentials['password'], 'disabled_at' => null], $request->boolean('remember'))) {
return back()->withErrors(['email' => 'The supplied credentials are invalid.'])->onlyInput('email'); return back()->withErrors(['email' => 'The supplied credentials are invalid.'])->onlyInput('email');
} }
$request->session()->regenerate(); $request->session()->regenerate();
@@ -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.'),
];
}
}
@@ -0,0 +1,53 @@
<?php
namespace App\Http\Controllers;
use App\Models\CmsPage;
use App\Models\SiteSetting;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Route;
final class SeoController extends Controller
{
public function sitemap(): Response
{
$static = collect([
['route' => 'home', 'priority' => '1.0', 'frequency' => 'weekly'],
['route' => 'features', 'priority' => '0.9', 'frequency' => 'monthly'],
['route' => 'buy', 'priority' => '0.9', 'frequency' => 'monthly'],
['route' => 'release', 'priority' => '0.8', 'frequency' => 'weekly'],
['route' => 'support', 'priority' => '0.6', 'frequency' => 'monthly'],
['route' => 'privacy', 'priority' => '0.3', 'frequency' => 'yearly'],
['route' => 'terms', 'priority' => '0.3', 'frequency' => 'yearly'],
['route' => 'refund', 'priority' => '0.3', 'frequency' => 'yearly'],
['route' => 'cookies', 'priority' => '0.3', 'frequency' => 'yearly'],
])->filter(fn (array $item) => Route::has($item['route']))->values();
$pages = CmsPage::query()
->where('is_published', true)
->where('include_in_sitemap', true)
->where('robots_index', true)
->where(fn ($query) => $query->whereNull('published_at')->orWhere('published_at', '<=', now()))
->orderBy('slug')
->get();
return response()
->view('seo.sitemap', compact('static', 'pages'))
->header('Content-Type', 'application/xml; charset=UTF-8')
->header('Cache-Control', 'public, max-age=3600');
}
public function robots(): Response
{
$indexable = app()->environment('production') && SiteSetting::booleanFor('seo_robots_enabled', true);
$body = $indexable
? "User-agent: *\nAllow: /\nDisallow: /admin/\nDisallow: /portal/\nDisallow: /login\nDisallow: /register\n\nSitemap: ".route('sitemap')."\n"
: "User-agent: *\nDisallow: /\n";
return response($body, 200, [
'Content-Type' => 'text/plain; charset=UTF-8',
'Cache-Control' => 'public, max-age=3600',
]);
}
}
+9 -2
View File
@@ -6,10 +6,17 @@ use Illuminate\Database\Eloquent\Model;
final class CmsPage extends Model final class CmsPage extends Model
{ {
protected $fillable = ['title', 'slug', 'excerpt', 'body', 'meta_title', 'meta_description', 'is_published', 'published_at']; protected $fillable = [
'title', 'slug', 'excerpt', 'body', 'meta_title', 'meta_description', 'meta_keywords',
'canonical_url', 'og_title', 'og_description', 'og_image', 'robots_index', 'robots_follow',
'include_in_sitemap', 'sitemap_priority', 'sitemap_change_frequency', 'is_published', 'published_at',
];
protected function casts(): array protected function casts(): array
{ {
return ['is_published' => 'boolean', 'published_at' => 'datetime']; return [
'is_published' => 'boolean', 'published_at' => 'datetime', 'robots_index' => 'boolean',
'robots_follow' => 'boolean', 'include_in_sitemap' => 'boolean', 'sitemap_priority' => 'decimal:1',
];
} }
} }
@@ -0,0 +1,32 @@
<?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 booleanFor(string $key, bool $default = false): bool
{
$value = static::valueFor($key);
return $value === null ? $default : filter_var($value, FILTER_VALIDATE_BOOLEAN);
}
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';
}
}
+9 -2
View File
@@ -11,12 +11,19 @@ class User extends Authenticatable implements MustVerifyEmail
{ {
use HasFactory, Notifiable; use HasFactory, Notifiable;
protected $fillable = ['name', 'email', 'password', 'is_admin']; protected $fillable = [
'name',
'email',
'email_verified_at',
'password',
'is_admin',
'disabled_at',
];
protected $hidden = ['password', 'remember_token']; protected $hidden = ['password', 'remember_token'];
protected function casts(): array protected function casts(): array
{ {
return ['email_verified_at' => 'datetime', 'password' => 'hashed', 'is_admin' => 'boolean']; return ['email_verified_at' => 'datetime', 'password' => 'hashed', 'is_admin' => 'boolean', 'disabled_at' => 'datetime'];
} }
} }
@@ -0,0 +1,17 @@
<?php
namespace App\Providers;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
Blade::anonymousComponentPath(
resource_path('views/components/layouts'),
'layouts'
);
}
}
@@ -1,26 +0,0 @@
<?php
namespace App\Providers;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
final class SonarDeskLicensingServiceProvider extends ServiceProvider
{
public function boot(): void
{
RateLimiter::for('license-activation', function (Request $request): array {
return [
Limit::perMinute(12)->by($request->ip()),
Limit::perMinute(6)->by(hash('sha256', (string) $request->input('installationID'))),
];
});
RateLimiter::for('login', fn (Request $request) => Limit::perMinute(5)
->by(strtolower((string) $request->input('email')).'|'.$request->ip()));
RateLimiter::for('registration', fn (Request $request) => Limit::perHour(5)->by($request->ip()));
}
}
@@ -0,0 +1,108 @@
<?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.
*/
$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
);
}
}
@@ -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;
}
}
@@ -1,82 +0,0 @@
<?php
namespace App\Services\Payments;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use RuntimeException;
final class PaddleClient
{
public function customer(?string $customerID): ?array
{
if (! $customerID || ! config('sonardesk.paddle.api_key')) {
return null;
}
$response = $this->request()->get('/customers/'.$customerID);
if (! $response->successful()) {
return null;
}
$data = $response->json('data');
return is_array($data) ? $data : null;
}
public function updateSubscriptionPrice(string $subscriptionID, string $priceID): array
{
$this->requireWritableConfiguration();
$response = $this->request()->patch('/subscriptions/'.$subscriptionID, [
'proration_billing_mode' => 'prorated_immediately',
'items' => [[
'price_id' => $priceID,
'quantity' => 1,
]],
]);
return $this->subscriptionData($response, 'Paddle could not update the subscription.');
}
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()
{
return Http::baseUrl((string) config('sonardesk.paddle.api_url'))
->withToken((string) config('sonardesk.paddle.api_key'))
->acceptJson()
->timeout(15);
}
private function requireWritableConfiguration(): void
{
if (trim((string) config('sonardesk.paddle.api_key')) === '') {
throw new RuntimeException('Paddle subscription management is not configured.');
}
}
private function subscriptionData(Response $response, string $fallback): array
{
if (! $response->successful()) {
$detail = trim((string) ($response->json('error.detail') ?? ''));
throw new RuntimeException($detail !== '' ? $detail : $fallback);
}
$data = $response->json('data');
if (! is_array($data)) {
throw new RuntimeException($fallback);
}
return $data;
}
}
@@ -1,38 +0,0 @@
<?php
namespace App\Services\Payments;
use Illuminate\Http\Request;
use RuntimeException;
final class PaddleWebhookVerifier implements WebhookVerifier
{
public function verify(Request $request, string $rawBody): void
{
$secret = (string) config('sonardesk.paddle.webhook_secret');
$header = (string) $request->header('Paddle-Signature');
if ($secret === '' || $header === '') {
throw new RuntimeException('Paddle webhook verification is not configured.');
}
$parts = [];
foreach (explode(';', $header) as $part) {
[$k,$v] = array_pad(explode('=', trim($part), 2), 2, null);
if ($k && $v) {
$parts[$k][] = $v;
}
}
$ts = isset($parts['ts'][0]) ? (int) $parts['ts'][0] : 0;
$signatures = $parts['h1'] ?? [];
$tolerance = max(1, (int) config('sonardesk.paddle.timestamp_tolerance', 300));
if (! $ts || abs(time() - $ts) > $tolerance) {
throw new RuntimeException('Paddle webhook timestamp is outside the allowed tolerance.');
}
$expected = hash_hmac('sha256', $ts.':'.$rawBody, $secret);
foreach ($signatures as $signature) {
if (hash_equals($expected, $signature)) {
return;
}
}
throw new RuntimeException('Paddle webhook signature is invalid.');
}
}
@@ -1,246 +0,0 @@
<?php
namespace App\Services\Payments;
use App\Models\License;
use App\Models\LicenseProduct;
use App\Services\Licensing\LicenseIssuer;
use Illuminate\Support\Carbon;
use RuntimeException;
final class PurchaseProvisioner
{
public function __construct(private LicenseIssuer $issuer, private PaddleClient $paddle) {}
public function provision(string $provider, array $event): void
{
if ($provider === 'paddle') {
$this->provisionPaddle($event);
return;
}
if ($provider === 'lemon-squeezy') {
$this->provisionLemonSqueezy($event);
return;
}
throw new RuntimeException('Unsupported payment provider.');
}
private function provisionPaddle(array $event): void
{
$type = (string) ($event['event_type'] ?? '');
$data = $event['data'] ?? [];
if (str_starts_with($type, 'subscription.')) {
$this->syncPaddleSubscription($type, $data);
return;
}
if ($type === 'transaction.completed') {
$this->syncPaddleTransaction($data);
return;
}
if ($type === 'transaction.refunded') {
$transactionID = (string) ($data['id'] ?? '');
if ($transactionID !== '') {
License::where('payment_provider', 'paddle')
->where('external_order_id', $transactionID)
->whereNull('external_subscription_id')
->whereNull('revoked_at')
->update(['revoked_at' => now(), 'revoke_reason' => 'Payment refunded']);
}
}
}
private function syncPaddleSubscription(string $type, array $data): void
{
$subscriptionID = (string) ($data['id'] ?? '');
if ($subscriptionID === '') {
throw new RuntimeException('Paddle subscription webhook has no subscription ID.');
}
$status = (string) ($data['status'] ?? '');
$item = $this->firstRecurringItem($data['items'] ?? []);
$price = $item['price'] ?? [];
$priceID = (string) ($price['id'] ?? $item['price_id'] ?? '');
$productID = (string) ($price['product_id'] ?? $item['product_id'] ?? '');
$license = License::where('payment_provider', 'paddle')
->where('external_subscription_id', $subscriptionID)
->first();
if (! $license) {
$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));
[$license] = $this->issuer->issue(
$product,
$this->nullableString($custom['customer_name'] ?? $custom['name'] ?? $customer['name'] ?? null),
$this->nullableString($custom['customer_email'] ?? $custom['email'] ?? $customer['email'] ?? null),
'paddle',
$this->nullableString($data['transaction_id'] ?? null),
$this->nullableString($data['customer_id'] ?? null),
true,
);
$license->external_subscription_id = $subscriptionID;
} elseif ($priceID !== '' && $priceID !== $license->external_price_id) {
// Upgrades/downgrades change the entitlement attached to the existing key.
$product = $this->mappedProduct('paddle', $priceID, $productID);
$license->license_product_id = $product->id;
$license->edition = $product->edition;
$license->activation_limit = $product->activation_limit;
$license->max_major_version = $product->max_major_version;
}
$periodEnd = $data['current_billing_period']['ends_at'] ?? null;
$scheduled = $data['scheduled_change'] ?? null;
$cancelAt = is_array($scheduled) && ($scheduled['action'] ?? null) === 'cancel'
? ($scheduled['effective_at'] ?? null)
: null;
$license->external_customer_id = $this->nullableString($data['customer_id'] ?? null) ?? $license->external_customer_id;
$license->external_price_id = $priceID !== '' ? $priceID : $license->external_price_id;
$license->subscription_status = $status !== '' ? $status : $license->subscription_status;
$license->current_period_ends_at = $periodEnd ? Carbon::parse($periodEnd) : $license->current_period_ends_at;
$license->cancel_scheduled_at = $cancelAt ? Carbon::parse($cancelAt) : null;
if (in_array($status, ['active', 'trialing'], true)) {
$license->revoked_at = null;
$license->revoke_reason = null;
// Trialing/active subscriptions remain valid through the current period.
$license->expires_at = $periodEnd ? Carbon::parse($periodEnd) : null;
} 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.
$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->revoked_at = now();
$license->revoke_reason = $status === 'paused' ? 'Subscription paused' : 'Subscription canceled';
}
$license->save();
}
private function syncPaddleTransaction(array $data): void
{
$subscriptionID = $this->nullableString($data['subscription_id'] ?? null);
$transactionID = (string) ($data['id'] ?? '');
// Recurring purchases are provisioned from subscription events because those
// events contain the authoritative trial/status/current-period state.
if ($subscriptionID) {
License::where('payment_provider', 'paddle')
->where('external_subscription_id', $subscriptionID)
->whereNull('external_order_id')
->update(['external_order_id' => $transactionID ?: null]);
return;
}
// Keep support for any future one-time Paddle licence product.
$item = $data['items'][0] ?? [];
$price = $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'] : [];
$customer = $this->paddle->customer($this->nullableString($data['customer_id'] ?? null));
$this->issuer->issue(
$product,
$this->nullableString($custom['customer_name'] ?? $custom['name'] ?? $customer['name'] ?? null),
$this->nullableString($custom['customer_email'] ?? $custom['email'] ?? $customer['email'] ?? null),
'paddle',
$transactionID,
$this->nullableString($data['customer_id'] ?? null),
true,
);
}
private function provisionLemonSqueezy(array $event): void
{
$type = (string) ($event['meta']['event_name'] ?? '');
$attributes = $event['data']['attributes'] ?? [];
$order = (string) ($event['data']['id'] ?? $attributes['order_id'] ?? '');
if (in_array($type, ['order_refunded', 'subscription_expired'], true)) {
License::where('payment_provider', 'lemon-squeezy')
->where('external_order_id', $order)
->whereNull('revoked_at')
->update(['revoked_at' => now(), 'revoke_reason' => 'Payment refunded or subscription expired']);
return;
}
if (! in_array($type, ['order_created', 'subscription_created'], true)) {
return;
}
$product = $this->mappedProduct(
'lemon-squeezy',
(string) ($attributes['variant_id'] ?? ''),
(string) ($attributes['product_id'] ?? ''),
);
$this->issuer->issue(
$product,
$this->nullableString($attributes['user_name'] ?? null),
$this->nullableString($attributes['user_email'] ?? null),
'lemon-squeezy',
$order,
$this->nullableString($attributes['customer_id'] ?? null),
true,
);
}
private function mappedProduct(string $provider, string $priceID, string $productID): LicenseProduct
{
// Price IDs are the authoritative mapping because monthly and annual prices
// intentionally share the same Paddle product ID.
if ($priceID !== '') {
$product = LicenseProduct::where('payment_provider', $provider)
->where('external_price_id', $priceID)
->where('active', true)
->first();
} else {
$product = LicenseProduct::where('payment_provider', $provider)
->where('external_product_id', $productID)
->where('active', true)
->first();
}
if (! $product) {
throw new RuntimeException("No active licence product maps to {$provider} price {$priceID} / product {$productID}.");
}
return $product;
}
private function firstRecurringItem(array $items): array
{
foreach ($items as $item) {
if (($item['recurring'] ?? true) === true) {
return $item;
}
}
return $items[0] ?? [];
}
private function nullableString(mixed $value): ?string
{
if (! is_scalar($value)) {
return null;
}
$value = trim((string) $value);
return $value === '' ? null : $value;
}
}
+1 -3
View File
@@ -1,7 +1,5 @@
<?php <?php
use App\Providers\SonarDeskLicensingServiceProvider;
return [ return [
SonarDeskLicensingServiceProvider::class, App\Providers\AppServiceProvider::class,
]; ];
+21 -2
View File
@@ -3,13 +3,31 @@
"name": "laravel/laravel", "name": "laravel/laravel",
"type": "project", "type": "project",
"description": "The skeleton application for the Laravel framework.", "description": "The skeleton application for the Laravel framework.",
"keywords": ["laravel", "framework"], "keywords": [
"laravel",
"framework"
],
"license": "MIT", "license": "MIT",
"repositories": [
{
"type": "vcs",
"url": "git@github.com:MPBA-Ltd/laravel_modules.git"
},
{
"type": "vcs",
"url": "git@github.com:MPBA-Ltd/laravel_settings.git"
},
{
"type": "vcs",
"url": "git@github.com:MPBA-Ltd/laravel-charts.git"
}
],
"require": { "require": {
"php": "^8.3", "php": "^8.3",
"deployer/deployer": "^8.0", "deployer/deployer": "^8.0",
"laravel/framework": "^13.17", "laravel/framework": "^13.17",
"laravel/tinker": "^3.0", "laravel/tinker": "^3.0",
"mpba/laravel-modules": "dev-main",
"predis/predis": "^3.6" "predis/predis": "^3.6"
}, },
"require-dev": { "require-dev": {
@@ -25,7 +43,8 @@
"psr-4": { "psr-4": {
"App\\": "app/", "App\\": "app/",
"Database\\Factories\\": "database/factories/", "Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/" "Database\\Seeders\\": "database/seeders/",
"Modules\\": "modules/"
} }
}, },
"autoload-dev": { "autoload-dev": {
+110 -13
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "1f5b5a47e5910fdf5b2c47e1bc45e1b0", "content-hash": "d92f21f84e06e667b70468859bfaffd0",
"packages": [ "packages": [
{ {
"name": "brick/math", "name": "brick/math",
@@ -1542,16 +1542,16 @@
}, },
{ {
"name": "league/commonmark", "name": "league/commonmark",
"version": "2.10.1", "version": "2.10.3",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/thephpleague/commonmark.git", "url": "https://github.com/thephpleague/commonmark.git",
"reference": "9d489ab67a02960fd8ffe624d93f751daf95439e" "reference": "6efbd9c472b91db0a3350fcd601c8332c2382e1f"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/9d489ab67a02960fd8ffe624d93f751daf95439e", "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/6efbd9c472b91db0a3350fcd601c8332c2382e1f",
"reference": "9d489ab67a02960fd8ffe624d93f751daf95439e", "reference": "6efbd9c472b91db0a3350fcd601c8332c2382e1f",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -1645,7 +1645,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-09-07T13:44:26+00:00" "time": "2026-09-21T13:07:34+00:00"
}, },
{ {
"name": "league/config", "name": "league/config",
@@ -2256,6 +2256,101 @@
], ],
"time": "2026-09-02T12:39:56+00:00" "time": "2026-09-02T12:39:56+00:00"
}, },
{
"name": "mpba/laravel-modules",
"version": "dev-main",
"source": {
"type": "git",
"url": "https://github.com/MPBA-Ltd/laravel_modules.git",
"reference": "55d802560f6404d6b9fd71a89561ed46d32124ff"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/MPBA-Ltd/laravel_modules/zipball/55d802560f6404d6b9fd71a89561ed46d32124ff",
"reference": "55d802560f6404d6b9fd71a89561ed46d32124ff",
"shasum": ""
},
"require": {
"ext-json": "*",
"php": ">=7.3"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^2.16",
"laravel/framework": "^8.0",
"mockery/mockery": "~1.0",
"orchestra/testbench": "^6.0",
"phpstan/phpstan": "^0.12.14",
"phpunit/phpunit": "^8.5",
"spatie/phpunit-snapshot-assertions": "^2.1.0"
},
"default-branch": true,
"type": "library",
"extra": {
"laravel": {
"providers": [
"mpba\\Modules\\LaravelModulesServiceProvider"
],
"aliases": {
"Module": "mpba\\Modules\\Facades\\Module"
}
},
"branch-alias": {
"dev-master": "8.0-dev"
}
},
"autoload": {
"psr-4": {
"mpba\\Modules\\": "src"
},
"files": [
"src/helpers.php"
]
},
"autoload-dev": {
"psr-4": {
"mpba\\Modules\\Tests\\": "tests",
"Modules\\Recipe\\": "tests/stubs/valid/Recipe"
}
},
"scripts": {
"update-snapshots": [
"./vendor/bin/phpunit --no-coverage -d --update-snapshots"
],
"test": [
"vendor/bin/phpunit"
],
"test-coverage": [
"vendor/bin/phpunit --debug --coverage-html coverage"
],
"pcf": [
"vendor/bin/php-cs-fixer fix --verbose"
]
},
"license": [
"MIT"
],
"authors": [
{
"name": "Mark Gregory",
"email": "mark.gregory@mpba.co.uk",
"homepage": "https://mpba.co.uk",
"role": "Developer"
}
],
"description": "Laravel Module management",
"keywords": [
"laravel",
"module",
"modules",
"mpba",
"rad"
],
"support": {
"source": "https://github.com/MPBA-Ltd/laravel_modules/tree/main",
"issues": "https://github.com/MPBA-Ltd/laravel_modules/issues"
},
"time": "2026-09-30T13:50:27+00:00"
},
{ {
"name": "nesbot/carbon", "name": "nesbot/carbon",
"version": "3.13.2", "version": "3.13.2",
@@ -4875,16 +4970,16 @@
}, },
{ {
"name": "symfony/polyfill-php80", "name": "symfony/polyfill-php80",
"version": "v1.37.0", "version": "v1.43.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/polyfill-php80.git", "url": "https://github.com/symfony/polyfill-php80.git",
"reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" "reference": "9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74",
"reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", "reference": "9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -4935,7 +5030,7 @@
"shim" "shim"
], ],
"support": { "support": {
"source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" "source": "https://github.com/symfony/polyfill-php80/tree/v1.43.0"
}, },
"funding": [ "funding": [
{ {
@@ -4955,7 +5050,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-04-10T16:19:22+00:00" "time": "2026-09-25T15:50:05+00:00"
}, },
{ {
"name": "symfony/polyfill-php82", "name": "symfony/polyfill-php82",
@@ -8709,7 +8804,9 @@
], ],
"aliases": [], "aliases": [],
"minimum-stability": "stable", "minimum-stability": "stable",
"stability-flags": [], "stability-flags": {
"mpba/laravel-modules": 20
},
"prefer-stable": true, "prefer-stable": true,
"prefer-lowest": false, "prefer-lowest": false,
"platform": { "platform": {
+3 -14
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,20 +27,8 @@ 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' => [
'api_key' => env('PADDLE_API_KEY'),
'client_side_token' => env('PADDLE_CLIENT_SIDE_TOKEN'),
'environment' => env('PADDLE_ENVIRONMENT', env('APP_ENV') === 'production' ? 'production' : 'sandbox'),
'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'),
'timestamp_tolerance' => (int) env('PADDLE_WEBHOOK_TOLERANCE', 300),
'personal_product_id' => env('PADDLE_PERSONAL_PRODUCT_ID'),
'personal_monthly_price_id' => env('PADDLE_PERSONAL_MONTHLY_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_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,34 @@
<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
* @extends Factory<\App\Models\User>
*/
class UserFactory extends Factory
{
protected static ?string $password;
public function definition(): array
{
return [
'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(),
'password' => static::$password ??= Hash::make('password'),
'remember_token' => Str::random(10),
'is_admin' => false,
];
}
public function unverified(): static
{
return $this->state(fn (array $attributes) => [
'email_verified_at' => null,
]);
}
}
@@ -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');
}
};
@@ -0,0 +1,35 @@
<?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::table('cms_pages', function (Blueprint $table): void {
$table->string('meta_keywords', 500)->nullable()->after('meta_description');
$table->string('canonical_url', 2048)->nullable()->after('meta_keywords');
$table->string('og_title')->nullable()->after('canonical_url');
$table->string('og_description', 320)->nullable()->after('og_title');
$table->string('og_image', 2048)->nullable()->after('og_description');
$table->boolean('robots_index')->default(true)->after('og_image');
$table->boolean('robots_follow')->default(true)->after('robots_index');
$table->boolean('include_in_sitemap')->default(true)->after('robots_follow')->index();
$table->decimal('sitemap_priority', 2, 1)->default(0.5)->after('include_in_sitemap');
$table->string('sitemap_change_frequency', 20)->default('monthly')->after('sitemap_priority');
});
}
public function down(): void
{
Schema::table('cms_pages', function (Blueprint $table): void {
$table->dropIndex(['include_in_sitemap']);
$table->dropColumn([
'meta_keywords', 'canonical_url', 'og_title', 'og_description', 'og_image',
'robots_index', 'robots_follow', 'include_in_sitemap', 'sitemap_priority',
'sitemap_change_frequency',
]);
});
}
};
@@ -0,0 +1,22 @@
<?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
{
if (Schema::hasTable('users') && ! Schema::hasColumn('users', 'disabled_at')) {
Schema::table('users', fn (Blueprint $table) => $table->timestamp('disabled_at')->nullable()->index()->after('is_admin'));
}
}
public function down(): void
{
if (Schema::hasTable('users') && Schema::hasColumn('users', 'disabled_at')) {
Schema::table('users', fn (Blueprint $table) => $table->dropColumn('disabled_at'));
}
}
};
@@ -0,0 +1,26 @@
<?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('failed_jobs', function (Blueprint $table): void {
$table->id();
$table->string('uuid')->unique();
$table->text('connection');
$table->text('queue');
$table->longText('payload');
$table->longText('exception');
$table->timestamp('failed_at')->useCurrent();
});
}
public function down(): void
{
Schema::dropIfExists('failed_jobs');
}
};
+3 -3
View File
@@ -19,13 +19,13 @@ add('writable_dirs', ['storage', 'bootstrap/cache']);
// Host Definitions // Host Definitions
host('main') host('main')
->set('hostname', 'sonardesk.absdev.net') ->set('hostname', '217.154.47.197')
->set('remote_user', 'mag') ->set('remote_user', 'mag')
->set('branch', 'main') ->set('branch', 'main')
->set('deploy_path', '/var/www/sonardesk/main'); ->set('deploy_path', '/var/www/sonardesk/prod');
host('stage') host('stage')
->set('hostname', 'sonardesk.absdev.net') ->set('hostname', '192.168.1.2')
->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
@@ -0,0 +1,8 @@
<?php
return [
'name' => 'Licensing',
'features' =>
[
],
];
@@ -0,0 +1,20 @@
<?php
namespace Modules\Licensing\Console;
use Modules\Licensing\Services\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;
}
}
@@ -1,9 +1,9 @@
<?php <?php
namespace App\Console\Commands; namespace Modules\Licensing\Console;
use App\Models\LicenseProduct; use Modules\Licensing\Models\LicenseProduct;
use App\Services\Licensing\LicenseIssuer; use Modules\Licensing\Services\LicenseIssuer;
use Illuminate\Console\Command; use Illuminate\Console\Command;
class SonarDeskLicenseIssue extends Command class SonarDeskLicenseIssue extends Command
@@ -1,8 +1,8 @@
<?php <?php
namespace App\Console\Commands; namespace Modules\Licensing\Console;
use App\Models\License; use Modules\Licensing\Models\License;
use Illuminate\Console\Command; use Illuminate\Console\Command;
class SonarDeskLicenseRevoke extends Command class SonarDeskLicenseRevoke extends Command
@@ -1,8 +1,8 @@
<?php <?php
namespace App\Console\Commands; namespace Modules\Licensing\Console;
use App\Models\LicenseProduct; use Modules\Licensing\Models\LicenseProduct;
use Illuminate\Console\Command; use Illuminate\Console\Command;
class SonarDeskProductCreate extends Command class SonarDeskProductCreate extends Command
@@ -0,0 +1,92 @@
<?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('license_products', function (Blueprint $table) {
$table->id();
$table->string('code')->unique();
$table->string('name');
$table->string('edition')->default('Professional');
$table->unsignedInteger('activation_limit')->default(1);
$table->unsignedInteger('max_major_version')->nullable();
$table->string('payment_provider')->nullable();
$table->string('external_product_id')->nullable();
$table->string('external_price_id')->nullable();
$table->boolean('active')->default(true);
$table->timestamps();
$table->unique(['payment_provider', 'external_price_id']);
});
Schema::create('licenses', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->foreignId('license_product_id')->constrained('license_products');
$table->string('license_key_hash', 64)->unique();
$table->string('license_key_last4', 4);
$table->string('customer_name')->nullable();
$table->string('customer_email')->nullable()->index();
$table->string('edition');
$table->unsignedInteger('activation_limit');
$table->unsignedInteger('max_major_version')->nullable();
$table->timestamp('expires_at')->nullable();
$table->timestamp('revoked_at')->nullable();
$table->string('revoke_reason')->nullable();
$table->string('payment_provider')->nullable();
$table->string('external_order_id')->nullable();
$table->string('external_customer_id')->nullable();
$table->timestamps();
$table->unique(['payment_provider', 'external_order_id']);
});
Schema::create('license_delivery_outbox', function (Blueprint $table) {
$table->id();
$table->uuid('license_id')->unique();
$table->text('encrypted_license_key');
$table->string('recipient_email')->nullable();
$table->timestamp('delivered_at')->nullable();
$table->timestamps();
$table->foreign('license_id')->references('id')->on('licenses')->cascadeOnDelete();
});
Schema::create('license_activations', function (Blueprint $table) {
$table->id();
$table->uuid('license_id');
$table->uuid('installation_id');
$table->string('platform', 32)->nullable();
$table->string('app_version', 32)->nullable();
$table->timestamp('activated_at');
$table->timestamp('last_seen_at')->nullable();
$table->timestamp('deactivated_at')->nullable();
$table->foreign('license_id')->references('id')->on('licenses')->cascadeOnDelete();
$table->unique(['license_id', 'installation_id']);
$table->index(['license_id', 'deactivated_at']);
});
Schema::create('license_webhook_events', function (Blueprint $table) {
$table->id();
$table->string('provider');
$table->string('external_event_id');
$table->string('event_type');
$table->string('payload_sha256', 64);
$table->json('payload');
$table->timestamp('processed_at')->nullable();
$table->text('processing_error')->nullable();
$table->timestamps();
$table->unique(['provider', 'external_event_id']);
});
}
public function down(): void
{
Schema::dropIfExists('license_webhook_events');
Schema::dropIfExists('license_activations');
Schema::dropIfExists('license_delivery_outbox');
Schema::dropIfExists('licenses');
Schema::dropIfExists('license_products');
}
};
@@ -0,0 +1,30 @@
<?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
{
if (Schema::hasTable('users') && ! Schema::hasColumn('users', 'is_admin')) {
Schema::table('users', fn (Blueprint $table) => $table->boolean('is_admin')->default(false)->index());
}
if (Schema::hasTable('license_activations') && ! Schema::hasColumn('license_activations', 'installation_name')) {
Schema::table('license_activations', fn (Blueprint $table) => $table->string('installation_name')->nullable()->after('installation_id'));
}
}
public function down(): void
{
if (Schema::hasTable('license_activations') && Schema::hasColumn('license_activations', 'installation_name')) {
Schema::table('license_activations', fn (Blueprint $table) => $table->dropColumn('installation_name'));
}
if (Schema::hasTable('users') && Schema::hasColumn('users', 'is_admin')) {
Schema::table('users', fn (Blueprint $table) => $table->dropColumn('is_admin'));
}
}
};
@@ -0,0 +1,21 @@
<?php
namespace Modules\Licensing\Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Database\Eloquent\Model;
class LicensingDatabaseSeeder extends Seeder
{
/**
* Run the database seeds.
*
* @return void
*/
public function run()
{
Model::unguard();
// $this->call("OthersTableSeeder");
}
}
@@ -0,0 +1,12 @@
<?php
use Modules\Licensing\Support\ModuleInfo;
if (! function_exists('licensing_feature_enabled')) {
/**
* Check if a feature is enabled in the config.
*/
function licensing_feature_enabled(string $key): bool
{
return (bool) config(ModuleInfo::nameLower().".features.{$key}", false);
}
}
@@ -1,13 +1,13 @@
<?php <?php
namespace App\Http\Controllers\Api; namespace Modules\Licensing\Http\Controllers\Api;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\License; use Modules\Licensing\Models\License;
use App\Models\LicenseActivation; use Modules\Licensing\Models\LicenseActivation;
use App\Services\Licensing\LicenseKey; use Modules\Licensing\Services\LicenseKey;
use App\Services\Licensing\LicenseSigner; use Modules\Licensing\Services\LicenseSigner;
use App\Services\Licensing\LicenseTokenVerifier; use Modules\Licensing\Services\LicenseTokenVerifier;
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;
@@ -0,0 +1,191 @@
<?php
namespace Modules\Licensing\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Modules\Licensing\Models\LicenseWebhookEvent;
use Modules\Licensing\Services\LicenseDeliveryService;
use App\Services\Payments\LemonSqueezyWebhookVerifier;
use Modules\Paddle\Services\PaddleWebhookVerifier;
use Modules\Paddle\Services\PurchaseProvisioner;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use RuntimeException;
use Throwable;
final class PaymentWebhookController extends Controller
{
public function __invoke(
string $provider,
Request $request,
PurchaseProvisioner $provisioner,
LicenseDeliveryService $delivery
): JsonResponse {
$raw = (string) $request->getContent();
$verifier = match ($provider) {
'paddle' => app(PaddleWebhookVerifier::class),
'lemon-squeezy' => app(LemonSqueezyWebhookVerifier::class),
default => null,
};
if (! $verifier) {
return response()->json([
'message' => 'Unknown webhook provider.',
], 404);
}
try {
$verifier->verify($request, $raw);
} catch (Throwable $e) {
report($e);
return response()->json([
'message' => 'Invalid webhook signature.',
], 400);
}
try {
$payload = json_decode(
$raw,
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')
);
if ($externalID === '') {
return response()->json([
'message' => 'Webhook event has no stable identifier.',
], 400);
}
$payloadHash = hash('sha256', $raw);
try {
/*
* Paddle can deliver several related events concurrently.
*
* Laravel retries this complete transaction when MySQL reports
* a deadlock. Five attempts gives short-lived lock contention
* enough opportunity to resolve without acknowledging an event
* that was not actually processed.
*/
DB::transaction(
function () use (
$provider,
$externalID,
$eventType,
$payload,
$payloadHash,
$provisioner
): void {
/*
* 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,
]
);
$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 deliberately happens outside the webhook transaction.
*
* A temporary SMTP failure must not undo a successfully processed
* payment event. The outbox retains anything that still needs
* delivery.
*/
try {
$delivery->deliverPending(10);
} catch (Throwable $e) {
report($e);
}
return response()->json([
'ok' => true,
]);
}
}

Some files were not shown because too many files have changed in this diff Show More