diff --git a/src/Plugins/Compilers/IOSPluginCompiler.php b/src/Plugins/Compilers/IOSPluginCompiler.php index 2d7d9f4d..5f1ccf6b 100644 --- a/src/Plugins/Compilers/IOSPluginCompiler.php +++ b/src/Plugins/Compilers/IOSPluginCompiler.php @@ -4,11 +4,13 @@ use Illuminate\Filesystem\Filesystem; use Illuminate\Support\Collection; +use InvalidArgumentException; use Native\Mobile\Exceptions\PluginConflictException; use Native\Mobile\Plugins\Plugin; use Native\Mobile\Plugins\PluginHookRunner; use Native\Mobile\Plugins\PluginRegistry; use Native\Mobile\Plugins\SwiftSourceFilter; +use Native\Mobile\Support\PlistDocument; use Native\Mobile\Support\Stub; class IOSPluginCompiler @@ -177,9 +179,6 @@ public function compile(): void // Write per-locale InfoPlist.strings for any localized permission entries $this->writeInfoPlistLocalizations($allPlugins); - // Merge background modes into Info.plist - $this->mergeBackgroundModes($allPlugins); - // Merge entitlements from plugins $this->mergeEntitlements($allPlugins); @@ -413,50 +412,71 @@ protected function generateEmptyRendererRegistration(): void } /** - * Merge plugin Info.plist entries into main plist and simulator plist + * Merge every plugin's Info.plist contributions into the device and + * simulator plists: a resources/ios/Info.plist file, the manifest's + * info_plist entries and its background modes, with app-level + * overrides applied last so they always win over plugins. */ protected function mergeInfoPlistEntries(Collection $plugins): void { - // Both device and simulator Info.plist files need plugin entries $plistPaths = [ $this->iosProjectPath.'/NativePHP/Info.plist', $this->iosProjectPath.'/NativePHP-simulator-Info.plist', ]; - $appOverrides = $this->getAppInfoPlistOverrides(); - foreach ($plistPaths as $plistPath) { if (! $this->files->exists($plistPath)) { continue; } - $plist = $this->files->get($plistPath); + $plist = $this->openPlist($plistPath); foreach ($plugins as $plugin) { - // First check for Info.plist file $pluginPlistPath = $plugin->path.'/resources/ios/Info.plist'; if ($this->files->exists($pluginPlistPath)) { - $pluginPlist = $this->files->get($pluginPlistPath); - $plist = $this->mergePlists($plist, $pluginPlist); + $this->mergeIntoPlist($plist, $this->openPlist($pluginPlistPath)->all()); } - // Also merge info_plist entries from nativephp.json - $infoPlistEntries = $plugin->getIosInfoPlist(); - if (! empty($infoPlistEntries)) { - $plist = $this->injectPlistEntries($plist, $infoPlistEntries); + $this->mergeIntoPlist($plist, $plugin->getIosInfoPlist()); + + if ($modes = $plugin->getIosBackgroundModes()) { + $this->mergeIntoPlist($plist, ['UIBackgroundModes' => $modes]); } } - // Apply app-level overrides last so they always win over plugins. - if (! empty($appOverrides)) { - $plist = $this->injectPlistEntries($plist, $appOverrides); - } + $this->mergeIntoPlist($plist, $this->getAppInfoPlistOverrides()); - $this->files->put($plistPath, $plist); + $this->files->put($plistPath, $plist->toXml()); } } + /** + * Open a plist, naming the file when it does not parse. + */ + protected function openPlist(string $path): PlistDocument + { + try { + return PlistDocument::fromXml($this->files->get($path)); + } catch (InvalidArgumentException $e) { + throw new InvalidArgumentException("{$path}: {$e->getMessage()}", 0, $e); + } + } + + /** + * Merge entries into a plist, resolving ${ENV_VAR} placeholders on the way in. + */ + protected function mergeIntoPlist(PlistDocument $plist, array $entries): void + { + array_walk_recursive($entries, function (&$value) { + if (is_string($value)) { + $value = $this->substituteEnvPlaceholders($value); + } + }); + + $plist->merge($entries); + } + /** * Read app-level Info.plist overrides from config('nativephp.permissions'). */ @@ -625,100 +645,6 @@ protected function registerKnownRegions(array $locales): void } } - /** - * Merge two plist files - */ - protected function mergePlists(string $main, string $plugin): string - { - // Extract key-value pairs from plugin plist - preg_match_all('/([^<]+)<\/key>\s*([^<]+)<\/string>/s', $plugin, $matches, PREG_SET_ORDER); - - $entries = []; - foreach ($matches as $match) { - $entries[$match[1]] = $match[2]; - } - - return $this->injectPlistEntries($main, $entries); - } - - /** - * Inject entries into plist - */ - protected function injectPlistEntries(string $plist, array $entries): string - { - foreach ($entries as $key => $value) { - // Check if key already exists - if (str_contains($plist, "{$key}")) { - if (is_array($value)) { - $plist = $this->mergeArrayEntry($plist, $key, $value); - } elseif (is_string($value)) { - $plist = $this->updateStringEntry($plist, $key, $this->substituteEnvPlaceholders($value)); - } - - continue; - } - - // Handle array values - if (is_array($value)) { - $arrayContent = ''; - foreach ($value as $item) { - $item = $this->substituteEnvPlaceholders($item); - $arrayContent .= "\n\t\t{$item}"; - } - $entry = "\n\t{$key}\n\t{$arrayContent}\n\t"; - } else { - // Handle string values - substitute placeholders - $value = $this->substituteEnvPlaceholders($value); - $entry = "\n\t{$key}\n\t{$value}"; - } - - // Add before closing - $plist = preg_replace( - '/(\s*<\/dict>\s*<\/plist>)/s', - $entry.'$1', - $plist, - 1 - ); - } - - return $plist; - } - - /** - * Update an existing string entry's value in the plist - */ - protected function updateStringEntry(string $plist, string $key, string $value): string - { - $pattern = '/('.preg_quote($key, '/').'<\/key>\s*)([^<]*)(<\/string>)/'; - - return preg_replace_callback($pattern, function ($matches) use ($value) { - return $matches[1].htmlspecialchars($value, ENT_XML1 | ENT_QUOTES, 'UTF-8').$matches[3]; - }, $plist, 1); - } - - /** - * Merge array values into an existing plist array entry - */ - protected function mergeArrayEntry(string $plist, string $key, array $values): string - { - $pattern = '/('.preg_quote($key, '/').'<\/key>\s*)(.*?)(<\/array>)/s'; - - return preg_replace_callback($pattern, function ($matches) use ($values) { - $existingContent = $matches[2]; - $newItems = ''; - - foreach ($values as $item) { - $item = $this->substituteEnvPlaceholders($item); - // Only add if not already present - if (! str_contains($existingContent, "{$item}")) { - $newItems .= "\n\t\t{$item}"; - } - } - - return $matches[1].$existingContent.$newItems.$matches[3]; - }, $plist); - } - /** * Substitute ${ENV_VAR} placeholders with actual environment values */ @@ -737,52 +663,6 @@ protected function substituteEnvPlaceholders(string $value): string }, $value); } - /** - * Merge background modes from plugins into Info.plist UIBackgroundModes array - */ - protected function mergeBackgroundModes(Collection $plugins): void - { - $backgroundModes = []; - - foreach ($plugins as $plugin) { - $modes = $plugin->getIosBackgroundModes(); - foreach ($modes as $mode) { - $backgroundModes[$mode] = true; - } - } - - if (empty($backgroundModes)) { - return; - } - - // Both device and simulator Info.plist files need background modes - $plistPaths = [ - $this->iosProjectPath.'/NativePHP/Info.plist', - $this->iosProjectPath.'/NativePHP-simulator-Info.plist', - ]; - - foreach ($plistPaths as $plistPath) { - if (! $this->files->exists($plistPath)) { - continue; - } - - $plist = $this->files->get($plistPath); - - // Check if UIBackgroundModes already exists - if (str_contains($plist, 'UIBackgroundModes')) { - // Merge with existing array - $plist = $this->mergeArrayEntry($plist, 'UIBackgroundModes', array_keys($backgroundModes)); - } else { - // Add new UIBackgroundModes array - $plist = $this->injectPlistEntries($plist, [ - 'UIBackgroundModes' => array_keys($backgroundModes), - ]); - } - - $this->files->put($plistPath, $plist); - } - } - /** * Merge entitlements from plugins into the app's entitlements file */ diff --git a/src/Support/PlistDocument.php b/src/Support/PlistDocument.php new file mode 100644 index 00000000..9d27cea5 --- /dev/null +++ b/src/Support/PlistDocument.php @@ -0,0 +1,291 @@ +loadXML($xml, LIBXML_NONET); + $error = libxml_get_last_error(); + } finally { + libxml_clear_errors(); + libxml_use_internal_errors($previous); + } + + if (! $loaded) { + $reason = $error ? ': '.trim($error->message).' (line '.$error->line.')' : '.'; + + throw new InvalidArgumentException('Plist is not well-formed XML'.$reason); + } + + $root = (new DOMXPath($dom))->query('/*/dict[1]')->item(0); + + if (! $root instanceof DOMElement) { + throw new InvalidArgumentException('Plist has no root .'); + } + + return new static($dom, $root); + } + + public function toXml(): string + { + return $this->dom->saveXML(); + } + + /** + * Every top-level entry as PHP values. + */ + public function all(): array + { + return static::fromNode($this->root); + } + + public function get(string $key): mixed + { + $value = static::pairsOf($this->root)[$key] ?? null; + + return $value === null ? null : static::fromNode($value); + } + + /** + * Merge entries into the root dict, replacing or appending each key. + */ + public function merge(array $entries): void + { + foreach (static::withoutNulls($entries) as $key => $value) { + $this->set($key, static::mergeValues($this->get($key), $value)); + } + } + + public function set(string $key, mixed $value): void + { + $node = $this->toNode($value, 1); + $existing = static::pairsOf($this->root)[$key] ?? null; + + if ($existing) { + $this->root->replaceChild($node, $existing); + + return; + } + + // Insert ahead of the trailing whitespace the file already + // has, so the closing stays on a line of its own. + $anchor = $this->root->lastChild instanceof DOMText && trim($this->root->lastChild->data) === '' + ? $this->root->lastChild + : $this->root->appendChild($this->dom->createTextNode("\n")); + + $this->root->insertBefore($this->dom->createTextNode("\n\t"), $anchor); + $this->root->insertBefore($this->textElement('key', $key), $anchor); + $this->root->insertBefore($this->dom->createTextNode("\n\t"), $anchor); + $this->root->insertBefore($node, $anchor); + } + + /** + * Combine an existing value with an incoming one. Lists union on content + * so rebuilds and second plugins never duplicate an item, dicts merge + * key by key, an empty array contributes nothing, and any other + * pairing is replaced outright by the incoming value. + */ + protected static function mergeValues(mixed $existing, mixed $incoming): mixed + { + if (! is_array($incoming)) { + return $incoming; + } + + if ($incoming === []) { + return $existing ?? []; + } + + if (! is_array($existing) || array_is_list($existing) !== array_is_list($incoming)) { + // There is nothing of the same shape to merge onto, so the + // incoming value stands alone. A list is still unioned + // against itself to drop repeats it declared twice. + return array_is_list($incoming) ? static::union([], $incoming) : $incoming; + } + + if (array_is_list($existing)) { + return static::union($existing, $incoming); + } + + foreach ($incoming as $key => $value) { + $existing[$key] = static::mergeValues($existing[$key] ?? null, $value); + } + + return $existing; + } + + /** + * Append the incoming items an existing list does not already + * hold. Items compare on content, so a dict counts as + * present whatever order its keys arrived in. + */ + protected static function union(array $existing, array $incoming): array + { + $seen = array_map(static::canonical(...), $existing); + + foreach ($incoming as $item) { + $fingerprint = static::canonical($item); + + if (! in_array($fingerprint, $seen, true)) { + $existing[] = $item; + $seen[] = $fingerprint; + } + } + + return $existing; + } + + /** + * A dict's text mapped to the value element that follows it. + * + * @return array + */ + protected static function pairsOf(DOMElement $dict): array + { + $pairs = []; + $pendingKey = null; + + foreach (static::elementChildren($dict) as $child) { + if ($child->tagName === 'key') { + $pendingKey = $child->textContent; + } elseif ($pendingKey !== null) { + $pairs[$pendingKey] = $child; + $pendingKey = null; + } + } + + return $pairs; + } + + protected static function fromNode(DOMElement $node): mixed + { + return match ($node->tagName) { + 'true' => true, + 'false' => false, + 'integer' => (int) $node->textContent, + 'real' => (float) $node->textContent, + 'array' => array_map(static::fromNode(...), static::elementChildren($node)), + 'dict' => array_map(static::fromNode(...), static::pairsOf($node)), + default => $node->textContent, + }; + } + + protected function toNode(mixed $value, int $depth): DOMNode + { + if (is_bool($value)) { + return $this->dom->createElement($value ? 'true' : 'false'); + } + + if (is_int($value)) { + return $this->textElement('integer', (string) $value); + } + + if (is_float($value)) { + return $this->textElement('real', (string) $value); + } + + if (! is_array($value)) { + return $this->textElement('string', (string) $value); + } + + $isList = array_is_list($value); + $node = $this->dom->createElement($isList ? 'array' : 'dict'); + $indent = "\n".str_repeat("\t", $depth + 1); + + foreach ($value as $key => $item) { + if (! $isList) { + $node->appendChild($this->dom->createTextNode($indent)); + $node->appendChild($this->textElement('key', (string) $key)); + } + + $node->appendChild($this->dom->createTextNode($indent)); + $node->appendChild($this->toNode($item, $depth + 1)); + } + + if ($value !== []) { + $node->appendChild($this->dom->createTextNode("\n".str_repeat("\t", $depth))); + } + + return $node; + } + + /** + * An element whose text is escaped, which createElement's + * value argument would not do for characters like "&". + */ + protected function textElement(string $tag, string $text): DOMElement + { + $element = $this->dom->createElement($tag); + $element->appendChild($this->dom->createTextNode($text)); + + return $element; + } + + /** + * @return array + */ + protected static function elementChildren(DOMElement $node): array + { + return array_values(array_filter( + iterator_to_array($node->childNodes), + fn ($child) => $child instanceof DOMElement + )); + } + + /** + * Drop null entries at every depth, since a plist has no + * way to express one and JSON manifests may carry them. + */ + protected static function withoutNulls(array $values): array + { + $wasList = array_is_list($values); + + $values = array_map( + fn ($value) => is_array($value) ? static::withoutNulls($value) : $value, + array_filter($values, fn ($value) => $value !== null) + ); + + return $wasList ? array_values($values) : $values; + } + + /** + * A key-order independent fingerprint, so two dicts with + * the same content count as the same list item. + */ + protected static function canonical(mixed $value): string + { + if (is_array($value)) { + if (! array_is_list($value)) { + ksort($value); + } + + $value = array_map(static::canonical(...), $value); + } + + return json_encode($value); + } +} diff --git a/tests/Feature/Plugins/IOSCompilerTest.php b/tests/Feature/Plugins/IOSCompilerTest.php index d22f6712..2c2a3152 100644 --- a/tests/Feature/Plugins/IOSCompilerTest.php +++ b/tests/Feature/Plugins/IOSCompilerTest.php @@ -8,6 +8,7 @@ use Native\Mobile\Plugins\Plugin; use Native\Mobile\Plugins\PluginManifest; use Native\Mobile\Plugins\PluginRegistry; +use Native\Mobile\Support\PlistDocument; use Tests\TestCase; /** @@ -1074,6 +1075,180 @@ public function it_copies_only_the_declared_ios_sources(): void $this->assertFileDoesNotExist($copiedDir.'/Scratch/Draft.swift'); } + /** + * @test + * + * Apple keys such as SKAdNetworkItems are arrays of dicts. They must + * land with their structure intact, and a rebuild must not duplicate. + */ + public function it_merges_arrays_of_dicts_into_info_plist(): void + { + $items = [ + ['SKAdNetworkIdentifier' => 'cstr6suwn9.skadnetwork'], + ['SKAdNetworkIdentifier' => '4fzdc2evr5.skadnetwork'], + ]; + $plugin = $this->createTestPlugin([ + 'ios' => ['info_plist' => ['SKAdNetworkItems' => $items]], + ]); + + $this->files->copy( + $this->testBasePath.'/ios/NativePHP/Info.plist', + $this->testBasePath.'/ios/NativePHP-simulator-Info.plist' + ); + + $this->mockRegistry->shouldReceive('all')->andReturn(collect([$plugin])); + + $this->compiler->compile(); + $this->compiler->compile(); + + $this->assertSame($items, $this->readPlist()->get('SKAdNetworkItems')); + $this->assertSame($items, $this->readPlist('NativePHP-simulator-Info.plist')->get('SKAdNetworkItems')); + $this->assertNull($this->readPlist('NativePHP-simulator-Info.plist')->get('UIBackgroundModes')); + } + + /** + * @test + * + * Bools and integers keep their plist type, and a value the old text + * merge wrote with the wrong type is corrected on the next build. + */ + public function it_writes_typed_plist_values(): void + { + $plistPath = $this->testBasePath.'/ios/NativePHP/Info.plist'; + $this->files->put($plistPath, str_replace( + '', + "\n\tFirebaseAppDelegateProxyEnabled\n\t", + $this->files->get($plistPath) + )); + + $plugin = $this->createTestPlugin([ + 'ios' => ['info_plist' => [ + 'FirebaseAppDelegateProxyEnabled' => false, + 'UIFileSharingEnabled' => true, + 'ITSAppUsesNonExemptEncryption' => 0, + ]], + ]); + + $this->mockRegistry->shouldReceive('all')->andReturn(collect([$plugin])); + + $this->compiler->compile(); + + $plist = $this->readPlist(); + + $this->assertFalse($plist->get('FirebaseAppDelegateProxyEnabled')); + $this->assertTrue($plist->get('UIFileSharingEnabled')); + $this->assertSame(0, $plist->get('ITSAppUsesNonExemptEncryption')); + $this->assertStringNotContainsString('', $this->files->get($plistPath)); + } + + /** + * @test + * + * A plugin's resources/ios/Info.plist is merged with every value type, + * and keys nested inside it never surface at the top level. + */ + public function it_merges_plugin_info_plist_files_structurally(): void + { + $pluginPath = $this->testBasePath.'/plugins/test-plugin'; + $this->files->ensureDirectoryExists($pluginPath.'/resources/ios'); + $this->files->put($pluginPath.'/resources/ios/Info.plist', ' + + + UIFileSharingEnabled + + CFBundleDocumentTypes + + + CFBundleTypeName + Any file + LSItemContentTypes + + public.data + + + + +'); + + $plugin = $this->createTestPlugin([ + 'ios' => ['info_plist' => ['NSCameraUsageDescription' => 'Camera access']], + ], $pluginPath); + + $this->mockRegistry->shouldReceive('all')->andReturn(collect([$plugin])); + + $this->compiler->compile(); + + $plist = $this->readPlist(); + + $this->assertTrue($plist->get('UIFileSharingEnabled')); + $this->assertSame([[ + 'CFBundleTypeName' => 'Any file', + 'LSItemContentTypes' => ['public.data'], + ]], $plist->get('CFBundleDocumentTypes')); + $this->assertNull($plist->get('CFBundleTypeName')); + } + + /** + * @test + * + * ${ENV_VAR} placeholders resolve inside nested values too. + */ + public function it_substitutes_placeholders_inside_nested_values(): void + { + putenv('NATIVEPHP_TEST_SKAN=4fzdc2evr5.skadnetwork'); + $_ENV['NATIVEPHP_TEST_SKAN'] = '4fzdc2evr5.skadnetwork'; + + try { + $plugin = $this->createTestPlugin([ + 'ios' => ['info_plist' => [ + 'SKAdNetworkItems' => [['SKAdNetworkIdentifier' => '${NATIVEPHP_TEST_SKAN}']], + ]], + ]); + + $this->mockRegistry->shouldReceive('all')->andReturn(collect([$plugin])); + + $this->compiler->compile(); + + $this->assertSame('4fzdc2evr5.skadnetwork', $this->readPlist()->get('SKAdNetworkItems')[0]['SKAdNetworkIdentifier']); + } finally { + putenv('NATIVEPHP_TEST_SKAN'); + unset($_ENV['NATIVEPHP_TEST_SKAN']); + } + } + + /** + * @test + * + * Background modes share the plist merge, so they union with what + * the base plist already declares and never duplicate on rebuild. + */ + public function it_unions_background_modes_with_the_base_plist(): void + { + $plistPath = $this->testBasePath.'/ios/NativePHP/Info.plist'; + $this->files->put($plistPath, $this->files->get(__DIR__.'/../../../resources/xcode/NativePHP/Info.plist')); + + $plugin = $this->createTestPlugin([ + 'ios' => [ + 'info_plist' => ['NSLocationWhenInUseUsageDescription' => 'Location access'], + 'background_modes' => ['remote-notification', 'location'], + ], + ]); + + $this->mockRegistry->shouldReceive('all')->andReturn(collect([$plugin])); + + $this->compiler->compile(); + $this->compiler->compile(); + + $plist = $this->readPlist(); + + $this->assertSame(['remote-notification', 'location'], $plist->get('UIBackgroundModes')); + } + + private function readPlist(string $file = 'NativePHP/Info.plist'): PlistDocument + { + return PlistDocument::fromXml($this->files->get($this->testBasePath.'/ios/'.$file)); + } + /** * Helper method to create a test Plugin instance. */ diff --git a/tests/Unit/Plugins/PlistDocumentTest.php b/tests/Unit/Plugins/PlistDocumentTest.php new file mode 100644 index 00000000..846ed083 --- /dev/null +++ b/tests/Unit/Plugins/PlistDocumentTest.php @@ -0,0 +1,117 @@ +base = file_get_contents(__DIR__.'/../../../resources/xcode/NativePHP/Info.plist'); + + $this->plist = fn (string $dict = '') => ''."\n" + .''."\n" + .''."\n{$dict}\n\n\n"; +}); + +it('renders every value type and reads it back', function () { + $entries = [ + 'AString' => 'text', + 'ABool' => false, + 'AnInt' => 42, + 'AReal' => 1.5, + 'AList' => ['a', 'b'], + 'ADict' => ['Inner' => true, 'Items' => [['Id' => 'x']]], + ]; + + $doc = PlistDocument::fromXml(($this->plist)()); + $doc->merge($entries); + + $xml = $doc->toXml(); + expect($xml)->toContain('', '42', '1.5'); + expect(PlistDocument::fromXml($xml)->all())->toBe($entries); +}); + +it('unions lists on content so rebuilds and second plugins never duplicate', function () { + $item = ['SKAdNetworkIdentifier' => 'cstr6suwn9.skadnetwork']; + $doc = PlistDocument::fromXml(($this->plist)()); + + $doc->merge(['SKAdNetworkItems' => [$item, $item]]); + $doc->merge(['SKAdNetworkItems' => [$item]]); + expect($doc->get('SKAdNetworkItems'))->toBe([$item]); + + $other = ['SKAdNetworkIdentifier' => '4fzdc2evr5.skadnetwork']; + $doc->merge(['SKAdNetworkItems' => [$other]]); + expect($doc->get('SKAdNetworkItems'))->toBe([$item, $other]); +}); + +it('merges dicts key by key', function () { + $doc = PlistDocument::fromXml($this->base); + $doc->merge(['NSAppTransportSecurity' => ['NSAllowsArbitraryLoads' => true]]); + + expect($doc->get('NSAppTransportSecurity'))->toBe([ + 'NSAllowsArbitraryLoadsInWebContent' => true, + 'NSAllowsArbitraryLoads' => true, + ]); +}); + +it('treats an empty array as contributing nothing', function () { + // JSON {} and [] both decode to [], so neither may wipe an existing value. + $doc = PlistDocument::fromXml($this->base); + $doc->merge(['NSAppTransportSecurity' => [], 'UIBackgroundModes' => []]); + + expect($doc->toXml())->toBe($this->base); +}); + +it('drops null entries at any depth', function () { + $doc = PlistDocument::fromXml(($this->plist)()); + $doc->merge(['Skipped' => null, 'AList' => ['a', null, 'b'], 'ADict' => ['Keep' => 1, 'Gone' => null]]); + + expect($doc->all())->toBe(['AList' => ['a', 'b'], 'ADict' => ['Keep' => 1]]); +}); + +it('replaces a value whose type changed', function () { + // The old text-based merge left for a false bool in + // scaffolds that already exist, so the typed value must win on rebuild. + $doc = PlistDocument::fromXml(($this->plist)('FirebaseAppDelegateProxyEnabled')); + $doc->merge(['FirebaseAppDelegateProxyEnabled' => false]); + + expect($doc->get('FirebaseAppDelegateProxyEnabled'))->toBeFalse(); + expect(substr_count($doc->toXml(), 'FirebaseAppDelegateProxyEnabled'))->toBe(1); +}); + +it('appends array-of-dict items beside existing ones, never inside a nested array', function () { + $doc = PlistDocument::fromXml($this->base); + $doc->merge(['CFBundleURLTypes' => [['CFBundleURLSchemes' => ['probe']]]]); + + $types = $doc->get('CFBundleURLTypes'); + expect($types)->toHaveCount(2); + expect($types[0]['CFBundleURLSchemes'])->toBe(['nativephp']); + expect($types[1])->toBe(['CFBundleURLSchemes' => ['probe']]); +}); + +it('only matches keys at the top level', function () { + // CFBundleTypeRole exists inside CFBundleURLTypes in the base plist. + $doc = PlistDocument::fromXml($this->base); + $doc->merge(['CFBundleTypeRole' => 'Editor']); + + expect($doc->get('CFBundleTypeRole'))->toBe('Editor'); + expect($doc->get('CFBundleURLTypes')[0]['CFBundleTypeRole'])->toBe('Viewer'); +}); + +it('escapes markup in strings', function () { + $doc = PlistDocument::fromXml(($this->plist)()); + $doc->merge(['NSCameraUsageDescription' => "Foto's &