用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
直接命令不会经过审查 Prompt;运行前请先检查来源。
npx skills add https://github.com/rommapp/argosy-launcher --skill dual-screen命令会保持在同一行。复制前请横向滚动并检查完整内容。
想先保存到本地?可下载 SkillsMP 当前能够提供的文件。
Run local CI/CD checks before pushing code. Use before commits, releases, or when validating code quality. Catches issues before they reach remote.
Verify RetroAchievements hardcore compliance and RA Connect wire shapes. Use before any release touching RA, AND whenever editing RAApi, RAModels, RetroAchievementsRepository or any dorequest endpoint. Checks save state blocking, cheat blocking, rewind blocking, save isolation, and that request/response shapes match vendored rcheevos rather than being inferred.
Verify emulator, ROM, and file type support against official documentation. Use when adding new platforms, emulators, or cores to ensure completeness and correctness.
基于 SOC 职业分类
正在显示 SKILL.md
| name | dual-screen |
| description | Dual-screen development reference. Load this before implementing any dual-screen feature. |
Architecture: OSOT (one source of truth). DualScreenManager (DSM) owns ALL
cross-activity state as StateFlows; both activities are consumers. There is no
messaging layer between the activities.
All paths below are under app/src/main/kotlin/com/nendo/argosy/. References are by
SYMBOL, not line number - offsets rot on the next edit above them. Grep the symbol.
SecondaryHomeBroadcastHelper and every broadcast* method name in it (and a
few on DSM: broadcastForegroundState, broadcastUnifiedSaves,
broadcastSessionCleared, broadcastOpenOverlay) are HISTORICAL names from a
deleted broadcast-based layer. They are plain, same-process method calls into
DSM / CompanionHost. DualScreenBroadcasts.kt no longer exists; neither
activity registers a receiver or calls sendBroadcast for cross-activity
communication (verify: zero sendBroadcast/registerReceiver hits in
hardware/SecondaryHomeActivity.kt and hardware/SecondaryHomeBroadcastHelper.kt).
A rename is queued; until then do NOT let the names suggest a broadcast layer,
and do NOT add Android broadcasts between the activities.
DSM's own registerReceivers() / unregisterReceivers() (called from MainActivity) are
part of the same mess: they register a DisplayManager display listener, not a
BroadcastReceiver, and nothing about them concerns companion messaging.
DualScreenManager (DualScreenManager.kt)
held in DualScreenManagerHolder.instance
StateFlows (pull) CompanionHost (push)
dualScreenShowcase interface CompanionHost in DSM
dualGameDetailState implemented by SecondaryHomeActivity
dualViewMode / dualAppBarFocused
dualDrawerOpen / dualCollectionShowcase
dualSyncOverlay / dualSaveConflict (+focus indexes)
pendingOverlayEvent / isCompanionActive
isRolesSwapped / isDualScreenDevice
swapped* family
| |
v v
MainActivity + ArgosyApp SecondaryHomeActivity
(creates DSM, collects flows (collects flows in
in onCreate) initializeCompanion, receives
CompanionHost pushes)
onCreate and clears the Holder only when finishing.SecondaryHomeBroadcastHelper (dsm.onGameSelected, dsm.handleDirectAction,
dsm.handleInlineUpdate, ...).companionHost?.onX(...) pushes.LAW: never anchor shared state in an activity.
SecondaryHomeActivity.initializeCompanion; state parked in MainActivity never
reaches it. State parked in SecondaryHomeActivity dies on respawn - the OS recreates
the SECONDARY_HOME activity at will, and onResume reconnects to a possibly
NEW DSM (the stale-DSM reconnect).isScreenshotViewerOpen,
launchedExternalApp in SecondaryHomeActivity).ONE process, two activities. SecondaryHomeActivity has no android:process;
it is NOT a Hilt entry point and reaches shared singletons through dsm.*
internals (manual VM construction in initializeDependencies()).
launchMode=singleTop,
taskAffinity="", excludeFromRecents (grep SecondaryHomeActivity in
AndroidManifest.xml). The OS pins it: finish() respawns it. To actually
remove it, disable the component: SecondaryHomeComponent.setEnabled(context, false) (util/SecondaryHomeComponent.kt; called from applyDualScreenEnabled).CompanionGuardService, foregroundServiceType
specialUse|dataSync, subtype property companion_display_guard (grep
companion_display_guard in AndroidManifest.xml), keeps the display session alive.SessionStateStore.isDualScreenEnabled() finishes immediately
when off.onCreate): use the Holder instance if
present; if respawned without a running Argosy and not default home, disable the
component and finish; else launch MainActivity on the default display and poll
the Holder (100ms x 50) before initializing.onResume compares dsm to the current Holder instance
and re-runs initializeCompanion() on mismatch. Any init-time wiring you add
must live inside that path or it will be lost across a MainActivity recreate.SecondaryHomeTheme + live prefs collected through
dsm.preferencesRepository keyed on isInitialized. V2 theme locals are
available on the lower display; custom fonts flow through the fonts parameter.DualScreenManager.kt - all shared state, modal state machine, game actions,
save operations, launch/display routing, companion lifecycle watchdogs.DualScreenManagerHolder.kt - @Volatile var instance (the whole file is 6
lines). Set by MainActivity, read everywhere else.hardware/SecondaryHomeActivity.kt (993 lines) - lifecycle, CompanionHost
implementation, key dispatch entry, DSM flow collection. Logic is extracted
to the three helpers below; do not grow the activity.hardware/SecondaryHomeInputHandler.kt - ALL companion gamepad routing:
routeInput, per-view-mode handlers, handleModalInput,
handleCompanionInput (in-game dashboard), drawer input.hardware/SecondaryHomeStateManager.kt - boot-time state restore
(loadInitialState, incl. CarouselNavContext + GAME_DETAIL restore),
loadInputSwapPreferences, inputSwapStateFrom, loadCompanionGameData,
createGameDetailViewModel, persistCarouselPosition.hardware/SecondaryHomeBroadcastHelper.kt - thin adapter, companion -> DSM
method calls only (see NAMING WARNING).hardware/SecondaryHomeComposables.kt - CompanionScreen enum,
SecondaryHomeContent (normal role), ShowcaseRoleContent (swapped role),
lower dimming wiring.hardware/CompanionPanel.kt - CompanionInGameState,
withLiveQuickActionState, CompanionSessionTimer.ui/dualscreen/home/DualHomeViewModel.kt - lower home state,
DualHomeFocusZone, DualHomeViewMode, ForwardingMode, nav-context
save/restore.ui/dualscreen/gamedetail/DualGameDetailModels.kt - DualGameDetailTab
{SAVES, STATES, MEDIA, OPTIONS}, ActiveModal, GameDetailOption,
DualGameDetailUpperState, save-entry JSON DTOs.ui/dualscreen/ShowcaseViewModel.kt - touch/modal input adapter for the
showcase role, gated by isControlActive.util/DisplayAffinityHelper.kt - display enumeration, usability latch, launch
options.Dedup first, always: every dispatch path calls dsm.claimInput(event) before
handling (MainActivity.dispatchKeyEvent and dispatchGenericMotionEvent,
SecondaryHomeActivity.onKeyDown, plus the libretro activity). First claimant
wins; parallel deliveries of the same physical event are dropped
(InputDedupBuffer, reached via DualScreenManager.claimInput). New dispatch
paths MUST claim before handling.
Companion side (SecondaryHomeActivity.onKeyDown), in order:
dsm.claimInput - return if already claimed.dualSyncOverlay/dualSaveConflict and consume everything while active).isShowcaseRole, only showcase-modal events are handled
(via ShowcaseViewModel.handleModalGamepadEvent, and only while Argosy is
backgrounded); everything else falls through to super.inputHandler.routeInput(event, true, isGameActive, currentScreen).routeInput (SecondaryHomeInputHandler): conflicts again (forwarded keys enter
here directly), then HOME/GAME_DETAIL handlers when Argosy is foreground and no
game is active, else handleCompanionInput (the in-game/backgrounded app-bar
dashboard; returns UNHANDLED on external displays).
Primary side (MainActivity.dispatchKeyEvent), in order:
dsm.claimInput.dsm.handleConflictInput - conflict overlays win on both sides.dsm.emulatorKeyDispatcher (cross-display session input).companionHost?.onForwardKey(keyCode, swapAB, swapXY, swapStartSelect). The
companion re-maps and feeds the SAME routeInput. Sticks take the same path
via dispatchGenericMotionEvent.reassertCompanionForwarding).Rule: the companion's SecondaryHomeInputHandler is the single gamepad brain
for lower-screen content, whichever activity physically received the key.
routeInput, so state moves in
exactly one place.Swap prefs reach the companion by TWO paths, and only one of them is live:
loadInitialState calls stateManager.loadInputSwapPreferences() once and
writes the activity fields. This reads the SessionStateStore mirror and IGNORES
prefs.controllerLayout entirely.initializeCompanion collects dsm.preferencesRepository.userPreferences
and calls applyInputSwapState(stateManager.inputSwapStateFrom(prefs)) on every
emission. inputSwapStateFrom is a DIFFERENT method from
loadInputSwapPreferences and is the ONLY place the companion honours the
Controller Layout override.A swap-affecting preference wired only into the boot path silently never goes live -
add it to inputSwapStateFrom too. The resulting fields feed every
mapKeycodeToGamepadEvent call; icon swaps flow through CompositionLocals
LocalABIconsSwapped / LocalXYIconsSwapped / LocalSwapStartSelect.
ControllerDetector lives at core/input/ControllerDetector.kt (NOT ui/input).
isRolesSwapped mirrors DisplayRoleResolver output (override pref + display
type; external HDMI defaults swapped). swapRoles() toggles the override -
debounced 500ms, NO-OP while a session is active.
When swapped, the roles invert:
swappedDualHomeViewModel (built by initSwappedViewModel,
triggered from MainActivity's onRoleSwapped callback),
swappedCurrentScreen, swappedGameDetailViewModel (created by
selectGameSwapped), swappedIsGameActive, swappedCompanionState,
swappedSessionTimer.onRoleSwapped(isSwapped) sets
isShowcaseRole and setContent renders ShowcaseRoleContent from the
DSM-mirrored flows. Showcase touch on modals goes through ShowcaseViewModel
straight into DSM confirm/move methods.cleanupSwappedState resets the override to AUTO,
clears swapped VMs/timers, and ends the session if the emulator was on the
removed display.resolveEmulatorDisplaySwapped maps HERO/LIBRARY/TOP/BOTTOM, and
preGameRolesSwapped restores the pre-launch state at session end.This is why the OSOT law exists: the same feature must render on whichever display currently holds the showcase role, and only DSM flows reach both.
ForwardingMode { NONE, OVERLAY, BACKGROUND } (DualHomeViewModel.kt). While
!= NONE the lower home swallows all input (guard at the top of routeInput's
home path in SecondaryHomeInputHandler).
OVERLAY (drawer / quick menu / quick settings on the upper screen):
broadcasts.broadcastOpenOverlay(name)
with the name from overlayNameFor (names OVERLAY_MENU / QUICK_MENU /
QUICK_SETTINGS, matched in DSM's onOpenOverlayFromCompanion).startDrawerForwarding() (OVERLAY) then calls
dsm.onOpenOverlayFromCompanion.isOverlayFocused = true, publishes pendingOverlayEvent, calls
refocusMain().pendingOverlayEvent, opens the matching overlay, then
calls clearPendingOverlay().notifyOverlayClosed in
ArgosyApp -> isOverlayFocused = false + companionHost.onOverlayClosed() +
refocusSelf(); the companion's onOverlayClosed calls
stopDrawerForwarding().BACKGROUND: an overlay closed while the upper is NOT on the Home route (user
navigated into Apps/Settings) -> companionHost.onBackgroundForward() -> lower
enters BACKGROUND forwarding: keys swallowed, tap on the lower screen refocuses
the upper. Returning to Home fires notifyOverlayClosed and clears it.
Safety nets: dual-screen topology changes reset overlay focus and modal state
(the ArgosyApp LaunchedEffect keyed on isRolesSwapped, companionActive,
swappedGameActive - grep dsmForTopology); reassertCompanionForwarding
clears a latched isOverlayFocused when input arrives on a stale link.
Modals render on the upper screen inside DualGameDetailUpperScreen, state
lives in DSM's dualGameDetailState (DualGameDetailUpperState.modalType).
Opening always goes through a DSM open*Modal method which sets state and
calls refocusMain(). Those methods are NOT contiguous in DualScreenManager.kt
openModal, openEmulatorModal, openCollectionModal, openSaveNameModal,
openDiscModal, openSteamInstallModal, openSteamChooserForHome sit together,
while openCoreModal, openSavePathModal, openDisplayTargetModal,
openMemoryCardModal and openVariantModal are scattered several hundred lines
later. Grep fun open, do not scroll. The lower screen dims while a modal is
active (isDimmed = activeModal != ActiveModal.NONE, wired in
SecondaryHomeComposables.kt).ActiveModal - 15 values (DualGameDetailModels.kt):
NONE, RATING, DIFFICULTY, STATUS, EMULATOR, CORE, SAVE_PATH, DISPLAY_TARGET,
MEMORY_CARD, COLLECTION, SAVE_NAME, DISC_PICKER, VARIANT_PICKER, STEAM_INSTALL,
FILE_PICKER.
Input ownership per modal, normal (non-swapped) mode. "Companion-owned" =
handleModalInput (SecondaryHomeInputHandler) drives the companion VM and
mirrors focus to the upper via broadcastInlineUpdate(<field>) ->
dsm.handleInlineUpdate; confirm goes through broadcastModalConfirmResult ->
dsm.onModalConfirmResult.
| Modal | Owner | Mechanics (branch in handleModalInput) |
|---|---|---|
| NONE | - | no-op |
| RATING | Companion | Left/Right adjust, mirror modal_rating; Confirm/Back |
| DIFFICULTY | Companion | same branch as RATING |
| STATUS | Companion | Up/Down, mirror modal_status |
| EMULATOR | Companion | live focus forwarding via broadcastInlineUpdate("emulator_focus") |
| CORE | Companion | core_focus |
| SAVE_PATH | Companion | save_path_focus |
| DISPLAY_TARGET | Companion | display_target_focus |
| MEMORY_CARD | Companion | memory_card_focus; opened via broadcastMemoryCardModalOpen -> dsm.openMemoryCardModal; upper mirror is moveDualMemoryCardFocus / confirmDualMemoryCardSelection |
| COLLECTION | Companion | collection_focus; Confirm -> collection_toggle / collection_create |
| SAVE_NAME | UPPER | companion swallows ALL input incl. Back (the else fallthrough); text + confirm on the upper via updateDualSaveNameText / confirmDualSaveName on DSM |
| DISC_PICKER | Companion | disc_focus; Confirm closes modal + direct action PLAY_DISC |
| VARIANT_PICKER | Companion | variant_focus |
| STEAM_INSTALL | Companion | steam_install_focus; also opens from Home as a chooser (openSteamChooserForHome) |
| FILE_PICKER | UPPER | companion is Back-only dismiss; all focus/selection state is DSM filePicker* fields driven by the upper dualModalInputHandler and touch |
Rule: new picker modals are companion-owned with live focus forwarding - copy the EMULATOR branch, not FILE_PICKER.
ActiveModal value MUST get an explicit
handleModalInput branch, even if it is only Back-dismiss.The upper dualModalInputHandler (ArgosyApp.kt, subscribed while
dualModalActive) mirrors every picker for the cases where the upper owns input
(overlay focus, swapped role); keep both sides in sync when adding a modal.
Result delivery: DSM confirm/dismiss methods push
companionHost.onModalResult(...); the companion applies to its VM and
refocusSelf(). Watchdog: if the companion pauses with a modal open, DSM
auto-dismisses it after 5s (onCompanionPaused); resyncCompanionState also
clears stale modals on companion resume.
SessionStateStore.CarouselNavContext: restores the lower carousel by IDENTITY
(sectionKind + platformId + gameId) plus the full filter/sort context; legacy
index fields are fallback only. Persisted via
stateManager.persistCarouselPosition on selection moves and in onStop;
restored in stateManager.loadInitialState ->
dualHomeViewModel.restoreNavContext. The same load path also restores a
GAME_DETAIL screen (rebuilds the detail VM for the saved gameId) and clears any
persisted modal/screenshot-viewer state. Do not restore by index anywhere -
dynamic sections shift and the two screens end up on different games.
CompanionInGameState (hardware/CompanionPanel.kt) is the in-game dashboard
state. Merge rule: async metadata loads MUST NOT clobber the live quick-action
flags - always apply withLiveQuickActionState(quickActionsAvailable, hasQuickSave) after building a fresh snapshot (used by
SecondaryHomeActivity.loadCompanionGameData and by DSM's own load). DSM's
_swappedCompanionState copy is canonical in BOTH companion modes;
updateCompanionHasQuickSave maintains it regardless of role.
dsm.hasLiveSession() = in-memory PlaySessionTracker check, flips false the
moment teardown BEGINS. SessionStateStore.hasActiveSession() = persisted
flag, stays true until save sync completes. Pick deliberately: UI "is a game up
right now" -> hasLiveSession; "is it safe to relaunch/companion-launch" ->
hasActiveSession.onForegroundChanged ends the
companion's session view when Argosy comes foreground during a game.
!dsm.hasLiveSession() first - a
cross-display session survives the upper UI foregrounding.onResume, guarded by dsm.isLaunchingGame).cleanupSwappedState).swapRoles() refuses while hasActiveSession().ensureCompanionLaunched refuses during an active session unless
allowDuringSession; a startup guard retries every 1.5s until the companion
is up.DisplayAffinityHelper.getActivityOptions(forEmulator: Boolean, rolesSwapped: Boolean = false, overrideDisplayId: Int? = null) - note the two
newer params: swapped launches route the emulator to the secondary display, and
overrideDisplayId bypasses resolution entirely. Emulator display resolution at
launch goes through resolveEmulatorDisplaySwapped (per-game/per-platform
EmulatorDisplayTarget); DSM records emulatorDisplayId at launch and it drives
cross-display input forwarding (isGameOnOtherDisplay in MainActivity) and the
session-survival rules above. Companion launch uses getCompanionLaunchOptions().
hasSecondaryDisplay is gated by THREE conditions, not one:
dualScreenEnabled && secondaryDisplayUsable && hasPhysicalSecondaryDisplay.
dualScreenEnabled is a plain var - set it from prefs before trusting it.secondaryDisplayUsable is a FALLBACK LATCH, not a preference. It starts true,
is hydrated at MainActivity startup from
SessionStateStore.isSecondaryDisplayUsable(), and is set false by
DualScreenManager.fallbackToSingleScreen(persistent) once the companion has
been proven unable to initialize on the secondary display (OS builds that will
not run a home activity there). DualScreenManager.reprobeSecondaryDisplay()
clears it, and re-enabling dual screen in settings sets it true directly.DualHomeFocusZone { CAROUSEL, APP_BAR } and DualHomeViewMode { CAROUSEL, COLLECTIONS, COLLECTION_GAMES, LIBRARY_GRID }, both in
DualHomeViewModel.kt; one input handler per mode in SecondaryHomeInputHandler.SaveFocusColumn { SLOTS, HISTORY } from
ui/common/savechannel/), STATES, MEDIA (grid), OPTIONS (list + inline
LEFT/RIGHT adjust for RATING/DIFFICULTY/STATUS which mirror via
broadcastInlineUpdate).isDimmed into DualGameDetailLowerScreen.touchOnly/clickableNoFocus on the composables (grep
broadcastRefocusUpper in DualHomeLowerContent.kt for the dim-tap pattern).CompanionHost method (interface in DualScreenManager.kt), implement it
in SecondaryHomeActivity, call it from DSM next to its state update.initializeCompanion plus
ShowcaseRoleContent wiring.initializeCompanion).routeInput); upper branch in ArgosyApp's handler if the upper can own
input for it; both sides inherit dsm.claimInput dedup from their
activities.ActiveModal, add DualGameDetailUpperState fields, DSM
open/move/confirm methods + handleInlineUpdate field, a
handleModalInput branch, an ArgosyApp dualModalInputHandler branch,
onModalResult handling in SecondaryHomeActivity, the upper render
branch - and add the modal to the ownership table in this skill.resyncCompanionState / the pause watchdog should
do with your state when the companion bounces.data/preferences/SessionStateStore.kt - SharedPreferences persistence layer
(session flags, swap prefs, CarouselNavContext, companion screen).