pinokiod 8.0.18 → 8.0.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pinokiod",
3
- "version": "8.0.18",
3
+ "version": "8.0.20",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "scripts": {
package/server/index.js CHANGED
@@ -7632,7 +7632,7 @@ class Server {
7632
7632
  const category = classifyPluginMenuItem(pluginItem)
7633
7633
  const title = pluginItem?.title || pluginItem?.text || pluginItem?.name || "Plugin"
7634
7634
  const hasInstalledCheck = PluginSources.isInstalledCheck(pluginItem?.installed)
7635
- const installed = hasInstalledCheck ? await evaluatePluginInstalled(pluginItem) : null
7635
+ const installed = null
7636
7636
  return {
7637
7637
  index,
7638
7638
  title,
@@ -8262,6 +8262,11 @@ class Server {
8262
8262
  res.status(404).send("Plugin not found.")
8263
8263
  return
8264
8264
  }
8265
+ if (plugin.hasInstalledCheck) {
8266
+ const validation = await contentValidation.validatePluginByPath(plugin.pluginPath)
8267
+ const config = validation.valid && validation.context ? validation.context.config : null
8268
+ plugin.installed = config ? await evaluatePluginInstalled(config) : null
8269
+ }
8265
8270
  const [apps, shareState, sidebarContext] = await Promise.all([
8266
8271
  collectPluginApps(),
8267
8272
  buildPluginShareState(plugin),
@@ -3013,7 +3013,15 @@ if (typeof window !== 'undefined' && !window.__pinokioNavigateListenerInstalled)
3013
3013
  return;
3014
3014
  }
3015
3015
  }
3016
- const frame = document.activeElement;
3016
+ let frame = document.activeElement;
3017
+ if (
3018
+ (!frame || frame.tagName !== 'IFRAME') &&
3019
+ new URLSearchParams(window.location.search).get('mode') === 'explore' &&
3020
+ event.origin === 'https://pinokio.co'
3021
+ ) {
3022
+ const exploreFrame = document.querySelector('main > iframe');
3023
+ if (exploreFrame && event.source === exploreFrame.contentWindow) frame = exploreFrame;
3024
+ }
3017
3025
  if (!frame || frame.tagName !== 'IFRAME') {
3018
3026
  try {
3019
3027
  console.warn('[pinokio:navigate] no active iframe');
@@ -3029,6 +3037,23 @@ if (typeof window !== 'undefined' && !window.__pinokioNavigateListenerInstalled)
3029
3037
  } catch (_) {}
3030
3038
  return;
3031
3039
  }
3040
+ const localInstall =
3041
+ target.origin === 'http://localhost:42000' &&
3042
+ (
3043
+ (target.pathname === '/pinokio/download' && Boolean(target.searchParams.get('uri'))) ||
3044
+ (
3045
+ target.pathname === '/checkpoints' &&
3046
+ Boolean(target.searchParams.get('registry')) &&
3047
+ /^(?:sha256:)?[0-9a-f]{64}$/i.test(target.searchParams.get('hash') || '')
3048
+ )
3049
+ );
3050
+ const lanExplore =
3051
+ new URLSearchParams(window.location.search).get('mode') === 'explore' &&
3052
+ !['localhost', '127.0.0.1', '::1', '[::1]'].includes(window.location.hostname) &&
3053
+ event.origin === 'https://pinokio.co' && event.source === frame.contentWindow;
3054
+ if (lanExplore && localInstall) {
3055
+ target = new URL(`${target.pathname}${target.search}${target.hash}`, window.location.origin);
3056
+ }
3032
3057
  if (target.origin !== window.location.origin) {
3033
3058
  try {
3034
3059
  console.warn('[pinokio:navigate] blocked origin', target.origin);
@@ -590,7 +590,17 @@
590
590
  return `${detailUrl}${separator}next=install`
591
591
  }
592
592
  }
593
- redirectToPluginInstallIfNeeded(tool) {
593
+ async redirectToPluginInstallIfNeeded(tool) {
594
+ if (tool && tool.hasInstall === true && tool.hasInstalledCheck === true && typeof tool.installed !== 'boolean') {
595
+ const pluginPath = typeof tool.pluginPath === 'string' ? tool.pluginPath.trim() : ''
596
+ if (pluginPath) {
597
+ try {
598
+ const response = await fetch(`/api/plugin/install-state?path=${encodeURIComponent(pluginPath)}`)
599
+ const state = response.ok ? await response.json() : null
600
+ tool.installed = state && typeof state.installed === 'boolean' ? state.installed : null
601
+ } catch (_) {}
602
+ }
603
+ }
594
604
  const href = this.pluginInstallHref(tool)
595
605
  if (!href) {
596
606
  return false
@@ -992,7 +1002,7 @@
992
1002
  return
993
1003
  }
994
1004
  error.textContent = ''
995
- if (this.redirectToPluginInstallIfNeeded(tool)) {
1005
+ if (await this.redirectToPluginInstallIfNeeded(tool)) {
996
1006
  close()
997
1007
  return
998
1008
  }
@@ -544,6 +544,7 @@ body.dark .snapshot-comment {
544
544
  color: royalblue;
545
545
  }
546
546
  #sidebar-toggle {
547
+ --sidebar-edge-hint-rgb: 37, 99, 235;
547
548
  color: #0f172a;
548
549
  position: relative;
549
550
  width: 40px;
@@ -554,6 +555,7 @@ body.dark .snapshot-comment {
554
555
  transition: background-color 140ms cubic-bezier(0.25, 1, 0.5, 1), box-shadow 140ms cubic-bezier(0.25, 1, 0.5, 1), color 140ms cubic-bezier(0.25, 1, 0.5, 1);
555
556
  }
556
557
  body.dark #sidebar-toggle {
558
+ --sidebar-edge-hint-rgb: 96, 165, 250;
557
559
  color: rgba(248, 250, 252, 0.92);
558
560
  }
559
561
  #sidebar-toggle[aria-expanded="true"] {
@@ -570,6 +572,32 @@ body.dark #sidebar-toggle[aria-expanded="true"] {
570
572
  display: inline-flex;
571
573
  align-items: center;
572
574
  justify-content: center;
575
+ transition: filter 140ms cubic-bezier(0.25, 1, 0.5, 1), transform 140ms cubic-bezier(0.25, 1, 0.5, 1);
576
+ }
577
+ #sidebar-toggle.sidebar-edge-hint {
578
+ color: rgb(30, 64, 175);
579
+ background: rgba(var(--sidebar-edge-hint-rgb), 0.09);
580
+ box-shadow:
581
+ 0 0 0 1px rgba(var(--sidebar-edge-hint-rgb), 0.42),
582
+ 0 0 8px rgba(var(--sidebar-edge-hint-rgb), 0.28);
583
+ }
584
+ body.dark #sidebar-toggle.sidebar-edge-hint {
585
+ color: rgb(219, 234, 254);
586
+ background: rgba(var(--sidebar-edge-hint-rgb), 0.12);
587
+ box-shadow:
588
+ 0 0 0 1px rgba(147, 197, 253, 0.55),
589
+ 0 0 10px rgba(var(--sidebar-edge-hint-rgb), 0.38);
590
+ }
591
+ #sidebar-toggle.sidebar-edge-hint .sidebar-toggle-glyph {
592
+ transform: scale(1.03);
593
+ filter:
594
+ drop-shadow(0 0 1px rgba(var(--sidebar-edge-hint-rgb), 0.82))
595
+ drop-shadow(0 0 5px rgba(var(--sidebar-edge-hint-rgb), 0.55));
596
+ }
597
+ @media (prefers-reduced-motion: reduce) {
598
+ .sidebar-toggle-glyph {
599
+ transition: none;
600
+ }
573
601
  }
574
602
  .sidebar-toggle-frame {
575
603
  position: relative;
@@ -2664,6 +2692,65 @@ body.dark .appcanvas.panel-open .menu-actions #ask-ai-tab.selected {
2664
2692
  body.dark .appcanvas.vertical .menu-actions {
2665
2693
  border-top: none;
2666
2694
  }
2695
+ .appcanvas.vertical .sidebar-hover-preference {
2696
+ width: 100%;
2697
+ padding-top: 5px;
2698
+ border-top: 1px solid var(--pinokio-sidebar-separator);
2699
+ }
2700
+ .appcanvas.vertical .sidebar-hover-toggle,
2701
+ .appcanvas.vertical .sidebar-hover-toggle .tab {
2702
+ width: 100%;
2703
+ }
2704
+ .sidebar-hover-switch {
2705
+ display: inline-flex;
2706
+ align-items: center;
2707
+ width: 30px;
2708
+ height: 16px;
2709
+ margin-left: auto;
2710
+ padding: 2px;
2711
+ border: 1px solid rgba(100, 116, 139, 0.32);
2712
+ border-radius: 999px;
2713
+ background: rgba(148, 163, 184, 0.24);
2714
+ box-sizing: border-box;
2715
+ flex: 0 0 auto;
2716
+ transition: background 140ms ease, border-color 140ms ease;
2717
+ }
2718
+ .sidebar-hover-switch-thumb {
2719
+ width: 10px;
2720
+ height: 10px;
2721
+ border-radius: 50%;
2722
+ background: rgba(71, 85, 105, 0.82);
2723
+ transform: translateX(0);
2724
+ transition: transform 140ms cubic-bezier(0.25, 1, 0.5, 1), background 140ms ease;
2725
+ }
2726
+ .sidebar-hover-toggle[aria-checked="true"] .sidebar-hover-switch {
2727
+ border-color: rgba(15, 23, 42, 0.68);
2728
+ background: rgba(15, 23, 42, 0.82);
2729
+ }
2730
+ .sidebar-hover-toggle[aria-checked="true"] .sidebar-hover-switch-thumb {
2731
+ background: rgba(248, 250, 252, 0.96);
2732
+ transform: translateX(14px);
2733
+ }
2734
+ body.dark .sidebar-hover-switch {
2735
+ border-color: rgba(148, 163, 184, 0.32);
2736
+ background: rgba(148, 163, 184, 0.18);
2737
+ }
2738
+ body.dark .sidebar-hover-switch-thumb {
2739
+ background: rgba(203, 213, 225, 0.76);
2740
+ }
2741
+ body.dark .sidebar-hover-toggle[aria-checked="true"] .sidebar-hover-switch {
2742
+ border-color: rgba(226, 232, 240, 0.62);
2743
+ background: rgba(226, 232, 240, 0.72);
2744
+ }
2745
+ body.dark .sidebar-hover-toggle[aria-checked="true"] .sidebar-hover-switch-thumb {
2746
+ background: rgba(15, 23, 42, 0.9);
2747
+ }
2748
+ @media (prefers-reduced-motion: reduce) {
2749
+ .sidebar-hover-switch,
2750
+ .sidebar-hover-switch-thumb {
2751
+ transition: none;
2752
+ }
2753
+ }
2667
2754
  .appcanvas.vertical > aside .menu-actions .header-item + .header-item {
2668
2755
  box-shadow: none !important;
2669
2756
  }
@@ -8346,6 +8433,17 @@ body.dark .pinokio-custom-terminal-header {
8346
8433
  <% if (type === 'browse') { %>
8347
8434
  <%- include('./partials/fs_status') %>
8348
8435
  <% } %>
8436
+ <% if (type === 'run') { %>
8437
+ <div class="sidebar-hover-preference">
8438
+ <button type="button" class="btn header-item sidebar-hover-toggle" role="switch" aria-checked="true" data-app-sidebar-show-on-hover data-tippy-content="Show the sidebar when hovering over the left edge">
8439
+ <div class="tab">
8440
+ <i class="fa-solid fa-eye menu-action-leading-icon" aria-hidden="true"></i>
8441
+ <div class="display">Show on hover</div>
8442
+ <span class="sidebar-hover-switch" aria-hidden="true"><span class="sidebar-hover-switch-thumb"></span></span>
8443
+ </div>
8444
+ </button>
8445
+ </div>
8446
+ <% } %>
8349
8447
  <button type='button' id='logs-toggle' class="btn header-item" data-tippy-content="Get help with logs" aria-expanded="false" aria-controls="app-logs-drawer">
8350
8448
  <div class='tab'>
8351
8449
  <i class="fa-solid fa-circle-question menu-action-leading-icon"></i>
@@ -11463,7 +11561,7 @@ const rerenderMenuSection = (container, html) => {
11463
11561
  : "Protection beta is off for this app. You can change it later from the Run tab."
11464
11562
  })
11465
11563
  }
11466
- maybeShowProtectionInstallToast()
11564
+ // maybeShowProtectionInstallToast()
11467
11565
  const buildPluginLaunchFromQuery = () => {
11468
11566
  try {
11469
11567
  const pageUrl = new URL(window.location.href)
@@ -16498,6 +16596,7 @@ const rerenderMenuSection = (container, html) => {
16498
16596
  const aside = document.getElementById("app-sidebar")
16499
16597
  const toggle = document.getElementById("sidebar-toggle")
16500
16598
  const peekTrigger = document.querySelector("[data-app-sidebar-peek-trigger]")
16599
+ const showOnHoverToggle = document.querySelector("[data-app-sidebar-show-on-hover]")
16501
16600
  if (!appcanvas || !aside || !toggle) {
16502
16601
  if (toggle) {
16503
16602
  toggle.classList.add("hidden")
@@ -16509,6 +16608,21 @@ const rerenderMenuSection = (container, html) => {
16509
16608
  const storageKey = workspaceName
16510
16609
  ? `pinokio.sidebar-collapsed:${workspaceName}`
16511
16610
  : `pinokio.sidebar-collapsed:${location.pathname}`
16611
+ const showOnHoverStorageKey = "pinokio.sidebar-show-on-hover"
16612
+ const legacyAutoShowStorageKey = "pinokio.sidebar-auto-show"
16613
+ let showOnHoverEnabled = true
16614
+ try {
16615
+ const storedShowOnHover = window.localStorage.getItem(showOnHoverStorageKey)
16616
+ const storedPreference = storedShowOnHover === null
16617
+ ? window.localStorage.getItem(legacyAutoShowStorageKey)
16618
+ : storedShowOnHover
16619
+ showOnHoverEnabled = storedPreference !== "0"
16620
+ } catch (_) {}
16621
+
16622
+ const syncShowOnHoverToggle = () => {
16623
+ if (!showOnHoverToggle) return
16624
+ showOnHoverToggle.setAttribute("aria-checked", showOnHoverEnabled ? "true" : "false")
16625
+ }
16512
16626
 
16513
16627
  const updateToggle = (collapsed) => {
16514
16628
  const expanded = !collapsed
@@ -16524,20 +16638,10 @@ const rerenderMenuSection = (container, html) => {
16524
16638
  }
16525
16639
 
16526
16640
  const desktopPeekMq = window.matchMedia("(min-width: 769px) and (hover: hover) and (pointer: fine)")
16527
- const shouldAutoPeekOnIdle = <%- JSON.stringify(type === "run") %>
16528
16641
  let peekCloseTimer = 0
16529
16642
 
16530
16643
  const isCollapsed = () => appcanvas.classList.contains("sidebar-collapsed")
16531
16644
  const isPeeking = () => appcanvas.classList.contains("sidebar-peeking")
16532
- const hasVisibleBrowserSurface = () => {
16533
- const browserview = document.querySelector("main.browserview")
16534
- if (!browserview) return false
16535
- if (browserview.querySelector("iframe:not(.hidden)")) return true
16536
- const networkStatus = document.getElementById("browserview-network-status")
16537
- if (networkStatus && !networkStatus.hidden) return true
16538
- const launchStatus = document.querySelector("[data-launch-requirements-status]")
16539
- return Boolean(launchStatus && !launchStatus.hidden)
16540
- }
16541
16645
  const syncSidebarVisibility = () => {
16542
16646
  const peeking = isPeeking()
16543
16647
  aside.setAttribute("aria-hidden", isCollapsed() && !peeking ? "true" : "false")
@@ -16549,9 +16653,24 @@ const rerenderMenuSection = (container, html) => {
16549
16653
  const setPeeking = (peeking) => {
16550
16654
  const next = !!peeking && canPeek()
16551
16655
  window.clearTimeout(peekCloseTimer)
16656
+ if (next) {
16657
+ toggle.classList.remove("sidebar-edge-hint")
16658
+ }
16552
16659
  appcanvas.classList.toggle("sidebar-peeking", next)
16553
16660
  syncSidebarVisibility()
16554
16661
  }
16662
+ const setShowOnHoverEnabled = (enabled, options = {}) => {
16663
+ showOnHoverEnabled = !!enabled
16664
+ syncShowOnHoverToggle()
16665
+ if (showOnHoverEnabled) {
16666
+ toggle.classList.remove("sidebar-edge-hint")
16667
+ }
16668
+ if (options.persist !== false) {
16669
+ try {
16670
+ window.localStorage.setItem(showOnHoverStorageKey, showOnHoverEnabled ? "1" : "0")
16671
+ } catch (_) {}
16672
+ }
16673
+ }
16555
16674
  const shouldKeepPeekOpen = () => {
16556
16675
  const active = document.activeElement
16557
16676
  return (
@@ -16592,13 +16711,9 @@ const rerenderMenuSection = (container, html) => {
16592
16711
  initialCollapsed = window.localStorage.getItem(storageKey) === "1"
16593
16712
  } catch (_) {}
16594
16713
  setCollapsed(initialCollapsed, { persist: false })
16714
+ syncShowOnHoverToggle()
16595
16715
  requestAnimationFrame(() => {
16596
16716
  appcanvas.classList.add("sidebar-motion-ready")
16597
- requestAnimationFrame(() => {
16598
- if (shouldAutoPeekOnIdle && initialCollapsed && !hasVisibleBrowserSurface()) {
16599
- setPeeking(true)
16600
- }
16601
- })
16602
16717
  })
16603
16718
 
16604
16719
  toggle.addEventListener("click", (event) => {
@@ -16606,11 +16721,28 @@ const rerenderMenuSection = (container, html) => {
16606
16721
  setCollapsed(!appcanvas.classList.contains("sidebar-collapsed"))
16607
16722
  })
16608
16723
 
16724
+ if (showOnHoverToggle) {
16725
+ showOnHoverToggle.addEventListener("click", (event) => {
16726
+ event.preventDefault()
16727
+ event.stopPropagation()
16728
+ setShowOnHoverEnabled(!showOnHoverEnabled)
16729
+ })
16730
+ }
16731
+
16609
16732
  if (peekTrigger) {
16610
- peekTrigger.addEventListener("pointerenter", () => setPeeking(true))
16733
+ peekTrigger.addEventListener("pointerenter", () => {
16734
+ if (showOnHoverEnabled) {
16735
+ setPeeking(true)
16736
+ } else {
16737
+ toggle.classList.add("sidebar-edge-hint")
16738
+ }
16739
+ })
16611
16740
  peekTrigger.addEventListener("focus", () => setPeeking(true))
16612
16741
  peekTrigger.addEventListener("click", () => setPeeking(true))
16613
- peekTrigger.addEventListener("pointerleave", closePeekSoon)
16742
+ peekTrigger.addEventListener("pointerleave", () => {
16743
+ toggle.classList.remove("sidebar-edge-hint")
16744
+ closePeekSoon()
16745
+ })
16614
16746
  peekTrigger.addEventListener("blur", closePeekSoon)
16615
16747
  aside.addEventListener("pointerenter", () => {
16616
16748
  if (isPeeking()) {
@@ -16659,8 +16791,10 @@ const rerenderMenuSection = (container, html) => {
16659
16791
  window.PinokioSidebar = {
16660
16792
  setCollapsed,
16661
16793
  setPeeking,
16794
+ setShowOnHoverEnabled,
16662
16795
  isCollapsed,
16663
- isPeeking
16796
+ isPeeking,
16797
+ isShowOnHoverEnabled: () => showOnHoverEnabled
16664
16798
  }
16665
16799
  })()
16666
16800
  </script>
@@ -177,9 +177,9 @@ body.dark .explore-url {
177
177
  <%- include('partials/home_server_popover_assets') %>
178
178
  <main>
179
179
  <% if (theme === "dark") { %>
180
- <iframe name="<%=schema%>" src="<%=discover_dark%>" <%=agent === "electron" ? "no-referrer" : ""%> allow="fullscreen *;" allowfullscreen></iframe>
180
+ <iframe name="pinokio-explore" src="<%=discover_dark%>" <%=agent === "electron" ? "no-referrer" : ""%> allow="fullscreen *;" allowfullscreen></iframe>
181
181
  <% } else { %>
182
- <iframe name="<%=schema%>" src="<%=discover_light%>" <%=agent === "electron" ? "no-referrer" : ""%> allow="fullscreen *;" allowfullscreen></iframe>
182
+ <iframe name="pinokio-explore" src="<%=discover_light%>" <%=agent === "electron" ? "no-referrer" : ""%> allow="fullscreen *;" allowfullscreen></iframe>
183
183
  <% } %>
184
184
  <%- include('partials/main_sidebar', { selected: 'explore' }) %>
185
185
  </main>
@@ -16,6 +16,8 @@
16
16
  </span>
17
17
  </button>
18
18
  <div class="home-server-popover__panel" data-home-server-panel role="dialog" aria-label="Home Server" hidden>
19
- <div class="home-server-popover__loading">Checking Home Server...</div>
19
+ <div data-home-server-content>
20
+ <div class="home-server-popover__loading">Checking Home Server...</div>
21
+ </div>
20
22
  </div>
21
23
  </div>
@@ -642,8 +642,10 @@ body.dark .home-server-popover__qr-close:focus-visible {
642
642
  root.dataset.homeServerReady = "1"
643
643
  const trigger = root.querySelector("[data-home-server-trigger]")
644
644
  const panel = root.querySelector("[data-home-server-panel]")
645
+ const panelContent = root.querySelector("[data-home-server-content]")
645
646
  let payload = null
646
647
  let pollTimer = null
648
+ let qrReturnUrl = ""
647
649
  const setTriggerHintEnabled = (enabled) => {
648
650
  if (!trigger) {
649
651
  return
@@ -699,11 +701,12 @@ body.dark .home-server-popover__qr-close:focus-visible {
699
701
  existing.remove()
700
702
  }
701
703
  }
702
- const showQrPreview = (url, sourceButton = null) => {
704
+ const showQrPreview = (url) => {
703
705
  if (!panel || !url) {
704
706
  return
705
707
  }
706
708
  removeQrPreview()
709
+ qrReturnUrl = url
707
710
  const safeUrl = escapeHtml(url)
708
711
  const qrUrl = `/qr?data=${encodeURIComponent(url)}&s=7&m=0`
709
712
  panel.insertAdjacentHTML("beforeend", `
@@ -719,11 +722,6 @@ body.dark .home-server-popover__qr-close:focus-visible {
719
722
  </div>
720
723
  <div class="home-server-popover__qr-url" title="${safeUrl}">${safeUrl}</div>
721
724
  </div>`)
722
- const preview = panel.querySelector("[data-home-server-qr-preview]")
723
- if (preview && sourceButton) {
724
- preview.dataset.returnFocus = "true"
725
- root._homeServerQrReturnFocus = sourceButton
726
- }
727
725
  const closeButton = panel.querySelector("[data-home-server-qr-close]")
728
726
  if (closeButton && typeof closeButton.focus === "function") {
729
727
  closeButton.focus()
@@ -1002,19 +1000,18 @@ body.dark .home-server-popover__qr-close:focus-visible {
1002
1000
  document.dispatchEvent(new CustomEvent("pinokio:home-server-status", {
1003
1001
  detail: { status, payload: data || { status } }
1004
1002
  }))
1005
- if (!panel) {
1003
+ if (!panelContent) {
1006
1004
  return
1007
1005
  }
1008
1006
  if (status === "on") {
1009
- panel.innerHTML = renderOn(data)
1007
+ panelContent.innerHTML = renderOn(data)
1010
1008
  } else if (status === "starting") {
1011
- panel.innerHTML = renderStarting(data)
1009
+ panelContent.innerHTML = renderStarting(data)
1012
1010
  } else if (status === "setup") {
1013
- panel.innerHTML = renderSetup(data)
1011
+ panelContent.innerHTML = renderSetup(data)
1014
1012
  } else {
1015
- panel.innerHTML = renderOff(data)
1013
+ panelContent.innerHTML = renderOff(data)
1016
1014
  }
1017
- root._homeServerQrReturnFocus = null
1018
1015
  }
1019
1016
  const fetchState = async () => {
1020
1017
  const response = await fetch("/info/home-server", { cache: "no-store" })
@@ -1072,7 +1069,7 @@ body.dark .home-server-popover__qr-close:focus-visible {
1072
1069
  if (attempts >= 60) {
1073
1070
  stopPolling()
1074
1071
  setStatus("error")
1075
- panel.innerHTML = renderError()
1072
+ panelContent.innerHTML = renderError()
1076
1073
  }
1077
1074
  }
1078
1075
  }, 1500)
@@ -1111,11 +1108,11 @@ body.dark .home-server-popover__qr-close:focus-visible {
1111
1108
  }
1112
1109
  } catch (err) {
1113
1110
  setStatus("error")
1114
- panel.innerHTML = renderError(err && err.message ? err.message : "")
1111
+ panelContent.innerHTML = renderError(err && err.message ? err.message : "")
1115
1112
  }
1116
1113
  }
1117
1114
  const openPanel = async () => {
1118
- if (!panel || !trigger) {
1115
+ if (!panel || !panelContent || !trigger) {
1119
1116
  return
1120
1117
  }
1121
1118
  panel.hidden = false
@@ -1123,14 +1120,14 @@ body.dark .home-server-popover__qr-close:focus-visible {
1123
1120
  trigger.setAttribute("aria-expanded", "true")
1124
1121
  setTriggerHintEnabled(false)
1125
1122
  if (!payload) {
1126
- panel.innerHTML = `<div class="home-server-popover__loading">Checking Home Server...</div>`
1123
+ panelContent.innerHTML = `<div class="home-server-popover__loading">Checking Home Server...</div>`
1127
1124
  }
1128
1125
  try {
1129
1126
  await fetchState()
1130
1127
  refreshPolling(payload)
1131
1128
  } catch (err) {
1132
1129
  setStatus("error")
1133
- panel.innerHTML = renderError(err && err.message ? err.message : "")
1130
+ panelContent.innerHTML = renderError(err && err.message ? err.message : "")
1134
1131
  }
1135
1132
  }
1136
1133
  const closePanel = () => {
@@ -1139,7 +1136,7 @@ body.dark .home-server-popover__qr-close:focus-visible {
1139
1136
  }
1140
1137
  panel.hidden = true
1141
1138
  removeQrPreview()
1142
- root._homeServerQrReturnFocus = null
1139
+ qrReturnUrl = ""
1143
1140
  root.dataset.homeServerOpen = "false"
1144
1141
  trigger.setAttribute("aria-expanded", "false")
1145
1142
  setTriggerHintEnabled(true)
@@ -1192,17 +1189,22 @@ body.dark .home-server-popover__qr-close:focus-visible {
1192
1189
  if (qrButton) {
1193
1190
  event.preventDefault()
1194
1191
  event.stopPropagation()
1195
- showQrPreview(qrButton.getAttribute("data-home-server-qr") || "", qrButton)
1192
+ showQrPreview(qrButton.getAttribute("data-home-server-qr") || "")
1196
1193
  return
1197
1194
  }
1198
1195
  const qrCloseButton = event.target.closest("[data-home-server-qr-close]")
1199
1196
  if (qrCloseButton) {
1200
1197
  event.preventDefault()
1201
1198
  event.stopPropagation()
1199
+ const returnUrl = qrReturnUrl
1202
1200
  removeQrPreview()
1203
- const focusTarget = root._homeServerQrReturnFocus
1204
- root._homeServerQrReturnFocus = null
1205
- if (focusTarget && typeof focusTarget.focus === "function" && document.contains(focusTarget)) {
1201
+ qrReturnUrl = ""
1202
+ const focusTarget = panelContent
1203
+ ? Array.from(panelContent.querySelectorAll("[data-home-server-qr]")).find((button) => {
1204
+ return button.getAttribute("data-home-server-qr") === returnUrl
1205
+ })
1206
+ : null
1207
+ if (focusTarget && typeof focusTarget.focus === "function") {
1206
1208
  focusTarget.focus()
1207
1209
  }
1208
1210
  return
Binary file
@@ -0,0 +1,57 @@
1
+ module.exports = {
2
+ title: "Opencode",
3
+ icon: "icon.png",
4
+ description: "The AI coding agent built for the terminal.",
5
+ link: "https://opencode.ai/",
6
+ path: "plugin",
7
+ async installed(kernel) {
8
+ return Boolean(kernel.which("opencode"))
9
+ },
10
+ install: [{
11
+ method: "shell.run",
12
+ params: {
13
+ message: "npm install -g opencode-ai@latest"
14
+ }
15
+ }],
16
+ uninstall: [{
17
+ method: "shell.run",
18
+ params: {
19
+ message: "npm uninstall -g opencode-ai"
20
+ }
21
+ }, {
22
+ method: "fs.rm",
23
+ params: {
24
+ path: "."
25
+ }
26
+ }],
27
+ update: [{
28
+ method: "shell.run",
29
+ params: {
30
+ message: "git pull",
31
+ }
32
+ }, {
33
+ method: "shell.run",
34
+ params: {
35
+ message: "npm install -g opencode-ai@latest"
36
+ }
37
+ }],
38
+ run: [{
39
+ when: "{{platform === 'win32'}}",
40
+ method: "shell.run",
41
+ params: {
42
+ shell: "bash",
43
+ message: "opencode",
44
+ path: "{{args.cwd}}",
45
+ input: true
46
+ }
47
+ }, {
48
+ when: "{{platform !== 'win32'}}",
49
+ id: "run",
50
+ method: "shell.run",
51
+ params: {
52
+ message: "opencode",
53
+ path: "{{args.cwd}}",
54
+ input: true
55
+ }
56
+ }]
57
+ }
@@ -0,0 +1,14 @@
1
+ const opencode = require("../opencode/pinokio")
2
+
3
+ module.exports = {
4
+ ...opencode,
5
+ title: "Opencode Auto",
6
+ description: "OpenCode with permission prompts automatically approved unless explicitly denied.",
7
+ run: opencode.run.map((step) => ({
8
+ ...step,
9
+ params: {
10
+ ...step.params,
11
+ message: "opencode --auto",
12
+ },
13
+ })),
14
+ }
@@ -0,0 +1,145 @@
1
+ const assert = require("node:assert/strict")
2
+ const fs = require("node:fs/promises")
3
+ const path = require("node:path")
4
+ const test = require("node:test")
5
+ const { JSDOM, VirtualConsole } = require("jsdom")
6
+
7
+ const commonScriptPath = path.resolve(__dirname, "../server/public/common.js")
8
+ const exploreTemplatePath = path.resolve(__dirname, "../server/views/explore.ejs")
9
+
10
+ async function createHarness(url = "http://192.168.1.50:42000/home?mode=explore") {
11
+ const source = await fs.readFile(commonScriptPath, "utf8")
12
+ const virtualConsole = new VirtualConsole()
13
+ virtualConsole.on("jsdomError", (error) => {
14
+ throw error
15
+ })
16
+ const dom = new JSDOM("<!doctype html><body><main><iframe></iframe></main></body>", {
17
+ runScripts: "dangerously",
18
+ pretendToBeVisual: true,
19
+ url,
20
+ virtualConsole,
21
+ beforeParse(window) {
22
+ window.fetch = async () => ({ ok: true, json: async () => ({}) })
23
+ window.matchMedia = () => ({
24
+ matches: false,
25
+ addEventListener() {},
26
+ removeEventListener() {},
27
+ })
28
+ window.ResizeObserver = class {
29
+ observe() {}
30
+ disconnect() {}
31
+ }
32
+ },
33
+ })
34
+ const script = dom.window.document.createElement("script")
35
+ script.textContent = source
36
+ dom.window.document.body.appendChild(script)
37
+ const frame = dom.window.document.querySelector("iframe")
38
+ frame.src = "https://pinokio.co/?embed=1&theme=dark"
39
+ frame.focus()
40
+ assert.equal(dom.window.document.activeElement, frame)
41
+ return { dom, frame }
42
+ }
43
+
44
+ function navigate(dom, frame, target, options = {}) {
45
+ dom.window.dispatchEvent(new dom.window.MessageEvent("message", {
46
+ data: { e: "pinokio:navigate", url: target },
47
+ origin: options.origin || "https://pinokio.co",
48
+ source: options.source === undefined ? frame.contentWindow : options.source,
49
+ }))
50
+ }
51
+
52
+ test("Explore uses a stable frame identity instead of the launcher schema", async () => {
53
+ const source = await fs.readFile(exploreTemplatePath, "utf8")
54
+
55
+ assert.equal(source.match(/name="pinokio-explore"/g)?.length, 2)
56
+ assert.doesNotMatch(source, /name="<%=schema%>"/)
57
+ })
58
+
59
+ test("LAN Explore rebases registry download installs onto the Home Server origin", async (t) => {
60
+ const { dom, frame } = await createHarness()
61
+ t.after(() => dom.window.close())
62
+
63
+ navigate(dom, frame, "http://localhost:42000/pinokio/download?uri=https%3A%2F%2Fgithub.com%2Facme%2Fdemo&path=api&branch=dev")
64
+
65
+ assert.equal(
66
+ frame.src,
67
+ "http://192.168.1.50:42000/pinokio/download?uri=https%3A%2F%2Fgithub.com%2Facme%2Fdemo&path=api&branch=dev",
68
+ )
69
+ })
70
+
71
+ test("LAN Explore handles registry installs when mobile Safari leaves the parent body focused", async (t) => {
72
+ const { dom, frame } = await createHarness()
73
+ t.after(() => dom.window.close())
74
+ dom.window.document.body.tabIndex = -1
75
+ dom.window.document.body.focus()
76
+ assert.equal(dom.window.document.activeElement, dom.window.document.body)
77
+
78
+ navigate(dom, frame, "http://localhost:42000/pinokio/download?uri=https%3A%2F%2Fgithub.com%2Facme%2Fdemo&path=api")
79
+
80
+ assert.equal(
81
+ frame.src,
82
+ "http://192.168.1.50:42000/pinokio/download?uri=https%3A%2F%2Fgithub.com%2Facme%2Fdemo&path=api",
83
+ )
84
+ })
85
+
86
+ test("LAN Explore rebases valid checkpoint installs onto the Home Server origin", async (t) => {
87
+ const { dom, frame } = await createHarness()
88
+ t.after(() => dom.window.close())
89
+ const hash = `sha256%3A${"a".repeat(64)}`
90
+
91
+ navigate(dom, frame, `http://localhost:42000/checkpoints?registry=https%3A%2F%2Fapi.pinokio.co&hash=${hash}&path=api`)
92
+
93
+ assert.equal(
94
+ frame.src,
95
+ `http://192.168.1.50:42000/checkpoints?registry=https%3A%2F%2Fapi.pinokio.co&hash=${hash}&path=api`,
96
+ )
97
+ })
98
+
99
+ const blockedCases = [
100
+ ["unrelated local route", "http://localhost:42000/tasker?vars=url", {}],
101
+ ["download without a URI", "http://localhost:42000/pinokio/download", {}],
102
+ ["checkpoint with an invalid hash", "http://localhost:42000/checkpoints?registry=https%3A%2F%2Fapi.pinokio.co&hash=abc", {}],
103
+ ["different message origin", "http://localhost:42000/pinokio/download?uri=https%3A%2F%2Fgithub.com%2Facme%2Fdemo", { origin: "https://example.com" }],
104
+ ["different message source", "http://localhost:42000/pinokio/download?uri=https%3A%2F%2Fgithub.com%2Facme%2Fdemo", { source: null }],
105
+ ]
106
+
107
+ for (const [label, target, options] of blockedCases) {
108
+ test(`LAN Explore leaves ${label} blocked`, async (t) => {
109
+ const { dom, frame } = await createHarness()
110
+ t.after(() => dom.window.close())
111
+ const original = frame.src
112
+
113
+ navigate(dom, frame, target, options)
114
+
115
+ assert.equal(frame.src, original)
116
+ })
117
+ }
118
+
119
+ test("the additive branch does not run outside Explore", async (t) => {
120
+ const { dom, frame } = await createHarness("http://192.168.1.50:42000/home")
121
+ t.after(() => dom.window.close())
122
+ const original = frame.src
123
+
124
+ navigate(dom, frame, "http://localhost:42000/pinokio/download?uri=https%3A%2F%2Fgithub.com%2Facme%2Fdemo")
125
+
126
+ assert.equal(frame.src, original)
127
+ })
128
+
129
+ test("localhost keeps the existing same-origin navigation behavior", async (t) => {
130
+ const { dom, frame } = await createHarness("http://localhost:42000/home?mode=explore")
131
+ t.after(() => dom.window.close())
132
+
133
+ navigate(dom, frame, "http://localhost:42000/existing-local-route?value=1")
134
+
135
+ assert.equal(frame.src, "http://localhost:42000/existing-local-route?value=1")
136
+ })
137
+
138
+ test("LAN keeps the existing same-origin navigation behavior", async (t) => {
139
+ const { dom, frame } = await createHarness()
140
+ t.after(() => dom.window.close())
141
+
142
+ navigate(dom, frame, "http://192.168.1.50:42000/existing-lan-route?value=1")
143
+
144
+ assert.equal(frame.src, "http://192.168.1.50:42000/existing-lan-route?value=1")
145
+ })
@@ -0,0 +1,109 @@
1
+ const assert = require("node:assert/strict")
2
+ const fs = require("node:fs/promises")
3
+ const path = require("node:path")
4
+ const test = require("node:test")
5
+ const { JSDOM, VirtualConsole } = require("jsdom")
6
+
7
+ const root = path.resolve(__dirname, "..")
8
+ const popoverPath = path.join(root, "server/views/partials/home_server_popover.ejs")
9
+ const assetsPath = path.join(root, "server/views/partials/home_server_popover_assets.ejs")
10
+
11
+ async function waitFor(predicate, message) {
12
+ const deadline = Date.now() + 1000
13
+ while (Date.now() < deadline) {
14
+ if (predicate()) return
15
+ await new Promise((resolve) => setTimeout(resolve, 5))
16
+ }
17
+ assert.fail(`Timed out waiting for ${message}`)
18
+ }
19
+
20
+ async function createHarness() {
21
+ const [popover, assets] = await Promise.all([
22
+ fs.readFile(popoverPath, "utf8"),
23
+ fs.readFile(assetsPath, "utf8")
24
+ ])
25
+ const scriptStart = assets.indexOf("<script>")
26
+ const scriptEnd = assets.lastIndexOf("</script>")
27
+ assert.notEqual(scriptStart, -1)
28
+ assert.notEqual(scriptEnd, -1)
29
+ const script = assets.slice(scriptStart + "<script>".length, scriptEnd)
30
+
31
+ const state = {
32
+ interval: null,
33
+ payload: {
34
+ status: "on",
35
+ apps: [{
36
+ id: "maestro",
37
+ name: "Maestro",
38
+ url: "",
39
+ state: "starting"
40
+ }],
41
+ routes: [{
42
+ name: "Syncthing",
43
+ url: "http://192.168.1.10:42006"
44
+ }]
45
+ }
46
+ }
47
+
48
+ const virtualConsole = new VirtualConsole()
49
+ virtualConsole.on("jsdomError", (error) => {
50
+ throw error
51
+ })
52
+
53
+ const dom = new JSDOM(`<!doctype html><body>${popover}<script>${script}</script></body>`, {
54
+ url: "http://127.0.0.1:42000/",
55
+ runScripts: "dangerously",
56
+ pretendToBeVisual: true,
57
+ virtualConsole,
58
+ beforeParse(window) {
59
+ window.fetch = async () => ({
60
+ ok: true,
61
+ json: async () => JSON.parse(JSON.stringify(state.payload))
62
+ })
63
+ window.setInterval = (callback, delay) => {
64
+ state.interval = { callback, delay }
65
+ return 1
66
+ }
67
+ window.clearInterval = () => {
68
+ state.interval = null
69
+ }
70
+ }
71
+ })
72
+
73
+ await waitFor(
74
+ () => dom.window.document.querySelector('[data-home-server-qr="http://192.168.1.10:42006"]'),
75
+ "initial Home Server content"
76
+ )
77
+
78
+ return { dom, state }
79
+ }
80
+
81
+ test("Home Server polling refreshes content without closing an open QR preview", async (t) => {
82
+ const { dom, state } = await createHarness()
83
+ t.after(() => dom.window.close())
84
+ const { document } = dom.window
85
+ const panel = document.querySelector("[data-home-server-panel]")
86
+ const panelContent = document.querySelector("[data-home-server-content]")
87
+ const trigger = document.querySelector("[data-home-server-trigger]")
88
+
89
+ trigger.click()
90
+ await waitFor(() => state.interval, "Home Server polling")
91
+
92
+ const qrButton = panelContent.querySelector('[data-home-server-qr="http://192.168.1.10:42006"]')
93
+ qrButton.click()
94
+ const preview = panel.querySelector("[data-home-server-qr-preview]")
95
+ assert.ok(preview)
96
+ assert.equal(document.activeElement, preview.querySelector("[data-home-server-qr-close]"))
97
+
98
+ state.payload.routes[0].name = "Syncthing refreshed"
99
+ await state.interval.callback()
100
+
101
+ assert.equal(panel.querySelector("[data-home-server-qr-preview]"), preview)
102
+ assert.equal(qrButton.isConnected, false)
103
+ assert.match(panelContent.textContent, /Syncthing refreshed/)
104
+
105
+ preview.querySelector("[data-home-server-qr-close]").click()
106
+ const refreshedQrButton = panelContent.querySelector('[data-home-server-qr="http://192.168.1.10:42006"]')
107
+ assert.equal(panel.querySelector("[data-home-server-qr-preview]"), null)
108
+ assert.equal(document.activeElement, refreshedQrButton)
109
+ })
@@ -856,17 +856,21 @@ test("static guard: open without launching disables page-load script frame selec
856
856
  assert.match(appView, /if \(!target && !automaticSelectionDisabled && preselected && preselected !== devTab\)/)
857
857
  })
858
858
 
859
- test("static guard: collapsed idle run page peeks without persisting sidebar state", async () => {
859
+ test("static guard: collapsed app sidebar exposes a hover preference", async () => {
860
860
  const appView = await fs.readFile(path.resolve(root, "server/views/app.ejs"), "utf8")
861
861
 
862
- assert.match(appView, /const shouldAutoPeekOnIdle = <%- JSON\.stringify\(type === "run"\) %>/)
863
- assert.match(appView, /const hasVisibleBrowserSurface = \(\) => \{[\s\S]*browserview\.querySelector\("iframe:not\(\.hidden\)"\)[\s\S]*browserview-network-status[\s\S]*data-launch-requirements-status/)
864
- assert.match(appView, /if \(shouldAutoPeekOnIdle && initialCollapsed && !hasVisibleBrowserSurface\(\)\) \{[\s\S]*setPeeking\(true\)/)
862
+ assert.match(appView, /data-app-sidebar-show-on-hover/)
863
+ assert.match(appView, />Show on hover</)
864
+ assert.match(appView, /Show the sidebar when hovering over the left edge/)
865
+ assert.match(appView, /#sidebar-toggle\.sidebar-edge-hint \.sidebar-toggle-glyph/)
866
+ assert.match(appView, /peekTrigger\.addEventListener\("pointerenter", \(\) => \{[\s\S]*if \(showOnHoverEnabled\) \{[\s\S]*setPeeking\(true\)[\s\S]*\} else \{[\s\S]*toggle\.classList\.add\("sidebar-edge-hint"\)/)
867
+ assert.match(appView, /peekTrigger\.addEventListener\("pointerleave", \(\) => \{[\s\S]*toggle\.classList\.remove\("sidebar-edge-hint"\)[\s\S]*closePeekSoon\(\)/)
868
+ assert.doesNotMatch(appView, /shouldAutoShowOnEmptyRunView|hasVisibleBrowserSurface/)
865
869
  assert.match(appView, /setCollapsed\(initialCollapsed, \{ persist: false \}\)/)
866
870
  assert.match(appView, /aside\.addEventListener\("click", \(event\) => \{[\s\S]*target\.closest\("\.reveal, \.revealer, \[data-app-autolaunch-button\]"\)[\s\S]*window\.setTimeout\(\(\) => setPeeking\(false\), 0\)/)
867
871
  })
868
872
 
869
- test("app sidebar auto-peeks on collapsed idle run page and dismisses after click", async () => {
873
+ test("app sidebar hover preference gates hover without closing the active peek", async () => {
870
874
  const appView = await fs.readFile(path.resolve(root, "server/views/app.ejs"), "utf8")
871
875
  const start = appView.indexOf('<script>\n(() => {\n const appcanvas = document.querySelector(".appcanvas")')
872
876
  const end = appView.indexOf('</script>\n<script src="/tab-idle-notifier.js"></script>', start)
@@ -876,23 +880,20 @@ test("app sidebar auto-peeks on collapsed idle run page and dismisses after clic
876
880
  const sidebarScript = appView
877
881
  .slice(start + "<script>\n".length, end)
878
882
  .replace(/<%- JSON\.stringify\(typeof name === "string" \? name : ""\) %>/g, '"test-app"')
879
- .replace(/<%- JSON\.stringify\(type === "run"\) %>/g, "true")
880
883
 
881
- const createDom = (browserSurface = "") => {
884
+ const createDom = () => {
882
885
  return new JSDOM(`<!doctype html>
883
886
  <button id="sidebar-toggle"></button>
884
887
  <div class="appcanvas vertical">
885
888
  <button type="button" data-app-sidebar-peek-trigger></button>
886
889
  <aside id="app-sidebar">
890
+ <button type="button" role="switch" aria-checked="true" data-app-sidebar-show-on-hover>Show on hover</button>
887
891
  <button type="button" class="reveal" id="sidebar-reveal">Downloads</button>
888
892
  <button type="button" class="revealer" id="sidebar-revealer">Changes</button>
889
893
  <button type="button" data-app-autolaunch-button id="sidebar-autolaunch">Autolaunch</button>
890
894
  <button type="button" id="sidebar-action">Start</button>
891
895
  </aside>
892
- <main class="browserview">${browserSurface}</main>
893
- </div>
894
- <div id="browserview-network-status" hidden></div>
895
- <div data-launch-requirements-status hidden></div>`, {
896
+ </div>`, {
896
897
  url: "http://127.0.0.1:42000/v/test-app",
897
898
  runScripts: "outside-only",
898
899
  pretendToBeVisual: true,
@@ -908,6 +909,8 @@ test("app sidebar auto-peeks on collapsed idle run page and dismisses after clic
908
909
 
909
910
  const wait = (ms = 75) => new Promise((resolve) => setTimeout(resolve, ms))
910
911
  const storageKey = "pinokio.sidebar-collapsed:test-app"
912
+ const showOnHoverStorageKey = "pinokio.sidebar-show-on-hover"
913
+ const legacyAutoShowStorageKey = "pinokio.sidebar-auto-show"
911
914
 
912
915
  const dom = createDom()
913
916
  dom.window.localStorage.setItem(storageKey, "1")
@@ -916,10 +919,17 @@ test("app sidebar auto-peeks on collapsed idle run page and dismisses after clic
916
919
 
917
920
  const appcanvas = dom.window.document.querySelector(".appcanvas")
918
921
  const aside = dom.window.document.getElementById("app-sidebar")
922
+ const peekTrigger = dom.window.document.querySelector("[data-app-sidebar-peek-trigger]")
923
+ const sidebarToggle = dom.window.document.getElementById("sidebar-toggle")
919
924
  assert.equal(appcanvas.classList.contains("sidebar-collapsed"), true)
925
+ assert.equal(appcanvas.classList.contains("sidebar-peeking"), false)
926
+ assert.equal(aside.getAttribute("aria-hidden"), "true")
927
+ assert.equal(dom.window.localStorage.getItem(storageKey), "1")
928
+
929
+ peekTrigger.dispatchEvent(new dom.window.Event("pointerenter"))
920
930
  assert.equal(appcanvas.classList.contains("sidebar-peeking"), true)
931
+ assert.equal(sidebarToggle.classList.contains("sidebar-edge-hint"), false)
921
932
  assert.equal(aside.getAttribute("aria-hidden"), "false")
922
- assert.equal(dom.window.localStorage.getItem(storageKey), "1")
923
933
 
924
934
  aside.querySelector("#sidebar-reveal").dispatchEvent(new dom.window.MouseEvent("click", { bubbles: true }))
925
935
  await wait(5)
@@ -940,15 +950,38 @@ test("app sidebar auto-peeks on collapsed idle run page and dismisses after clic
940
950
  assert.equal(appcanvas.classList.contains("sidebar-peeking"), false)
941
951
  assert.equal(dom.window.localStorage.getItem(storageKey), "1")
942
952
 
943
- const activeDom = createDom('<iframe src="about:blank"></iframe>')
944
- activeDom.window.localStorage.setItem(storageKey, "1")
945
- activeDom.window.eval(sidebarScript)
953
+ const disabledDom = createDom()
954
+ disabledDom.window.localStorage.setItem(storageKey, "1")
955
+ disabledDom.window.localStorage.setItem(legacyAutoShowStorageKey, "0")
956
+ disabledDom.window.eval(sidebarScript)
946
957
  await wait()
947
958
 
948
- const activeCanvas = activeDom.window.document.querySelector(".appcanvas")
949
- assert.equal(activeCanvas.classList.contains("sidebar-collapsed"), true)
950
- assert.equal(activeCanvas.classList.contains("sidebar-peeking"), false)
951
- assert.equal(activeDom.window.localStorage.getItem(storageKey), "1")
959
+ const disabledCanvas = disabledDom.window.document.querySelector(".appcanvas")
960
+ const disabledPeekTrigger = disabledDom.window.document.querySelector("[data-app-sidebar-peek-trigger]")
961
+ const showOnHoverToggle = disabledDom.window.document.querySelector("[data-app-sidebar-show-on-hover]")
962
+ const disabledSidebarToggle = disabledDom.window.document.getElementById("sidebar-toggle")
963
+ assert.equal(disabledCanvas.classList.contains("sidebar-collapsed"), true)
964
+ assert.equal(disabledCanvas.classList.contains("sidebar-peeking"), false)
965
+ assert.equal(showOnHoverToggle.getAttribute("aria-checked"), "false")
966
+
967
+ disabledPeekTrigger.dispatchEvent(new disabledDom.window.Event("pointerenter"))
968
+ assert.equal(disabledCanvas.classList.contains("sidebar-peeking"), false)
969
+ assert.equal(disabledSidebarToggle.classList.contains("sidebar-edge-hint"), true)
970
+
971
+ disabledPeekTrigger.dispatchEvent(new disabledDom.window.Event("pointerleave"))
972
+ assert.equal(disabledSidebarToggle.classList.contains("sidebar-edge-hint"), false)
973
+
974
+ disabledPeekTrigger.click()
975
+ assert.equal(disabledCanvas.classList.contains("sidebar-peeking"), true)
976
+ assert.equal(disabledSidebarToggle.classList.contains("sidebar-edge-hint"), false)
977
+ showOnHoverToggle.click()
978
+ assert.equal(showOnHoverToggle.getAttribute("aria-checked"), "true")
979
+ assert.equal(disabledDom.window.localStorage.getItem(showOnHoverStorageKey), "1")
980
+
981
+ showOnHoverToggle.click()
982
+ assert.equal(showOnHoverToggle.getAttribute("aria-checked"), "false")
983
+ assert.equal(disabledDom.window.localStorage.getItem(showOnHoverStorageKey), "0")
984
+ assert.equal(disabledCanvas.classList.contains("sidebar-peeking"), true)
952
985
  })
953
986
 
954
987
  test("app identity popover exposes launcher metadata and remote browser link", async () => {
@@ -0,0 +1,45 @@
1
+ const assert = require("node:assert/strict")
2
+ const test = require("node:test")
3
+
4
+ const opencode = require("../system/plugin/opencode/pinokio")
5
+ const opencodeAuto = require("../system/plugin/opencode-auto/pinokio")
6
+
7
+ test("OpenCode installed status follows executable availability", async () => {
8
+ const lookups = []
9
+ const kernel = {
10
+ which(name) {
11
+ lookups.push(name)
12
+ return null
13
+ },
14
+ }
15
+
16
+ assert.equal(await opencode.installed(kernel, {}), false)
17
+
18
+ kernel.which = (name) => {
19
+ lookups.push(name)
20
+ return "/pinokio/bin/npm/bin/opencode"
21
+ }
22
+
23
+ assert.equal(await opencode.installed(kernel, {}), true)
24
+ assert.deepEqual(lookups, ["opencode", "opencode"])
25
+ })
26
+
27
+ test("OpenCode Auto shares the managed lifecycle and launches in auto mode", () => {
28
+ for (const action of ["install", "uninstall", "update", "installed"]) {
29
+ assert.equal(opencodeAuto[action], opencode[action])
30
+ }
31
+
32
+ assert.equal(opencodeAuto.run.length, opencode.run.length)
33
+ for (let index = 0; index < opencodeAuto.run.length; index += 1) {
34
+ const normalStep = opencode.run[index]
35
+ const autoStep = opencodeAuto.run[index]
36
+
37
+ assert.equal(autoStep.when, normalStep.when)
38
+ assert.equal(autoStep.id, normalStep.id)
39
+ assert.equal(autoStep.method, normalStep.method)
40
+ assert.equal(autoStep.params.path, normalStep.params.path)
41
+ assert.equal(autoStep.params.input, normalStep.params.input)
42
+ assert.equal(autoStep.params.shell, normalStep.params.shell)
43
+ assert.equal(autoStep.params.message, "opencode --auto")
44
+ }
45
+ })
@@ -208,7 +208,7 @@ module.exports = {
208
208
  const systemPlugins = menu.filter((item) => item.source === "system")
209
209
  const localPlugins = menu.filter((item) => item.source === "local")
210
210
 
211
- assert.strictEqual(systemPlugins.length, 10)
211
+ assert.strictEqual(systemPlugins.length, 12)
212
212
  assert.ok(systemPlugins.every((item) => item.system === true))
213
213
  assert.ok(systemPlugins.every((item) => item.href.startsWith("/pinokio/run/plugin/")))
214
214
  assert.ok(systemPlugins.every((item) => item.image.startsWith("/pinokio/asset/plugin/")))