1
0
mirror of https://github.com/nicoverbruggen/phpmon.git synced 2025-11-05 04:20:06 +01:00

🚀 Version 25.09

This commit is contained in:
2025-09-30 16:14:39 +02:00
112 changed files with 2395 additions and 2144 deletions

View File

@@ -3,13 +3,25 @@ disabled_rules:
- identifier_name - identifier_name
- force_try - force_try
- force_cast - force_cast
- private_over_fileprivate
opt_in_rules: opt_in_rules:
- empty_count - empty_count
included: included:
- phpmon - phpmon
- phpmon-tests - phpmon-updater
- tests
excluded: excluded:
- phpmon/Vendor - phpmon/Vendor
line_length:
ignores_function_declarations: true
ignores_comments: true
ignores_urls: true
warning: 120
error: 200
analyzer_rules:
- unused_import

File diff suppressed because it is too large Load Diff

View File

@@ -1,146 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1640"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "C41C1B3222B0097F00E7CF16"
BuildableName = "PHP Monitor.app"
BlueprintName = "PHP Monitor"
ReferencedContainer = "container:PHP Monitor.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug.Dev"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
codeCoverageEnabled = "YES">
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "C4F7807825D7F84B000DBC97"
BuildableName = "Unit Tests.xctest"
BlueprintName = "Unit Tests"
ReferencedContainer = "container:PHP Monitor.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "C471E7BB28F9B90F0021E251"
BuildableName = "UI Tests.xctest"
BlueprintName = "UI Tests"
ReferencedContainer = "container:PHP Monitor.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "C471E7AC28F9B4940021E251"
BuildableName = "Feature Tests.xctest"
BlueprintName = "Feature Tests"
ReferencedContainer = "container:PHP Monitor.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug.Dev"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
enableGPUValidationMode = "1"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "C41C1B3222B0097F00E7CF16"
BuildableName = "PHP Monitor.app"
BlueprintName = "PHP Monitor"
ReferencedContainer = "container:PHP Monitor.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
<CommandLineArgument
argument = "--v"
isEnabled = "NO">
</CommandLineArgument>
<CommandLineArgument
argument = "--cli"
isEnabled = "NO">
</CommandLineArgument>
<CommandLineArgument
argument = "--configuration:~/.phpmon_fconf_working.json"
isEnabled = "NO">
</CommandLineArgument>
<CommandLineArgument
argument = "--configuration:~/.phpmon_fconf_working_no_valet.json"
isEnabled = "NO">
</CommandLineArgument>
<CommandLineArgument
argument = "--configuration:~/.phpmon_fconf_broken.json"
isEnabled = "NO">
</CommandLineArgument>
</CommandLineArguments>
<EnvironmentVariables>
<EnvironmentVariable
key = "EXTREME_DOCTOR_MODE"
value = ""
isEnabled = "NO">
</EnvironmentVariable>
<EnvironmentVariable
key = "PAINT_PHPMON_SWIFTUI_VIEWS"
value = ""
isEnabled = "NO">
</EnvironmentVariable>
</EnvironmentVariables>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release.Dev"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "C41C1B3222B0097F00E7CF16"
BuildableName = "PHP Monitor.app"
BlueprintName = "PHP Monitor"
ReferencedContainer = "container:PHP Monitor.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug.Dev">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release.Dev"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<Scheme <Scheme
LastUpgradeVersion = "1640" LastUpgradeVersion = "2600"
version = "1.3"> version = "1.3">
<BuildAction <BuildAction
parallelizeBuildables = "YES" parallelizeBuildables = "YES"
@@ -31,7 +31,8 @@
<Testables> <Testables>
<TestableReference <TestableReference
skipped = "NO" skipped = "NO"
parallelizable = "YES"> parallelizable = "NO"
testExecutionOrdering = "random">
<BuildableReference <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
BlueprintIdentifier = "C4F7807825D7F84B000DBC97" BlueprintIdentifier = "C4F7807825D7F84B000DBC97"
@@ -41,7 +42,7 @@
</BuildableReference> </BuildableReference>
</TestableReference> </TestableReference>
<TestableReference <TestableReference
skipped = "NO" skipped = "YES"
parallelizable = "YES"> parallelizable = "YES">
<BuildableReference <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
@@ -52,7 +53,8 @@
</BuildableReference> </BuildableReference>
</TestableReference> </TestableReference>
<TestableReference <TestableReference
skipped = "NO"> skipped = "NO"
parallelizable = "NO">
<BuildableReference <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
BlueprintIdentifier = "C471E7AC28F9B4940021E251" BlueprintIdentifier = "C471E7AC28F9B4940021E251"

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<Scheme <Scheme
LastUpgradeVersion = "1640" LastUpgradeVersion = "2600"
version = "1.3"> version = "1.3">
<BuildAction <BuildAction
parallelizeBuildables = "YES" parallelizeBuildables = "YES"

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<Scheme <Scheme
LastUpgradeVersion = "1640" LastUpgradeVersion = "2600"
version = "1.7"> version = "1.7">
<BuildAction <BuildAction
parallelizeBuildables = "YES" parallelizeBuildables = "YES"
@@ -26,13 +26,8 @@
buildConfiguration = "Debug" buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"> shouldUseLaunchSchemeArgsEnv = "YES"
<TestPlans> shouldAutocreateTestPlan = "YES">
<TestPlanReference
reference = "container:PHP Monitor.xcodeproj/PHP Monitor.xctestplan"
default = "YES">
</TestPlanReference>
</TestPlans>
<Testables> <Testables>
<TestableReference <TestableReference
skipped = "NO"> skipped = "NO">

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<Scheme <Scheme
LastUpgradeVersion = "1640" LastUpgradeVersion = "2600"
version = "1.3"> version = "1.3">
<BuildAction <BuildAction
parallelizeBuildables = "YES" parallelizeBuildables = "YES"

View File

@@ -399,13 +399,15 @@ PHP Monitor is a universal app and supports both architectures, so [find out her
<details> <details>
<summary><strong>Why is the app doing network requests?</strong></summary> <summary><strong>Why is the app doing network requests?</strong></summary>
The app will automatically check for updates, which is the most likely culprit. This happens for various reasons.
This happens at launch (unless disabled), and the app directly checks the Caskfile hosted on GitHub. This data is not, and will not be used for analytics (and, as far as I can tell, cannot). PHP Monitor will connect to the `api.phpmon.app` domain to check for updates. To provide a good update experience, some information about which version of PHP Monitor and macOS you are using is transmitted to determine which updates are available for your system configuration.
I also can't prevent `brew` from doing things via the network when PHP Monitor uses the binary. The app includes an Internet Access Policy file, so if you're using something like [Little Snitch](https://www.obdev.at/products/littlesnitch/index.html) there should be a description why these connections occur.
The app includes an Internet Access Policy file, so if you're using something like Little Snitch there should be a description why these calls occur. Certain connections may not be documented if Homebrew functionality is being invoked via the GUI. I also can't prevent `brew` from doing things via the network when PHP Monitor invokes `brew`, obviously.
For example: Homebrew automatically sends analytics to an `influxdata.com` endpoint (more info [here](https://docs.brew.sh/Analytics)). You can disable this by running `brew analytics off`.
</details> </details>

View File

@@ -6,9 +6,7 @@ Generally speaking, only the latest version of **PHP Monitor** is supported, exc
| Version | Apple Silicon | Supported | Supported macOS | Minimum Deployment | Detected PHP Versions | Recommended Valet Version | | Version | Apple Silicon | Supported | Supported macOS | Minimum Deployment | Detected PHP Versions | Recommended Valet Version |
| ------- | ------------- | ------------------ | ----- | ----- | ----- | ---- | ------- | ------------- | ------------------ | ----- | ----- | ----- | ----
| 25 | ✅ Universal binary | ✅ Yes | Ventura (13.5+)<br/>Sonoma (14.0+)<br/>Sequoia (15.0+)<br/>Tahoe (26.0+)* | macOS 13.5+ | PHP 5.6—PHP 8.2 (w/ Valet 2.x)<br/>PHP 7.0—PHP 8.4 (w/ Valet 3.x)<br/>PHP 7.1-PHP 8.5 (w/ Valet 4.x)| 3.0 or higher recommended<br/> 2.16.2 minimum | | 25 | ✅ Universal binary | ✅ Yes | Ventura (13.5+)<br/>Sonoma (14.0+)<br/>Sequoia (15.0+)<br/>Tahoe (26.0+) | macOS 13.5+ | PHP 5.6—PHP 8.2 (w/ Valet 2.x)<br/>PHP 7.0—PHP 8.4 (w/ Valet 3.x)<br/>PHP 7.1-PHP 8.5 (w/ Valet 4.x)| 3.0 or higher recommended<br/> 2.16.2 minimum |
(*) Denotes preliminary supported based on the app being built with the latest version of the SDK prior to the release of the latest release of macOS. Please check out the pinned issue for more information.
## Legacy versions ## Legacy versions

View File

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 171 175" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g>
<g id="Updater-Badge" serif:id="Updater Badge" transform="matrix(1,0,0,1,-319.58,-314.735)">
<g>
<g transform="matrix(1,0,0,1,20.6989,12.9824)">
<ellipse cx="383.215" cy="389.494" rx="69.228" ry="71.667" style="fill:rgb(189,237,171);"/>
</g>
<g transform="matrix(0.302874,0,0,0.311711,327.157,322.241)">
<path d="M256,504C393,504 504,393 504,256C504,119 393,8 256,8C119,8 8,119 8,256C8,393 119,504 256,504ZM256,56C366.5,56 456,145.5 456,256C456,366.5 366.5,456 256,456C145.5,456 56,366.5 56,256C56,145.5 145.5,56 256,56ZM276,384L236,384C229.4,384 224,378.6 224,372L224,256L157,256C146.3,256 141,243.1 148.5,235.5L247.5,136.5C252.2,131.8 259.8,131.8 264.5,136.5L363.5,235.5C371.1,243.1 365.7,256 355,256L288,256L288,372C288,378.6 282.6,384 276,384Z" style="fill:url(#_Linear1);fill-rule:nonzero;"/>
</g>
</g>
<g>
<g transform="matrix(1,0,0,1,20.6989,12.9824)">
<ellipse cx="383.215" cy="389.494" rx="69.228" ry="71.667" style="fill:rgb(189,237,171);"/>
</g>
<g transform="matrix(0.302874,0,0,0.311711,327.157,322.241)">
<path d="M256,504C393,504 504,393 504,256C504,119 393,8 256,8C119,8 8,119 8,256C8,393 119,504 256,504ZM256,56C366.5,56 456,145.5 456,256C456,366.5 366.5,456 256,456C145.5,456 56,366.5 56,256C56,145.5 145.5,56 256,56ZM276,384L236,384C229.4,384 224,378.6 224,372L224,256L157,256C146.3,256 141,243.1 148.5,235.5L247.5,136.5C252.2,131.8 259.8,131.8 264.5,136.5L363.5,235.5C371.1,243.1 365.7,256 355,256L288,256L288,372C288,378.6 282.6,384 276,384Z" style="fill:url(#_Linear2);fill-rule:nonzero;"/>
</g>
</g>
</g>
</g>
<defs>
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(2.82822e-14,448.789,-461.884,2.74804e-14,256.903,32.5209)"><stop offset="0" style="stop-color:rgb(0,179,48);stop-opacity:1"/><stop offset="0.5" style="stop-color:rgb(0,156,42);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(0,133,37);stop-opacity:1"/></linearGradient>
<linearGradient id="_Linear2" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(2.82822e-14,448.789,-461.884,2.74804e-14,256.903,32.5209)"><stop offset="0" style="stop-color:rgb(0,179,48);stop-opacity:1"/><stop offset="0.5" style="stop-color:rgb(0,156,42);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(0,133,37);stop-opacity:1"/></linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@@ -0,0 +1,84 @@
{
"fill" : {
"linear-gradient" : [
"srgb:0.27800,0.58000,0.98800,1.00000",
"srgb:0.27800,0.58000,0.98800,1.00000"
]
},
"groups" : [
{
"blur-material" : null,
"layers" : [
{
"blend-mode" : "normal",
"fill" : "none",
"glass" : true,
"image-name" : "upgrade.svg",
"name" : "upgrade",
"position" : {
"scale" : 2.3,
"translation-in-points" : [
251.390625,
248.5546875
]
}
}
],
"lighting" : "individual",
"position" : {
"scale" : 1,
"translation-in-points" : [
0.609375,
9.453125
]
},
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"specular" : true,
"translucency" : {
"enabled" : false,
"value" : 0.5
}
},
{
"blend-mode" : "screen",
"blur-material" : null,
"layers" : [
{
"blend-mode" : "normal",
"fill" : {
"solid" : "srgb:1.00000,0.99038,0.96423,1.00000"
},
"glass" : true,
"image-name" : "phpmon.svg",
"name" : "phpmon",
"position" : {
"scale" : 1.85,
"translation-in-points" : [
3.890625,
2.3828125
]
}
}
],
"lighting" : "individual",
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"specular" : true,
"translucency" : {
"enabled" : true,
"value" : 0.5
}
}
],
"supported-platforms" : {
"circles" : [
"watchOS"
],
"squares" : "shared"
}
}

View File

@@ -1,68 +0,0 @@
{
"images" : [
{
"filename" : "icon_16x16.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "16x16"
},
{
"filename" : "icon_16x16@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "16x16"
},
{
"filename" : "icon_32x32.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "32x32"
},
{
"filename" : "icon_32x32@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "32x32"
},
{
"filename" : "icon_128x128.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "128x128"
},
{
"filename" : "icon_128x128@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "128x128"
},
{
"filename" : "icon_256x256.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "256x256"
},
{
"filename" : "icon_256x256@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "256x256"
},
{
"filename" : "icon_512x512.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "512x512"
},
{
"filename" : "icon_512x512@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "512x512"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 811 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 KiB

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 500 500" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(1.25033,-0.175723,0.175723,1.25033,14.4412,107.226)">
<path d="M133.3,83.75L120.4,83.75L120.4,54.437C120.4,52.134 118.465,50.25 116.1,50.25L107.5,50.25C105.135,50.25 103.2,52.134 103.2,54.438L103.2,96.312C103.2,98.616 105.135,100.5 107.5,100.5L133.3,100.5C135.665,100.5 137.6,98.616 137.6,96.312L137.6,87.938C137.6,85.634 135.665,83.75 133.3,83.75ZM335.4,184.25L326.8,184.25L326.8,127.666C326.8,121.019 324.059,114.633 319.221,109.922L265.525,57.63C260.688,52.92 254.13,50.25 247.304,50.25L223.6,50.25L223.6,25.125C223.6,11.254 212.044,0 197.8,0L25.8,0C11.556,0 0,11.254 0,25.125L0,192.625C0,206.496 11.556,217.75 25.8,217.75L34.4,217.75C34.4,245.492 57.513,268 86,268C114.487,268 137.6,245.492 137.6,217.75L206.4,217.75C206.4,245.492 229.512,268 258,268C286.488,268 309.6,245.492 309.6,217.75L335.4,217.75C340.13,217.75 344,213.981 344,209.375L344,192.625C344,188.019 340.13,184.25 335.4,184.25ZM86,242.875C71.756,242.875 60.2,231.621 60.2,217.75C60.2,203.879 71.756,192.625 86,192.625C100.244,192.625 111.8,203.879 111.8,217.75C111.8,231.621 100.244,242.875 86,242.875ZM111.8,150.75C78.529,150.75 51.6,124.526 51.6,92.125C51.6,59.725 78.529,33.5 111.8,33.5C145.071,33.5 172,59.724 172,92.125C172,124.525 145.071,150.75 111.8,150.75ZM258,242.875C243.756,242.875 232.2,231.621 232.2,217.75C232.2,203.879 243.756,192.625 258,192.625C272.244,192.625 283.8,203.879 283.8,217.75C283.8,231.621 272.244,242.875 258,242.875ZM301,134L223.6,134L223.6,75.375L247.304,75.375L301,127.666L301,134Z" style="fill:url(#_Linear1);fill-rule:nonzero;"/>
</g>
<defs>
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(1.96882e-14,321.533,-321.533,1.96882e-14,172.079,-41.4918)"><stop offset="0" style="stop-color:rgb(81,194,251);stop-opacity:1"/><stop offset="0" style="stop-color:rgb(81,194,251);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(28,145,254);stop-opacity:1"/></linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 343 138" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g id="EAP-Pill" serif:id="EAP Pill" transform="matrix(0.989959,-5.55112e-17,5.55112e-17,1,-226.512,-352.307)">
<g transform="matrix(1.49702,2.46519e-32,-4.1157e-17,1,-175.953,-20.6096)">
<path d="M485.35,426.302L485.35,456.577C485.35,472.795 476.465,485.962 465.522,485.962L306.401,485.962C295.458,485.962 286.573,472.795 286.573,456.577L286.573,426.302C286.573,410.084 295.458,396.917 306.401,396.917L465.522,396.917C476.465,396.917 485.35,410.084 485.35,426.302Z" style="fill:url(#_Linear1);"/>
</g>
<g transform="matrix(1.49702,2.46519e-32,-4.1157e-17,1,-175.953,-20.6096)">
<path d="M485.35,426.302L485.35,456.577C485.35,472.795 476.465,485.962 465.522,485.962L306.401,485.962C295.458,485.962 286.573,472.795 286.573,456.577L286.573,426.302C286.573,410.084 295.458,396.917 306.401,396.917L465.522,396.917C476.465,396.917 485.35,410.084 485.35,426.302Z" style="fill:url(#_Linear2);"/>
</g>
<g transform="matrix(0.811641,-6.24693e-17,-3.25779e-17,1,143.651,1.2882)">
<g>
<path d="M184.151,434.829L184.151,422.301L202.79,422.301L202.79,412.221L184.151,412.221L184.151,404.445L207.27,404.445L207.27,394.365L169.366,394.365L169.366,444.909L207.987,444.909L207.987,434.829L184.151,434.829Z" style="fill:white;fill-rule:nonzero;"/>
<path d="M184.151,434.829L184.151,422.301L202.79,422.301L202.79,412.221L184.151,412.221L184.151,404.445L207.27,404.445L207.27,394.365L169.366,394.365L169.366,444.909L207.987,444.909L207.987,434.829L184.151,434.829Z" style="fill:white;fill-rule:nonzero;"/>
<path d="M230.389,444.909L233.974,438.789L257.541,438.789L261.125,444.909L277.524,444.909L248.222,393.933L243.383,393.933L213.812,444.909L230.389,444.909ZM245.713,417.117L252.254,428.997L239.171,428.997L245.713,417.117Z" style="fill:white;fill-rule:nonzero;"/>
<path d="M230.389,444.909L233.974,438.789L257.541,438.789L261.125,444.909L277.524,444.909L248.222,393.933L243.383,393.933L213.812,444.909L230.389,444.909ZM245.713,417.117L252.254,428.997L239.171,428.997L245.713,417.117Z" style="fill:white;fill-rule:nonzero;"/>
<path d="M327.346,412.581C327.346,399.333 310.679,390.333 283.348,394.869L283.348,444.909L298.134,444.909L298.134,432.093C314.263,432.021 327.346,424.461 327.346,412.581ZM298.134,404.733C308.17,403.725 312.113,408.621 312.113,412.581C312.113,417.189 308.349,421.941 298.134,421.581L298.134,404.733Z" style="fill:white;fill-rule:nonzero;"/>
</g>
<path d="M327.346,412.581C327.346,399.333 310.679,390.333 283.348,394.869L283.348,444.909L298.134,444.909L298.134,432.093C314.263,432.021 327.346,424.461 327.346,412.581ZM298.134,404.733C308.17,403.725 312.113,408.621 312.113,412.581C312.113,417.189 308.349,421.941 298.134,421.581L298.134,404.733Z" style="fill:white;fill-rule:nonzero;"/>
</g>
</g>
<defs>
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(4.99972e-15,-81.6517,36.5774,2.23972e-15,387.828,484.137)"><stop offset="0" style="stop-color:rgb(240,21,150);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(246,86,109);stop-opacity:1"/></linearGradient>
<linearGradient id="_Linear2" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(4.99972e-15,-81.6517,36.5774,2.23972e-15,387.828,484.137)"><stop offset="0" style="stop-color:rgb(240,21,150);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(246,86,109);stop-opacity:1"/></linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 500 500" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(1.25033,-0.175723,0.175723,1.25033,14.4412,107.226)">
<path d="M133.3,83.75L120.4,83.75L120.4,54.437C120.4,52.134 118.465,50.25 116.1,50.25L107.5,50.25C105.135,50.25 103.2,52.134 103.2,54.438L103.2,96.312C103.2,98.616 105.135,100.5 107.5,100.5L133.3,100.5C135.665,100.5 137.6,98.616 137.6,96.312L137.6,87.938C137.6,85.634 135.665,83.75 133.3,83.75ZM335.4,184.25L326.8,184.25L326.8,127.666C326.8,121.019 324.059,114.633 319.221,109.922L265.525,57.63C260.688,52.92 254.13,50.25 247.304,50.25L223.6,50.25L223.6,25.125C223.6,11.254 212.044,0 197.8,0L25.8,0C11.556,0 0,11.254 0,25.125L0,192.625C0,206.496 11.556,217.75 25.8,217.75L34.4,217.75C34.4,245.492 57.513,268 86,268C114.487,268 137.6,245.492 137.6,217.75L206.4,217.75C206.4,245.492 229.512,268 258,268C286.488,268 309.6,245.492 309.6,217.75L335.4,217.75C340.13,217.75 344,213.981 344,209.375L344,192.625C344,188.019 340.13,184.25 335.4,184.25ZM86,242.875C71.756,242.875 60.2,231.621 60.2,217.75C60.2,203.879 71.756,192.625 86,192.625C100.244,192.625 111.8,203.879 111.8,217.75C111.8,231.621 100.244,242.875 86,242.875ZM111.8,150.75C78.529,150.75 51.6,124.526 51.6,92.125C51.6,59.725 78.529,33.5 111.8,33.5C145.071,33.5 172,59.724 172,92.125C172,124.525 145.071,150.75 111.8,150.75ZM258,242.875C243.756,242.875 232.2,231.621 232.2,217.75C232.2,203.879 243.756,192.625 258,192.625C272.244,192.625 283.8,203.879 283.8,217.75C283.8,231.621 272.244,242.875 258,242.875ZM301,134L223.6,134L223.6,75.375L247.304,75.375L301,127.666L301,134Z" style="fill:url(#_Linear1);fill-rule:nonzero;"/>
</g>
<defs>
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(1.96882e-14,321.533,-321.533,1.96882e-14,172.079,-41.4918)"><stop offset="0" style="stop-color:rgb(81,194,251);stop-opacity:1"/><stop offset="0" style="stop-color:rgb(81,194,251);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(28,145,254);stop-opacity:1"/></linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -0,0 +1,80 @@
{
"fill" : {
"linear-gradient" : [
"srgb:0.27800,0.58000,0.98800,1.00000",
"srgb:0.27800,0.58000,0.98800,1.00000"
]
},
"groups" : [
{
"layers" : [
{
"glass" : true,
"image-name" : "eap.svg",
"name" : "eap",
"position" : {
"scale" : 2.5,
"translation-in-points" : [
345.96875,
354.0390625
]
}
}
],
"position" : {
"scale" : 1,
"translation-in-points" : [
1.3203125,
6.5390625
]
},
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"specular" : true,
"translucency" : {
"enabled" : false,
"value" : 0.5
}
},
{
"blend-mode" : "screen",
"blur-material" : null,
"layers" : [
{
"blend-mode" : "normal",
"fill" : {
"solid" : "srgb:1.00000,0.99038,0.96423,1.00000"
},
"glass" : true,
"image-name" : "phpmon.svg",
"name" : "phpmon",
"position" : {
"scale" : 1.85,
"translation-in-points" : [
21.8515625,
-6.34375
]
}
}
],
"lighting" : "individual",
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"specular" : true,
"translucency" : {
"enabled" : true,
"value" : 0.5
}
}
],
"supported-platforms" : {
"circles" : [
"watchOS"
],
"squares" : "shared"
}
}

View File

@@ -1,68 +0,0 @@
{
"images" : [
{
"filename" : "icon_16x16.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "16x16"
},
{
"filename" : "icon_16x16@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "16x16"
},
{
"filename" : "icon_32x32.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "32x32"
},
{
"filename" : "icon_32x32@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "32x32"
},
{
"filename" : "icon_128x128.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "128x128"
},
{
"filename" : "icon_128x128@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "128x128"
},
{
"filename" : "icon_256x256.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "256x256"
},
{
"filename" : "icon_256x256@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "256x256"
},
{
"filename" : "icon_512x512.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "512x512"
},
{
"filename" : "icon_512x512@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "512x512"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 783 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 457 KiB

View File

@@ -1,68 +0,0 @@
{
"images" : [
{
"filename" : "icon_16x16.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "16x16"
},
{
"filename" : "icon_16x16@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "16x16"
},
{
"filename" : "icon_32x32.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "32x32"
},
{
"filename" : "icon_32x32@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "32x32"
},
{
"filename" : "icon_128x128.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "128x128"
},
{
"filename" : "icon_128x128@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "128x128"
},
{
"filename" : "icon_256x256.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "256x256"
},
{
"filename" : "icon_256x256@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "256x256"
},
{
"filename" : "icon_512x512.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "512x512"
},
{
"filename" : "icon_512x512@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "512x512"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 820 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 485 KiB

View File

@@ -26,7 +26,30 @@ struct Constants {
be displayed. This is based on an appropriate launch time on a be displayed. This is based on an appropriate launch time on a
basic M1 Apple chip, with some margin for slower Intel chips. basic M1 Apple chip, with some margin for slower Intel chips.
*/ */
static let SlowBootThresholdInterval: TimeInterval = 30.0 static let SlowBootThresholdInterval: TimeInterval = .seconds(30)
/**
The interval between automatic background update checks.
*/
static let AutomaticUpdateCheckInterval: TimeInterval = .hours(24)
/**
The minimum interval that must pass before allowing another
automatic update check. This prevents excessive checking
on frequent app restarts (due to crashes or bad config).
*/
static let MinimumUpdateCheckInterval: TimeInterval = .minutes(60)
/**
Retry intervals for failed automatic update checks.
Uses exponential backoff before falling back to normal schedule.
*/
static let UpdateCheckRetryIntervals: [TimeInterval] = [
.minutes(5),
.minutes(15),
.hours(1),
.hours(3)
]
/** /**
PHP Monitor supplies a hardcoded list of PHP packages in its own PHP Monitor supplies a hardcoded list of PHP packages in its own
@@ -39,8 +62,12 @@ struct Constants {
If users launch an older version of the app, then a warning If users launch an older version of the app, then a warning
will be displayed to let them know that certain operations will be displayed to let them know that certain operations
will not work correctly and that they need to update their app. will not work correctly and that they need to update their app.
The cutoff date is always a few days after GA of the latest
release, as it often takes a while for Homebrew to make the
new release available and not everyone uses a separate tap.
*/ */
static let PhpFormulaeCutoffDate = "2025-11-30" // YYYY-MM-DD static let PhpFormulaeCutoffDate = "2025-11-20" // YYYY-MM-DD
/** /**
* The PHP versions that are considered pre-release versions. * The PHP versions that are considered pre-release versions.
@@ -49,7 +76,8 @@ struct Constants {
*/ */
static var ExperimentalPhpVersions: Set<String> { static var ExperimentalPhpVersions: Set<String> {
let releaseDates = [ let releaseDates = [
"8.5": Date.fromString(Self.PhpFormulaeCutoffDate), // "8.6": Date.fromString("2026-11-30"), // TBD
"8.5": Date.fromString(PhpFormulaeCutoffDate),
"8.4": Date.fromString("2024-11-22") "8.4": Date.fromString("2024-11-22")
] ]
@@ -85,6 +113,7 @@ struct Constants {
"7.0", "7.1", "7.2", "7.3", "7.4", "7.0", "7.1", "7.2", "7.3", "7.4",
"8.0", "8.1", "8.2", "8.3", "8.4", "8.0", "8.1", "8.2", "8.3", "8.4",
"8.5" // DEV "8.5" // DEV
// "8.6" // TBD
] ]
/** /**
@@ -107,57 +136,28 @@ struct Constants {
"7.1", "7.2", "7.3", "7.4", "7.1", "7.2", "7.3", "7.4",
"8.0", "8.1", "8.2", "8.3", "8.4", "8.0", "8.1", "8.2", "8.3", "8.4",
"8.5" // DEV "8.5" // DEV
// "8.6" // TBD
] ]
] ]
struct Urls { struct Urls {
// phpmon.app URLs (these are aliased to redirect correctly) // phpmon.app URLs (these are aliased to redirect correctly)
static let DonationPage = url("https://phpmon.app/sponsor")
static let DonationPage = URL( static let FrequentlyAskedQuestions = url("https://phpmon.app/faq")
string: "https://phpmon.app/sponsor"
)!
static let FrequentlyAskedQuestions = URL( static let WikiPhpUnavailable = url("https://phpmon.app/php-unavailable")
string: "https://phpmon.app/faq"
)!
static let WikiPhpUnavailable = URL( static let WikiPhpUpgrade = url("https://phpmon.app/php-upgrade")
string: "https://phpmon.app/php-unavailable"
)!
static let WikiPhpUpgrade = URL( static let DonationPayment = url("https://phpmon.app/sponsor/now")
string: "https://phpmon.app/php-upgrade"
)!
static let DonationPayment = URL( static let EarlyAccessChangelog = url("https://phpmon.app/early-access/release-notes")
string: "https://phpmon.app/sponsor/now"
)! // API endpoints (via api.phpmon.app)
static let UpdateCheckEndpoint = url("https://api.phpmon.app/api/v1/update-check")
// GitHub URLs (do not alias these) // GitHub URLs (do not alias these)
static let GitHubReleases = url("https://github.com/nicoverbruggen/phpmon/releases")
static let GitHubReleases = URL(
string: "https://github.com/nicoverbruggen/phpmon/releases"
)!
static let StableBuildCaskFile = URL(
string: "https://raw.githubusercontent.com/nicoverbruggen/homebrew-cask/master/Casks/phpmon.rb"
)!
static let DevBuildCaskFile = URL(
string: "https://raw.githubusercontent.com/nicoverbruggen/homebrew-cask/master/Casks/phpmon-dev.rb"
)!
// EAP URLs
static let EarlyAccessCaskFile = URL(
string: "https://phpmon.app/builds/early-access/sponsors/phpmon-eap.rb"
)!
static let EarlyAccessChangelog = URL(
string: "https://phpmon.app/early-access/release-notes"
)!
} }
} }

View File

@@ -8,6 +8,8 @@
// MARK: Common Shell Commands // MARK: Common Shell Commands
import Foundation
/** /**
Runs a `brew` command. Can run as superuser. Runs a `brew` command. Can run as superuser.
*/ */
@@ -49,3 +51,10 @@ func grepContains(file: String, query: String) async -> Bool {
func delay(seconds: Double) async { func delay(seconds: Double) async {
try! await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) try! await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
} }
/**
A simpler way to initialize a fixed, valid URL.
*/
func url(_ string: String) -> URL {
return URL(string: string)!
}

View File

@@ -111,8 +111,7 @@ public class Paths {
} }
public static var caskroomPath: String { public static var caskroomPath: String {
return "\(shared.baseDir.rawValue)/Caskroom/" return "\(shared.baseDir.rawValue)/Caskroom/phpmon"
+ (App.identifier.contains(".dev") ? "phpmon-dev" : "phpmon")
} }
public static var shell: String { public static var shell: String {

View File

@@ -25,8 +25,7 @@ struct Localization {
return Bundle.main return Bundle.main
} }
let foundBundle = Bundle(identifier: "com.nicoverbruggen.phpmon.dev") let foundBundle = Bundle(identifier: "com.nicoverbruggen.phpmon")
?? Bundle(identifier: "com.nicoverbruggen.phpmon")
?? Bundle(identifier: "com.nicoverbruggen.phpmon.ui-tests") ?? Bundle(identifier: "com.nicoverbruggen.phpmon.ui-tests")
if foundBundle == nil { if foundBundle == nil {

View File

@@ -9,7 +9,14 @@
import Foundation import Foundation
extension TimeInterval { extension TimeInterval {
public static func minutes(_ amount: Int) -> TimeInterval { static func seconds(_ value: Double) -> TimeInterval { value }
return Double(amount * 60) static func minutes(_ value: Double) -> TimeInterval { value * 60 }
static func hours(_ value: Double) -> TimeInterval { value * 3600 }
static func days(_ value: Double) -> TimeInterval { value * 86400 }
}
extension Date {
func adding(_ interval: TimeInterval) -> Date {
return self.addingTimeInterval(interval)
} }
} }

View File

@@ -0,0 +1,25 @@
//
// ActiveApi.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 29/09/2025.
// Copyright © 2025 Nico Verbruggen. All rights reserved.
//
import Foundation
var Api: ApiProtocol {
return ActiveApi.shared
}
class ActiveApi {
static var shared: ApiProtocol = RealApi()
public static func useTestable(_ responses: [URL: FakeApiResponse]) {
Self.shared = TestableApi(responses: responses)
}
public static func useReal() {
Self.shared = RealApi()
}
}

View File

@@ -0,0 +1,11 @@
//
// ApiProtocol.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 30/09/2025.
// Copyright © 2025 Nico Verbruggen. All rights reserved.
//
protocol ApiProtocol {
}

View File

@@ -0,0 +1,9 @@
//
// RealApi.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 30/09/2025.
// Copyright © 2025 Nico Verbruggen. All rights reserved.
//
class RealApi: ApiProtocol {}

View File

@@ -111,7 +111,13 @@ class PhpEnvironments {
It's possible for the alias to be newer than the actual installed version of PHP. It's possible for the alias to be newer than the actual installed version of PHP.
*/ */
static var homebrewBrewPhpAlias: String { static var homebrewBrewPhpAlias: String {
if PhpEnvironments.shared.homebrewPackage == nil { return "8.2" } if PhpEnvironments.shared.homebrewPackage == nil {
// For UI testing and as a fallback, determine this version by using (fake) php-config
let version = Command.execute(path: "/opt/homebrew/bin/php-config",
arguments: ["--version"],
trimNewlines: true)
return try! VersionNumber.parse(version).short
}
return PhpEnvironments.shared.homebrewPackage.version return PhpEnvironments.shared.homebrewPackage.version
} }

View File

@@ -0,0 +1,41 @@
//
// TestableApi.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 29/09/2025.
// Copyright © 2025 Nico Verbruggen. All rights reserved.
//
import Foundation
class TestableApi: ApiProtocol {
private var fakeResponses: [URL: FakeApiResponse] = [:]
init(responses: [URL: FakeApiResponse]) {
self.fakeResponses = responses
}
public func hasResponse(for url: URL) -> Bool {
return fakeResponses.keys.contains(url)
}
public func getResponse(for url: URL) -> FakeApiResponse {
return fakeResponses[url]!
}
}
struct FakeApiResponse {
let statusCode: Int
let headers: [String: String]
let data: Data?
init(statusCode: Int, headers: [String: String], text: String) {
self.statusCode = statusCode
self.headers = headers
self.data = text.data(using: .utf8)
}
var text: String {
return String(data: self.data!, encoding: .utf8) ?? ""
}
}

View File

@@ -140,6 +140,12 @@ public struct TestableConfiguration: Codable {
Log.info("Applying fake Valet domain interactor...") Log.info("Applying fake Valet domain interactor...")
ValetInteractor.useFake() ValetInteractor.useFake()
} }
// Clear volatile app state for tests
UserDefaults.standard.removeObject(forKey: PersistentAppState.lastAutomaticUpdateCheck.rawValue)
// Set variable to tell app we're testin'
App.hasLoadedTestableConfiguration = true
} }
// MARK: Persist and load // MARK: Persist and load

View File

@@ -23,11 +23,11 @@ class TestableFileSystem: FileSystemProtocol {
let adjustedKey = key.contains("~") ? key.replacingOccurrences(of: "~", with: self.homeDirectory) : key let adjustedKey = key.contains("~") ? key.replacingOccurrences(of: "~", with: self.homeDirectory) : key
self.files[adjustedKey] = value self.files[adjustedKey] = value
} }
}
// Ensure that intermediate directories are created // Ensure that intermediate directories are created
for file in self.files { for file in self.files {
self.createIntermediateDirectories(file.key) self.createIntermediateDirectories(file.key)
}
} }
} }

View File

@@ -14,6 +14,9 @@ class App {
/** The static app instance. Accessible at any time. */ /** The static app instance. Accessible at any time. */
static let shared = App() static let shared = App()
/** Use to determine whether a loaded testable configuration is being used. */
static var hasLoadedTestableConfiguration: Bool = false
/** Retrieve the version number from the main info dictionary, Info.plist. */ /** Retrieve the version number from the main info dictionary, Info.plist. */
static var version: String { static var version: String {
let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as! String let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as! String
@@ -53,6 +56,11 @@ class App {
return machine return machine
} }
static var macVersion: String {
let version = ProcessInfo.processInfo.operatingSystemVersion
return "\(version.majorVersion).\(version.minorVersion).\(version.patchVersion)"
}
/** /**
A fake architecture. A fake architecture.
When set, the real machine's system architecture is not used, When set, the real machine's system architecture is not used,

View File

@@ -10,32 +10,28 @@ import Foundation
import Cocoa import Cocoa
import NVAlert import NVAlert
enum UpdateCheckResult {
case success
case networkError
case parseError
}
class AppUpdater { class AppUpdater {
var caskFile: CaskFile! var caskFile: CaskFile!
var latestVersionOnline: AppVersion! var latestVersionOnline: AppVersion!
var interactive: Bool = false var interactive: Bool = false
public func checkForUpdates(userInitiated: Bool) async { public func checkForUpdates(userInitiated: Bool) async -> UpdateCheckResult {
self.interactive = userInitiated self.interactive = userInitiated
if !interactive && !Preferences.isEnabled(.automaticBackgroundUpdateCheck) {
Log.info("Skipping automatic update check due to user preference.")
return
}
Log.info("The app will search for updates...") Log.info("The app will search for updates...")
var caskUrl = Constants.Urls.StableBuildCaskFile let caskUrl = Constants.Urls.UpdateCheckEndpoint
if App.identifier.contains(".phpmon.eap") {
caskUrl = Constants.Urls.EarlyAccessCaskFile
} else if App.identifier.contains(".phpmon.dev") {
caskUrl = Constants.Urls.DevBuildCaskFile
}
guard let caskFile = await CaskFile.from(url: caskUrl) else { guard let caskFile = await CaskFile.from(url: caskUrl) else {
Log.err("The contents of the CaskFile at '\(caskUrl.absoluteString)' could not be retrieved.") Log.err("The contents of the CaskFile at '\(caskUrl.absoluteString)' could not be retrieved.")
return presentCouldNotRetrieveUpdateIfInteractive() presentCouldNotRetrieveUpdateIfInteractive()
return .networkError
} }
self.caskFile = caskFile self.caskFile = caskFile
@@ -44,7 +40,8 @@ class AppUpdater {
guard let onlineVersion = AppVersion.from(caskFile.version) else { guard let onlineVersion = AppVersion.from(caskFile.version) else {
Log.err("The version string from the CaskFile could not be read.") Log.err("The version string from the CaskFile could not be read.")
return presentCouldNotRetrieveUpdateIfInteractive() presentCouldNotRetrieveUpdateIfInteractive()
return .parseError
} }
latestVersionOnline = onlineVersion latestVersionOnline = onlineVersion
@@ -55,6 +52,8 @@ class AppUpdater {
} else if interactive { } else if interactive {
presentNoNewerVersionAvailableAlert() presentNoNewerVersionAvailableAlert()
} }
return .success
} }
private func presentCouldNotRetrieveUpdateIfInteractive() { private func presentCouldNotRetrieveUpdateIfInteractive() {
@@ -68,9 +67,7 @@ class AppUpdater {
// MARK: - Alerts // MARK: - Alerts
public func presentNewerVersionAvailableAlert() { public func presentNewerVersionAvailableAlert() {
let command = App.identifier.contains(".dev") let command = "brew upgrade phpmon"
? "brew upgrade phpmon-dev"
: "brew upgrade phpmon"
Task { @MainActor in Task { @MainActor in
NVAlert().withInformation( NVAlert().withInformation(
@@ -188,7 +185,7 @@ class AppUpdater {
// Cleanup the upgrade.success file // Cleanup the upgrade.success file
if FileSystem.fileExists("~/.config/phpmon/updater/upgrade.success") { if FileSystem.fileExists("~/.config/phpmon/updater/upgrade.success") {
Task { @MainActor in Task { @MainActor in
if App.identifier.contains(".phpmon.eap") || App.identifier.contains(".phpmon.dev") { if App.identifier.contains(".phpmon.eap") {
LocalNotification.send( LocalNotification.send(
title: "notification.phpmon_updated.title".localized, title: "notification.phpmon_updated.title".localized,
subtitle: "notification.phpmon_updated_dev.desc".localized(App.shortVersion, App.bundleVersion), subtitle: "notification.phpmon_updated_dev.desc".localized(App.shortVersion, App.bundleVersion),

View File

@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.Cocoa.Storyboard.XIB" version="3.0" toolsVersion="23727" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES"> <document type="com.apple.InterfaceBuilder3.Cocoa.Storyboard.XIB" version="3.0" toolsVersion="24127" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES">
<dependencies> <dependencies>
<deployment identifier="macosx"/> <deployment identifier="macosx"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="23727"/> <plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="24127"/>
<capability name="Image references" minToolsVersion="12.0"/> <capability name="Image references" minToolsVersion="12.0"/>
<capability name="Named colors" minToolsVersion="9.0"/> <capability name="Named colors" minToolsVersion="9.0"/>
<capability name="Search Toolbar Item" minToolsVersion="12.0" minSystemVersion="11.0"/> <capability name="Search Toolbar Item" minToolsVersion="12.0" minSystemVersion="11.0"/>
@@ -436,7 +436,7 @@
</toolbarItem> </toolbarItem>
<searchToolbarItem implicitItemIdentifier="7C834FBE-7118-4082-A09F-7CBECEC1356A" label="Search" paletteLabel="Search" visibilityPriority="1001" id="G2g-jS-RVc"> <searchToolbarItem implicitItemIdentifier="7C834FBE-7118-4082-A09F-7CBECEC1356A" label="Search" paletteLabel="Search" visibilityPriority="1001" id="G2g-jS-RVc">
<nil key="toolTip"/> <nil key="toolTip"/>
<searchField key="view" focusRingType="none" verticalHuggingPriority="750" textCompletion="NO" id="0gE-Yr-MLy"> <searchField key="view" verticalHuggingPriority="750" textCompletion="NO" id="0gE-Yr-MLy">
<rect key="frame" x="0.0" y="0.0" width="100" height="21"/> <rect key="frame" x="0.0" y="0.0" width="100" height="21"/>
<autoresizingMask key="autoresizingMask"/> <autoresizingMask key="autoresizingMask"/>
<searchFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" selectable="YES" editable="YES" sendsActionOnEndEditing="YES" borderStyle="bezel" usesSingleLineMode="YES" bezelStyle="round" sendsSearchStringImmediately="YES" id="vp9-vH-goQ"> <searchFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" selectable="YES" editable="YES" sendsActionOnEndEditing="YES" borderStyle="bezel" usesSingleLineMode="YES" bezelStyle="round" sendsSearchStringImmediately="YES" id="vp9-vH-goQ">
@@ -582,7 +582,7 @@ Gw
<constraint firstAttribute="bottom" secondItem="8zu-cF-KCX" secondAttribute="bottom" constant="20" symbolic="YES" id="wIl-uw-y3p"/> <constraint firstAttribute="bottom" secondItem="8zu-cF-KCX" secondAttribute="bottom" constant="20" symbolic="YES" id="wIl-uw-y3p"/>
</constraints> </constraints>
</visualEffectView> </visualEffectView>
<textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="U1c-qS-cIm"> <textField horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="U1c-qS-cIm">
<rect key="frame" x="98" y="153" width="384" height="19"/> <rect key="frame" x="98" y="153" width="384" height="19"/>
<constraints> <constraints>
<constraint firstAttribute="width" relation="lessThanOrEqual" constant="380" id="WgB-hj-d4P"/> <constraint firstAttribute="width" relation="lessThanOrEqual" constant="380" id="WgB-hj-d4P"/>
@@ -593,7 +593,7 @@ Gw
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/> <color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
</textFieldCell> </textFieldCell>
</textField> </textField>
<textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="yI6-qf-htf"> <textField horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="yI6-qf-htf">
<rect key="frame" x="98" y="127" width="384" height="16"/> <rect key="frame" x="98" y="127" width="384" height="16"/>
<textFieldCell key="cell" selectable="YES" title="This is a slightly more expanded explanation." id="rY3-Nd-Iit"> <textFieldCell key="cell" selectable="YES" title="This is a slightly more expanded explanation." id="rY3-Nd-Iit">
<font key="font" metaFont="system"/> <font key="font" metaFont="system"/>
@@ -617,7 +617,7 @@ Gw
</constraints> </constraints>
<imageCell key="cell" refusesFirstResponder="YES" alignment="left" imageScaling="proportionallyDown" id="7eT-Hw-EL9"/> <imageCell key="cell" refusesFirstResponder="YES" alignment="left" imageScaling="proportionallyDown" id="7eT-Hw-EL9"/>
</imageView> </imageView>
<textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="hml-dl-Cah"> <textField horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="hml-dl-Cah">
<rect key="frame" x="98" y="70" width="384" height="42"/> <rect key="frame" x="98" y="70" width="384" height="42"/>
<textFieldCell key="cell" selectable="YES" id="7iW-Lc-DqO"> <textFieldCell key="cell" selectable="YES" id="7iW-Lc-DqO">
<font key="font" metaFont="smallSystem"/> <font key="font" metaFont="smallSystem"/>
@@ -706,7 +706,7 @@ Gw
<action selector="pressedCancel:" target="glS-wF-sEU" id="q0L-YZ-F3J"/> <action selector="pressedCancel:" target="glS-wF-sEU" id="q0L-YZ-F3J"/>
</connections> </connections>
</button> </button>
<textField focusRingType="none" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="ZX9-s1-23i"> <textField verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="ZX9-s1-23i">
<rect key="frame" x="20" y="150" width="440" height="21"/> <rect key="frame" x="20" y="150" width="440" height="21"/>
<textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" selectable="YES" editable="YES" sendsActionOnEndEditing="YES" borderStyle="bezel" placeholderString="Enter a domain name here." drawsBackground="YES" id="NFa-1D-Bi4"> <textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" selectable="YES" editable="YES" sendsActionOnEndEditing="YES" borderStyle="bezel" placeholderString="Enter a domain name here." drawsBackground="YES" id="NFa-1D-Bi4">
<font key="font" metaFont="system"/> <font key="font" metaFont="system"/>
@@ -717,7 +717,7 @@ Gw
<outlet property="delegate" destination="glS-wF-sEU" id="Dyf-0M-Gwj"/> <outlet property="delegate" destination="glS-wF-sEU" id="Dyf-0M-Gwj"/>
</connections> </connections>
</textField> </textField>
<textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="VzR-5a-cmT"> <textField horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="VzR-5a-cmT">
<rect key="frame" x="18" y="128" width="444" height="14"/> <rect key="frame" x="18" y="128" width="444" height="14"/>
<textFieldCell key="cell" title="[i18n] Preview text here" id="bJr-s6-tdP"> <textFieldCell key="cell" title="[i18n] Preview text here" id="bJr-s6-tdP">
<font key="font" metaFont="smallSystem"/> <font key="font" metaFont="smallSystem"/>
@@ -735,7 +735,7 @@ Gw
<action selector="pressedSecure:" target="glS-wF-sEU" id="OIj-Pz-5Ea"/> <action selector="pressedSecure:" target="glS-wF-sEU" id="OIj-Pz-5Ea"/>
</connections> </connections>
</button> </button>
<textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="mmQ-7e-dlb"> <textField horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="mmQ-7e-dlb">
<rect key="frame" x="18" y="60" width="444" height="28"/> <rect key="frame" x="18" y="60" width="444" height="28"/>
<textFieldCell key="cell" title="[i18n] Securing a domain requires administrative privileges. You may be prompted for your password or Touch ID." id="4gd-KM-5Fu"> <textFieldCell key="cell" title="[i18n] Securing a domain requires administrative privileges. You may be prompted for your password or Touch ID." id="4gd-KM-5Fu">
<font key="font" metaFont="smallSystem"/> <font key="font" metaFont="smallSystem"/>
@@ -750,7 +750,7 @@ Gw
<url key="url" string="file:///Users/"/> <url key="url" string="file:///Users/"/>
</pathCell> </pathCell>
</pathControl> </pathControl>
<textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="P0B-Ht-R8n"> <textField horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="P0B-Ht-R8n">
<rect key="frame" x="18" y="209" width="128" height="16"/> <rect key="frame" x="18" y="209" width="128" height="16"/>
<textFieldCell key="cell" lineBreakMode="clipping" title="[i18n] Link a Folder" id="S4j-ZC-ddT"> <textFieldCell key="cell" lineBreakMode="clipping" title="[i18n] Link a Folder" id="S4j-ZC-ddT">
<font key="font" textStyle="headline" name=".SFNS-Bold"/> <font key="font" textStyle="headline" name=".SFNS-Bold"/>
@@ -758,7 +758,7 @@ Gw
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/> <color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
</textFieldCell> </textFieldCell>
</textField> </textField>
<textField hidden="YES" focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="900-Z2-tID"> <textField hidden="YES" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="900-Z2-tID">
<rect key="frame" x="140" y="23" width="180" height="14"/> <rect key="frame" x="140" y="23" width="180" height="14"/>
<textFieldCell key="cell" lineBreakMode="clipping" title="That domain name already exists." id="jOt-n6-TQf"> <textFieldCell key="cell" lineBreakMode="clipping" title="That domain name already exists." id="jOt-n6-TQf">
<font key="font" metaFont="smallSystem"/> <font key="font" metaFont="smallSystem"/>
@@ -893,7 +893,7 @@ Gw
<rect key="frame" x="69" y="0.0" width="200" height="54"/> <rect key="frame" x="69" y="0.0" width="200" height="54"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews> <subviews>
<textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" horizontalCompressionResistancePriority="250" translatesAutoresizingMaskIntoConstraints="NO" id="XJL-Uw-frD"> <textField horizontalHuggingPriority="251" verticalHuggingPriority="750" horizontalCompressionResistancePriority="250" translatesAutoresizingMaskIntoConstraints="NO" id="XJL-Uw-frD">
<rect key="frame" x="3" y="26" width="145" height="16"/> <rect key="frame" x="3" y="26" width="145" height="16"/>
<textFieldCell key="cell" lineBreakMode="truncatingTail" sendsActionOnEndEditing="YES" title="my-domain-name.test" id="SGC-Gm-Mxd"> <textFieldCell key="cell" lineBreakMode="truncatingTail" sendsActionOnEndEditing="YES" title="my-domain-name.test" id="SGC-Gm-Mxd">
<font key="font" metaFont="systemSemibold" size="13"/> <font key="font" metaFont="systemSemibold" size="13"/>
@@ -901,7 +901,7 @@ Gw
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/> <color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
</textFieldCell> </textFieldCell>
</textField> </textField>
<textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" horizontalCompressionResistancePriority="250" translatesAutoresizingMaskIntoConstraints="NO" id="CXK-Q9-CpO"> <textField horizontalHuggingPriority="251" verticalHuggingPriority="750" horizontalCompressionResistancePriority="250" translatesAutoresizingMaskIntoConstraints="NO" id="CXK-Q9-CpO">
<rect key="frame" x="3" y="12" width="75" height="14"/> <rect key="frame" x="3" y="12" width="75" height="14"/>
<textFieldCell key="cell" lineBreakMode="truncatingTail" sendsActionOnEndEditing="YES" title="~/path/to/site" id="fe7-Ha-mR9"> <textFieldCell key="cell" lineBreakMode="truncatingTail" sendsActionOnEndEditing="YES" title="~/path/to/site" id="fe7-Ha-mR9">
<font key="font" metaFont="smallSystem"/> <font key="font" metaFont="smallSystem"/>
@@ -927,7 +927,7 @@ Gw
<rect key="frame" x="69" y="54" width="200" height="54"/> <rect key="frame" x="69" y="54" width="200" height="54"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews> <subviews>
<textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" horizontalCompressionResistancePriority="250" translatesAutoresizingMaskIntoConstraints="NO" id="aot-FJ-HIk"> <textField horizontalHuggingPriority="251" verticalHuggingPriority="750" horizontalCompressionResistancePriority="250" translatesAutoresizingMaskIntoConstraints="NO" id="aot-FJ-HIk">
<rect key="frame" x="33" y="26" width="145" height="16"/> <rect key="frame" x="33" y="26" width="145" height="16"/>
<textFieldCell key="cell" lineBreakMode="truncatingTail" sendsActionOnEndEditing="YES" title="my-domain-name.test" id="LHu-UF-QlC"> <textFieldCell key="cell" lineBreakMode="truncatingTail" sendsActionOnEndEditing="YES" title="my-domain-name.test" id="LHu-UF-QlC">
<font key="font" metaFont="systemSemibold" size="13"/> <font key="font" metaFont="systemSemibold" size="13"/>
@@ -935,7 +935,7 @@ Gw
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/> <color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
</textFieldCell> </textFieldCell>
</textField> </textField>
<textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" horizontalCompressionResistancePriority="250" translatesAutoresizingMaskIntoConstraints="NO" id="GNH-l8-oki"> <textField horizontalHuggingPriority="251" verticalHuggingPriority="750" horizontalCompressionResistancePriority="250" translatesAutoresizingMaskIntoConstraints="NO" id="GNH-l8-oki">
<rect key="frame" x="33" y="12" width="75" height="14"/> <rect key="frame" x="33" y="12" width="75" height="14"/>
<textFieldCell key="cell" lineBreakMode="truncatingTail" sendsActionOnEndEditing="YES" title="~/path/to/site" id="LNw-Ju-0Ot"> <textFieldCell key="cell" lineBreakMode="truncatingTail" sendsActionOnEndEditing="YES" title="~/path/to/site" id="LNw-Ju-0Ot">
<font key="font" metaFont="smallSystem"/> <font key="font" metaFont="smallSystem"/>
@@ -1078,7 +1078,7 @@ Gw
<rect key="frame" x="470" y="0.0" width="97" height="54"/> <rect key="frame" x="470" y="0.0" width="97" height="54"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews> <subviews>
<textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="Ljl-8B-key"> <textField horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="Ljl-8B-key">
<rect key="frame" x="6" y="26" width="93" height="14"/> <rect key="frame" x="6" y="26" width="93" height="14"/>
<textFieldCell key="cell" alignment="left" title="Laravel" id="0lu-L6-oKr"> <textFieldCell key="cell" alignment="left" title="Laravel" id="0lu-L6-oKr">
<font key="font" metaFont="smallSystem"/> <font key="font" metaFont="smallSystem"/>
@@ -1086,7 +1086,7 @@ Gw
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/> <color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
</textFieldCell> </textFieldCell>
</textField> </textField>
<textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="aPK-Xc-J4B"> <textField horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="aPK-Xc-J4B">
<rect key="frame" x="6" y="15" width="93" height="11"/> <rect key="frame" x="6" y="15" width="93" height="11"/>
<textFieldCell key="cell" alignment="left" title="PHP 8.0" id="puf-Jh-ham"> <textFieldCell key="cell" alignment="left" title="PHP 8.0" id="puf-Jh-ham">
<font key="font" metaFont="miniSystem"/> <font key="font" metaFont="miniSystem"/>
@@ -1153,7 +1153,7 @@ Gw
<constraint firstAttribute="height" constant="30" id="lfW-dB-Eu3"/> <constraint firstAttribute="height" constant="30" id="lfW-dB-Eu3"/>
</constraints> </constraints>
</progressIndicator> </progressIndicator>
<textField wantsLayer="YES" focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="xoy-5Y-WDT"> <textField wantsLayer="YES" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="xoy-5Y-WDT">
<rect key="frame" x="15" y="14" width="71" height="13"/> <rect key="frame" x="15" y="14" width="71" height="13"/>
<textFieldCell key="cell" lineBreakMode="clipping" title="PLEASE WAIT" id="tMX-Ky-caT"> <textFieldCell key="cell" lineBreakMode="clipping" title="PLEASE WAIT" id="tMX-Ky-caT">
<font key="font" metaFont="system" size="10"/> <font key="font" metaFont="system" size="10"/>
@@ -1209,7 +1209,7 @@ Gw
<rect key="frame" x="0.0" y="0.0" width="540" height="286"/> <rect key="frame" x="0.0" y="0.0" width="540" height="286"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews> <subviews>
<textField focusRingType="none" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="QCK-Z9-w7g"> <textField verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="QCK-Z9-w7g">
<rect key="frame" x="20" y="196" width="500" height="21"/> <rect key="frame" x="20" y="196" width="500" height="21"/>
<textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" selectable="YES" editable="YES" sendsActionOnEndEditing="YES" borderStyle="bezel" title="http://127.0.0.1:80" placeholderString="http://127.0.0.1:80" drawsBackground="YES" id="muS-8M-KSy"> <textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" selectable="YES" editable="YES" sendsActionOnEndEditing="YES" borderStyle="bezel" title="http://127.0.0.1:80" placeholderString="http://127.0.0.1:80" drawsBackground="YES" id="muS-8M-KSy">
<font key="font" metaFont="system"/> <font key="font" metaFont="system"/>
@@ -1220,7 +1220,7 @@ Gw
<outlet property="delegate" destination="dwh-CF-6iv" id="lNE-OI-G93"/> <outlet property="delegate" destination="dwh-CF-6iv" id="lNE-OI-G93"/>
</connections> </connections>
</textField> </textField>
<textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="Uib-vA-HRc"> <textField horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="Uib-vA-HRc">
<rect key="frame" x="18" y="221" width="325" height="14"/> <rect key="frame" x="18" y="221" width="325" height="14"/>
<textFieldCell key="cell" lineBreakMode="clipping" title="[i18n] Proxy subject (usually: protocol, IP address and port)" id="G1Z-3f-BhL"> <textFieldCell key="cell" lineBreakMode="clipping" title="[i18n] Proxy subject (usually: protocol, IP address and port)" id="G1Z-3f-BhL">
<font key="font" metaFont="systemMedium" size="11"/> <font key="font" metaFont="systemMedium" size="11"/>
@@ -1228,7 +1228,7 @@ Gw
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/> <color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
</textFieldCell> </textFieldCell>
</textField> </textField>
<textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="mlA-Zt-Hu8"> <textField horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="mlA-Zt-Hu8">
<rect key="frame" x="18" y="172" width="112" height="14"/> <rect key="frame" x="18" y="172" width="112" height="14"/>
<textFieldCell key="cell" lineBreakMode="clipping" title="[i18n] Domain name" id="dQs-oZ-80e"> <textFieldCell key="cell" lineBreakMode="clipping" title="[i18n] Domain name" id="dQs-oZ-80e">
<font key="font" metaFont="systemMedium" size="11"/> <font key="font" metaFont="systemMedium" size="11"/>
@@ -1236,7 +1236,7 @@ Gw
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/> <color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
</textFieldCell> </textFieldCell>
</textField> </textField>
<textField focusRingType="none" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="SNw-oQ-bnb"> <textField verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="SNw-oQ-bnb">
<rect key="frame" x="20" y="147" width="500" height="21"/> <rect key="frame" x="20" y="147" width="500" height="21"/>
<textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" selectable="YES" editable="YES" sendsActionOnEndEditing="YES" borderStyle="bezel" placeholderString="Enter a domain name here." drawsBackground="YES" id="gTQ-Y2-Y9w"> <textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" selectable="YES" editable="YES" sendsActionOnEndEditing="YES" borderStyle="bezel" placeholderString="Enter a domain name here." drawsBackground="YES" id="gTQ-Y2-Y9w">
<font key="font" metaFont="system"/> <font key="font" metaFont="system"/>
@@ -1292,7 +1292,7 @@ Gw
<action selector="pressedCancel:" target="dwh-CF-6iv" id="J2T-Zj-A0j"/> <action selector="pressedCancel:" target="dwh-CF-6iv" id="J2T-Zj-A0j"/>
</connections> </connections>
</button> </button>
<textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="JSZ-x8-Pqi"> <textField horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="JSZ-x8-Pqi">
<rect key="frame" x="18" y="128" width="504" height="14"/> <rect key="frame" x="18" y="128" width="504" height="14"/>
<constraints> <constraints>
<constraint firstAttribute="width" relation="lessThanOrEqual" constant="500" id="sF1-RG-URI"/> <constraint firstAttribute="width" relation="lessThanOrEqual" constant="500" id="sF1-RG-URI"/>
@@ -1313,7 +1313,7 @@ Gw
<action selector="pressedSecure:" target="dwh-CF-6iv" id="b74-8T-AzO"/> <action selector="pressedSecure:" target="dwh-CF-6iv" id="b74-8T-AzO"/>
</connections> </connections>
</button> </button>
<textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="5x7-ll-2f7"> <textField horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="5x7-ll-2f7">
<rect key="frame" x="18" y="60" width="504" height="28"/> <rect key="frame" x="18" y="60" width="504" height="28"/>
<textFieldCell key="cell" title="[i18n] Securing a domain requires administrative privileges. You may be prompted for your password or Touch ID." id="IMB-O5-ZOy"> <textFieldCell key="cell" title="[i18n] Securing a domain requires administrative privileges. You may be prompted for your password or Touch ID." id="IMB-O5-ZOy">
<font key="font" metaFont="smallSystem"/> <font key="font" metaFont="smallSystem"/>
@@ -1321,7 +1321,7 @@ Gw
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/> <color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
</textFieldCell> </textFieldCell>
</textField> </textField>
<textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="DAh-br-Dfx"> <textField horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="DAh-br-Dfx">
<rect key="frame" x="18" y="250" width="123" height="16"/> <rect key="frame" x="18" y="250" width="123" height="16"/>
<textFieldCell key="cell" lineBreakMode="clipping" title="[i18n] Add a Proxy" id="AZ1-04-kUl"> <textFieldCell key="cell" lineBreakMode="clipping" title="[i18n] Add a Proxy" id="AZ1-04-kUl">
<font key="font" textStyle="headline" name=".SFNS-Bold"/> <font key="font" textStyle="headline" name=".SFNS-Bold"/>
@@ -1329,7 +1329,7 @@ Gw
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/> <color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
</textFieldCell> </textFieldCell>
</textField> </textField>
<textField hidden="YES" focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="w0k-CK-0u4"> <textField hidden="YES" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="w0k-CK-0u4">
<rect key="frame" x="191" y="23" width="180" height="14"/> <rect key="frame" x="191" y="23" width="180" height="14"/>
<textFieldCell key="cell" lineBreakMode="clipping" title="That domain name already exists." id="4sH-94-UJl"> <textFieldCell key="cell" lineBreakMode="clipping" title="That domain name already exists." id="4sH-94-UJl">
<font key="font" metaFont="smallSystem"/> <font key="font" metaFont="smallSystem"/>
@@ -1443,7 +1443,7 @@ Gw
<subviews> <subviews>
<button wantsLayer="YES" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="L5n-Gw-J27"> <button wantsLayer="YES" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="L5n-Gw-J27">
<rect key="frame" x="-7" y="-7" width="172" height="32"/> <rect key="frame" x="-7" y="-7" width="172" height="32"/>
<buttonCell key="cell" type="push" title="[i18n] Create a Link" bezelStyle="rounded" image="IconLinked" imagePosition="left" alignment="center" borderStyle="border" imageScaling="proportionallyUpOrDown" inset="2" id="8UP-Sw-TP6"> <buttonCell key="cell" type="push" title="[i18n] Create a Link" bezelStyle="rounded" image="IconLinked" imagePosition="leading" alignment="center" borderStyle="border" imageScaling="proportionallyDown" inset="2" id="8UP-Sw-TP6">
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/> <behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
<font key="font" metaFont="system"/> <font key="font" metaFont="system"/>
<string key="keyEquivalent">l</string> <string key="keyEquivalent">l</string>
@@ -1454,7 +1454,7 @@ Gw
</button> </button>
<button wantsLayer="YES" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="01Z-IV-hv1"> <button wantsLayer="YES" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="01Z-IV-hv1">
<rect key="frame" x="159" y="-7" width="181" height="32"/> <rect key="frame" x="159" y="-7" width="181" height="32"/>
<buttonCell key="cell" type="push" title="[i18n] Create a Proxy" bezelStyle="rounded" image="IconProxy" imagePosition="left" alignment="center" borderStyle="border" imageScaling="proportionallyUpOrDown" inset="2" id="bJ4-q8-1Ej"> <buttonCell key="cell" type="push" title="[i18n] Create a Proxy" bezelStyle="rounded" image="IconProxy" imagePosition="leading" alignment="center" borderStyle="border" imageScaling="proportionallyDown" inset="2" id="bJ4-q8-1Ej">
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/> <behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
<font key="font" metaFont="system"/> <font key="font" metaFont="system"/>
<string key="keyEquivalent">p</string> <string key="keyEquivalent">p</string>
@@ -1473,7 +1473,7 @@ Gw
<real value="3.4028234663852886e+38"/> <real value="3.4028234663852886e+38"/>
</customSpacing> </customSpacing>
</stackView> </stackView>
<textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="fJK-Ke-IK3"> <textField horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="fJK-Ke-IK3">
<rect key="frame" x="18" y="138" width="504" height="19"/> <rect key="frame" x="18" y="138" width="504" height="19"/>
<textFieldCell key="cell" selectable="YES" alignment="left" title="[i18n] What kind of domain would you like to set up?" id="agk-Nj-FLd"> <textFieldCell key="cell" selectable="YES" alignment="left" title="[i18n] What kind of domain would you like to set up?" id="agk-Nj-FLd">
<font key="font" metaFont="systemBold" size="15"/> <font key="font" metaFont="systemBold" size="15"/>
@@ -1481,7 +1481,7 @@ Gw
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/> <color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
</textFieldCell> </textFieldCell>
</textField> </textField>
<textField wantsLayer="YES" focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="urj-Xq-TrJ"> <textField wantsLayer="YES" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="urj-Xq-TrJ">
<rect key="frame" x="18" y="60" width="504" height="70"/> <rect key="frame" x="18" y="60" width="504" height="70"/>
<constraints> <constraints>
<constraint firstAttribute="width" relation="lessThanOrEqual" constant="500" id="tbl-AV-4qB"/> <constraint firstAttribute="width" relation="lessThanOrEqual" constant="500" id="tbl-AV-4qB"/>

View File

@@ -0,0 +1,126 @@
//
// UpdateScheduler.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 26/09/2025.
// Copyright © 2025 Nico Verbruggen. All rights reserved.
//
import Foundation
actor UpdateScheduler {
static let shared = UpdateScheduler()
private var currentTimer: Timer?
private init() {}
/**
Start the automatic update checking process.
This should be called once during app startup.
*/
func startAutomaticUpdateChecking() async {
await performUpdateCheck()
}
/**
Perform an automatic update check and schedule the next one.
*/
private func performUpdateCheck() async {
guard Preferences.isEnabled(.automaticBackgroundUpdateCheck) else {
Log.info("Automatic update checks disabled. Skipping check but maintaining schedule.")
scheduleTimer()
return
}
guard isNotThrottled() else {
Log.info("Last check was too recent. Skipping check but maintaining schedule.")
scheduleTimer()
return
}
let result = await AppUpdater().checkForUpdates(userInitiated: false)
switch result {
case .success:
// Reset failure count and record successful check
UserDefaults.standard.removeObject(forKey: PersistentAppState.updateCheckFailureCount.rawValue)
UserDefaults.standard.set(Date(), forKey: PersistentAppState.lastAutomaticUpdateCheck.rawValue)
scheduleTimer()
case .networkError, .parseError:
// Handle failures with exponential backoff
handleFailure(result: result)
}
}
/**
Handle update check failures with exponential backoff retry logic.
*/
private func handleFailure(result: UpdateCheckResult) {
let currentFailureCount = UserDefaults.standard.integer(
forKey: PersistentAppState.updateCheckFailureCount.rawValue
)
let newFailureCount = currentFailureCount + 1
UserDefaults.standard.set(newFailureCount, forKey: PersistentAppState.updateCheckFailureCount.rawValue)
let retryInterval: TimeInterval
if newFailureCount <= Constants.UpdateCheckRetryIntervals.count {
// Use exponential backoff
retryInterval = Constants.UpdateCheckRetryIntervals[newFailureCount - 1]
Log.info("Update check failed (\(result)). Retry \(newFailureCount) in \(retryInterval)s.")
} else {
// Exceeded max retries, fall back to normal schedule and reset counter
retryInterval = Constants.AutomaticUpdateCheckInterval
UserDefaults.standard.removeObject(forKey: PersistentAppState.updateCheckFailureCount.rawValue)
Log.info("Update check failed (\(result)). Max retries exceeded. Normal schedule in \(retryInterval)s.")
}
scheduleTimer(after: retryInterval)
}
/**
Determine whether another automatic update check should occur based on the last check timestamp.
Returns true if a check should happen, false otherwise.
*/
private func isNotThrottled() -> Bool {
let minimumTimeAgo = Date().addingTimeInterval(-Constants.MinimumUpdateCheckInterval)
let lastCheckTime = UserDefaults.standard.object(
forKey: PersistentAppState.lastAutomaticUpdateCheck.rawValue
) as? Date
// If no previous check or last check was > minimum time frame, should check now
return lastCheckTime == nil || lastCheckTime! < minimumTimeAgo
}
/**
Schedule a timer to perform an update check after the specified interval.
*/
private func scheduleTimer(after interval: TimeInterval = Constants.AutomaticUpdateCheckInterval) {
// Invalidate any existing timer
currentTimer?.invalidate()
// Ensure timer is scheduled on main run loop since actors run on background threads
Task { @MainActor in
let timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { _ in
Task {
Log.info("Performing scheduled update check after \(interval)s.")
await self.performUpdateCheck()
}
}
// Store timer reference back in actor
await self.setCurrentTimer(timer)
}
Log.info("Next update check scheduled in \(interval)s.")
}
/**
Set the current timer reference. Used to store timer from main thread back to actor.
*/
private func setCurrentTimer(_ timer: Timer) {
currentTimer = timer
}
}

View File

@@ -0,0 +1,27 @@
//
// LoggableEvent.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 12/09/2025.
// Copyright © 2025 Nico Verbruggen. All rights reserved.
//
// TODO: Add anonymous analytics system
// Batch events and dispatch them every hour.
// Reset the counts when send successfully.
// That's the plan. Currently not implemented!
// Also, there should be an opt-out.
enum LoggableEvent: String {
case menuOpened = "menu_opened"
case phpVersionSwitched = "php_version_switched"
case openedDomainManagement = "opened_domain_management"
case openedPhpInstallations = "opened_php_installations"
case openedPhpExtensions = "opened_php_extensions"
case openedSettings = "opened_settings"
// TODO: Add more tracked things.
}

View File

@@ -24,13 +24,28 @@ struct CaskFile {
return self.properties["version"]! return self.properties["version"]!
} }
private static func loadFromApi(_ url: URL) async -> String {
if App.hasLoadedTestableConfiguration || url.absoluteString.contains("https://raw.githubusercontent.com") {
return await Shell.pipe("curl -s --max-time 10 '\(url.absoluteString)'").out
} else {
return await Shell.pipe("""
curl -s --max-time 10 \
-H "User-Agent: phpmon-curl/1.0" \
-H "X-phpmon-version: \(App.shortVersion) (\(App.bundleVersion))" \
-H "X-phpmon-os-version: \(App.macVersion)" \
-H "X-phpmon-bundle-id: \(App.identifier)" \
'\(url.absoluteString)'
""").out
}
}
public static func from(url: URL) async -> CaskFile? { public static func from(url: URL) async -> CaskFile? {
var string: String? var string: String?
if url.scheme == "file" { if url.scheme == "file" {
string = try? String(contentsOf: url) string = try? String(contentsOf: url)
} else { } else {
string = await Shell.pipe("curl -s --max-time 10 '\(url.absoluteString)'").out string = await CaskFile.loadFromApi(url)
} }
guard let string else { guard let string else {

View File

@@ -142,7 +142,7 @@ extension MainMenu {
} }
} else { } else {
// Check for updates // Check for updates
await AppUpdater().checkForUpdates(userInitiated: false) await UpdateScheduler.shared.startAutomaticUpdateChecking()
// Check if the linked version has changed between launches of phpmon // Check if the linked version has changed between launches of phpmon
await PhpGuard().compareToLastGlobalVersion() await PhpGuard().compareToLastGlobalVersion()

View File

@@ -10,9 +10,6 @@
These are the keys used for every preference in the app. These are the keys used for every preference in the app.
*/ */
enum PreferenceName: String, Codable { enum PreferenceName: String, Codable {
// FIRST-TIME LAUNCH
case wasLaunchedBefore = "launched_before"
// GENERAL // GENERAL
case autoServiceRestartAfterExtensionToggle = "auto_restart_after_extension_toggle" case autoServiceRestartAfterExtensionToggle = "auto_restart_after_extension_toggle"
case autoComposerGlobalUpdateAfterSwitch = "auto_composer_global_update_after_switch" case autoComposerGlobalUpdateAfterSwitch = "auto_composer_global_update_after_switch"
@@ -104,6 +101,18 @@ enum RetiredPreferenceName: String {
case shouldDisplayPhpHintInIcon = "add_php_to_icon" case shouldDisplayPhpHintInIcon = "add_php_to_icon"
} }
/**
Persistent internal application state keys for UserDefaults.
These track internal app state and behavior that persists across launches,
but are not user preferences or statistics.
*/
enum PersistentAppState: String {
case wasLaunchedBefore = "launched_before"
case lastAutomaticUpdateCheck = "last_automatic_update_check"
case userFavorites = "user_favorites"
case updateCheckFailureCount = "update_check_failure_count"
}
/** /**
These are internal stats. They NEVER get shared. These are internal stats. They NEVER get shared.
*/ */

View File

@@ -83,6 +83,10 @@ class Preferences {
PreferenceName.displayPresets.rawValue: true, PreferenceName.displayPresets.rawValue: true,
PreferenceName.displayMisc.rawValue: true, PreferenceName.displayMisc.rawValue: true,
/// Persistent App State
PersistentAppState.lastAutomaticUpdateCheck.rawValue: 0,
PersistentAppState.updateCheckFailureCount.rawValue: 0,
/// Stats /// Stats
InternalStats.switchCount.rawValue: 0, InternalStats.switchCount.rawValue: 0,
InternalStats.launchCount.rawValue: 0, InternalStats.launchCount.rawValue: 0,
@@ -90,13 +94,13 @@ class Preferences {
InternalStats.lastGlobalPhpVersion.rawValue: "" InternalStats.lastGlobalPhpVersion.rawValue: ""
]) ])
if UserDefaults.standard.bool(forKey: PreferenceName.wasLaunchedBefore.rawValue) { if UserDefaults.standard.bool(forKey: PersistentAppState.wasLaunchedBefore.rawValue) {
handleMigration() handleMigration()
return return
} }
Log.info("Saving first-time preferences!") Log.info("Saving first-time preferences!")
UserDefaults.standard.setValue(true, forKey: PreferenceName.wasLaunchedBefore.rawValue) UserDefaults.standard.setValue(true, forKey: PersistentAppState.wasLaunchedBefore.rawValue)
UserDefaults.standard.synchronize() UserDefaults.standard.synchronize()
} }

View File

@@ -50,7 +50,6 @@ class AppearancePreferencesVC: GenericPreferenceVC {
class MenuStructurePreferencesVC: GenericPreferenceVC { class MenuStructurePreferencesVC: GenericPreferenceVC {
// swiftlint:disable line_length
public static func fromStoryboard() -> GenericPreferenceVC { public static func fromStoryboard() -> GenericPreferenceVC {
let vc = NSStoryboard(name: "Main", bundle: nil) let vc = NSStoryboard(name: "Main", bundle: nil)
.instantiateController(withIdentifier: "preferencesTemplateVC") as! GenericPreferenceVC .instantiateController(withIdentifier: "preferencesTemplateVC") as! GenericPreferenceVC
@@ -67,7 +66,6 @@ class MenuStructurePreferencesVC: GenericPreferenceVC {
.addView(when: true, vc.displayFeature("prefs.display_misc", .displayMisc)) .addView(when: true, vc.displayFeature("prefs.display_misc", .displayMisc))
.addView(when: true, vc.displayFeature("prefs.display_driver", .displayDriver)) .addView(when: true, vc.displayFeature("prefs.display_driver", .displayDriver))
} }
// swiftlint:enable line_length
} }
class NotificationPreferencesVC: GenericPreferenceVC { class NotificationPreferencesVC: GenericPreferenceVC {

View File

@@ -110,7 +110,7 @@ class Stats {
return Log.info("A fake shell is in use, skipping sponsor alert.") return Log.info("A fake shell is in use, skipping sponsor alert.")
} }
if App.identifier.contains(".dev") || App.identifier.contains(".eap") { if App.identifier.contains(".eap") {
return Log.info("Sponsor messages never apply to beta builds.") return Log.info("Sponsor messages never apply to beta builds.")
} }

View File

@@ -172,7 +172,6 @@ struct Preset: Codable, Equatable {
// MARK: - Menu Items // MARK: - Menu Items
// swiftlint:disable void_function_in_ternary
public func getMenuItemText() -> String { public func getMenuItemText() -> String {
var info = extensions.count == 1 var info = extensions.count == 1
? "preset.extension".localized(extensions.count) ? "preset.extension".localized(extensions.count)
@@ -197,7 +196,6 @@ struct Preset: Codable, Equatable {
+ info + "</i>" + info + "</i>"
+ "</span>" + "</span>"
} }
// swiftlint:enable void_function_in_ternary
// MARK: - Reverting // MARK: - Reverting

View File

@@ -10,6 +10,22 @@
<string>https://github.com/nicoverbruggen/phpmon</string> <string>https://github.com/nicoverbruggen/phpmon</string>
<key>Connections</key> <key>Connections</key>
<array> <array>
<dict>
<key>IsIncoming</key>
<false/>
<key>Host</key>
<string>phpmon.app, api.phpmon.app</string>
<key>NetworkProtocol</key>
<string>TCP</string>
<key>Port</key>
<string>80, 443</string>
<key>Relevance</key>
<string>Essential</string>
<key>Purpose</key>
<string>PHP Monitor contacts the official phpmon domain to check for updates, and visit aliased links that point to documentation.</string>
<key>DenyConsequences</key>
<string>If you deny these connections, PHP Monitor will not be able to determine if a newer version is available, and certain documentation links in the app may not function as desired.</string>
</dict>
<dict> <dict>
<key>IsIncoming</key> <key>IsIncoming</key>
<false/> <false/>

View File

@@ -14,7 +14,7 @@ class Favorites {
var items: [String] var items: [String]
init() { init() {
if let items = UserDefaults.standard.array(forKey: "user_favorites") as? [String] { if let items = UserDefaults.standard.array(forKey: PersistentAppState.userFavorites.rawValue) as? [String] {
self.items = items self.items = items
} else { } else {
self.items = [] self.items = []
@@ -32,7 +32,7 @@ class Favorites {
items.append(domain) items.append(domain)
} }
UserDefaults.standard.setValue(items, forKey: "user_favorites") UserDefaults.standard.setValue(items, forKey: PersistentAppState.userFavorites.rawValue)
UserDefaults.standard.synchronize() UserDefaults.standard.synchronize()
} }
} }

View File

@@ -439,7 +439,7 @@ This has no effect on other terminals, only for the particular terminal session
"prefs.open_protocol_desc" = "When checked, this will allow the interaction with third party utilities to work (e.g. Alfred, Raycast). If you disable this, PHP Monitor will still receive the commands, but will not act upon them."; "prefs.open_protocol_desc" = "When checked, this will allow the interaction with third party utilities to work (e.g. Alfred, Raycast). If you disable this, PHP Monitor will still receive the commands, but will not act upon them.";
"prefs.automatic_update_check_title" = "Automatically check for updates"; "prefs.automatic_update_check_title" = "Automatically check for updates";
"prefs.automatic_update_check_desc" = "When checked, PHP Monitor will automatically check if there is a newer version available, and notify you if that is the case."; "prefs.automatic_update_check_desc" = "When checked, PHP Monitor will automatically check daily if there is a newer version available, and notify you if that is the case.";
"prefs.php_doctor_suggestions_title" = "Always show suggestions"; "prefs.php_doctor_suggestions_title" = "Always show suggestions";
"prefs.php_doctor_suggestions_desc" = "If you uncheck this item, no PHP Doctor suggestions will appear in PHP Monitor's menu. Keep in mind that PHP Doctor will not appear if there are no recommendations."; "prefs.php_doctor_suggestions_desc" = "If you uncheck this item, no PHP Doctor suggestions will appear in PHP Monitor's menu. Keep in mind that PHP Doctor will not appear if there are no recommendations.";

View File

@@ -1,10 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?> <?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"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict/>
<key>com.apple.security.app-sandbox</key>
<false/>
<key>com.apple.security.files.user-selected.read-only</key>
<false/>
</dict>
</plist> </plist>

View File

@@ -1,67 +0,0 @@
{
"configurations" : [
{
"id" : "98F42C11-E6D2-4AD9-A5CA-40EFE44F384A",
"name" : "Configuration 1",
"options" : {
}
}
],
"defaultOptions" : {
"codeCoverage" : false,
"commandLineArgumentEntries" : [
{
"argument" : "--v",
"enabled" : false
}
],
"environmentVariableEntries" : [
{
"enabled" : false,
"key" : "EXTREME_DOCTOR_MODE",
"value" : ""
},
{
"enabled" : false,
"key" : "SLOW_SHELL_MODE",
"value" : ""
},
{
"enabled" : false,
"key" : "PAINT_PHPMON_SWIFTUI_VIEWS",
"value" : ""
}
],
"targetForVariableExpansion" : {
"containerPath" : "container:PHP Monitor.xcodeproj",
"identifier" : "C41C1B3222B0097F00E7CF16",
"name" : "PHP Monitor"
}
},
"testTargets" : [
{
"parallelizable" : true,
"target" : {
"containerPath" : "container:PHP Monitor.xcodeproj",
"identifier" : "C4F7807825D7F84B000DBC97",
"name" : "Unit Tests"
}
},
{
"target" : {
"containerPath" : "container:PHP Monitor.xcodeproj",
"identifier" : "C471E7AC28F9B4940021E251",
"name" : "Feature Tests"
}
},
{
"target" : {
"containerPath" : "container:PHP Monitor.xcodeproj",
"identifier" : "C471E7BB28F9B90F0021E251",
"name" : "UI Tests"
}
}
],
"version" : 1
}

View File

@@ -8,6 +8,7 @@
import Foundation import Foundation
// swiftlint:disable colon
class TestableConfigurations { class TestableConfigurations {
/** A functional, working system setup that is compatible with PHP Monitor. */ /** A functional, working system setup that is compatible with PHP Monitor. */
static var working: TestableConfiguration { static var working: TestableConfiguration {
@@ -52,17 +53,16 @@ class TestableConfigurations {
shellOutput: [ shellOutput: [
"/opt/homebrew/bin/brew --version" "/opt/homebrew/bin/brew --version"
: .instant(""" : .instant("""
Homebrew 4.0.17-93-gb0dc84b Homebrew 4.6.11
Homebrew/homebrew-core (git revision 4113c35d80d; last commit 2023-04-06)
Homebrew/homebrew-cask (git revision bcd8ecb74c; last commit 2023-04-06)
"""), """),
"/opt/homebrew/bin/php -v" "/opt/homebrew/bin/php -v"
: .instant(""" : .instant("""
PHP 8.2.6 (cli) (built: May 11 2023 12:51:38) (NTS) PHP 8.4.5 (cli) (built: Aug 26 2025 13:36:28) (NTS)
Copyright (c) The PHP Group Copyright (c) The PHP Group
Zend Engine v4.2.6, Copyright (c) Zend Technologies Built by Homebrew
with Zend OPcache v8.2.6, Copyright (c), by Zend Technologies Zend Engine v4.4.12, Copyright (c) Zend Technologies
with Xdebug v3.2.0, Copyright (c) 2002-2022, by Derick Rethans with Xdebug v3.4.5, Copyright (c) 2002-2025, by Derick Rethans
with Zend OPcache v8.4.12, Copyright (c), by Zend Technologies
"""), """),
"sysctl -n sysctl.proc_translated" "sysctl -n sysctl.proc_translated"
: .instant("0"), : .instant("0"),
@@ -105,7 +105,7 @@ class TestableConfigurations {
%admin ALL=(root) NOPASSWD:SETENV: VALET %admin ALL=(root) NOPASSWD:SETENV: VALET
"""), """),
"valet --version" "valet --version"
: .instant("Laravel Valet 3.1.11"), : .instant("Laravel Valet 4.9.0"),
"/opt/homebrew/bin/brew tap" "/opt/homebrew/bin/brew tap"
: .instant(""" : .instant("""
homebrew/cask homebrew/cask
@@ -138,12 +138,27 @@ class TestableConfigurations {
: .instant(ShellStrings.shared.brewServicesAsRoot), : .instant(ShellStrings.shared.brewServicesAsRoot),
"/opt/homebrew/bin/brew services info --all --json" "/opt/homebrew/bin/brew services info --all --json"
: .instant(ShellStrings.shared.brewServicesAsUser), : .instant(ShellStrings.shared.brewServicesAsUser),
"curl -s --max-time 10 '\(Constants.Urls.DevBuildCaskFile.absoluteString)'" "curl -s --max-time 10 '\(Constants.Urls.UpdateCheckEndpoint.absoluteString)'"
: .delayed(0.5, """ : .delayed(0.5, """
cask 'phpmon-dev' do cask 'phpmon-dev' do
depends_on formula: 'gnu-sed' depends_on formula: 'gnu-sed'
version '6.0.0_1000' version '25.08.0_1000'
sha256 '1cb147bd1b1fbd52971d90dff577465b644aee7c878f15ede57f46e8f217067a'
url 'https://github.com/nicoverbruggen/phpmon/releases/download/v6.0/phpmon-dev.zip'
name 'PHP Monitor DEV'
homepage 'https://phpmon.app'
app 'PHP Monitor DEV.app', target: "PHP Monitor DEV.app"
end
"""),
"curl -s --max-time 10 'https://raw.githubusercontent.com/nicoverbruggen/homebrew-cask/master/Casks/phpmon.rb''" :
.delayed(0.5, """
cask 'phpmon-dev' do
depends_on formula: 'gnu-sed'
version '25.08.0_1000'
sha256 '1cb147bd1b1fbd52971d90dff577465b644aee7c878f15ede57f46e8f217067a' sha256 '1cb147bd1b1fbd52971d90dff577465b644aee7c878f15ede57f46e8f217067a'
url 'https://github.com/nicoverbruggen/phpmon/releases/download/v6.0/phpmon-dev.zip' url 'https://github.com/nicoverbruggen/phpmon/releases/download/v6.0/phpmon-dev.zip'
@@ -171,17 +186,19 @@ class TestableConfigurations {
: .delayed(0.2, "OK"), : .delayed(0.2, "OK"),
"sudo /opt/homebrew/bin/brew services start dnsmasq" "sudo /opt/homebrew/bin/brew services start dnsmasq"
: .delayed(0.2, "OK"), : .delayed(0.2, "OK"),
"ln -sF ~/.config/valet/valet82.sock ~/.config/valet/valet.sock" "ln -sF ~/.config/valet/valet84.sock ~/.config/valet/valet.sock"
: .instant("OK"), : .instant("OK"),
"/opt/homebrew/bin/brew update >/dev/null && /opt/homebrew/bin/brew outdated --json --formulae": .delayed(2.0, """ "/opt/homebrew/bin/brew update >/dev/null && /opt/homebrew/bin/brew outdated --json --formulae"
: .delayed(2.0,
"""
{ {
"formulae": [ "formulae": [
{ {
"name": "php", "name": "php",
"installed_versions": [ "installed_versions": [
"8.2.6" "8.4.5"
], ],
"current_version": "8.2.11", "current_version": "8.4.11",
"pinned": false, "pinned": false,
"pinned_version": null "pinned_version": null
} }
@@ -193,12 +210,14 @@ class TestableConfigurations {
commandOutput: [ commandOutput: [
"/opt/homebrew/bin/php -r echo ini_get('memory_limit');": "512M", "/opt/homebrew/bin/php -r echo ini_get('memory_limit');": "512M",
"/opt/homebrew/bin/php -r echo ini_get('upload_max_filesize');": "512M", "/opt/homebrew/bin/php -r echo ini_get('upload_max_filesize');": "512M",
"/opt/homebrew/bin/php -r echo ini_get('post_max_size');": "512M", "/opt/homebrew/bin/php -r echo ini_get('post_max_size');": "512M"
], ],
preferenceOverrides: [ preferenceOverrides: [
.automaticBackgroundUpdateCheck: false .automaticBackgroundUpdateCheck: false
], ],
phpVersions: [ phpVersions: [
VersionNumber(major: 8, minor: 4, patch: 5),
VersionNumber(major: 8, minor: 3, patch: 5),
VersionNumber(major: 8, minor: 2, patch: 6), VersionNumber(major: 8, minor: 2, patch: 6),
VersionNumber(major: 8, minor: 1, patch: 0), VersionNumber(major: 8, minor: 1, patch: 0),
VersionNumber(major: 8, minor: 0, patch: 0), VersionNumber(major: 8, minor: 0, patch: 0),
@@ -215,6 +234,7 @@ class TestableConfigurations {
return configuration return configuration
} }
} }
// swiftlint:enable colon
class ShellStrings { class ShellStrings {
static var shared = ShellStrings() static var shared = ShellStrings()

View File

@@ -17,7 +17,7 @@ class FeatureTestCase: XCTestCase {
return fs as! TestableFileSystem return fs as! TestableFileSystem
} }
fatalError("The active filesystem is not a TestableFileSystem. Please use `ActiveFileSystem` to use the fake filesystem.") fatalError("The active filesystem is not a TestableFileSystem. Please use `ActiveFileSystem`.")
} }
public func assertFileSystemHas( public func assertFileSystemHas(
@@ -44,6 +44,4 @@ class FeatureTestCase: XCTestCase {
) { ) {
XCTAssertEqual(contents, fakeFileSystem.files[path]?.content, file: file, line: line) XCTAssertEqual(contents, fakeFileSystem.files[path]?.content, file: file, line: line)
} }
} }

View File

@@ -35,7 +35,7 @@ final class DomainsListTest: UITestCase {
searchField.click() searchField.click()
searchField.typeText("non-existent thing") searchField.typeText("non-existent thing")
Thread.sleep(forTimeInterval: 0.2) Thread.sleep(forTimeInterval: 0.2)
XCTAssertTrue(window.tables.tableRows.count == 0) XCTAssertTrue(window.tables.tableRows.count == 0) // swiftlint:disable:this empty_count
searchField.clearText() searchField.clearText()
searchField.click() searchField.click()

View File

@@ -18,11 +18,12 @@ final class MainMenuTest: UITestCase {
let app = launch(openMenu: true) let app = launch(openMenu: true)
assertAllExist([ assertAllExist([
// "Switch to PHP 8.2 (php)" should be visible since it is aliased to `php` // "Switch to PHP 8.4 (php)" should be visible since it is aliased to `php`
app.menuItems["\("mi_php_switch".localized) 8.2 (php)"], app.menuItems["\("mi_php_switch".localized) 8.4 (php)"],
// "Switch to PHP 8.1" should be the non-disabled option // "Switch to PHP 8.1" should be the non-disabled option
app.menuItems["\("mi_php_switch".localized) 8.3 (php@8.3)"],
app.menuItems["\("mi_php_switch".localized) 8.2 (php@8.2)"],
app.menuItems["\("mi_php_switch".localized) 8.1 (php@8.1)"], app.menuItems["\("mi_php_switch".localized) 8.1 (php@8.1)"],
// "Switch to PHP 8.0" should be the non-disabled option
app.menuItems["\("mi_php_switch".localized) 8.0 (php@8.0)"], app.menuItems["\("mi_php_switch".localized) 8.0 (php@8.0)"],
// We should see the about and quit items // We should see the about and quit items
app.menuItems["mi_about".localized], app.menuItems["mi_about".localized],
@@ -107,10 +108,10 @@ final class MainMenuTest: UITestCase {
// But not PHP 8.6 (yet) // But not PHP 8.6 (yet)
assertNotExists(app.staticTexts["PHP 8.6"]) assertNotExists(app.staticTexts["PHP 8.6"])
// Also, PHP 8.2 should have an update available // Also, PHP 8.4 should have an update available
assertExists(app.staticTexts["phpman.version.has_update".localized( assertExists(app.staticTexts["phpman.version.has_update".localized(
"8.2.6", "8.4.5",
"8.2.11" "8.4.11"
)], 5) )], 5)
} }

View File

@@ -26,7 +26,7 @@ final class StartupTest: UITestCase {
assertAllExist([ assertAllExist([
app.dialogs["generic.notice".localized], app.dialogs["generic.notice".localized],
app.staticTexts["startup.errors.php_binary.title".localized], app.staticTexts["startup.errors.php_binary.title".localized],
app.buttons["generic.ok".localized], app.buttons["generic.ok".localized]
]) ])
click(app.buttons["generic.ok".localized]) click(app.buttons["generic.ok".localized])
@@ -48,7 +48,7 @@ final class StartupTest: UITestCase {
final func test_get_warning_about_missing_fpm_symlink() throws { final func test_get_warning_about_missing_fpm_symlink() throws {
var configuration = TestableConfigurations.working var configuration = TestableConfigurations.working
configuration.filesystem["/opt/homebrew/etc/php/8.2/php-fpm.d/valet-fpm.conf"] = nil configuration.filesystem["/opt/homebrew/etc/php/8.4/php-fpm.d/valet-fpm.conf"] = nil
let app = launch(with: configuration) let app = launch(with: configuration)

View File

@@ -32,7 +32,7 @@ final class UpdateCheckTest: UITestCase {
// Ensure an update is available // Ensure an update is available
configuration.shellOutput[ configuration.shellOutput[
"curl -s --max-time 10 '\(Constants.Urls.DevBuildCaskFile.absoluteString)'" "curl -s --max-time 10 '\(Constants.Urls.UpdateCheckEndpoint.absoluteString)'"
] = .delayed(0.5, """ ] = .delayed(0.5, """
cask 'phpmon-dev' do cask 'phpmon-dev' do
depends_on formula: 'gnu-sed' depends_on formula: 'gnu-sed'
@@ -50,22 +50,21 @@ final class UpdateCheckTest: UITestCase {
let app = launch(openMenu: false, with: configuration) let app = launch(openMenu: false, with: configuration)
// Expect to see the content of the appropriate alert box // Expect to see the content of the appropriate alert box, but this may take a while; if this test fails try increasing the timeout
assertExists(app.staticTexts["updater.alerts.newer_version_available.title".localized("99.0.0 (9999)")], 3.0) let timeout: TimeInterval = 10.0
assertExists(app.staticTexts["updater.alerts.newer_version_available.title".localized("99.0.0 (9999)")], timeout)
assertExists(app.buttons["updater.alerts.buttons.install".localized]) assertExists(app.buttons["updater.alerts.buttons.install".localized])
assertExists(app.buttons["updater.alerts.buttons.dismiss".localized]) assertExists(app.buttons["updater.alerts.buttons.dismiss".localized])
} }
final func test_will_require_manual_search_for_update() throws { final func test_does_not_do_automatic_background_check() throws {
var configuration = TestableConfigurations.working var configuration = TestableConfigurations.working
// Ensure automatic check is disabled // Ensure automatic check is disabled
configuration.preferenceOverrides[.automaticBackgroundUpdateCheck] = false configuration.preferenceOverrides[.automaticBackgroundUpdateCheck] = false
// Ensure an update is available // Ensure an update is available
configuration.shellOutput[ configuration.shellOutput["curl -s --max-time 10 '\(Constants.Urls.UpdateCheckEndpoint.absoluteString)'"] = .delayed(0.5, """
"curl -s --max-time 10 '\(Constants.Urls.DevBuildCaskFile.absoluteString)'"
] = .delayed(0.5, """
cask 'phpmon-dev' do cask 'phpmon-dev' do
depends_on formula: 'gnu-sed' depends_on formula: 'gnu-sed'
@@ -85,9 +84,32 @@ final class UpdateCheckTest: UITestCase {
// The check should not happen if the preference is disabled // The check should not happen if the preference is disabled
assertNotExists(app.staticTexts["updater.alerts.newer_version_available.title".localized("99.0.0 (9999)")], 2) assertNotExists(app.staticTexts["updater.alerts.newer_version_available.title".localized("99.0.0 (9999)")], 2)
}
// Open the menu and check manually final func test_will_require_manual_search_for_update() throws {
app.statusItems.firstMatch.click() var configuration = TestableConfigurations.working
// Ensure automatic check is disabled
configuration.preferenceOverrides[.automaticBackgroundUpdateCheck] = false
// Ensure an update is available
configuration.shellOutput["curl -s --max-time 10 '\(Constants.Urls.UpdateCheckEndpoint.absoluteString)'"] = .delayed(0.5, """
cask 'phpmon-dev' do
depends_on formula: 'gnu-sed'
version '99.0.0_9999'
sha256 '1cb147bd1b1fbd52971d90dff577465b644aee7c878f15ede57f46e8f217067a'
url 'https://github.com/nicoverbruggen/phpmon/releases/download/v99.0/phpmon-dev.zip'
name 'PHP Monitor DEV'
homepage 'https://phpmon.app'
app 'PHP Monitor DEV.app', target: "PHP Monitor DEV.app"
end
""")
// Wait for the menu to open and search for updates
let app = launch(openMenu: true, with: configuration)
app.menuItems["mi_check_for_updates".localized].click() app.menuItems["mi_check_for_updates".localized].click()
// Expect to see the content of the appropriate alert box // Expect to see the content of the appropriate alert box
@@ -103,9 +125,7 @@ final class UpdateCheckTest: UITestCase {
configuration.preferenceOverrides[.automaticBackgroundUpdateCheck] = false configuration.preferenceOverrides[.automaticBackgroundUpdateCheck] = false
// Ensure an update is available // Ensure an update is available
configuration.shellOutput[ configuration.shellOutput["curl -s --max-time 10 '\(Constants.Urls.UpdateCheckEndpoint.absoluteString)'"] = .delayed(0.5, "404 PAGE NOT FOUND")
"curl -s --max-time 10 '\(Constants.Urls.DevBuildCaskFile.absoluteString)'"
] = .delayed(0.5, "404 PAGE NOT FOUND")
// Wait for the menu to open and search for updates // Wait for the menu to open and search for updates
let app = launch(openMenu: true, with: configuration) let app = launch(openMenu: true, with: configuration)

View File

@@ -1,51 +0,0 @@
//
// CaskFileParserTest.swift
// Unit Tests
//
// Created by Nico Verbruggen on 04/02/2023.
// Copyright © 2023 Nico Verbruggen. All rights reserved.
//
import XCTest
class CaskFileParserTest: XCTestCase {
// MARK: - Test Files
static var exampleFilePath: URL {
return Bundle(for: Self.self)
.url(forResource: "phpmon-dev", withExtension: "rb")!
}
func test_can_extract_fields_from_cask_file() async throws {
guard let caskFile = await CaskFile.from(url: CaskFileParserTest.exampleFilePath) else {
return XCTFail("The CaskFile could not be parsed, check the log for more info")
}
XCTAssertEqual(
caskFile.version,
"5.7.2_1035"
)
XCTAssertEqual(
caskFile.sha256,
"1cb147bd1b1fbd52971d90dff577465b644aee7c878f15ede57f46e8f217067a"
)
XCTAssertEqual(
caskFile.name,
"PHP Monitor DEV"
)
XCTAssertEqual(
caskFile.url,
"https://github.com/nicoverbruggen/phpmon/releases/download/v5.7.2/phpmon-dev.zip"
)
}
func test_can_extract_fields_from_remote_cask_file() async throws {
guard let caskFile = await CaskFile.from(url: Constants.Urls.StableBuildCaskFile) else {
return XCTFail("The remote CaskFile could not be parsed, check the log for more info")
}
XCTAssertTrue(caskFile.properties.keys.contains("version"))
XCTAssertTrue(caskFile.properties.keys.contains("homepage"))
XCTAssertTrue(caskFile.properties.keys.contains("url"))
}
}

View File

@@ -1,42 +0,0 @@
//
// BytePhpPreferenceTest.swift
// Unit Tests
//
// Created by Nico Verbruggen on 04/09/2023.
// Copyright © 2023 Nico Verbruggen. All rights reserved.
//
import XCTest
class BytePhpPreferenceTest: XCTestCase {
func test_can_extract_memory_value() throws {
let pref = BytePhpPreference(key: "memory_limit")
XCTAssertEqual(pref.internalValue, "512M")
XCTAssertEqual(pref.unit, .megabyte)
XCTAssertEqual(pref.value, 512)
}
func test_can_parse_all_kinds_of_values() throws {
var (unit, value) = BytePhpPreference.readFrom(internalValue: "1G")!
XCTAssertEqual(unit, .gigabyte)
XCTAssertEqual(value, 1)
(unit, value) = BytePhpPreference.readFrom(internalValue: "256M")!
XCTAssertEqual(unit, .megabyte)
XCTAssertEqual(value, 256)
(unit, value) = BytePhpPreference.readFrom(internalValue: "512K")!
XCTAssertEqual(unit, .kilobyte)
XCTAssertEqual(value, 512)
(unit, value) = BytePhpPreference.readFrom(internalValue: "1024")!
XCTAssertEqual(unit, .kilobyte)
XCTAssertEqual(value, 1024)
(unit, value) = BytePhpPreference.readFrom(internalValue: "-1")!
XCTAssertEqual(unit, .kilobyte)
XCTAssertEqual(value, -1)
}
}

View File

@@ -1,81 +0,0 @@
//
// NginxConfigurationTest.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 29/11/2021.
// Copyright © 2023 Nico Verbruggen. All rights reserved.
//
import XCTest
class NginxConfigurationTest: XCTestCase {
// MARK: - Test Files
static var regularUrl: URL {
return Bundle(for: Self.self).url(forResource: "nginx-site", withExtension: "test")!
}
static var isolatedUrl: URL {
return Bundle(for: Self.self).url(forResource: "nginx-site-isolated", withExtension: "test")!
}
static var proxyUrl: URL {
return Bundle(for: Self.self).url(forResource: "nginx-proxy", withExtension: "test")!
}
static var secureProxyUrl: URL {
return Bundle(for: Self.self).url(forResource: "nginx-secure-proxy", withExtension: "test")!
}
static var customTldProxyUrl: URL {
return Bundle(for: Self.self).url(forResource: "nginx-secure-proxy-custom-tld", withExtension: "test")!
}
// MARK: - Tests
func test_can_determine_site_name_and_tld() throws {
XCTAssertEqual(
"nginx-site",
NginxConfigurationFile.from(filePath: NginxConfigurationTest.regularUrl.path)?.domain
)
XCTAssertEqual(
"test",
NginxConfigurationFile.from(filePath: NginxConfigurationTest.regularUrl.path)?.tld
)
}
func test_can_determine_isolation() throws {
XCTAssertNil(
NginxConfigurationFile.from(filePath: NginxConfigurationTest.regularUrl.path)?.isolatedVersion
)
XCTAssertEqual(
"8.1",
NginxConfigurationFile.from(filePath: NginxConfigurationTest.isolatedUrl.path)?.isolatedVersion
)
}
func test_can_determine_proxy() throws {
let proxied = NginxConfigurationFile.from(filePath: NginxConfigurationTest.proxyUrl.path)!
XCTAssertTrue(proxied.contents.contains("# valet stub: proxy.valet.conf"))
XCTAssertEqual("http://127.0.0.1:90", proxied.proxy)
let normal = NginxConfigurationFile.from(filePath: NginxConfigurationTest.regularUrl.path)!
XCTAssertFalse(normal.contents.contains("# valet stub: proxy.valet.conf"))
XCTAssertEqual(nil, normal.proxy)
}
func test_can_determine_secured_proxy() throws {
let proxied = NginxConfigurationFile.from(filePath: NginxConfigurationTest.secureProxyUrl.path)!
XCTAssertTrue(proxied.contents.contains("# valet stub: secure.proxy.valet.conf"))
XCTAssertEqual("http://127.0.0.1:90", proxied.proxy)
}
func test_can_determine_proxy_with_custom_tld() throws {
let proxied = NginxConfigurationFile.from(filePath: NginxConfigurationTest.customTldProxyUrl.path)!
XCTAssertTrue(proxied.contents.contains("# valet stub: secure.proxy.valet.conf"))
XCTAssertEqual("http://localhost:8080", proxied.proxy)
}
}

View File

@@ -1,72 +0,0 @@
//
// ExtensionParserTest.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 13/02/2021.
// Copyright © 2023 Nico Verbruggen. All rights reserved.
//
import XCTest
class PhpExtensionTest: XCTestCase {
static var phpIniFileUrl: URL {
return Bundle(for: Self.self).url(forResource: "php", withExtension: "ini")!
}
func test_can_load_extension() throws {
let extensions = PhpExtension.from(filePath: Self.phpIniFileUrl.path)
XCTAssertGreaterThan(extensions.count, 0)
}
func test_extension_name_is_correct() throws {
let extensions = PhpExtension.from(filePath: Self.phpIniFileUrl.path)
let extensionNames = extensions.map { (ext) -> String in
return ext.name
}
// These 6 should be found
XCTAssertTrue(extensionNames.contains("xdebug"))
XCTAssertTrue(extensionNames.contains("imagick"))
XCTAssertTrue(extensionNames.contains("sodium-next"))
XCTAssertTrue(extensionNames.contains("opcache"))
XCTAssertTrue(extensionNames.contains("yaml"))
XCTAssertTrue(extensionNames.contains("custom"))
XCTAssertFalse(extensionNames.contains("fake"))
XCTAssertFalse(extensionNames.contains("nice"))
}
func test_extension_status_is_correct() throws {
let extensions = PhpExtension.from(filePath: Self.phpIniFileUrl.path)
// xdebug should be enabled
XCTAssertEqual(extensions[0].enabled, true)
// imagick should be disabled
XCTAssertEqual(extensions[1].enabled, false)
}
func test_toggle_works_as_expected() async throws {
let destination = Utility.copyToTemporaryFile(resourceName: "php", fileExtension: "ini")!
let extensions = PhpExtension.from(filePath: destination.path)
XCTAssertEqual(extensions.count, 6)
// Try to disable xdebug (should be detected first)!
let xdebug = extensions.first!
XCTAssertTrue(xdebug.name == "xdebug")
XCTAssertEqual(xdebug.enabled, true)
await xdebug.toggle()
XCTAssertEqual(xdebug.enabled, false)
// Check if the file contains the appropriate data
let file = try! String(contentsOf: destination, encoding: .utf8)
XCTAssertTrue(file.contains("; zend_extension=\"xdebug.so\""))
// Make sure if we load the data again, it's disabled
XCTAssertEqual(PhpExtension.from(filePath: destination.path).first!.enabled, false)
}
}

View File

@@ -1,50 +0,0 @@
//
// ValetRcTest.swift
// Unit Tests
//
// Created by Nico Verbruggen on 20/01/2023.
// Copyright © 2023 Nico Verbruggen. All rights reserved.
//
import XCTest
class ValetRcTest: XCTestCase {
// MARK: - Test Files
static var validPath: URL {
return Bundle(for: Self.self)
.url(forResource: "valetrc", withExtension: "valid")!
}
static var brokenPath: URL {
return Bundle(for: Self.self)
.url(forResource: "valetrc", withExtension: "broken")!
}
// MARK: - Tests
func test_can_extract_fields_from_valetrc_file() throws {
let fakeFile = RCFile.fromPath("/Users/fake/file.rc")
XCTAssertNil(fakeFile)
// Can parse the file
let validFile = RCFile.fromPath(ValetRcTest.validPath.path)
XCTAssertNotNil(validFile)
let fields = validFile!.fields
// Correctly parses and trims (and omits double quotes) per line
XCTAssertEqual(fields["PHP"], "php@8.2")
XCTAssertEqual(fields["OTHER"], "thing")
XCTAssertEqual(fields["PHPMON_WATCH"], "true")
XCTAssertEqual(fields["SYNTAX"], "variable")
// Ignores entries prefixed with #
XCTAssertTrue(!fields.keys.contains("#PHP"))
// Ignores invalid lines
XCTAssertTrue(!fields.keys.contains("OOF"))
}
}

View File

@@ -0,0 +1,29 @@
//
// Untitled.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 29/09/2025.
// Copyright © 2025 Nico Verbruggen. All rights reserved.
//
import Testing
import Foundation
struct TestableApiTest {
@Test func createFakeApi() {
let api = TestableApi(responses: [
url("https://api.phpmon.test"): FakeApiResponse(
statusCode: 200,
headers: [:],
text: "{\"success\": true}"
)
])
#expect(api.hasResponse(for: url("https://api.phpmon.test")) == true)
let response = api.getResponse(for: url("https://api.phpmon.test"))
#expect(response.statusCode == 200)
#expect(response.text.contains("success"))
}
}

View File

@@ -8,11 +8,8 @@
import Testing import Testing
@Suite("Commands")
struct CommandTest { struct CommandTest {
@Test func determinePhpVersion() {
@Test
func determinePhpVersion() {
let version = Command.execute( let version = Command.execute(
path: Paths.php, path: Paths.php,
arguments: ["-v"], arguments: ["-v"],
@@ -24,5 +21,4 @@ struct CommandTest {
#expect(version.contains("built")) #expect(version.contains("built"))
#expect(version.contains("Zend")) #expect(version.contains("Zend"))
} }
} }

View File

@@ -8,7 +8,6 @@
import Testing import Testing
@Suite("Integration")
struct PackagistTest { struct PackagistTest {
@Test func canRetrieveLaravelValetVersion() async { @Test func canRetrieveLaravelValetVersion() async {
let packageToCheck = "laravel/valet" let packageToCheck = "laravel/valet"

View File

@@ -0,0 +1,41 @@
//
// BytePhpPreferenceTest.swift
// Unit Tests
//
// Created by Nico Verbruggen on 04/09/2023.
// Copyright © 2023 Nico Verbruggen. All rights reserved.
//
import Testing
struct BytePhpPreferenceTest {
@Test func can_extract_memory_value() throws {
let pref = BytePhpPreference(key: "memory_limit")
#expect(pref.internalValue == "512M")
#expect(pref.unit == .megabyte)
#expect(pref.value == 512)
}
@Test func can_parse_all_kinds_of_values() throws {
var (unit, value) = BytePhpPreference.readFrom(internalValue: "1G")!
#expect(unit == .gigabyte)
#expect(value == 1)
(unit, value) = BytePhpPreference.readFrom(internalValue: "256M")!
#expect(unit == .megabyte)
#expect(value == 256)
(unit, value) = BytePhpPreference.readFrom(internalValue: "512K")!
#expect(unit == .kilobyte)
#expect(value == 512)
(unit, value) = BytePhpPreference.readFrom(internalValue: "1024")!
#expect(unit == .kilobyte)
#expect(value == 1024)
(unit, value) = BytePhpPreference.readFrom(internalValue: "-1")!
#expect(unit == .kilobyte)
#expect(value == -1)
}
}

View File

@@ -0,0 +1,60 @@
//
// CaskFileParserTest.swift
// Unit Tests
//
// Created by Nico Verbruggen on 04/02/2023.
// Copyright © 2023 Nico Verbruggen. All rights reserved.
//
import Testing
import Foundation
@Suite(.serialized)
struct CaskFileParserTest {
init() async throws {
ActiveShell.useSystem()
}
// MARK: - Test Files
static var exampleFilePath: URL {
TestBundle.url(forResource: "phpmon-dev", withExtension: "rb")!
}
@Test func can_extract_fields_from_cask_file() async throws {
guard let caskFile = await CaskFile.from(url: CaskFileParserTest.exampleFilePath) else {
Issue.record("The CaskFile could not be parsed, check the log for more info")
return
}
#expect(
caskFile.version ==
"5.7.2_1035"
)
#expect(
caskFile.sha256 ==
"1cb147bd1b1fbd52971d90dff577465b644aee7c878f15ede57f46e8f217067a"
)
#expect(
caskFile.name ==
"PHP Monitor DEV"
)
#expect(
caskFile.url ==
"https://github.com/nicoverbruggen/phpmon/releases/download/v5.7.2/phpmon-dev.zip"
)
}
@Test func can_extract_fields_from_remote_cask_file() async throws {
let url = URL(string: "https://raw.githubusercontent.com/nicoverbruggen/homebrew-cask/master/Casks/phpmon.rb")!
guard let caskFile = await CaskFile.from(url: url) else {
Issue.record("The remote CaskFile could not be parsed, check the log for more info")
return
}
#expect(caskFile.properties.keys.contains("version"))
#expect(caskFile.properties.keys.contains("homepage"))
#expect(caskFile.properties.keys.contains("url"))
}
}

View File

@@ -6,36 +6,32 @@
// Copyright © 2023 Nico Verbruggen. All rights reserved. // Copyright © 2023 Nico Verbruggen. All rights reserved.
// //
import XCTest import Testing
import Foundation
final class ExtensionEnumeratorTest: XCTestCase { struct ExtensionEnumeratorTest {
init() async throws {
override func setUp() async throws {
ActiveFileSystem.useTestable([ ActiveFileSystem.useTestable([
"\(Paths.tapPath)/shivammathur/homebrew-extensions/Formula/xdebug@8.1.rb": .fake(.text, "<test>"), "\(Paths.tapPath)/shivammathur/homebrew-extensions/Formula/xdebug@8.1.rb": .fake(.text, "<test>"),
"\(Paths.tapPath)/shivammathur/homebrew-extensions/Formula/xdebug@8.2.rb": .fake(.text, "<test>"), "\(Paths.tapPath)/shivammathur/homebrew-extensions/Formula/xdebug@8.2.rb": .fake(.text, "<test>"),
"\(Paths.tapPath)/shivammathur/homebrew-extensions/Formula/xdebug@8.3.rb": .fake(.text, "<test>"), "\(Paths.tapPath)/shivammathur/homebrew-extensions/Formula/xdebug@8.3.rb": .fake(.text, "<test>"),
"\(Paths.tapPath)/shivammathur/homebrew-extensions/Formula/xdebug@8.4.rb": .fake(.text, "<test>"), "\(Paths.tapPath)/shivammathur/homebrew-extensions/Formula/xdebug@8.4.rb": .fake(.text, "<test>")
]) ])
} }
func testCanReadFormulae() throws { @Test func can_read_formulae() throws {
let directory = "\(Paths.tapPath)/shivammathur/homebrew-extensions/Formula" let directory = "\(Paths.tapPath)/shivammathur/homebrew-extensions/Formula"
let files = try FileSystem.getShallowContentsOfDirectory(directory) let files = try FileSystem.getShallowContentsOfDirectory(directory)
XCTAssertEqual( #expect(Set(files) == Set(["xdebug@8.1.rb", "xdebug@8.2.rb", "xdebug@8.3.rb", "xdebug@8.4.rb"]))
Set(["xdebug@8.1.rb", "xdebug@8.2.rb", "xdebug@8.3.rb", "xdebug@8.4.rb"]),
Set(files)
)
} }
func testCanParseFormulaeBasedOnSyntax() throws { @Test func can_parse_formulae_based_on_syntax() throws {
let formulae = BrewTapFormulae.from(tap: "shivammathur/homebrew-extensions") let formulae = BrewTapFormulae.from(tap: "shivammathur/homebrew-extensions")
XCTAssertEqual(formulae["8.1"], [BrewPhpExtension(path: "/", name: "xdebug", phpVersion: "8.1")]) #expect(formulae["8.1"] == [BrewPhpExtension(path: "/", name: "xdebug", phpVersion: "8.1")])
XCTAssertEqual(formulae["8.2"], [BrewPhpExtension(path: "/", name: "xdebug", phpVersion: "8.2")]) #expect(formulae["8.2"] == [BrewPhpExtension(path: "/", name: "xdebug", phpVersion: "8.2")])
XCTAssertEqual(formulae["8.3"], [BrewPhpExtension(path: "/", name: "xdebug", phpVersion: "8.3")]) #expect(formulae["8.3"] == [BrewPhpExtension(path: "/", name: "xdebug", phpVersion: "8.3")])
XCTAssertEqual(formulae["8.4"], [BrewPhpExtension(path: "/", name: "xdebug", phpVersion: "8.4")]) #expect(formulae["8.4"] == [BrewPhpExtension(path: "/", name: "xdebug", phpVersion: "8.4")])
} }
} }

View File

@@ -6,54 +6,53 @@
// Copyright © 2023 Nico Verbruggen. All rights reserved. // Copyright © 2023 Nico Verbruggen. All rights reserved.
// //
import XCTest import Testing
import Foundation
class HomebrewPackageTest: XCTestCase { struct HomebrewPackageTest {
// - MARK: SYNTHETIC TESTS // - MARK: SYNTHETIC TESTS
static var jsonBrewFile: URL { static var jsonBrewFile: URL {
return Bundle(for: Self.self) TestBundle.url(forResource: "brew-formula", withExtension: "json")!
.url(forResource: "brew-formula", withExtension: "json")!
} }
func test_can_load_extension_json() throws { static var jsonBrewServicesFile: URL {
TestBundle.url(forResource: "brew-services", withExtension: "json")!
}
@Test func can_load_extension_json() throws {
let json = try! String(contentsOf: Self.jsonBrewFile, encoding: .utf8) let json = try! String(contentsOf: Self.jsonBrewFile, encoding: .utf8)
let package = try! JSONDecoder().decode( let package = try! JSONDecoder().decode(
[HomebrewPackage].self, from: json.data(using: .utf8)! [HomebrewPackage].self, from: json.data(using: .utf8)!
).first! ).first!
XCTAssertEqual(package.full_name, "php") #expect(package.full_name == "php")
XCTAssertEqual(package.aliases.first!, "php@8.2") #expect(package.aliases.first! == "php@8.4")
XCTAssertEqual(package.installed.contains(where: { installed in #expect(package.installed.contains(where: { installed in
installed.version.starts(with: "8.2") installed.version.starts(with: "8.4")
}), true) }) == true)
} }
static var jsonBrewServicesFile: URL { @Test func can_parse_services_json() throws {
return Bundle(for: Self.self)
.url(forResource: "brew-services", withExtension: "json")!
}
func test_can_parse_services_json() throws {
let json = try! String(contentsOf: Self.jsonBrewServicesFile, encoding: .utf8) let json = try! String(contentsOf: Self.jsonBrewServicesFile, encoding: .utf8)
let services = try! JSONDecoder().decode( let services = try! JSONDecoder().decode(
[HomebrewService].self, from: json.data(using: .utf8)! [HomebrewService].self, from: json.data(using: .utf8)!
) )
XCTAssertGreaterThan(services.count, 0) #expect(!services.isEmpty)
XCTAssertEqual(services.first?.name, "dnsmasq") #expect(services.first?.name == "dnsmasq")
XCTAssertEqual(services.first?.service_name, "homebrew.mxcl.dnsmasq") #expect(services.first?.service_name == "homebrew.mxcl.dnsmasq")
} }
/*
// - MARK: LIVE TESTS // - MARK: LIVE TESTS
/// This test requires that you have a valid Homebrew installation set up, /// This test requires that you have a valid Homebrew installation set up,
/// and requires the Valet services to be installed: php, nginx and dnsmasq. /// and requires the Valet services to be installed: php, nginx and dnsmasq.
/// If this test fails, there is an issue with your Homebrew installation /// If this test fails, there is an issue with your Homebrew installation
/// or the JSON API of the Homebrew output may have changed. /// or the JSON API of the Homebrew output may have changed.
func test_can_parse_services_json_from_cli_output() async throws { @Test(.disabled("Uses system command; enable at your own risk"))
func can_parse_services_json_from_cli_output() async throws {
ActiveShell.useSystem() ActiveShell.useSystem()
let services = try! JSONDecoder().decode( let services = try! JSONDecoder().decode(
@@ -65,17 +64,19 @@ class HomebrewPackageTest: XCTestCase {
return ["php", "nginx", "dnsmasq"].contains(service.name) return ["php", "nginx", "dnsmasq"].contains(service.name)
}) })
XCTAssertTrue(services.contains(where: {$0.name == "php"})) #expect(services.contains(where: {$0.name == "php"}))
XCTAssertTrue(services.contains(where: {$0.name == "nginx"})) #expect(services.contains(where: {$0.name == "nginx"}))
XCTAssertTrue(services.contains(where: {$0.name == "dnsmasq"})) #expect(services.contains(where: {$0.name == "dnsmasq"}))
XCTAssertEqual(services.count, 3) #expect(services.count == 3)
} }
/// This test requires that you have a valid Homebrew installation set up, /// This test requires that you have a valid Homebrew installation set up,
/// and requires the `php` formula to be installed. /// and requires the `php` formula to be installed.
/// If this test fails, there is an issue with your Homebrew installation /// If this test fails, there is an issue with your Homebrew installation
/// or the JSON API of the Homebrew output may have changed. /// or the JSON API of the Homebrew output may have changed.
func test_can_load_extension_json_from_cli_output() async throws { @Test(.disabled("Uses system command; enable at your own risk"))
func can_load_extension_json_from_cli_output() async throws {
ActiveShell.useSystem() ActiveShell.useSystem()
let package = try! JSONDecoder().decode( let package = try! JSONDecoder().decode(
@@ -83,7 +84,6 @@ class HomebrewPackageTest: XCTestCase {
from: await Shell.pipe("\(Paths.brew) info php --json").out.data(using: .utf8)! from: await Shell.pipe("\(Paths.brew) info php --json").out.data(using: .utf8)!
).first! ).first!
XCTAssertTrue(package.name == "php") #expect(package.full_name == "php")
} }
*/
} }

View File

@@ -6,26 +6,34 @@
// Copyright © 2023 Nico Verbruggen. All rights reserved. // Copyright © 2023 Nico Verbruggen. All rights reserved.
// //
import XCTest import Testing
import Foundation
class HomebrewUpgradableTest: XCTestCase { struct HomebrewUpgradableTest {
static var outdatedFileUrl: URL { static var outdatedFileUrl: URL {
return Bundle(for: Self.self) return TestBundle.url(forResource: "brew-outdated", withExtension: "json")!
.url(forResource: "brew-outdated", withExtension: "json")!
} }
func test_upgradable_php_versions_can_be_parsed() async throws { @Test func upgradable_php_versions_can_be_determined() async throws {
// Do not execute production cli commands
ActiveShell.useTestable([ ActiveShell.useTestable([
"/opt/homebrew/bin/brew update >/dev/null && /opt/homebrew/bin/brew outdated --json --formulae" "/opt/homebrew/bin/brew update >/dev/null && /opt/homebrew/bin/brew outdated --json --formulae"
: .instant(try! String(contentsOf: Self.outdatedFileUrl)), : .instant(try! String(contentsOf: Self.outdatedFileUrl)),
"/opt/homebrew/bin/php --ini | grep -E -o '(/[^ ]+\\.ini)'" "/opt/homebrew/bin/php --ini | grep -E -o '(/[^ ]+\\.ini)'"
: .instant("/opt/homebrew/etc/php/8.2/conf.d/php-memory-limits.ini"), : .instant("/opt/homebrew/etc/php/8.2/conf.d/php-memory-limits.ini"),
"/opt/homebrew/opt/php@8.1.16/bin/php --ini | grep -E -o '(/[^ ]+\\.ini)'" "/opt/homebrew/opt/php@8.1.16/bin/php --ini | grep -E -o '(/[^ ]+\\.ini)'"
: .instant("/opt/homebrew/etc/php/8.1/conf.d/php-memory-limits.ini"), : .instant("/opt/homebrew/etc/php/8.1/conf.d/php-memory-limits.ini"),
"/opt/homebrew/opt/php@8.2.3/bin/php --ini | grep -E -o '(/[^ ]+\\.ini)'" "/opt/homebrew/opt/php@8.2.3/bin/php --ini | grep -E -o '(/[^ ]+\\.ini)'"
: .instant("/opt/homebrew/etc/php/8.2/conf.d/php-memory-limits.ini"), : .instant("/opt/homebrew/etc/php/8.2/conf.d/php-memory-limits.ini"),
"/opt/homebrew/opt/php@7.4.11/bin/php --ini | grep -E -o '(/[^ ]+\\.ini)'" "/opt/homebrew/opt/php@7.4.11/bin/php --ini | grep -E -o '(/[^ ]+\\.ini)'"
: .instant("/opt/homebrew/etc/php/7.4/conf.d/php-memory-limits.ini") : .instant("/opt/homebrew/etc/php/7.4/conf.d/php-memory-limits.ini")
])
// Do not read our production files
ActiveFileSystem.useTestable([
"/opt/homebrew/etc/php/8.2/conf.d/php-memory-limits.ini": .fake(.text),
"/opt/homebrew/etc/php/8.1/conf.d/php-memory-limits.ini": .fake(.text),
"/opt/homebrew/etc/php/7.4/conf.d/php-memory-limits.ini": .fake(.text)
]) ])
// This config file assumes our PHP alias (`php`) is v8.2 // This config file assumes our PHP alias (`php`) is v8.2
@@ -39,11 +47,11 @@ class HomebrewUpgradableTest: XCTestCase {
let data = await BrewPhpFormulaeHandler().loadPhpVersions(loadOutdated: true) let data = await BrewPhpFormulaeHandler().loadPhpVersions(loadOutdated: true)
XCTAssertTrue(data.contains(where: { formula in #expect(true == data.contains(where: { formula in
formula.installedVersion == "8.1.16" && formula.upgradeVersion == "8.1.17" formula.installedVersion == "8.1.16" && formula.upgradeVersion == "8.1.17"
})) }))
XCTAssertTrue(data.contains(where: { formula in #expect(true == data.contains(where: { formula in
formula.installedVersion == "8.2.3" && formula.upgradeVersion == "8.2.4" formula.installedVersion == "8.2.3" && formula.upgradeVersion == "8.2.4"
})) }))
} }

View File

@@ -0,0 +1,70 @@
//
// NginxConfigurationTest.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 29/11/2021.
// Copyright © 2023 Nico Verbruggen. All rights reserved.
//
import Testing
import Foundation
struct NginxConfigurationTest {
// MARK: - Test Files
static var regularUrl: URL {
TestBundle.url(forResource: "nginx-site", withExtension: "test")!
}
static var isolatedUrl: URL {
TestBundle.url(forResource: "nginx-site-isolated", withExtension: "test")!
}
static var proxyUrl: URL {
TestBundle.url(forResource: "nginx-proxy", withExtension: "test")!
}
static var secureProxyUrl: URL {
TestBundle.url(forResource: "nginx-secure-proxy", withExtension: "test")!
}
static var customTldProxyUrl: URL {
TestBundle.url(forResource: "nginx-secure-proxy-custom-tld", withExtension: "test")!
}
// MARK: - Tests
@Test func can_determine_site_name_and_tld() throws {
#expect("nginx-site" == NginxConfigurationFile.from(filePath: Self.regularUrl.path)?.domain)
#expect("test" == NginxConfigurationFile.from(filePath: Self.regularUrl.path)?.tld)
}
@Test func can_determine_isolation() throws {
#expect(nil == NginxConfigurationFile.from(filePath: Self.regularUrl.path)?.isolatedVersion)
#expect("8.1" == NginxConfigurationFile.from(filePath: Self.isolatedUrl.path)?.isolatedVersion)
}
@Test func can_determine_proxy() throws {
let proxied = NginxConfigurationFile.from(filePath: Self.proxyUrl.path)!
#expect(proxied.contents.contains("# valet stub: proxy.valet.conf"))
#expect("http://127.0.0.1:90" == proxied.proxy)
let normal = NginxConfigurationFile.from(filePath: Self.regularUrl.path)!
#expect(false == normal.contents.contains("# valet stub: proxy.valet.conf"))
#expect(nil == normal.proxy)
}
@Test func can_determine_secured_proxy() throws {
let proxied = NginxConfigurationFile.from(filePath: Self.secureProxyUrl.path)!
#expect(proxied.contents.contains("# valet stub: secure.proxy.valet.conf"))
#expect("http://127.0.0.1:90" == proxied.proxy)
}
@Test func can_determine_proxy_with_custom_tld() throws {
let proxied = NginxConfigurationFile.from(filePath: Self.customTldProxyUrl.path)!
#expect(proxied.contents.contains("# valet stub: secure.proxy.valet.conf"))
#expect("http://localhost:8080" == proxied.proxy)
}
}

View File

@@ -6,41 +6,43 @@
// Copyright © 2023 Nico Verbruggen. All rights reserved. // Copyright © 2023 Nico Verbruggen. All rights reserved.
// //
import XCTest import Testing
import Foundation
class PhpConfigurationFileTest: XCTestCase {
@Suite(.serialized)
class PhpConfigurationFileTest {
static var phpIniFileUrl: URL { static var phpIniFileUrl: URL {
return Bundle(for: Self.self).url(forResource: "php", withExtension: "ini")! return TestBundle.url(forResource: "php", withExtension: "ini")!
} }
func test_can_load_extension() throws { @Test func can_load_extension() throws {
ActiveFileSystem.useSystem()
let iniFile = PhpConfigurationFile.from(filePath: Self.phpIniFileUrl.path)
#expect(iniFile != nil)
#expect(!iniFile!.extensions.isEmpty)
}
@Test func can_check_key_existence() throws {
print(Self.phpIniFileUrl.path)
let iniFile = PhpConfigurationFile.from(filePath: Self.phpIniFileUrl.path)! let iniFile = PhpConfigurationFile.from(filePath: Self.phpIniFileUrl.path)!
XCTAssertNotNil(iniFile) #expect(iniFile.has(key: "error_reporting"))
#expect(iniFile.has(key: "display_errors"))
XCTAssertGreaterThan(iniFile.extensions.count, 0) #expect(false == iniFile.has(key: "my_unknown_key"))
} }
func test_can_check_key_existence() throws { @Test func can_check_key_value() throws {
let iniFile = PhpConfigurationFile.from(filePath: Self.phpIniFileUrl.path)! let iniFile = PhpConfigurationFile.from(filePath: Self.phpIniFileUrl.path)!
XCTAssertTrue(iniFile.has(key: "error_reporting")) #expect(iniFile.get(for: "error_reporting") != nil)
XCTAssertTrue(iniFile.has(key: "display_errors")) #expect(iniFile.get(for: "error_reporting") == "E_ALL")
XCTAssertFalse(iniFile.has(key: "my_unknown_key"))
#expect(iniFile.get(for: "display_errors") != nil)
#expect(iniFile.get(for: "display_errors") == "On")
} }
func test_can_check_key_value() throws { @Test func can_customize_configuration_value() throws {
let iniFile = PhpConfigurationFile.from(filePath: Self.phpIniFileUrl.path)!
XCTAssertNotNil(iniFile.get(for: "error_reporting"))
XCTAssert(iniFile.get(for: "error_reporting") == "E_ALL")
XCTAssertNotNil(iniFile.get(for: "display_errors"))
XCTAssert(iniFile.get(for: "display_errors") == "On")
}
func test_can_customize_configuration_value() throws {
let destination = Utility let destination = Utility
.copyToTemporaryFile(resourceName: "php", fileExtension: "ini")! .copyToTemporaryFile(resourceName: "php", fileExtension: "ini")!
@@ -48,15 +50,15 @@ class PhpConfigurationFileTest: XCTestCase {
.from(filePath: destination.path)! .from(filePath: destination.path)!
// 0. Verify the original value // 0. Verify the original value
XCTAssertEqual(configurationFile.get(for: "error_reporting"), "E_ALL") #expect(configurationFile.get(for: "error_reporting") == "E_ALL")
// 1. Change the value // 1. Change the value
try! configurationFile.replace( try! configurationFile.replace(
key: "error_reporting", key: "error_reporting",
value: "E_ALL & ~E_DEPRECATED & ~E_STRICT" value: "E_ALL & ~E_DEPRECATED & ~E_STRICT"
) )
XCTAssertEqual( #expect(
configurationFile.get(for: "error_reporting"), configurationFile.get(for: "error_reporting") ==
"E_ALL & ~E_DEPRECATED & ~E_STRICT" "E_ALL & ~E_DEPRECATED & ~E_STRICT"
) )
@@ -65,20 +67,14 @@ class PhpConfigurationFileTest: XCTestCase {
key: "error_reporting", key: "error_reporting",
value: "error_reporting" value: "error_reporting"
) )
XCTAssertEqual( #expect(configurationFile.get(for: "error_reporting") == "error_reporting")
configurationFile.get(for: "error_reporting"),
"error_reporting"
)
// 3. Verify subsequent saves weren't broken // 3. Verify subsequent saves weren't broken
try! configurationFile.replace( try! configurationFile.replace(
key: "error_reporting", key: "error_reporting",
value: "E_ALL" value: "E_ALL"
) )
XCTAssertEqual( #expect(configurationFile.get(for: "error_reporting") == "E_ALL")
configurationFile.get(for: "error_reporting"),
"E_ALL"
)
} }
} }

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