Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eac5910dbb | ||
|
|
736765180c | ||
|
|
6bb62ebf69 | ||
|
|
211115c9bc | ||
|
|
f9c697619a | ||
|
|
6313d784fe | ||
|
|
ead4483b7c | ||
|
|
3499fe382a | ||
|
|
5bb4e43665 | ||
|
|
bf5357cbcf | ||
|
|
1a7d026edd |
Generated
+3
@@ -5,6 +5,9 @@
|
||||
<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/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>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
|
||||
Generated
+252
@@ -1,19 +1,271 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<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/=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/=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/=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/=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/=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/=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/=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/=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/=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/CodeStyle/CodeFormatting/CppClangFormat/EnableClangFormatSupport/@EntryValue" value="false" 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-----
|
||||
@@ -11,6 +11,8 @@
|
||||
<key>CFBundleShortVersionString</key><string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key><string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>LSApplicationCategoryType</key><string>public.app-category.developer-tools</string>
|
||||
<key>SonarDeskEnvironment</key><string>$(SONARDESK_ENVIRONMENT)</string>
|
||||
<key>SonarDeskLicenseBaseURL</key><string>$(SONARDESK_LICENSE_BASE_URL)</string>
|
||||
<key>NSUbiquitousContainers</key>
|
||||
<dict>
|
||||
<key>iCloud.net.absdev.SonarDesk</key>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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."))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
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 */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
@@ -16,9 +17,21 @@
|
||||
path = SonarDesk;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B6 /* SonarDesk-iOS */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = "SonarDesk-iOS";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
B10 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
A10 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -33,6 +46,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A6 /* SonarDesk */,
|
||||
B6 /* SonarDesk-iOS */,
|
||||
A4 /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
@@ -41,6 +55,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A7 /* SonarDesk.app */,
|
||||
B7 /* SonarDesk iOS.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
@@ -70,6 +85,28 @@
|
||||
productReference = A7 /* SonarDesk.app */;
|
||||
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 */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
@@ -94,6 +131,7 @@
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
A5 /* SonarDesk */,
|
||||
B5 /* SonarDesk-iOS */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
@@ -106,6 +144,13 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
B11 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
@@ -116,10 +161,17 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
B9 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
A12 /* Debug */ = {
|
||||
A19 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
@@ -162,6 +214,93 @@
|
||||
};
|
||||
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 = {
|
||||
@@ -203,7 +342,7 @@
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
A14 /* Debug */ = {
|
||||
A20 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
@@ -226,12 +365,77 @@
|
||||
MARKETING_VERSION = 2.2.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = net.absdev.SonarDesk;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SONARDESK_ENVIRONMENT = Develop;
|
||||
SONARDESK_LICENSE_BASE_URL = "https://sonardesk.absdev.net";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_STRICT_CONCURRENCY = targeted;
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
A14 /* Develop */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = SonarDesk/SonarDesk.entitlements;
|
||||
CODE_SIGN_INJECT_BASE_ENTITLEMENTS = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 6;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = Configuration/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = SonarDesk;
|
||||
INFOPLIST_KEY_CFBundleIconName = AppIcon;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
|
||||
MARKETING_VERSION = 2.2.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = net.absdev.SonarDesk;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SONARDESK_ENVIRONMENT = Develop;
|
||||
SONARDESK_LICENSE_BASE_URL = "https://sonardesk.absdev.net";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_STRICT_CONCURRENCY = targeted;
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
name = Develop;
|
||||
};
|
||||
A18 /* Stage */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = SonarDesk/SonarDesk.entitlements;
|
||||
CODE_SIGN_INJECT_BASE_ENTITLEMENTS = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 6;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = Configuration/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = SonarDesk;
|
||||
INFOPLIST_KEY_CFBundleIconName = AppIcon;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
|
||||
MARKETING_VERSION = 2.2.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = net.absdev.SonarDesk;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SONARDESK_ENVIRONMENT = Stage;
|
||||
SONARDESK_LICENSE_BASE_URL = "https://sonardesk.stage.absdev.net";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_STRICT_CONCURRENCY = targeted;
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
name = Stage;
|
||||
};
|
||||
|
||||
A15 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
@@ -255,6 +459,8 @@
|
||||
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";
|
||||
@@ -264,13 +470,20 @@
|
||||
};
|
||||
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 */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
A2 /* Build configuration list for PBXProject "SonarDesk" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
A12 /* Debug */,
|
||||
A19 /* Debug */,
|
||||
A12 /* Develop */,
|
||||
A16 /* Stage */,
|
||||
A13 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
@@ -279,12 +492,21 @@
|
||||
A8 /* Build configuration list for PBXNativeTarget "SonarDesk" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
A14 /* Debug */,
|
||||
A20 /* Debug */,
|
||||
A14 /* Develop */,
|
||||
A18 /* Stage */,
|
||||
A15 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
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 */
|
||||
};
|
||||
rootObject = A1 /* Project object */;
|
||||
|
||||
@@ -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>
|
||||
@@ -13,6 +13,7 @@ struct SonarDeskApp: App {
|
||||
@NSApplicationDelegateAdaptor(SonarDeskAppDelegate.self) private var appDelegate
|
||||
@StateObject private var appState = AppState()
|
||||
@StateObject private var reportStore = ReportStore()
|
||||
@StateObject private var licenseStore = LicenseStore()
|
||||
@State private var launchPhase: LaunchPhase = .splash
|
||||
|
||||
private enum LaunchPhase {
|
||||
@@ -28,10 +29,17 @@ struct SonarDeskApp: App {
|
||||
LaunchView()
|
||||
.task { await startApplication() }
|
||||
case .application:
|
||||
RootView()
|
||||
.environmentObject(appState)
|
||||
.environmentObject(reportStore)
|
||||
.transition(.opacity)
|
||||
Group {
|
||||
if licenseStore.isLicensed {
|
||||
RootView()
|
||||
.environmentObject(appState)
|
||||
.environmentObject(reportStore)
|
||||
} else {
|
||||
LicenseRequiredView()
|
||||
}
|
||||
}
|
||||
.environmentObject(licenseStore)
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 1040, minHeight: 680)
|
||||
@@ -51,7 +59,8 @@ struct SonarDeskApp: App {
|
||||
SettingsView()
|
||||
.environmentObject(appState)
|
||||
.environmentObject(reportStore)
|
||||
.frame(width: 620, height: 430)
|
||||
.environmentObject(licenseStore)
|
||||
.frame(width: 720, height: 560)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,11 +69,13 @@ struct SonarDeskApp: App {
|
||||
// Keep the branded launch experience visible long enough to be perceptible,
|
||||
// while using the time to perform the first SonarQube refresh.
|
||||
async let minimumSplash: Void = Task.sleep(for: .seconds(1.6))
|
||||
async let licenceRefresh: Void = licenseStore.refresh()
|
||||
|
||||
if appState.projects.isEmpty, appState.selectedServer != nil {
|
||||
_ = await licenceRefresh
|
||||
|
||||
if licenseStore.isLicensed, appState.projects.isEmpty, appState.selectedServer != nil {
|
||||
await appState.refresh()
|
||||
}
|
||||
|
||||
_ = try? await minimumSplash
|
||||
withAnimation(.easeInOut(duration: 0.32)) {
|
||||
launchPhase = .application
|
||||
|
||||
@@ -1,7 +1,22 @@
|
||||
{
|
||||
"images" : [
|
||||
{ "filename" : "SonarDeskLogo.png", "idiom" : "universal", "scale" : "1x" },
|
||||
{ "filename" : "SonarDeskLogo@2x.png", "idiom" : "universal", "scale" : "2x" }
|
||||
{
|
||||
"filename" : "SonarDeskLogo.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "SonarDeskLogo@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : { "author" : "xcode", "version" : 1 }
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,58 +5,23 @@ enum KeychainService {
|
||||
static let service = "net.absdev.SonarDesk"
|
||||
|
||||
static func saveToken(_ token: String, serverID: UUID) throws {
|
||||
let cleanToken = token.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
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) }
|
||||
try saveLocal(token, account: serverID.uuidString)
|
||||
}
|
||||
|
||||
static func token(serverID: UUID) -> String? {
|
||||
let 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)
|
||||
readWithLegacyMigration(account: serverID.uuidString)
|
||||
}
|
||||
|
||||
static func saveProjectToken(_ token: String, serverID: UUID, projectKey: String) throws {
|
||||
let clean = token.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
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) }
|
||||
try saveLocal(token, account: "project|\(serverID.uuidString)|\(projectKey)")
|
||||
}
|
||||
|
||||
static func projectToken(serverID: UUID, projectKey: String) -> String? {
|
||||
let 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)
|
||||
readWithLegacyMigration(account: "project|\(serverID.uuidString)|\(projectKey)")
|
||||
}
|
||||
|
||||
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] {
|
||||
@@ -65,6 +30,47 @@ enum KeychainService {
|
||||
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 {
|
||||
@@ -79,3 +85,19 @@ enum KeychainError: LocalizedError {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension KeychainService {
|
||||
private static let licenseTokenAccount = "sonardesk.license-token"
|
||||
|
||||
static func saveLicenseToken(_ token: String) throws {
|
||||
try saveLocal(token, account: licenseTokenAccount)
|
||||
}
|
||||
|
||||
static func licenseToken() -> String? {
|
||||
readWithLegacyMigration(account: licenseTokenAccount)
|
||||
}
|
||||
|
||||
static func deleteLicenseToken() {
|
||||
deleteAll(account: licenseTokenAccount)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import Foundation
|
||||
import IOKit
|
||||
|
||||
struct LicenseStatus: Equatable {
|
||||
let valid: Bool
|
||||
let edition: String?
|
||||
let expiresAt: Date?
|
||||
let message: String?
|
||||
}
|
||||
|
||||
private struct ActivateRequest: Encodable {
|
||||
let licenseKey: String
|
||||
let installationID: String
|
||||
let appVersion: String
|
||||
let platform = "macOS"
|
||||
}
|
||||
|
||||
private struct ValidateRequest: Encodable {
|
||||
let licenseToken: String
|
||||
let installationID: String
|
||||
let appVersion: String
|
||||
let platform = "macOS"
|
||||
}
|
||||
|
||||
private struct DeactivateRequest: Encodable {
|
||||
let licenseToken: String
|
||||
let installationID: String
|
||||
}
|
||||
|
||||
private struct TokenResponse: Decodable {
|
||||
let active: Bool?
|
||||
let licenseToken: String?
|
||||
}
|
||||
|
||||
private struct TokenPayload: Decodable {
|
||||
let edition: String?
|
||||
let expiresAt: String?
|
||||
let installationID: String?
|
||||
}
|
||||
|
||||
enum LicenseEnvironment {
|
||||
static let baseURL: URL = {
|
||||
guard let value = Bundle.main.object(forInfoDictionaryKey: "SonarDeskLicenseBaseURL") as? String,
|
||||
let url = URL(string: value),
|
||||
url.scheme == "https" else {
|
||||
fatalError("SonarDeskLicenseBaseURL is missing or invalid for this build configuration.")
|
||||
}
|
||||
return url
|
||||
}()
|
||||
|
||||
static let displayName: String = {
|
||||
Bundle.main.object(forInfoDictionaryKey: "SonarDeskEnvironment") as? String ?? "Unknown"
|
||||
}()
|
||||
}
|
||||
|
||||
actor LicenseService {
|
||||
static let shared = LicenseService()
|
||||
private let session: URLSession
|
||||
|
||||
init() {
|
||||
let configuration = URLSessionConfiguration.ephemeral
|
||||
configuration.timeoutIntervalForRequest = 15
|
||||
configuration.timeoutIntervalForResource = 30
|
||||
configuration.waitsForConnectivity = false
|
||||
session = URLSession(configuration: configuration)
|
||||
}
|
||||
|
||||
func activate(key: String) async throws -> (LicenseStatus, String) {
|
||||
let response: TokenResponse = try await post(path: "/api/v1/activate", payload: ActivateRequest(
|
||||
licenseKey: key,
|
||||
installationID: MachineIdentity.id,
|
||||
appVersion: Self.appVersion
|
||||
))
|
||||
guard let token = response.licenseToken else { throw LicenseError.invalidResponse }
|
||||
return (try status(from: token), token)
|
||||
}
|
||||
|
||||
func refresh(token: String) async throws -> (LicenseStatus, String) {
|
||||
let response: TokenResponse = try await post(path: "/api/v1/validate", payload: ValidateRequest(
|
||||
licenseToken: token,
|
||||
installationID: MachineIdentity.id,
|
||||
appVersion: Self.appVersion
|
||||
))
|
||||
guard response.active == true, let refreshed = response.licenseToken else {
|
||||
throw LicenseError.server("This licence is not active.")
|
||||
}
|
||||
return (try status(from: refreshed), refreshed)
|
||||
}
|
||||
|
||||
func deactivate(token: String) async throws {
|
||||
let _: EmptyResponse = try await post(path: "/api/v1/deactivate", payload: DeactivateRequest(
|
||||
licenseToken: token,
|
||||
installationID: MachineIdentity.id
|
||||
))
|
||||
}
|
||||
|
||||
func cachedStatus(token: String) throws -> LicenseStatus { try status(from: token) }
|
||||
|
||||
private func post<T: Encodable, R: Decodable>(path: String, payload: T) async throws -> R {
|
||||
var request = URLRequest(url: LicenseEnvironment.baseURL.appendingPathComponent(path))
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
request.httpBody = try JSONEncoder().encode(payload)
|
||||
|
||||
let (data, response) = try await session.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse else { throw LicenseError.invalidResponse }
|
||||
guard (200..<300).contains(http.statusCode) else {
|
||||
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||
let message = object?["message"] as? String
|
||||
?? ((object?["errors"] as? [String: [String]])?.values.first?.first)
|
||||
throw LicenseError.server(message ?? "Licence server returned HTTP \(http.statusCode).")
|
||||
}
|
||||
do { return try JSONDecoder().decode(R.self, from: data) }
|
||||
catch { throw LicenseError.invalidResponse }
|
||||
}
|
||||
|
||||
private func status(from token: String) throws -> LicenseStatus {
|
||||
let parts = token.split(separator: ".", omittingEmptySubsequences: false)
|
||||
guard parts.count == 3, parts[0] == "RS3", let data = Self.base64URLData(String(parts[1])) else {
|
||||
throw LicenseError.invalidResponse
|
||||
}
|
||||
let payload = try JSONDecoder().decode(TokenPayload.self, from: data)
|
||||
guard payload.installationID?.lowercased() == MachineIdentity.id.lowercased() else {
|
||||
throw LicenseError.server("This licence belongs to another Mac installation.")
|
||||
}
|
||||
let expires = payload.expiresAt.flatMap { ISO8601DateFormatter().date(from: $0) }
|
||||
return LicenseStatus(valid: expires.map { $0 > Date() } ?? true, edition: payload.edition, expiresAt: expires, message: nil)
|
||||
}
|
||||
|
||||
private static var appVersion: String {
|
||||
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0"
|
||||
}
|
||||
|
||||
private static func base64URLData(_ value: String) -> Data? {
|
||||
var base64 = value.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
|
||||
base64 += String(repeating: "=", count: (4 - base64.count % 4) % 4)
|
||||
return Data(base64Encoded: base64)
|
||||
}
|
||||
}
|
||||
|
||||
private struct EmptyResponse: Decodable {}
|
||||
|
||||
enum MachineIdentity {
|
||||
static let id: String = {
|
||||
let service = IOServiceGetMatchingService(kIOMainPortDefault, IOServiceMatching("IOPlatformExpertDevice"))
|
||||
defer { if service != 0 { IOObjectRelease(service) } }
|
||||
if service != 0,
|
||||
let value = IORegistryEntryCreateCFProperty(service, "IOPlatformUUID" as CFString, kCFAllocatorDefault, 0)?.takeRetainedValue() as? String,
|
||||
UUID(uuidString: value) != nil { return value.lowercased() }
|
||||
// The API requires a UUID. Persisted fallback prevents a transient IOKit failure
|
||||
// from creating a different installation identity on every launch.
|
||||
let key = "SonarDeskInstallationID"
|
||||
if let existing = UserDefaults.standard.string(forKey: key), UUID(uuidString: existing) != nil { return existing.lowercased() }
|
||||
let generated = UUID().uuidString.lowercased()
|
||||
UserDefaults.standard.set(generated, forKey: key)
|
||||
return generated
|
||||
}()
|
||||
}
|
||||
|
||||
enum LicenseError: LocalizedError {
|
||||
case invalidResponse
|
||||
case server(String)
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidResponse: return "The licence server returned an invalid response."
|
||||
case .server(let message): return message
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import AppKit
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
@MainActor
|
||||
enum ProjectPDFReportExporter {
|
||||
static func export(project: ProjectSnapshot, issues: [Issue], serverURL: String, serverVersion: String?) throws {
|
||||
let panel = NSSavePanel()
|
||||
panel.canCreateDirectories = true
|
||||
panel.allowedContentTypes = [.pdf]
|
||||
panel.nameFieldStringValue = "SonarDesk-\(safe(project.project.name))-\(dateStamp()).pdf"
|
||||
guard panel.runModal() == .OK, let url = panel.url else { return }
|
||||
try makePDF(project: project, issues: issues, serverURL: serverURL, serverVersion: serverVersion).write(to: url, options: .atomic)
|
||||
}
|
||||
|
||||
static func makePDF(project: ProjectSnapshot, issues: [Issue], serverURL: String, serverVersion: String?) throws -> Data {
|
||||
let data = NSMutableData()
|
||||
guard let consumer = CGDataConsumer(data: data as CFMutableData) else { throw ExportError.couldNotCreatePDF }
|
||||
var mediaBox = CGRect(x: 0, y: 0, width: 595, height: 842)
|
||||
guard let context = CGContext(consumer: consumer, mediaBox: &mediaBox, nil) else { throw ExportError.couldNotCreatePDF }
|
||||
let writer = Writer(context: context, page: mediaBox)
|
||||
|
||||
writer.begin(title: "Project Quality Report", project: project.project.name)
|
||||
writer.heading("Executive Summary")
|
||||
writer.paragraph(summary(project: project, issues: issues))
|
||||
writer.metricGrid([
|
||||
("Quality Gate", project.qualityGate == "OK" ? "PASSED" : project.qualityGate),
|
||||
("Bugs", metric(project, "bugs")),
|
||||
("Vulnerabilities", metric(project, "vulnerabilities")),
|
||||
("Code Smells", metric(project, "code_smells")),
|
||||
("Security Hotspots", metric(project, "security_hotspots")),
|
||||
("Coverage", metric(project, "coverage") + "%"),
|
||||
("Duplications", metric(project, "duplicated_lines_density") + "%"),
|
||||
("Lines of Code", metric(project, "ncloc"))
|
||||
])
|
||||
|
||||
writer.heading("Issue Overview")
|
||||
let severities = ["BLOCKER", "CRITICAL", "MAJOR", "MINOR", "INFO"].map { severity in
|
||||
(severity.capitalized, issues.filter { ($0.severity ?? "").uppercased() == severity }.count)
|
||||
}
|
||||
writer.barChart(severities)
|
||||
writer.smallText("Open issues exported: \(issues.count). Counts and classifications are supplied by SonarQube.")
|
||||
|
||||
writer.heading("Project Information")
|
||||
writer.keyValues([
|
||||
("Project", project.project.name), ("Project key", project.project.key),
|
||||
("SonarQube server", serverURL), ("SonarQube version", serverVersion ?? "Unknown"),
|
||||
("Last analysis", project.project.lastAnalysisDate ?? "Unknown"),
|
||||
("Report generated", Date().formatted(date: .long, time: .shortened))
|
||||
])
|
||||
|
||||
writer.heading("Detailed Findings")
|
||||
if issues.isEmpty {
|
||||
writer.paragraph("No unresolved issues were returned by SonarQube for this project.")
|
||||
} else {
|
||||
for (index, issue) in sorted(issues).enumerated() {
|
||||
writer.issue(index: index + 1, issue: issue)
|
||||
}
|
||||
}
|
||||
|
||||
writer.heading("About this report")
|
||||
writer.paragraph("This report was generated locally by SonarDesk from data returned by the connected SonarQube server. SonarDesk has not independently audited the source code. Findings, ratings and quality-gate results should be interpreted in the context of the SonarQube configuration and analysis used for this project.")
|
||||
writer.finish()
|
||||
context.closePDF()
|
||||
return data as Data
|
||||
}
|
||||
|
||||
private static func metric(_ p: ProjectSnapshot, _ key: String) -> String { p.measures[key] ?? "0" }
|
||||
private static func safe(_ value: String) -> String { value.replacingOccurrences(of: "/", with: "-").replacingOccurrences(of: ":", with: "-") }
|
||||
private static func dateStamp() -> String { let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"; return f.string(from: Date()) }
|
||||
private static func sorted(_ issues: [Issue]) -> [Issue] {
|
||||
let rank = ["BLOCKER": 0, "CRITICAL": 1, "MAJOR": 2, "MINOR": 3, "INFO": 4]
|
||||
return issues.sorted { (rank[$0.severity?.uppercased() ?? ""] ?? 99) < (rank[$1.severity?.uppercased() ?? ""] ?? 99) }
|
||||
}
|
||||
private static func summary(project: ProjectSnapshot, issues: [Issue]) -> String {
|
||||
let gate = project.qualityGate == "OK" ? "passed" : project.qualityGate.lowercased()
|
||||
return "The SonarQube quality gate is \(gate). The current analysis reports \(metric(project, "bugs")) bugs, \(metric(project, "vulnerabilities")) vulnerabilities, \(metric(project, "code_smells")) code smells and \(metric(project, "security_hotspots")) security hotspots. Test coverage is \(metric(project, "coverage"))% and duplicated-lines density is \(metric(project, "duplicated_lines_density"))%. This report contains \(issues.count) unresolved findings returned by SonarQube."
|
||||
}
|
||||
|
||||
enum ExportError: LocalizedError { case couldNotCreatePDF; var errorDescription: String? { "SonarDesk could not create the PDF report." } }
|
||||
|
||||
private final class Writer {
|
||||
private let context: CGContext
|
||||
private let page: CGRect
|
||||
private let margin: CGFloat = 42
|
||||
private var y: CGFloat = 0
|
||||
private var pageNumber = 0
|
||||
private var projectName = ""
|
||||
private let accent = NSColor(calibratedRed: 0.95, green: 0.42, blue: 0.12, alpha: 1)
|
||||
init(context: CGContext, page: CGRect) { self.context = context; self.page = page }
|
||||
|
||||
func begin(title: String, project: String) {
|
||||
projectName = project; newPage()
|
||||
text("SONARDESK", x: margin, y: y, width: 220, font: .boldSystemFont(ofSize: 13), color: accent); y += 28
|
||||
text(title, x: margin, y: y, width: page.width - margin * 2, font: .boldSystemFont(ofSize: 30)); y += 42
|
||||
text(project, x: margin, y: y, width: page.width - margin * 2, font: .systemFont(ofSize: 17), color: .darkGray); y += 36
|
||||
rule(); y += 18
|
||||
}
|
||||
func finish() {
|
||||
footer()
|
||||
endPage()
|
||||
}
|
||||
|
||||
private func newPage() {
|
||||
if pageNumber > 0 {
|
||||
footer()
|
||||
endPage()
|
||||
}
|
||||
|
||||
context.beginPDFPage(nil)
|
||||
context.saveGState()
|
||||
|
||||
// Quartz PDF pages use a bottom-left origin. The report layout below is
|
||||
// intentionally expressed in normal AppKit top-left coordinates. Flip
|
||||
// the PDF context once per page so NSString/NSBezierPath drawing lands
|
||||
// on the page instead of being rendered outside/inverted.
|
||||
context.translateBy(x: 0, y: page.height)
|
||||
context.scaleBy(x: 1, y: -1)
|
||||
NSGraphicsContext.current = NSGraphicsContext(cgContext: context, flipped: true)
|
||||
|
||||
pageNumber += 1
|
||||
y = 52
|
||||
|
||||
if pageNumber > 1 {
|
||||
text("SONARDESK • \(projectName)", x: margin, y: 25, width: page.width - margin * 2, font: .boldSystemFont(ofSize: 8), color: .gray)
|
||||
}
|
||||
}
|
||||
|
||||
private func endPage() {
|
||||
NSGraphicsContext.current = nil
|
||||
context.restoreGState()
|
||||
context.endPDFPage()
|
||||
}
|
||||
private func footer() {
|
||||
rule(at: page.height - 37)
|
||||
text("Generated by SonarDesk • SonarQube analysis data", x: margin, y: page.height - 27, width: 360, font: .systemFont(ofSize: 7.5), color: .gray)
|
||||
text("Page \(pageNumber)", x: page.width - margin - 60, y: page.height - 27, width: 60, font: .systemFont(ofSize: 7.5), color: .gray, align: .right)
|
||||
}
|
||||
private func ensure(_ height: CGFloat) { if y + height > page.height - 58 { newPage(); y = 55 } }
|
||||
func heading(_ value: String) { ensure(44); y += 10; text(value, x: margin, y: y, width: page.width - margin * 2, font: .boldSystemFont(ofSize: 17)); y += 30 }
|
||||
func paragraph(_ value: String) { let h = measured(value, width: page.width - margin * 2, font: .systemFont(ofSize: 10.5)) + 8; ensure(h); text(value, x: margin, y: y, width: page.width - margin * 2, font: .systemFont(ofSize: 10.5), color: .darkGray); y += h }
|
||||
func smallText(_ value: String) { ensure(28); text(value, x: margin, y: y, width: page.width - margin * 2, font: .systemFont(ofSize: 8.5), color: .gray); y += 25 }
|
||||
func metricGrid(_ values: [(String,String)]) {
|
||||
let w = (page.width - margin * 2 - 12) / 2; let h: CGFloat = 56
|
||||
for i in stride(from: 0, to: values.count, by: 2) {
|
||||
ensure(h + 10)
|
||||
metricBox(values[i], x: margin, width: w, height: h)
|
||||
if i + 1 < values.count { metricBox(values[i+1], x: margin + w + 12, width: w, height: h) }
|
||||
y += h + 10
|
||||
}
|
||||
}
|
||||
private func metricBox(_ value: (String,String), x: CGFloat, width: CGFloat, height: CGFloat) {
|
||||
NSColor(calibratedWhite: 0.96, alpha: 1).setFill(); NSBezierPath(roundedRect: CGRect(x: x, y: y, width: width, height: height), xRadius: 7, yRadius: 7).fill()
|
||||
text(value.0.uppercased(), x: x + 12, y: y + 10, width: width - 24, font: .boldSystemFont(ofSize: 7.5), color: .gray)
|
||||
text(value.1, x: x + 12, y: y + 27, width: width - 24, font: .boldSystemFont(ofSize: 16))
|
||||
}
|
||||
func barChart(_ values: [(String,Int)]) {
|
||||
ensure(CGFloat(values.count) * 30 + 15); let maxValue = max(1, values.map(\.1).max() ?? 1); let chartW = page.width - margin * 2 - 120
|
||||
for (label, value) in values {
|
||||
text(label, x: margin, y: y + 3, width: 75, font: .systemFont(ofSize: 9))
|
||||
NSColor(calibratedWhite: 0.92, alpha: 1).setFill(); NSBezierPath(roundedRect: CGRect(x: margin + 82, y: y + 4, width: chartW, height: 12), xRadius: 3, yRadius: 3).fill()
|
||||
if value > 0 { accent.setFill(); NSBezierPath(roundedRect: CGRect(x: margin + 82, y: y + 4, width: chartW * CGFloat(value) / CGFloat(maxValue), height: 12), xRadius: 3, yRadius: 3).fill() }
|
||||
text("\(value)", x: page.width - margin - 30, y: y + 2, width: 30, font: .boldSystemFont(ofSize: 9), align: .right); y += 28
|
||||
}; y += 4
|
||||
}
|
||||
func keyValues(_ values: [(String,String)]) {
|
||||
for value in values { ensure(25); text(value.0, x: margin, y: y, width: 110, font: .boldSystemFont(ofSize: 9), color: .gray); text(value.1, x: margin + 120, y: y, width: page.width - margin * 2 - 120, font: .systemFont(ofSize: 9)); y += 22 }
|
||||
}
|
||||
func issue(index: Int, issue: Issue) {
|
||||
let message = issue.message ?? "Issue"; let details = "\(issue.type ?? "Unknown") • \(issue.severity ?? "Unknown") • \(issue.status ?? "Unknown")"
|
||||
let location = [issue.component, issue.line.map { "line \($0)" }].compactMap { $0 }.joined(separator: " • ")
|
||||
let needed = max(88, measured(message, width: page.width - margin * 2 - 24, font: .boldSystemFont(ofSize: 11)) + 68); ensure(needed)
|
||||
NSColor(calibratedWhite: 0.975, alpha: 1).setFill(); NSBezierPath(roundedRect: CGRect(x: margin, y: y, width: page.width - margin * 2, height: needed - 8), xRadius: 6, yRadius: 6).fill()
|
||||
text("\(index). \(message)", x: margin + 12, y: y + 10, width: page.width - margin * 2 - 24, font: .boldSystemFont(ofSize: 11))
|
||||
text(details, x: margin + 12, y: y + 34, width: page.width - margin * 2 - 24, font: .boldSystemFont(ofSize: 8), color: accent)
|
||||
text(location.isEmpty ? "Location unavailable" : location, x: margin + 12, y: y + 50, width: page.width - margin * 2 - 24, font: .systemFont(ofSize: 8.5), color: .darkGray)
|
||||
text("Rule: \(issue.rule ?? "Unknown") • Effort: \(issue.effort ?? "Unknown")", x: margin + 12, y: y + 66, width: page.width - margin * 2 - 24, font: .systemFont(ofSize: 8), color: .gray)
|
||||
y += needed
|
||||
}
|
||||
private func rule(at value: CGFloat? = nil) { NSColor(calibratedWhite: 0.85, alpha: 1).setStroke(); let p = NSBezierPath(); p.move(to: CGPoint(x: margin, y: value ?? y)); p.line(to: CGPoint(x: page.width - margin, y: value ?? y)); p.lineWidth = 0.6; p.stroke() }
|
||||
private func measured(_ value: String, width: CGFloat, font: NSFont) -> CGFloat { (value as NSString).boundingRect(with: CGSize(width: width, height: 10_000), options: [.usesLineFragmentOrigin, .usesFontLeading], attributes: [.font: font]).height }
|
||||
private func text(_ value: String, x: CGFloat, y: CGFloat, width: CGFloat, font: NSFont, color: NSColor = .black, align: NSTextAlignment = .left) {
|
||||
let style = NSMutableParagraphStyle(); style.alignment = align; style.lineBreakMode = .byWordWrapping
|
||||
(value as NSString).draw(with: CGRect(x: x, y: y, width: width, height: 10_000), options: [.usesLineFragmentOrigin, .usesFontLeading], attributes: [.font: font, .foregroundColor: color, .paragraphStyle: style])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -14,12 +14,15 @@ final class AppState: ObservableObject {
|
||||
@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 } }
|
||||
@@ -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) {
|
||||
projects.move(fromOffsets: source, toOffset: destination)
|
||||
saveProjectOrder()
|
||||
@@ -211,8 +242,19 @@ final class AppState: ObservableObject {
|
||||
|
||||
private func saveServers() {
|
||||
guard !isLoadingPersistentData else { return }
|
||||
do { try driveStore.write(servers, fileName: "servers.json") }
|
||||
catch { errorMessage = error.localizedDescription }
|
||||
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() {
|
||||
@@ -220,64 +262,120 @@ final class AppState: ObservableObject {
|
||||
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 {
|
||||
let existing = try driveStore.read(PreferencesFile.self, fileName: "preferences.json")
|
||||
let preferences = PreferencesFile(
|
||||
selectedServerID: selectedServerID,
|
||||
projectOrder: projectOrder ?? existing?.projectOrder ?? []
|
||||
)
|
||||
try driveStore.write(preferences, fileName: "preferences.json")
|
||||
} catch { errorMessage = error.localizedDescription }
|
||||
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 {
|
||||
var 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 = 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")
|
||||
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 {
|
||||
if try driveStore.read([SonarServer].self, fileName: "servers.json") != 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 }
|
||||
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")
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class LicenseStore: ObservableObject {
|
||||
@Published private(set) var status: LicenseStatus?
|
||||
@Published private(set) var isWorking = false
|
||||
@Published private(set) var hasChecked = false
|
||||
@Published var errorMessage: String?
|
||||
|
||||
var hasStoredLicense: Bool { KeychainService.licenseToken() != nil }
|
||||
var isLicensed: Bool { status?.valid == true }
|
||||
|
||||
func activate(_ key: String) async {
|
||||
let clean = key.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
|
||||
guard !clean.isEmpty else {
|
||||
errorMessage = "Enter your SonarDesk licence key."
|
||||
return
|
||||
}
|
||||
|
||||
isWorking = true
|
||||
errorMessage = nil
|
||||
defer {
|
||||
isWorking = false
|
||||
hasChecked = true
|
||||
}
|
||||
|
||||
do {
|
||||
let (result, token) = try await LicenseService.shared.activate(key: clean)
|
||||
try KeychainService.saveLicenseToken(token)
|
||||
status = result
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func replaceLicense(with key: String) async -> Bool {
|
||||
let clean = key.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
|
||||
guard !clean.isEmpty else {
|
||||
errorMessage = "Enter your new SonarDesk licence key."
|
||||
return false
|
||||
}
|
||||
|
||||
let previousToken = KeychainService.licenseToken()
|
||||
|
||||
isWorking = true
|
||||
errorMessage = nil
|
||||
defer {
|
||||
isWorking = false
|
||||
hasChecked = true
|
||||
}
|
||||
|
||||
do {
|
||||
// Validate the replacement before touching the currently working licence.
|
||||
let (result, newToken) = try await LicenseService.shared.activate(key: clean)
|
||||
try KeychainService.saveLicenseToken(newToken)
|
||||
status = result
|
||||
|
||||
// Once the replacement is safely stored, release the old activation.
|
||||
// Failure here must not undo a successfully installed replacement licence.
|
||||
if let previousToken, previousToken != newToken {
|
||||
try? await LicenseService.shared.deactivate(token: previousToken)
|
||||
}
|
||||
|
||||
return true
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func refresh() async {
|
||||
guard let token = KeychainService.licenseToken() else {
|
||||
status = nil
|
||||
hasChecked = true
|
||||
return
|
||||
}
|
||||
|
||||
// Always restore the locally signed status before attempting the network.
|
||||
// This keeps the application licensed while the validation request is in flight.
|
||||
if let cached = try? await LicenseService.shared.cachedStatus(token: token) {
|
||||
status = cached
|
||||
}
|
||||
|
||||
isWorking = true
|
||||
errorMessage = nil
|
||||
defer {
|
||||
isWorking = false
|
||||
hasChecked = true
|
||||
}
|
||||
|
||||
do {
|
||||
let (result, refreshedToken) = try await LicenseService.shared.refresh(token: token)
|
||||
try KeychainService.saveLicenseToken(refreshedToken)
|
||||
status = result
|
||||
} catch {
|
||||
// Do NOT clear status or delete the token here. A failed Check Licence can
|
||||
// be caused by networking, DNS, TLS, the licensing server, or a transient
|
||||
// server error. Explicit deactivation is the only path that removes the
|
||||
// persisted activation.
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func deactivate() async {
|
||||
guard let token = KeychainService.licenseToken() else { return }
|
||||
|
||||
isWorking = true
|
||||
errorMessage = nil
|
||||
defer {
|
||||
isWorking = false
|
||||
hasChecked = true
|
||||
}
|
||||
|
||||
do {
|
||||
try await LicenseService.shared.deactivate(token: token)
|
||||
KeychainService.deleteLicenseToken()
|
||||
status = nil
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ struct DashboardView: View {
|
||||
private var failed: Int { app.projects.filter { $0.qualityGate != "OK" }.count }
|
||||
private var filtered: [ProjectSnapshot] {
|
||||
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)))) }
|
||||
@@ -79,7 +79,7 @@ struct DashboardView: View {
|
||||
SonarPanel { VStack(alignment:.leading,spacing:10) {
|
||||
Text("Project issue load").font(.headline)
|
||||
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)
|
||||
}.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)
|
||||
@@ -94,7 +94,7 @@ struct DashboardView: View {
|
||||
SonarPanel { VStack(alignment:.leading,spacing:10) {
|
||||
Text("Coverage by project").font(.headline)
|
||||
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)
|
||||
}.padding(16) }.frame(maxWidth:.infinity)
|
||||
}
|
||||
@@ -141,7 +141,7 @@ struct DashboardView: View {
|
||||
HStack {
|
||||
HStack(spacing: 11) {
|
||||
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)
|
||||
metricPill(item.measures["bugs"] ?? "0", .red, 65)
|
||||
metricPill(item.measures["vulnerabilities"] ?? "0", .orange, 65)
|
||||
|
||||
@@ -12,7 +12,7 @@ struct GlobalMetricView: View {
|
||||
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" }
|
||||
}
|
||||
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 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: {
|
||||
HStack(spacing: 14) {
|
||||
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)
|
||||
if metric == .qualityGates || metric == .failedGates { GateBadge(status: item.qualityGate) }
|
||||
Image(systemName: "chevron.right").foregroundStyle(.tertiary)
|
||||
|
||||
@@ -9,18 +9,37 @@ struct ProjectDetailView: View {
|
||||
@State private var credentialError: String?
|
||||
@State private var generatingCredentials = false
|
||||
@State private var exportingAI = false
|
||||
@State private var exportingPDF = false
|
||||
@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 } }
|
||||
|
||||
var body: some View {
|
||||
if let item {
|
||||
VStack(spacing: 0) {
|
||||
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()
|
||||
Button { Task { await exportAIReport(item) } } label: {
|
||||
if exportingAI { ProgressView().controlSize(.small) } else { Label("Export for AI Analysis…", systemImage: "brain.head.profile") }
|
||||
}.buttonStyle(.bordered).disabled(exportingAI)
|
||||
Menu {
|
||||
Button { Task { await exportPDFReport(item) } } label: { Label("PDF Report…", systemImage: "doc.richtext") }
|
||||
Button { Task { await exportAIReport(item) } } label: { Label("AI Analysis Export…", systemImage: "brain.head.profile") }
|
||||
} label: {
|
||||
if exportingPDF || exportingAI { ProgressView().controlSize(.small) } else { Label("Export Report…", systemImage: "square.and.arrow.up") }
|
||||
}
|
||||
.menuStyle(.borderlessButton)
|
||||
.fixedSize()
|
||||
.disabled(exportingPDF || exportingAI)
|
||||
GateBadge(status: item.qualityGate)
|
||||
}.padding(.horizontal, 26).padding(.top, 22).padding(.bottom, 14)
|
||||
|
||||
@@ -55,9 +74,29 @@ struct ProjectDetailView: View {
|
||||
case .scanDetails: scanDetails(item)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingAliasEditor) { aliasEditor }
|
||||
} 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 {
|
||||
ScrollView {
|
||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 220), spacing: 14)], spacing: 14) {
|
||||
@@ -172,6 +211,20 @@ struct ProjectDetailView: View {
|
||||
catch { credentialError = error.localizedDescription }
|
||||
}
|
||||
|
||||
@MainActor private func exportPDFReport(_ item: ProjectSnapshot) async {
|
||||
exportingPDF = true; exportError = nil
|
||||
defer { exportingPDF = false }
|
||||
do {
|
||||
let issues = try await app.allIssues(projectKey: item.project.key)
|
||||
try ProjectPDFReportExporter.export(
|
||||
project: item,
|
||||
issues: issues,
|
||||
serverURL: app.selectedServer?.baseURL ?? "Unknown",
|
||||
serverVersion: app.serverVersion
|
||||
)
|
||||
} catch { exportError = error.localizedDescription }
|
||||
}
|
||||
|
||||
@MainActor private func exportAIReport(_ item: ProjectSnapshot) async {
|
||||
exportingAI = true; exportError = nil
|
||||
defer { exportingAI = false }
|
||||
|
||||
@@ -5,6 +5,8 @@ struct RootView: View {
|
||||
@EnvironmentObject var app: AppState
|
||||
@EnvironmentObject var reportStore: ReportStore
|
||||
@State private var selection: Destination = .dashboard
|
||||
@State private var aliasProjectKey: String?
|
||||
@State private var aliasText = ""
|
||||
|
||||
var body: some View {
|
||||
HSplitView {
|
||||
@@ -18,6 +20,9 @@ struct RootView: View {
|
||||
.background(SonarTheme.canvas)
|
||||
.tint(SonarTheme.accent)
|
||||
.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 } })) {
|
||||
Button("OK", role: .cancel) { app.errorMessage = nil }
|
||||
} message: { Text(app.errorMessage ?? "Unknown error") }
|
||||
@@ -65,7 +70,7 @@ struct RootView: View {
|
||||
|
||||
sidebarHeading("PROJECTS")
|
||||
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)
|
||||
.dropDestination(for: String.self) { keys, _ in
|
||||
guard let sourceKey = keys.first else { return false }
|
||||
@@ -73,6 +78,14 @@ struct RootView: View {
|
||||
return true
|
||||
}
|
||||
.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 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))
|
||||
}
|
||||
|
||||
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 {
|
||||
Text(title).font(.caption2.bold()).tracking(1.1).foregroundStyle(.tertiary).padding(.horizontal, 10).padding(.top, 13).padding(.bottom, 5)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import SwiftUI
|
||||
|
||||
struct LicenseRequiredView: View {
|
||||
@EnvironmentObject private var license: LicenseStore
|
||||
@State private var key = ""
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 22) {
|
||||
Image("SonarDeskLogo").resizable().scaledToFit().frame(width: 86, height: 86)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
|
||||
VStack(spacing: 8) {
|
||||
Text("Activate SonarDesk").font(.largeTitle.bold())
|
||||
Text("Enter the licence key emailed to you after starting your SonarDesk subscription.")
|
||||
.foregroundStyle(.secondary).multilineTextAlignment(.center)
|
||||
}
|
||||
GroupBox {
|
||||
VStack(spacing: 14) {
|
||||
SecureField("XXXXX-XXXXX-XXXXX-XXXXX", text: $key)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 390)
|
||||
.onSubmit { Task { await license.activate(key) } }
|
||||
Button("Activate This Mac") { Task { await license.activate(key) } }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(key.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || license.isWorking)
|
||||
if license.isWorking { ProgressView().controlSize(.small) }
|
||||
if let error = license.errorMessage {
|
||||
Text(error).font(.callout).foregroundStyle(.red).multilineTextAlignment(.center).textSelection(.enabled)
|
||||
}
|
||||
}.padding(12)
|
||||
}
|
||||
Text("Licence server: \(LicenseEnvironment.displayName)").font(.caption).foregroundStyle(.tertiary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding(40)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import SwiftUI
|
||||
|
||||
struct LicenseSettingsView: View {
|
||||
@EnvironmentObject private var license: LicenseStore
|
||||
@State private var key = ""
|
||||
@State private var isChangingLicense = false
|
||||
@State private var showingDeactivateConfirmation = false
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
Text("Licence")
|
||||
.font(.title2.bold())
|
||||
|
||||
Text("Manage the SonarDesk licence used to activate this Mac.")
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
GroupBox {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
if license.isLicensed, let status = license.status {
|
||||
activatedContent(status)
|
||||
} else {
|
||||
activationForm
|
||||
}
|
||||
|
||||
if license.isWorking {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
}
|
||||
|
||||
if let error = license.errorMessage {
|
||||
Text(error)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.red)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
.padding(8)
|
||||
}
|
||||
|
||||
Text("SonarDesk stores the signed activation token in this Mac's Keychain. Licence keys are used only for activation and are not retained by the app.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: 620, alignment: .leading)
|
||||
.padding(32)
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Deactivate SonarDesk on this Mac?",
|
||||
isPresented: $showingDeactivateConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Deactivate This Mac", role: .destructive) {
|
||||
Task {
|
||||
await license.deactivate()
|
||||
if !license.isLicensed {
|
||||
key = ""
|
||||
isChangingLicense = false
|
||||
}
|
||||
}
|
||||
}
|
||||
Button("Cancel", role: .cancel) { }
|
||||
} message: {
|
||||
Text("This removes the activation from this Mac. You will need a valid licence key to activate SonarDesk again.")
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func activatedContent(_ status: LicenseStatus) -> some View {
|
||||
Label("SonarDesk is activated", systemImage: "checkmark.seal.fill")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.green)
|
||||
|
||||
LabeledContent("Edition", value: status.edition ?? "SonarDesk")
|
||||
|
||||
if let expires = status.expiresAt {
|
||||
LabeledContent(
|
||||
"Valid until",
|
||||
value: expires.formatted(date: .abbreviated, time: .omitted)
|
||||
)
|
||||
}
|
||||
|
||||
LabeledContent("Server", value: LicenseEnvironment.displayName)
|
||||
|
||||
if isChangingLicense {
|
||||
Divider()
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Change Licence")
|
||||
.font(.headline)
|
||||
|
||||
Text("Enter the replacement licence key. Your current activation remains in place unless the new licence is successfully validated.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
SecureField("XXXXX-XXXXX-XXXXX-XXXXX", text: $key)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.disabled(license.isWorking)
|
||||
.onSubmit { replaceLicense() }
|
||||
|
||||
HStack {
|
||||
Button("Cancel") {
|
||||
key = ""
|
||||
isChangingLicense = false
|
||||
license.errorMessage = nil
|
||||
}
|
||||
.disabled(license.isWorking)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("Activate New Licence") {
|
||||
replaceLicense()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(cleanKey.isEmpty || license.isWorking)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
HStack {
|
||||
Button("Check Licence") {
|
||||
Task { await license.refresh() }
|
||||
}
|
||||
.disabled(license.isWorking)
|
||||
|
||||
Button("Change Licence…") {
|
||||
key = ""
|
||||
license.errorMessage = nil
|
||||
isChangingLicense = true
|
||||
}
|
||||
.disabled(license.isWorking)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("Deactivate This Mac", role: .destructive) {
|
||||
showingDeactivateConfirmation = true
|
||||
}
|
||||
.disabled(license.isWorking)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var activationForm: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text("Activate SonarDesk")
|
||||
.font(.headline)
|
||||
|
||||
Text("Enter the licence key emailed to you after purchase.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
SecureField("XXXXX-XXXXX-XXXXX-XXXXX", text: $key)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.disabled(license.isWorking)
|
||||
.onSubmit { activateLicense() }
|
||||
|
||||
HStack {
|
||||
Text("Server: \(LicenseEnvironment.displayName)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("Activate") {
|
||||
activateLicense()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(cleanKey.isEmpty || license.isWorking)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var cleanKey: String {
|
||||
key.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private func activateLicense() {
|
||||
guard !cleanKey.isEmpty else { return }
|
||||
Task {
|
||||
await license.activate(key)
|
||||
if license.isLicensed {
|
||||
key = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func replaceLicense() {
|
||||
guard !cleanKey.isEmpty else { return }
|
||||
Task {
|
||||
if await license.replaceLicense(with: key) {
|
||||
key = ""
|
||||
isChangingLicense = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import SwiftUI
|
||||
struct SettingsView: View {
|
||||
@EnvironmentObject private var app: AppState
|
||||
@EnvironmentObject private var reportStore: ReportStore
|
||||
@EnvironmentObject private var license: LicenseStore
|
||||
@State private var editingServer: SonarServer?
|
||||
@State private var showingAddServer = false
|
||||
|
||||
@@ -10,6 +11,9 @@ struct SettingsView: View {
|
||||
TabView {
|
||||
serversPage
|
||||
.tabItem { Label("Servers", systemImage: "server.rack") }
|
||||
LicenseSettingsView()
|
||||
.environmentObject(license)
|
||||
.tabItem { Label("Licence", systemImage: "key.fill") }
|
||||
reportsPage
|
||||
.tabItem { Label("Reports", systemImage: "doc.text") }
|
||||
aboutPage
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# SonarDesk environments
|
||||
|
||||
| Environment | Branch | Licensing/website URL | Paddle | Purpose |
|
||||
|---|---|---|---|---|
|
||||
| Develop | develop | https://sonardesk.vm | Sandbox | Local development + Mailpit |
|
||||
| Stage | stage | https://sonardesk.stage.absdev.net | Sandbox | Release-candidate testing |
|
||||
| Production | main | https://sonardsk.absdev.net | Live | Paying customers |
|
||||
|
||||
## macOS build configurations
|
||||
|
||||
The Xcode project contains `Develop`, `Stage`, and `Release`. `Release` is production. The licensing URL is injected into Info.plist at build time; there is no Debug/Release URL heuristic.
|
||||
|
||||
## Laravel
|
||||
|
||||
Start each environment from the matching template: `.env.develop.example`, `.env.stage.example`, or `.env.production.example`. Never copy Paddle secrets between environments. Develop and Stage use Paddle Sandbox but should use different webhook destinations/secrets. Production uses Paddle Live credentials and its own webhook secret.
|
||||
|
||||
## Deployment
|
||||
|
||||
Develop runs locally and is not a Deployer target. Deploy Stage with `dep deploy stage`; deploy Production with `dep deploy production`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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 "========================================"
|
||||
@@ -25,3 +25,4 @@ _ide_helper.php
|
||||
Homestead.json
|
||||
Homestead.yaml
|
||||
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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\License;
|
||||
use App\Models\LicenseActivation;
|
||||
use Modules\Licensing\Models\License;
|
||||
use Modules\Licensing\Models\LicenseActivation;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
|
||||
@@ -27,7 +27,17 @@ final class CmsPageController extends Controller
|
||||
'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,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)
|
||||
{
|
||||
$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');
|
||||
}
|
||||
$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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,17 @@ use Illuminate\Database\Eloquent\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
|
||||
{
|
||||
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';
|
||||
}
|
||||
}
|
||||
@@ -11,12 +11,19 @@ class User extends Authenticatable implements MustVerifyEmail
|
||||
{
|
||||
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 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,7 +1,5 @@
|
||||
<?php
|
||||
|
||||
use App\Providers\SonarDeskLicensingServiceProvider;
|
||||
|
||||
return [
|
||||
SonarDeskLicensingServiceProvider::class,
|
||||
App\Providers\AppServiceProvider::class,
|
||||
];
|
||||
|
||||
@@ -3,13 +3,31 @@
|
||||
"name": "laravel/laravel",
|
||||
"type": "project",
|
||||
"description": "The skeleton application for the Laravel framework.",
|
||||
"keywords": ["laravel", "framework"],
|
||||
"keywords": [
|
||||
"laravel",
|
||||
"framework"
|
||||
],
|
||||
"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": {
|
||||
"php": "^8.3",
|
||||
"deployer/deployer": "^8.0",
|
||||
"laravel/framework": "^13.17",
|
||||
"laravel/tinker": "^3.0",
|
||||
"mpba/laravel-modules": "dev-main",
|
||||
"predis/predis": "^3.6"
|
||||
},
|
||||
"require-dev": {
|
||||
@@ -25,7 +43,8 @@
|
||||
"psr-4": {
|
||||
"App\\": "app/",
|
||||
"Database\\Factories\\": "database/factories/",
|
||||
"Database\\Seeders\\": "database/seeders/"
|
||||
"Database\\Seeders\\": "database/seeders/",
|
||||
"Modules\\": "modules/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
|
||||
Generated
+110
-13
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "1f5b5a47e5910fdf5b2c47e1bc45e1b0",
|
||||
"content-hash": "d92f21f84e06e667b70468859bfaffd0",
|
||||
"packages": [
|
||||
{
|
||||
"name": "brick/math",
|
||||
@@ -1542,16 +1542,16 @@
|
||||
},
|
||||
{
|
||||
"name": "league/commonmark",
|
||||
"version": "2.10.1",
|
||||
"version": "2.10.3",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/thephpleague/commonmark.git",
|
||||
"reference": "9d489ab67a02960fd8ffe624d93f751daf95439e"
|
||||
"reference": "6efbd9c472b91db0a3350fcd601c8332c2382e1f"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/9d489ab67a02960fd8ffe624d93f751daf95439e",
|
||||
"reference": "9d489ab67a02960fd8ffe624d93f751daf95439e",
|
||||
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/6efbd9c472b91db0a3350fcd601c8332c2382e1f",
|
||||
"reference": "6efbd9c472b91db0a3350fcd601c8332c2382e1f",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -1645,7 +1645,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-09-07T13:44:26+00:00"
|
||||
"time": "2026-09-21T13:07:34+00:00"
|
||||
},
|
||||
{
|
||||
"name": "league/config",
|
||||
@@ -2256,6 +2256,101 @@
|
||||
],
|
||||
"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",
|
||||
"version": "3.13.2",
|
||||
@@ -4875,16 +4970,16 @@
|
||||
},
|
||||
{
|
||||
"name": "symfony/polyfill-php80",
|
||||
"version": "v1.37.0",
|
||||
"version": "v1.43.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/polyfill-php80.git",
|
||||
"reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411"
|
||||
"reference": "9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411",
|
||||
"reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411",
|
||||
"url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74",
|
||||
"reference": "9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -4935,7 +5030,7 @@
|
||||
"shim"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0"
|
||||
"source": "https://github.com/symfony/polyfill-php80/tree/v1.43.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -4955,7 +5050,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-04-10T16:19:22+00:00"
|
||||
"time": "2026-09-25T15:50:05+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/polyfill-php82",
|
||||
@@ -8709,7 +8804,9 @@
|
||||
],
|
||||
"aliases": [],
|
||||
"minimum-stability": "stable",
|
||||
"stability-flags": [],
|
||||
"stability-flags": {
|
||||
"mpba/laravel-modules": 20
|
||||
},
|
||||
"prefer-stable": true,
|
||||
"prefer-lowest": false,
|
||||
"platform": {
|
||||
|
||||
Executable → Regular
+3
-14
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'environment' => env('SONARDESK_ENVIRONMENT', 'develop'),
|
||||
'product_code' => env('SONARDESK_PRODUCT_CODE', 'sonardesk'),
|
||||
'private_key_path' => env('SONARDESK_LICENSE_PRIVATE_KEY_PATH'),
|
||||
'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', ''))))),
|
||||
'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' => [
|
||||
'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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
+42
@@ -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');
|
||||
}
|
||||
};
|
||||
+35
@@ -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',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
+22
@@ -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'));
|
||||
}
|
||||
}
|
||||
};
|
||||
+26
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -19,13 +19,13 @@ add('writable_dirs', ['storage', 'bootstrap/cache']);
|
||||
|
||||
// Host Definitions
|
||||
host('main')
|
||||
->set('hostname', 'sonardesk.absdev.net')
|
||||
->set('hostname', '217.154.47.197')
|
||||
->set('remote_user', 'mag')
|
||||
->set('branch', 'main')
|
||||
->set('deploy_path', '/var/www/sonardesk/main');
|
||||
->set('deploy_path', '/var/www/sonardesk/prod');
|
||||
|
||||
host('stage')
|
||||
->set('hostname', 'sonardesk.absdev.net')
|
||||
->set('hostname', '192.168.1.2')
|
||||
->set('remote_user', 'mag')
|
||||
->set('branch', '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.
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -1,9 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
namespace Modules\Licensing\Console;
|
||||
|
||||
use App\Models\LicenseProduct;
|
||||
use App\Services\Licensing\LicenseIssuer;
|
||||
use Modules\Licensing\Models\LicenseProduct;
|
||||
use Modules\Licensing\Services\LicenseIssuer;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class SonarDeskLicenseIssue extends Command
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
namespace Modules\Licensing\Console;
|
||||
|
||||
use App\Models\License;
|
||||
use Modules\Licensing\Models\License;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class SonarDeskLicenseRevoke extends Command
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
namespace Modules\Licensing\Console;
|
||||
|
||||
use App\Models\LicenseProduct;
|
||||
use Modules\Licensing\Models\LicenseProduct;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class SonarDeskProductCreate extends Command
|
||||
+92
@@ -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');
|
||||
}
|
||||
};
|
||||
+30
@@ -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'));
|
||||
}
|
||||
}
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user