diff --git a/app/Filament/Resources/PluginResource.php b/app/Filament/Resources/PluginResource.php index d34c124d..7a9875e1 100644 --- a/app/Filament/Resources/PluginResource.php +++ b/app/Filament/Resources/PluginResource.php @@ -121,6 +121,10 @@ public static function form(Schema $schema): Schema Forms\Components\Toggle::make('is_active') ->label('Active'), + + Forms\Components\Toggle::make('works_in_jump') + ->label('Works in Jump') + ->helperText('Show a badge indicating this plugin runs in the Jump preview app (i.e. it ships no custom native code)'), ]), Schemas\Components\Section::make('Review Checks') @@ -312,6 +316,10 @@ public static function table(Table $table): Table ->label('Active') ->sortable(), + Tables\Columns\ToggleColumn::make('works_in_jump') + ->label('Jump') + ->sortable(), + Tables\Columns\IconColumn::make('reviewed_at') ->label('Reviewed') ->boolean() @@ -346,6 +354,8 @@ public static function table(Table $table): Table Tables\Filters\TernaryFilter::make('featured'), Tables\Filters\TernaryFilter::make('is_active') ->label('Active'), + Tables\Filters\TernaryFilter::make('works_in_jump') + ->label('Works in Jump'), ]) ->actions([ Actions\ActionGroup::make([ diff --git a/app/Http/Controllers/ApplinksController.php b/app/Http/Controllers/ApplinksController.php index 860569f4..4b8a7854 100644 --- a/app/Http/Controllers/ApplinksController.php +++ b/app/Http/Controllers/ApplinksController.php @@ -2,21 +2,10 @@ namespace App\Http\Controllers; +use App\Support\JumpApp; + class ApplinksController extends Controller { - /** - * Jump's iOS app identity: .. - */ - private const IOS_APP_ID = 'J68WFCX458.com.bifrosttech.jump'; - - /** - * Jump's Android package + the SHA-256 of its Play app-signing cert. - * These are public identifiers, not secrets. - */ - private const ANDROID_PACKAGE = 'com.bifrosttech.jump'; - - private const ANDROID_SHA256 = 'D8:31:4E:55:E5:FF:06:17:D8:49:EA:3B:1F:BF:6C:58:B3:8D:AD:2C:30:CA:13:D2:CA:42:B0:85:B4:7D:CB:38'; - /** * URL path prefixes that open in Jump. * @@ -46,9 +35,9 @@ public function assetLinks() ], 'target' => [ 'namespace' => 'android_app', - 'package_name' => self::ANDROID_PACKAGE, + 'package_name' => JumpApp::ANDROID_PACKAGE, 'sha256_cert_fingerprints' => [ - self::ANDROID_SHA256, + JumpApp::ANDROID_SHA256, ], ], ], @@ -76,13 +65,13 @@ public function appSiteAssociation() 'applinks' => [ 'details' => [ [ - 'appIDs' => [self::IOS_APP_ID], + 'appIDs' => [JumpApp::IOS_APP_ID], 'components' => $components, ], ], ], 'webcredentials' => [ - 'apps' => [self::IOS_APP_ID], + 'apps' => [JumpApp::IOS_APP_ID], ], ]); } diff --git a/app/Http/Controllers/ShowDocumentationController.php b/app/Http/Controllers/ShowDocumentationController.php index cea666fb..42d4f4b8 100644 --- a/app/Http/Controllers/ShowDocumentationController.php +++ b/app/Http/Controllers/ShowDocumentationController.php @@ -6,6 +6,7 @@ use App\Support\CommonMark\CommonMark; use Artesaos\SEOTools\Facades\SEOMeta; use Artesaos\SEOTools\Facades\SEOTools; +use Closure; use Illuminate\Contracts\View\Factory as ViewFactory; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -24,16 +25,12 @@ class ShowDocumentationController extends Controller { public function __invoke(Request $request, string $platform, string $version, ?string $page = null) { - if (config('app.env') === 'local') { - Cache::flush(); - } - abort_unless(is_dir(resource_path('views/docs/'.$platform.'/'.$version)), 404); session(['viewing_docs_version' => $version]); session(['viewing_docs_platform' => $platform]); - $navigation = Cache::remember("docs_nav_{$platform}_{$version}", now()->addDay(), + $navigation = $this->cacheOrCompute("docs_nav_{$platform}_{$version}", fn () => $this->getNavigation($platform, $version) ); @@ -42,7 +39,7 @@ public function __invoke(Request $request, string $platform, string $version, ?s } try { - $pageProperties = Cache::remember("docs_{$platform}_{$version}_{$page}", now()->addDay(), + $pageProperties = $this->cacheOrCompute("docs_{$platform}_{$version}_{$page}", fn () => $this->getPageProperties($platform, $version, $page) ); } catch (InvalidArgumentException $e) { @@ -83,6 +80,19 @@ public function __invoke(Request $request, string $platform, string $version, ?s return view('docs.index')->with($pageProperties); } + /** + * Cache the callback's result for a day, or compute it fresh in local so + * docs edits show up immediately without clearing (or racing on) the cache. + */ + private function cacheOrCompute(string $key, Closure $callback): mixed + { + if (config('app.env') === 'local') { + return $callback(); + } + + return Cache::remember($key, now()->addDay(), $callback); + } + public function serveRawMarkdown(Request $request, string $platform, string $version, string $page) { abort_unless(is_dir(resource_path('views/docs/'.$platform.'/'.$version)), 404); diff --git a/app/Models/Plugin.php b/app/Models/Plugin.php index c9ae7631..f0026d32 100644 --- a/app/Models/Plugin.php +++ b/app/Models/Plugin.php @@ -295,6 +295,11 @@ public function isOfficial(): bool return $this->is_official ?? false; } + public function worksInJump(): bool + { + return $this->works_in_jump ?? false; + } + public function isSatisSynced(): bool { return $this->satis_synced_at !== null; @@ -729,6 +734,7 @@ protected function casts(): array 'featured' => 'boolean', 'is_active' => 'boolean', 'is_official' => 'boolean', + 'works_in_jump' => 'boolean', 'composer_data' => 'array', 'nativephp_data' => 'array', 'last_synced_at' => 'datetime', diff --git a/app/Support/JumpApp.php b/app/Support/JumpApp.php new file mode 100644 index 00000000..5dd3a535 --- /dev/null +++ b/app/Support/JumpApp.php @@ -0,0 +1,54 @@ +.. + */ + public const IOS_APP_ID = 'J68WFCX458.com.bifrosttech.jump'; + + public const IOS_APP_STORE_URL = 'https://apps.apple.com/us/app/bifrost-jump/id6757173334'; + + public const ANDROID_PACKAGE = 'com.bifrosttech.jump'; + + /** + * SHA-256 of Jump's Play app-signing cert (public, not a secret). + */ + public const ANDROID_SHA256 = 'D8:31:4E:55:E5:FF:06:17:D8:49:EA:3B:1F:BF:6C:58:B3:8D:AD:2C:30:CA:13:D2:CA:42:B0:85:B4:7D:CB:38'; + + public const ANDROID_PLAY_STORE_URL = 'https://play.google.com/store/apps/details?id=com.bifrosttech.jump'; + + /** + * The only domain whose deep links open in Jump. + */ + public const CANONICAL_DOMAIN = 'https://nativephp.com'; + + /** + * Marks a visit as originating from a scanned QR code. + */ + public const QR_PARAM = 'jump=qr'; + + /** + * Build the scannable deep link for a docs page. + * + * Deliberately pinned to the canonical domain rather than the current host: + * Jump only claims links for nativephp.com (see {@see ApplinksController}), + * and a phone can't resolve a local or preview hostname anyway — so a QR + * built from url() would be unscannable everywhere except production. + */ + public static function docsDeepLink(string $path): string + { + return self::CANONICAL_DOMAIN.'/'.ltrim($path, '/').'?'.self::QR_PARAM; + } +} diff --git a/app/Support/QrCode.php b/app/Support/QrCode.php new file mode 100644 index 00000000..122cc91d --- /dev/null +++ b/app/Support/QrCode.php @@ -0,0 +1,41 @@ + QROutputInterface::MARKUP_SVG, + 'outputBase64' => false, + 'eccLevel' => EccLevel::M, + 'addQuietzone' => true, + 'quietzoneSize' => 2, + 'drawLightModules' => false, + 'svgAddXmlHeader' => false, + 'cssClass' => 'jump-qr', + ]); + + $svg = (new QRCodeGenerator($options))->render($data); + + // chillerlan emits a viewBox-only (no width/height) so it can scale; + // pin it to fill its container as a crisp square. + return str_replace('state(fn (array $attributes) => [ + 'works_in_jump' => true, + ]); + } + public function free(): static { return $this->state(fn (array $attributes) => [ diff --git a/database/migrations/2026_07_24_112852_add_works_in_jump_to_plugins_table.php b/database/migrations/2026_07_24_112852_add_works_in_jump_to_plugins_table.php new file mode 100644 index 00000000..e04af3ba --- /dev/null +++ b/database/migrations/2026_07_24_112852_add_works_in_jump_to_plugins_table.php @@ -0,0 +1,28 @@ +boolean('works_in_jump')->default(false)->after('mobile_min_version'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('plugins', function (Blueprint $table) { + $table->dropColumn('works_in_jump'); + }); + } +}; diff --git a/resources/views/components/docs/jump-app-overlay.blade.php b/resources/views/components/docs/jump-app-overlay.blade.php new file mode 100644 index 00000000..11cd64e2 --- /dev/null +++ b/resources/views/components/docs/jump-app-overlay.blade.php @@ -0,0 +1,101 @@ +{{-- + Shown when a QR visitor lands here without the Jump app installed. + + Must stay outside the desktop-only right sidebar: it is the phone — not the + desktop — that renders this, after the deep link falls back to the browser. +--}} + diff --git a/resources/views/components/docs/jump-preview.blade.php b/resources/views/components/docs/jump-preview.blade.php new file mode 100644 index 00000000..f8e3780f --- /dev/null +++ b/resources/views/components/docs/jump-preview.blade.php @@ -0,0 +1,54 @@ +@props(['path']) + +@php + // Points at this page on nativephp.com with a marker query param. Jump claims + // /docs/* deep links there, so a device with the app opens it natively; a + // device without it falls back to the browser, where the marker triggers the + // "get the app" prompt. + $deepLink = \App\Support\JumpApp::docsDeepLink($path); +@endphp + +{{-- Lives in the right sidebar, which is itself desktop-only (hidden xl:flex) --}} +
+ + +
+ {{-- Dark QR modules need a light background in both colour schemes --}} + + +

+ Scan with your phone to preview this component live. +

+
+
diff --git a/resources/views/components/docs/toc-and-sponsors.blade.php b/resources/views/components/docs/toc-and-sponsors.blade.php index ea95251f..676627c1 100644 --- a/resources/views/components/docs/toc-and-sponsors.blade.php +++ b/resources/views/components/docs/toc-and-sponsors.blade.php @@ -1,6 +1,10 @@ {{-- Copy as Markdown Button --}} +@isset($beforeAds) + {{ $beforeAds }} +@endisset +
diff --git a/resources/views/components/plugin-card.blade.php b/resources/views/components/plugin-card.blade.php index d68401e4..dd3663df 100644 --- a/resources/views/components/plugin-card.blade.php +++ b/resources/views/components/plugin-card.blade.php @@ -20,19 +20,31 @@ class="size-12 shrink-0 rounded-xl object-cover"
@endif - @if ($plugin->isPaid() && $plugin->isOfficial() && auth()->user()?->hasUltraAccess()) - - Included with Ultra - - @elseif ($plugin->isPaid()) - - Premium - - @else - - Free - - @endif +
+ @if ($plugin->isPaid() && $plugin->isOfficial() && auth()->user()?->hasUltraAccess()) + + Included with Ultra + + @elseif ($plugin->isPaid()) + + Premium + + @else + + Free + + @endif + + @if ($plugin->worksInJump()) + + + @endif +
diff --git a/resources/views/docs/index.blade.php b/resources/views/docs/index.blade.php index ab2b5459..2e8aeb82 100644 --- a/resources/views/docs/index.blade.php +++ b/resources/views/docs/index.blade.php @@ -3,6 +3,13 @@ @endpush +@php + // Jump previews EDGE components, which only exist in the Mobile v4 docs. + $showJumpPreview = $platform === 'mobile' + && (string) $version === '4' + && str_starts_with((string) request()->route('page'), 'edge-components/'); +@endphp + {!! $navigation !!} @@ -25,6 +32,13 @@ @endif + {{-- EDGE component pages can be previewed live in the Jump app --}} + @if ($showJumpPreview) + + + + @endif + {{-- Ad rotation --}} + {{-- Prompts QR visitors who don't have Jump installed. Kept out of the + desktop-only sidebar, since it renders on the phone. --}} + @if ($showJumpPreview) + + @endif + {{-- Table of contents --}}
diff --git a/resources/views/plugin-show.blade.php b/resources/views/plugin-show.blade.php index be18bc15..38045a0a 100644 --- a/resources/views/plugin-show.blade.php +++ b/resources/views/plugin-show.blade.php @@ -95,6 +95,17 @@ class="text-2xl font-bold sm:text-3xl" {{ $plugin->description }}

@endif + @if ($plugin->worksInJump()) +
+ + +
+ @endif
diff --git a/tests/Feature/ApplinksTest.php b/tests/Feature/ApplinksTest.php new file mode 100644 index 00000000..4618dc59 --- /dev/null +++ b/tests/Feature/ApplinksTest.php @@ -0,0 +1,31 @@ +get('/.well-known/assetlinks.json') + ->assertStatus(200) + ->assertJsonFragment(['package_name' => JumpApp::ANDROID_PACKAGE]) + ->assertJsonFragment(['sha256_cert_fingerprints' => [JumpApp::ANDROID_SHA256]]); + } + + public function test_apple_app_site_association_exposes_the_jump_ios_identity(): void + { + $this->get('/.well-known/apple-app-site-association') + ->assertStatus(200) + ->assertJsonFragment(['appIDs' => [JumpApp::IOS_APP_ID]]); + } + + public function test_apple_app_site_association_scopes_to_docs_deep_links(): void + { + $this->get('/.well-known/apple-app-site-association') + ->assertStatus(200) + ->assertJsonFragment(['/' => '/docs/*']); + } +} diff --git a/tests/Feature/Docs/DocsCachingTest.php b/tests/Feature/Docs/DocsCachingTest.php new file mode 100644 index 00000000..e284fd32 --- /dev/null +++ b/tests/Feature/Docs/DocsCachingTest.php @@ -0,0 +1,59 @@ + 'test-token']); + Http::fake([ + '*' => Http::response(['blocks' => []], 200), + ]); + } + + public function test_local_docs_request_does_not_flush_the_entire_cache(): void + { + config(['app.env' => 'local']); + Cache::put('unrelated-key', 'keep-me', now()->addHour()); + + $this->get('/docs/mobile/4/edge-components/stack')->assertStatus(200); + + $this->assertSame('keep-me', Cache::get('unrelated-key')); + } + + public function test_local_docs_request_does_not_cache_page_properties(): void + { + config(['app.env' => 'local']); + + $this->get('/docs/mobile/4/edge-components/stack')->assertStatus(200); + + $this->assertFalse(Cache::has('docs_mobile_4_edge-components/stack')); + } + + public function test_non_local_docs_request_caches_page_properties(): void + { + config(['app.env' => 'production']); + + $this->get('/docs/mobile/4/edge-components/stack')->assertStatus(200); + + $this->assertTrue(Cache::has('docs_mobile_4_edge-components/stack')); + } +} diff --git a/tests/Feature/Docs/JumpPreviewTest.php b/tests/Feature/Docs/JumpPreviewTest.php new file mode 100644 index 00000000..3379f46f --- /dev/null +++ b/tests/Feature/Docs/JumpPreviewTest.php @@ -0,0 +1,159 @@ + 'test-token']); + Http::fake([ + '*' => Http::response(['blocks' => []], 200), + ]); + } + + public function test_edge_component_page_shows_the_jump_qr_and_store_overlay(): void + { + $response = $this->get('/docs/mobile/4/edge-components/stack'); + + $response->assertStatus(200) + ->assertSee('Preview in Jump') + ->assertSee('Get the Jump app') + ->assertSee(JumpApp::IOS_APP_STORE_URL, false) + ->assertSee(JumpApp::ANDROID_PLAY_STORE_URL, false); + } + + public function test_qr_deep_link_targets_this_page_with_the_marker_param(): void + { + $this->get('/docs/mobile/4/edge-components/badge') + ->assertStatus(200) + ->assertSee('edge-components/badge?jump=qr', false); + } + + public function test_qr_deep_link_is_pinned_to_the_canonical_domain(): void + { + // Jump only claims deep links for nativephp.com, so the QR must encode + // that domain regardless of the host the docs are being served from. + $this->get('/docs/mobile/4/edge-components/badge') + ->assertStatus(200) + ->assertSee('https://nativephp.com/docs/mobile/4/edge-components/badge?jump=qr', false); + } + + public function test_qr_card_renders_in_the_desktop_only_right_sidebar(): void + { + $html = $this->get('/docs/mobile/4/edge-components/stack') + ->assertStatus(200) + ->getContent(); + + // The right sidebar is the desktop-only region and follows . + $this->assertGreaterThan( + strrpos($html, ''), + strpos($html, 'Preview in Jump'), + 'The QR card should render inside the right sidebar.' + ); + } + + public function test_qr_card_sits_between_the_copy_button_and_the_ads(): void + { + $html = $this->get('/docs/mobile/4/edge-components/stack') + ->assertStatus(200) + ->getContent(); + + $copyButton = strrpos($html, 'Copy as Markdown'); + $qrCard = strpos($html, 'Preview in Jump'); + $ads = strpos($html, 'Become a Partner', $qrCard); + + $this->assertGreaterThan($copyButton, $qrCard, 'The QR card should follow the Copy as Markdown button.'); + $this->assertGreaterThan($qrCard, $ads, 'The QR card should come before the ads.'); + } + + public function test_qr_card_is_collapsed_to_its_title_by_default(): void + { + $html = $this->get('/docs/mobile/4/edge-components/stack') + ->assertStatus(200) + ->getContent(); + + // The card occupies the sidebar slot between the copy button and the ads. + $start = strrpos($html, 'Copy as Markdown'); + $end = strpos($html, 'Become a Partner', $start); + $cardMarkup = substr($html, $start, $end - $start); + + $this->assertStringContainsString('Preview in Jump', $cardMarkup); + $this->assertStringContainsString('x-data="{ open: false }"', $cardMarkup); + $this->assertStringContainsString('open = !open', $cardMarkup); + $this->assertStringContainsString('x-collapse', $cardMarkup); + } + + public function test_store_overlay_stays_outside_the_desktop_only_sidebar(): void + { + $html = $this->get('/docs/mobile/4/edge-components/stack') + ->assertStatus(200) + ->getContent(); + + // Phones render the overlay, so it must not be hidden with the sidebar. + $this->assertLessThan( + strrpos($html, ''), + strpos($html, 'Get the Jump app'), + 'The overlay should render in the always-visible article body.' + ); + } + + public function test_non_edge_docs_page_has_no_jump_preview(): void + { + $this->get('/docs/mobile/4/getting-started/introduction') + ->assertStatus(200) + ->assertDontSee('Preview in Jump') + ->assertDontSee('Get the Jump app') + ->assertDontSee('jump=qr', false); + } + + /** + * EDGE sections also exist in Mobile v2 and v3, but Jump only previews v4. + * + * @return array + */ + public static function nonV4EdgePagesProvider(): array + { + return [ + 'mobile v2 edge components' => ['/docs/mobile/2/edge-components/bottom-nav'], + 'mobile v3 edge components' => ['/docs/mobile/3/edge-components/bottom-nav'], + ]; + } + + #[DataProvider('nonV4EdgePagesProvider')] + public function test_older_mobile_edge_pages_have_no_jump_preview(string $url): void + { + $this->get($url) + ->assertStatus(200) + ->assertDontSee('Preview in Jump') + ->assertDontSee('Get the Jump app') + ->assertDontSee('jump=qr', false); + } + + public function test_desktop_docs_have_no_jump_preview(): void + { + $this->get('/docs/desktop/2/the-basics/windows') + ->assertStatus(200) + ->assertDontSee('Preview in Jump') + ->assertDontSee('Get the Jump app') + ->assertDontSee('jump=qr', false); + } +} diff --git a/tests/Feature/PluginJumpIndicatorTest.php b/tests/Feature/PluginJumpIndicatorTest.php new file mode 100644 index 00000000..d7b863f3 --- /dev/null +++ b/tests/Feature/PluginJumpIndicatorTest.php @@ -0,0 +1,73 @@ +create()->refresh(); + + $this->assertFalse($plugin->works_in_jump); + $this->assertFalse($plugin->worksInJump()); + } + + public function test_works_in_jump_helper_reflects_the_column(): void + { + $plugin = Plugin::factory()->worksInJump()->create(); + + $this->assertTrue($plugin->works_in_jump); + $this->assertTrue($plugin->worksInJump()); + } + + public function test_detail_page_shows_pill_when_plugin_works_in_jump(): void + { + $plugin = Plugin::factory()->approved()->worksInJump()->create(); + + $this->get(route('plugins.show', $plugin->routeParams())) + ->assertStatus(200) + ->assertSee('Works in Jump'); + } + + public function test_detail_page_hides_pill_when_plugin_does_not_work_in_jump(): void + { + $plugin = Plugin::factory()->approved()->create(); + + $this->get(route('plugins.show', $plugin->routeParams())) + ->assertStatus(200) + ->assertDontSee('Works in Jump'); + } + + public function test_plugin_card_shows_badge_when_plugin_works_in_jump(): void + { + Plugin::factory()->approved()->worksInJump()->create(); + + Livewire::test(PluginDirectory::class) + ->assertSee('Works in Jump'); + } + + public function test_plugin_card_hides_badge_when_plugin_does_not_work_in_jump(): void + { + Plugin::factory()->approved()->create(); + + Livewire::test(PluginDirectory::class) + ->assertDontSee('Works in Jump'); + } +} diff --git a/tests/Unit/Support/JumpAppTest.php b/tests/Unit/Support/JumpAppTest.php new file mode 100644 index 00000000..ef53be87 --- /dev/null +++ b/tests/Unit/Support/JumpAppTest.php @@ -0,0 +1,39 @@ +assertSame( + 'https://nativephp.com/docs/mobile/4/edge-components/stack?jump=qr', + $link + ); + } + + public function test_docs_deep_link_tolerates_a_leading_slash(): void + { + $link = JumpApp::docsDeepLink('/docs/mobile/4/edge-components/badge'); + + $this->assertSame( + 'https://nativephp.com/docs/mobile/4/edge-components/badge?jump=qr', + $link + ); + } + + public function test_docs_deep_link_ignores_the_configured_app_url(): void + { + config(['app.url' => 'https://keen-pony.test']); + + $this->assertStringStartsWith( + 'https://nativephp.com/', + JumpApp::docsDeepLink('docs/mobile/4/edge-components/stack') + ); + } +} diff --git a/tests/Unit/Support/QrCodeTest.php b/tests/Unit/Support/QrCodeTest.php new file mode 100644 index 00000000..a088f35c --- /dev/null +++ b/tests/Unit/Support/QrCodeTest.php @@ -0,0 +1,31 @@ +assertStringContainsString('assertStringContainsString('', $svg); + } + + public function test_it_returns_raw_markup_not_a_base64_data_uri(): void + { + $svg = QrCode::svg('https://example.test'); + + $this->assertStringNotContainsString('data:image', $svg); + } + + public function test_it_pins_the_svg_to_fill_its_container(): void + { + $svg = QrCode::svg('https://example.test'); + + $this->assertStringContainsString('width:100%', $svg); + } +}