zudo-text

検索したい単語を入力

いつでも検索バーを開ける

l-env-feature-matrix

Look up how zudo-text detects its runtime environment (Tauri vs browser, macOS vs iOS, touch vs no-touch, local vs cloud) and gates features by platform and capability. Covers the five shipped environ...

Env Feature Matrix

Source of truth for the shipped gating decisions: the two-comment audit on issue #4594 (method + full 83-field verdict table). This skill summarizes that audit AND verifies it against the code that actually landed (#4592, #4598, #4599) — where the two disagree, this document follows the code.

The five environments

CodeEnvironmentAdapterKey capability shape
T-macTauri on macOS (ROOT + every LEAF)TauriAdapterlocalFiles:true, dialogs:true, fs:"local"
T-iOSTauri on iOSTauriAdapter (TAURI_IOS_CAPABILITIES)localFiles:false, dialogs:false, fs:"local"
B-deskBrowser, cloud workspace, desktop viewportRestAdapterlocalFiles:false, dialogs:false, fs:"cloud"
B-iOSBrowser, cloud workspace, iPhone/iPad SafariRestAdaptersame as B-desk, plus iOS input constraints
Mockpnpm dev:mock / the Cloudflare Pages previewMockAdapterlocalFiles:true, dialogs:true, fs:"local" (mirrors T-mac)

ai is true on all five adapters — AI features are never platform-gated, only capability-gated (inline-ai-command section visibility is capabilities.ai === true, currently always true).

Env-label detection — packages/backend-bridge/src/env.ts

getEnv() returns a Set<EnvLabel> built from independent axes: runtime (tauri/browser), OS (ios/macos/windows/linux/android/chromeos), device (iphone/ipad/android-phone/android-tablet), device class (phone/tablet/desktop), input (touch/no-touch), PWA (standalone-pwa). inEnv(label) is sugar over getEnv().has(label).

iPadOS-desktop-mode heuristic. iPadOS 13+ Safari reports a desktop Macintosh user-agent by default — the only distinguishing signal is navigator.maxTouchPoints. detectOS() treats a Macintosh UA with no explicit iPad/iPhone token AND maxTouchPoints > 1 as iPadDesktopMode, which getEnv() then classifies as ios/ipad, not macos/desktop.

Platform predicates — packages/backend-bridge/src/platform.ts

PredicateMeaning
isTauriMacOS()Tauri runtime AND macOS. Excludes Windows/Linux Tauri dev builds.
isTauriDesktop()Tauri runtime AND NOT iOS. Broader than isTauriMacOS() on purpose — desktop-gated boot paths should behave the same on Windows/Linux dev hosts.
isTauriIOS()Tauri runtime AND iOS.
isBrowser()Browser context (Mock, REST, or no Tauri global).
isIOSBrowser()Browser runtime AND iOS (Safari, home-screen PWA).
isAnyIOS()iOS regardless of runtime — union of isTauriIOS() and isIOSBrowser().
isTouchDevice()pointer: coarse or maxTouchPoints > 0 — an INPUT axis, true on Android/Windows-touch too, not just iOS. Don't conflate "touch" with "iOS."
isDocCloudAvailable()One gate for every Doc Cloud registration/settings/pin/command surface: false on Tauri iOS; on Tauri desktop requires a valid injected VITE_DOC_CLOUD_ORIGIN; true in browser and Mock.

Adapter capabilities — packages/backend-bridge/src/capabilities.ts

Four exported constants (TAURI_CAPABILITIES, TAURI_IOS_CAPABILITIES, MOCK_CAPABILITIES, REST_CAPABILITIES), each shaped { ai, dialogs, fs: "local"|"cloud", localFiles } (the historical shell / pins / localWorkspacePicker fields were deleted post-pivot). These are static — baked in when the adapter is constructed (the TauriAdapter picks TAURI_IOS_CAPABILITIES vs TAURI_CAPABILITIES once, at construction, based on isTauriIOS()).

Contrast with dynamic isSupported() probes that exist on specific bridge namespaces, not in the static Capabilities shape. Their contracts are NOT uniform — check the actual signature before writing a consumer:

  • Async (Promise<boolean>): bridge.fileSearch.isSupported() (Spotlight) and bridge.noteHistory.isSupported(). Spotlight's three adapters disagree for three different reasons (Tauri asks Rust; REST hardcodes false; Mock disambiguates real macOS from iPadOS-in-Mock via maxTouchPoints, see Pitfalls below), so it cannot be a static boolean. A consumer gating on one of these must hold the resolved value in React state — see general-settings.tsx's spotlightSupported state.

  • Synchronous (() => boolean): bridge.workspaceFiles.isSupported() and bridge.similarDocs.isSupported() — call and use the result directly, no .then()/state needed.

filterByCapabilities(items, caps) — the command-palette filter — reads item.requiresCapability and keeps the item unless the corresponding capability is boolean false (see "boolean-only" rule below).

The shipped feature-availability matrix

Verdict vocabulary (from the #4594 audit, unchanged in the shipped code):

  • A — available. Works as labeled in this environment.

  • I — inert. Does nothing here; writing it changes nothing anywhere.

  • H — harmful-if-shown. Inert here and the persisted value reaches another device through the workspace-synced settings document (epic #4204 D3) — e.g. Window Opacity on an iPhone silently reconfiguring the user's Mac. This is why some inert fields are hidden and others (Vim, Sync, Notifications, Move Buttons, Raw Settings) are deliberately left visible everywhere: an inert-but-not-harmful field configuring a different device the user owns is a legitimate workflow, not a bug.

The default bias across all 83 audited static fields is keep visible. The table below covers what the #4594/#4598/#4599 gating work actually touched — settings fields/sections and the EFE-adjacent palette commands. It is not a full inventory of every requiresCapability in the app: four AI commands (open-ai-assistant, inline-ai-command.insert, inline-ai-command.open-panel, inline-ai-command.toggle-enabled) also declare requiresCapability: "ai", and open-directory declares requiresCapability: "dialogs" — these predate #4592 and are omitted here because ai is true on all five shipped environments (a no-op gate today) and dialogs is a pre-existing, unrelated gate, not one of these issues' outputs. Grep requiresCapability in app-commands.tsx for the full, current list.

SurfaceField / idPredicateWhere
Settings rowgeneral.window-opacityisTauriMacOS()general-settings.tsx showWindowOpacity
Settings rowgeneral.spotlight-searchawait bridge.fileSearch.isSupported() (state)general-settings.tsx showSpotlightSearch
Settings rowgeneral.external-editor-tab-positioncaps.localFilesgeneral-settings.tsx showEfeTabPosition
Settings rowinline-ai-command.skills-folderisTauriDesktop()inline-ai-command-section.tsx showSkillsFolder
Settings rowsync.realtime!isBrowser() (browser shows a static "always on" note instead, #4756)sync-settings.tsx render branch + renderer settings-search-metadata.ts isVisible
Search catalog entry (orphan)sync.publish-server-urldeleted outrightsettings-search-metadata.ts (renderer)
Shortcut rows (3)toggleEfeDirViewSidebar, cycleExternalFileEditorTabPosition, toggleExternalFileEditorPreviewrequiresCapability: "localFiles"shortcut-groups.ts
Settings sectionslidesslidesEnabledsettings-section-registry.ts
Settings sectioninline-ai-commandcapabilities.ai === truesettings-section-registry.ts
Settings sectionfrontmatter!isTauriIOS (section-level only — hidden on T-iOS, no further field gate)settings-section-registry.ts
Settings sectionsios, ios-deviceisTauriIOSsettings-section-registry.ts
View providerExternal File Editor (core.external-file-editor, #4599)hasUsableLocalFiles(caps)caps.localFilessee "View providers" below
Settings section + view provider + pin/picker surfacesDoc Cloud (core.doc-cloud)isDocCloudAvailable()one shared platform predicate; never reconstruct from isBrowser() or capabilities
Palette commandopen-provider:doc-cloudisDocCloudAvailable()command is absent where the provider is unavailable
Palette commanddoc-cloud:publishDoc Cloud available and active frame is core.doc-cloudvisible but disabled with a reason when no Doc Cloud frame is active
Palette commands (3)toggle-efe-dir-view-sidebar, cycle-efe-tab-position, toggle-efe-previewrequiresCapability: "localFiles"app-commands.tsx
Palette commandinline-ai-command.reveal-skillsrequiresCapability: "localFiles"app-commands.tsx (see Pitfalls — this is an approximation of isTauriDesktop())

Everything else — Vim, Sync, Notifications, Move Buttons, Aliases, Quick Actions, API Tokens, Raw Settings, the menu-bar and other shortcuts rows, Native Context Menu, Font Family — stays visible in all five environments by design. Frontmatter is the one exception to call out explicitly: its section IS hidden on T-iOS (row above) — it is not in this "visible everywhere" list, and its single field (frontmatter.schema) has no gate beyond that section-level one. Two notable non-gates among the "stays visible" set, because the reasoning generalizes:

  • Raw Settings must stay visible everywhere. It is the one surface where a user on an iPhone can still see and correct a field the gated UI no longer renders — load-bearing for the "hidden ≠ deleted" contract.

  • Menu Bar icons are not visibility-gated at all — they render with an explicit "Unavailable now" annotation instead of being hidden (resolveMenuBarPreviewCapabilities()). Don't add hide-based gating on top of this; it would regress a deliberately more informative design.

Doc Cloud availability and credential matrix

Doc Cloud is a platform predicate rather than an adapter capability because availability depends on the runtime plus a validated build-time origin, not on localFiles/dialogs/fs:

EnvironmentSurfaceTransportCalling-token storage
T-macAvailable only with valid VITE_DOC_CLOUD_ORIGINnative Rust HTTP/SSE allowlistmacOS Keychain
T-iOSHidden/unregisteredunavailable until packaged transport is provennone
B-deskAvailablefetch-based Doc Cloud bridgememory-only by default
B-iOSAvailablesame browser bridgememory-only by default
MockAvailabledeterministic in-memory servicememory-only

Non-macOS persistence is never automatic. DocCloudAdapterConfig.persistTokenInLocalStorage is accepted only as an explicit DEV opt-in (dev: true); production defaults to memory. A token is never an AppSettings or device-override field. Only docCloud.defaultProjectSlug is device-overridable. An inaccessible effective slug falls back to Projects.

Use the same isDocCloudAvailable() result for the provider registries, settings registry/search, add/edit-pin flows, and commands. Mock is a browser, so an isBrowser() test would accidentally conflate the test environment with production availability rules. The e2e:mock-capabilities seam does not affect Doc Cloud; this is not a capability-gated surface.

How to gate a new feature

There are four distinct mechanisms — pick the one matching the surface, don't invent a fifth.

1. A settings FIELD (row) — section stays visible, one row doesn't

Add the axis to SettingsFieldVisibilityContext in packages/settings-sections/src/sections/settings-search-metadata.ts if it isn't already there (isTauriMacOS, isTauriDesktop, isBrowser, spotlightSupported, localFiles today) — deliberately plain booleans, no React/backend import, so this module stays mountable without hooks.

Declare the predicate once (see GENERAL_FIELD_VISIBILITY in the same file) and reuse it in exactly two places:

  • the search-catalog entry's isVisible (passed to defineSettingsSearchEntries), and

  • the rendering section component's own gate (e.g. general-settings.tsx builds one fieldVisibility object and calls GENERAL_FIELD_VISIBILITY.xxx(fieldVisibility)).

This is what keeps the rendered DOM and the settings-search results in agreement by construction — re-deriving the condition independently at each site is exactly the kind of thing that silently drifts.

2. A settings SECTION (whole section shown/hidden)

Add isVisible: (context: SettingsSectionVisibilityContext) => boolean to the section's entry in SETTINGS_SECTION_REGISTRY (tauri-app/renderer/components/settings/settings-section-registry.ts). That context (slidesEnabled, isTauriIOS, isIOSBrowser, isTauriDesktop, isCloud, capabilities) is built once, in settings-dialog.tsx (literally commented there as "the single visibility context threaded through all consumers (#4598)"), and fed to buildSettingsSidebarNodes (sidebar tree — the mobile tree inherits automatically since it renders the same nodes), buildSettingsSearchCatalog (search groups), and buildDeviceSubtree (per-device override subtree). Don't construct a second context object for a new consumer — thread the existing one through.

3. A view provider or other non-settings surface — the EFE pattern

Write one predicate function taking Capabilities and returning boolean, exported from the provider's own registration module — the canonical example is hasUsableLocalFiles(caps) in tauri-app/renderer/view-providers/register-external-file-editor.ts (return caps.localFiles). Then apply that same imported function at every construction/surfacing site — for EFE that's: view-providers/index.ts (×2, registration), lib/picker-registry.ts (caps threaded into registerAllProviders), pages/archives-page.tsx, pages/frameset-host-page.tsx, components/header-pins-dialog/add-pin-flow.tsx, and components/pin-settings-dialog/edit-pin-flow.tsx (the pin flows filter by the shared LOCAL_FILES_ONLY_PROVIDER_IDS set from the same module, #4756). Missing even one site leaves a path where the surface can still be constructed/pinned in an environment where it's unusable.

Explicitly do not gate this class of surface on isBrowser() — it's true for Mock exactly as much as for REST (neither has a Tauri global), so it cannot distinguish "Mock, which should show EFE" from "cloud browser, which shouldn't" — or on caps.fs === "local" (also true on Tauri iOS, where local files are still unusable — sandboxed, no dialogs/pins). caps.localFiles is the one capability that means "can bridge.files actually read/write/watch absolute host paths," which is the real question; REST declares it false (fs: "cloud") regardless of whether the browser session is pnpm dev:rest or production cloud.

If a future provider needs the same treatment, import hasUsableLocalFiles rather than declaring a second predicate, and add its id to LOCAL_FILES_ONLY_PROVIDER_IDS so the pin flows pick it up automatically.

4. A command palette entry

Add requiresCapability: "<key>" to the command definition. filterByCapabilities() in capabilities.ts enforces it.

Boolean-only rule. filterByCapabilities does typeof value === "boolean" ? value : Boolean(value) — so it only actually filters when the named capability key holds a boolean. Point requiresCapability at one of ai / dialogs / localFiles. Pointing it at fs (a "local"|"cloud" string) would always resolve Boolean("cloud")true — a silent no-op gate, not an error.

requiresCapability also cannot express an arbitrary predicate like isTauriDesktop() — it can only key into the adapter's static Capabilities object. See "Reveal Skills Folder" in Pitfalls below for what happens when a command needs a non-capability predicate and settles for the nearest boolean proxy instead.

Known pitfalls

MOCK declares all capabilities true — and the e2e:mock-capabilities seam only escapes that if you use it. MOCK_CAPABILITIES mirrors TAURI_CAPABILITIES exactly, so pnpm dev:mock / the Pages preview exercises the full desktop feature surface by default and cannot, on its own, demonstrate REST/iOS-only gating. To exercise capability-gated UI without a real Tauri iOS runtime, seed localStorage["e2e:mock-capabilities"] with a JSON partial Capabilities object via page.addInitScript before navigation — main-mock.tsx's readE2ESeededCapabilities() merges it over MOCK_CAPABILITIES. As of this writing this seam exists (added with #4592) but no e2e spec actually uses it yet — it's infrastructure waiting for its first consumer, not an established pattern to copy from an existing spec.

A command can be visible-but-dead in Mock even without touching the seam. The "Inline AI: reveal skills folder" palette command (inline-ai-command.reveal-skills, app-commands.tsx) wants the same gate as its settings-row twin ("Skills folder" in inline-ai-command-section.tsx, gated on isTauriDesktop()) — but isTauriDesktop() isn't a capability, so it can't be a requiresCapability value. The command settles for requiresCapability: "localFiles" as the nearest boolean proxy (with a code comment acknowledging the approximation). Net effect: in Mock (localFiles: true, but no real Finder to reveal) the palette command shows and does nothing when invoked, while the settings row for the identical feature correctly disappears (isTauriDesktop() is false in Mock, a browser). This is the boolean-only rule's real consequence, not a bug to "fix" by making the two sides match — they can't, without a way to express non-capability predicates in requiresCapability.

The iPadOS UA heuristic is implemented twice, independently, for different reasons. env.ts's detectOS() (see above) answers "is this device an iPad" for the whole app's env-label system. The Mock adapter's bridge.fileSearch.isSupported() (mock-adapter.ts, around the Spotlight implementation) re-derives the same Macintosh UA + maxTouchPoints > 1 signal separately, because it needs to tell real-macOS-in-Mock (Spotlight should appear) from iPadOS-in-Mock (Spotlight should not) on a different code path with different call timing. Don't assume fixing one heuristic fixes the other, and don't assume a new consumer can skip writing its own check just because getEnv() exists — some consumers (like this one) need the answer before/without going through the env.ts label set.

isTauriIOS vs isIOSBrowser vs isAnyIOS/isTouchDevice are four different axes — picking the wrong one compiles fine and mostly works.

  • isTauriIOS() — gates Tauri-plugin-only surfaces with no web-API equivalent (the ios/ios-device settings sections: local notifications, diagnostics export, sign-out, reset-local-data).

  • isIOSBrowser() — gates browser-specific copy/behavior on iOS Safari/PWA (e.g. the auto-zoom hint under Display Scale in general-settings.tsx).

  • isAnyIOS() — the union; drives the env-ios CSS root class so styling can target "any iOS, any runtime" without a per-component JS branch.

  • isTouchDevice() — a separate INPUT axis (pointer: coarse / maxTouchPoints > 0), true on Android and Windows-touch too. Not an iOS check at all.

The failure mode for using the wrong one is narrow and easy to miss in review: #4604 (filed from the #4594 audit) is the editor context-menu handler checking viewport width instead of isAnyIOS()/isTouchDevice(), so an iPad in Safari landscape — wide viewport, not Tauri — still gets the custom React context menu it shouldn't.

The two settings-search-metadata catalogs are disjoint halves, not duplicates. packages/settings-sections/src/sections/settings-search-metadata.ts (SHARED_SETTINGS_SEARCH_ENTRIES — general/styles/editor/preview/slides; also consumed by the ROOT's generate-child settings step) and tauri-app/renderer/components/settings/sections/settings-search-metadata.ts (RENDERER_SETTINGS_SEARCH_ENTRIES — vim/aliases/move-buttons/quick-actions/ inline-ai-command/sync/api-tokens/notifications/frontmatter/ios/ios-device/ deviceOverride/raw/menu-bar/shortcuts) have no overlapping section keys. buildSettingsSearchCatalog() simply concatenates both arrays. Both import the same SettingsSearchEntry / SettingsFieldVisibilityContext types and the same defineSettingsSearchEntries() helper from @takazudo/settings-sections/settings-search — so a new field in either half uses the identical gating mechanism (§1 above). The split is about which sections the ROOT's generate-child wizard reuses (only the shared half), not about two competing gating systems.

env-* root classes and the 640px viewport breakpoint answer different questions — don't conflate them. env-ios / env-touch / env-tauri / env-browser (tauri-app/renderer/lib/apply-env-classes.ts, stamped on document.documentElement before first paint by all four renderer bootstrap paths) answer "what kind of device/runtime is this," independent of window size — .env-ios styling still applies to an iPad in landscape at 1194px wide. MOBILE_BREAKPOINT_PX (640, packages/ui-components/src/use-is-mobile-viewport.ts) and useIsMobileViewport() / the max-mobile: Tailwind variant answer "is the window narrow right now," independent of device — true for a narrowed desktop browser window, false for an iPad in landscape. The env-* classes exist specifically because a width-only check misses iPad landscape (wide, but still touch-only iOS) — see the module doc comment in apply-env-classes.ts. A new feature needing "iOS/touch behavior" wants the env-* classes or the platform predicates; one needing "cramped-screen layout" wants the viewport breakpoint. Some need both, independently — picking the wrong one is the same class of bug as #4604 above.