Skip to content

Events and imperative control

Subscribe to everything happening inside the desktop with onEvent, and drive it programmatically with a ref — the foundation for analytics, guided demos, and the scenario system (#84).

jsx
import { useRef } from 'react';
import { WindowsXP } from '@caoergou/windows-xp';
import type { XPHandle, XPEvent } from '@caoergou/windows-xp';

function App() {
  const xp = useRef<XPHandle>(null);

  return (
    <>
      <button onClick={() => xp.current?.openApp('Notepad')}>Open Notepad</button>
      <WindowsXP
        ref={xp}
        autoLogin
        onEvent={(e: XPEvent) => {
          if (e.type === 'file:open') console.log('opened', e.path.join('/'));
          if (e.type === 'cmd:exec') console.log('ran command', e.command);
        }}
      />
    </>
  );
}

Event types (typed payloads on the XPEvent union). The catalog below is generated from src/events.ts; the naming grammar, domain list and payload conventions live in docs/EVENTS.md.

EventPayloadDescription
app:launchappId, windowId, titleAn application window was opened.
app:closeappId, windowIdAn application window was closed.
window:focuswindowId, appIdA window gained focus (was brought to the front).
window:minimizewindowId, appIdA window was minimized to the taskbar.
window:maximizewindowId, appIdA window was maximized.
window:restorewindowId, appIdA window was restored from a minimized/maximized state.
startmenu:openThe Start menu was opened.
startmenu:closeThe Start menu was closed.
contextmenu:opentarget?, path?A right-click context menu was opened; target classifies what was clicked ('desktop' / 'file' / 'app' …) and path locates the node when one was the target.
file:openpath, name, nodeType, app?A file or folder was opened (double-clicked / launched).
file:createpath, name, nodeTypeA file or folder was created.
file:updatepath, name, content?A file's properties were edited; content is present when its text changed (the "player typed the passphrase" puzzle beat).
file:deletepath, nameA file was deleted (moved to the Recycle Bin).
file:renamepath, oldName, newNameA file or folder was renamed.
file:movefrom, to, nameA file was moved (cut+paste or drag) from from to to.
file:copyfrom, to, nameA file was copied from from to to.
file:restorenameA file was restored from the Recycle Bin.
file:unlocknameA locked node was unlocked (correct password, or a host/scenario force-unlock).
file:propertiespath, nameA file's Properties dialog was opened — metadata (size, dates) inspected; a clue channel for scenarios (M1).
folder:deletepath, nameA folder was deleted (files emit file:delete; folders emit this).
recyclebin:emptyThe Recycle Bin was emptied.
password:failpath, name, attemptA wrong password was entered for a locked node; attempt counts consecutive failures.
session:loginThe user logged in successfully.
session:login-failA login attempt failed (wrong password).
session:logoutThe user logged out.
session:boot-completeThe desktop finished booting and is interactive.
session:shutdownmodeThe machine was shut down, restarted, or logged out via the Start menu.
flag:changeflag, valueA scenario flag's value changed (set/inc). Lets a trigger fire on progress itself, not only on a UI event. Emitted by the scenario runtime, not the core engine.
cmd:execcommandA command was executed in the Command Prompt.
ie:navigateurl, generated?Internet Explorer navigated to a URL; generated is true when the page came from a host content provider rather than a bundled/authored page (#149).
wallpaper:changewallpaperThe desktop wallpaper was changed (wallpaper is the id or URL).
screensaver:startThe screensaver started.
screensaver:stopThe screensaver was dismissed.
notification:showid, title, body?A tray notification balloon was shown.
notification:clickidA tray notification balloon was clicked.
time:hourhourFired on the top of each hour; hour is 0–23 (drives the 整点报时 chime).
time:fireidA persisted schedule fired (delay elapsed or its at deadline passed, incl. while the page was closed).
user:idleidleMsThe user has been inactive for the idle threshold; idleMs is that threshold.
user:activeThe user resumed activity after being idle.
qq:loginThe player logged into QQ (the buddy-list panel opened).
qq:openbuddyId?The QQ client opened, or a specific buddy chat was opened (buddyId).
qq:onlinebuddyId, nicknameA buddy came online.
qq:messagebuddyId, direction, textA QQ message was sent or received; direction is 'incoming' (from the buddy) or 'outgoing' (from the player).
qq:replybuddyId, textThe player sent a reply to a buddy (the puzzle-relevant "player answered" beat).
qq:offlinebuddyIdA buddy went offline.
qq:statusbuddyId, status?, signature?A buddy's status or signature changed — a world reaction (e.g. a mood line the player is meant to notice).
qq:choicebuddyId, choiceIdThe player picked a scripted reply option (a branching choice, distinct from the free-text qq:reply).
game:startappId, difficulty?A game started a new round; appId names the game and difficulty is present when it applies.
game:winappId, difficulty?, timeMs?A game was won; timeMs is the completion time when the game tracks one.
game:loseappId, difficulty?A game was lost.
media:playpath?, title?Media playback started or resumed; path is the source when known.
media:pausepath?Media playback was paused.
media:endedpath?Media playback reached the end of the track.
media:seekpath?, positionThe playhead was moved; position is the new time in seconds.
search:queryquery, hit, resultIds?A query was run against an in-world search engine (a fake 百度/AltaVista); hit is whether authored results matched. Emitted by the scenario runtime/app, not the core engine.
evidence:collecttermId, source?A term/clue entered the player's word bank (clicked a highlighted term, or granted by the scenario).
evidence:pinitemIdAn item was pinned to the evidence board.
evidence:linksourceId, targetIdTwo pinned items were linked on the evidence board.
evidence:unpinitemIdAn item was removed from the evidence board.
deduction:submitformId, slots?The player submitted a deduction form (Mad-Libs slots / Obra-Dinn triples); slots maps slot id → chosen value.
deduction:verifiedformId, groups?A submitted deduction verified as correct; groups names the slot-groups that matched (supports verify-in-batches).
deduction:failedformId, groups?A submitted deduction was rejected; groups names the slot-groups that failed.
lesson:startlessonIdA guided lesson started.
lesson:step-completelessonId, stepIdA lesson step was completed (the learner performed the expected action).
lesson:hint-shownlessonId, stepId, hintId?A hint was shown for the current step (hint-ladder escalation).
lesson:step-failedlessonId, stepIdThe learner took a wrong action on a step.
lesson:completelessonId, score?A lesson finished; score is the assessed result when the lesson grades.
install:startappIdA software install/setup flow started.
install:completeappIdA software install completed.
install:cancelledappIdA software install was cancelled before completing.
ui:actionappId, control, value?A semantic app control changed (checkbox toggled, option selected); control names it and value is the new value. Emitted by data-driven apps (defineApp), gated by settingEquals.
link:externalurl, newTab, source?The visitor followed a link out of the fiction to an external URL — the conversion signal campaigns measure. newTab is whether it opened in a new tab; source is the originating window id or file path, when known.

Generated from src/events.ts by npm run docs:events — do not edit by hand.

The XPHandle (via ref) exposes the top-level openApp(appId, props?), openFile(path), closeWindow(id), showAlert(title, message) and reset(), plus grouped actuation APIs (#115):

  • fs: readFile(path), writeFile(path, content), createFile(path, node?), deleteFile(path), getNode(path), exists(path), unlockNode(path)
  • session: login(password?), logout(), shutdown(), restart()
  • appearance: setWallpaper(idOrUrl), setLanguage(lang)
  • windows: list(), focus(id), minimize(id), maximize(id), restore(id)
  • sound.play(name) and emit(event) (inject onto the same bus onEvent and scenario triggers read)
  • schedule({ id?, delayMs?, at?, event? }) / cancelSchedule(id) — time-based triggers (#130). A schedule fires a time:fire event (or a caller-supplied event) after delayMs or at the at epoch-ms deadline. Pending schedules persist per instance and fire on the next load if the deadline passed while the page was closed ("compute elapsed effects at launch" — there is no background execution). The same subsystem emits the wall-clock time:hour and user:idle / user:active events on the bus.
jsx
// Remind the player 90s after they lock a folder — survives a reload.
ref.current.schedule({ id: 'hint', delayMs: 90_000,
  event: { type: 'notification:show', id: 'hint', title: 'Psst… try 2003' } });

The classic hourly chime (整点报时) is an opt-in consumer of time:hour: <WindowsXP hourlyChime /> (or a culture package's hourlyChime: true); it is off by default. idleThresholdMs tunes when user:idle fires (default 60000).

reset() clears both storage layers (localStorage + IndexedDB file contents) for the instance's storagePrefix, then reloads. For save/load, see "Save / load a snapshot" below.

Inside the tree (custom apps), subscribe without prop-drilling:

jsx
import { useXPEvents } from '@caoergou/windows-xp';

function EventLogger() {
  useXPEvents((e) => {
    if (e.type === 'file:open') {
      /* react to the world */
    }
  });
  return null;
}

Bare-provider composition (#122). Advanced composers using the bare providers (the AppProviders escape hatch) can create their own bus and observe the exact instance the desktop emits on:

jsx
import { createXPEventBus, EventBusProvider } from '@caoergou/windows-xp';

const bus = createXPEventBus();
bus.subscribe((e) => console.log(e.type));
// <EventBusProvider bus={bus}> … your providers … </EventBusProvider>

Driving the desktop from the host

With only a ref — no custom apps, no context access — a host can plant a clue file and react when the player opens it (the core ARG loop):

jsx
import { useRef, useEffect } from 'react';
import { WindowsXP } from '@caoergou/windows-xp';
import type { XPHandle, XPEvent } from '@caoergou/windows-xp';

function Arg() {
  const xp = useRef<XPHandle>(null);

  // Plant the first file + a locked folder once the desktop has mounted.
  useEffect(() => {
    xp.current?.fs.createFile(['diary.txt'], { type: 'file', app: 'Notepad', content: '…' });
    xp.current?.fs.createFile(['vault'], { type: 'folder', locked: true, password: 'BLISS' });
  }, []);

  return (
    <WindowsXP
      ref={xp}
      autoLogin
      onEvent={(e: XPEvent) => {
        // The player opened the locked diary → drop the next clue.
        if (e.type === 'file:open' && e.name === 'diary.txt') {
          xp.current?.fs.createFile(['clue2.txt'], {
            type: 'file',
            app: 'Notepad',
            content: 'The password is BLISS',
          });
        }
      }}
    />
  );
}

Later, unlock the folder programmatically and theme the desktop:

jsx
xp.current?.fs.unlockNode(['vault']);
xp.current?.appearance.setWallpaper('bliss');

Save / load a snapshot ("share your save")

getSnapshot() captures the whole instance — filesystem (with file contents), recycle bin, open windows, wallpaper, language, and a reserved flags slot — as a portable, versioned XPSnapshot JSON object. loadSnapshot() replaces this instance's state with a snapshot and reloads to rehydrate. Snapshots move between machines/browsers, so a player can share a save or an author can ship a checkpoint (#117).

jsx
import type { XPSnapshot } from '@caoergou/windows-xp';

// Export: download the current desktop as a .json file.
function downloadSave(xp) {
  const snapshot = xp.current.getSnapshot();
  const blob = new Blob([JSON.stringify(snapshot)], { type: 'application/json' });
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = 'my-desktop.xpsave.json';
  a.click();
  URL.revokeObjectURL(url);
}

// Import: load a save file (reloads to apply).
async function uploadSave(xp, file) {
  const snapshot = JSON.parse(await file.text());
  await xp.current.loadSnapshot(snapshot); // throws XPSnapshotVersionError if too new
}

Loading a snapshot whose version is newer than the running build throws XPSnapshotVersionError (exported) rather than corrupting state — so guard the import with a try/catch and surface a "please update" message.

Map URLs to desktop state so a blog post, a search result, or a campaign QR code can land the visitor on a specific open window.

Inbound — open a window from a URL. openOnLoad takes one or more key paths (the sequence of filesystem keys from the desktop root, joined with /). Windows open once the desktop is interactive (after skipBoot/autoLogin); invalid paths fail silently to the plain desktop. Wire it to your own URL — the component takes no router dependency:

jsx
import { WindowsXP } from '@caoergou/windows-xp';

const open = new URLSearchParams(location.search).getAll('open');
// e.g. visiting …?open=My Documents/readme.txt&lang=en opens that file, focused.
export default () => <WindowsXP openOnLoad={open} />;

For prettier URLs, pass a host-router-agnostic routes map plus the current location (any framework — you supply the string):

jsx
<WindowsXP
  location={location.pathname}
  routes={{ '/blog/:slug': ({ slug }) => ({ open: `D:/posts/${slug}.md` }) }}
/>

Share links. getShareUrl(windowId) returns a ?open=… permalink that reproduces a path-opened window on a fresh profile (component-only windows return null). A share button captures "this window, open, focused"; encode the same URL into a QR code for print/campaign use:

jsx
const url = xp.current?.getShareUrl(windowId); // …/?open=D%3A/posts/hello.md&lang=en

Browser Back. Set historyIntegration so opening/closing top-level windows push/pop history and Back closes the last-opened window — expected on content sites, off by default (games and embeds don't want it):

jsx
<WindowsXP openOnLoad={open} historyIntegration />

Outbound — links that leave the fiction. An external_link filesystem node is a desktop shortcut that opens a real URL instead of a window; or call openExternal(url, { newTab }) from the handle. Either way a link:external event fires — the conversion signal every campaign funnel measures. Feed it to your analytics:

jsx
<WindowsXP
  customFileSystem={{
    'Buy tickets': { type: 'external_link', name: 'Buy tickets', href: 'https://example.com/tickets', icon: 'ie' },
  }}
  onEvent={e => {
    if (e.type === 'link:external') gtag('event', 'outbound_click', { url: e.url });
  }}
/>

New tabs open with noopener,noreferrer, so an embedded desktop never hijacks its host page.