workoffdesk 0.0.0-stage → 1.0.0
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/CHANGELOG.md +21 -0
- package/LICENSE +21 -0
- package/README.md +199 -2
- package/SECURITY.md +50 -0
- package/dist/build.json +1 -0
- package/dist/server/agents/claude/accounts.js +105 -0
- package/dist/server/agents/claude/hooks/asks.js +135 -0
- package/dist/server/agents/claude/hooks/hooks.js +114 -0
- package/dist/server/agents/claude/hooks/status.js +34 -0
- package/dist/server/agents/claude/hooks/tracker.js +79 -0
- package/dist/server/agents/claude/offers/commands.js +219 -0
- package/dist/server/agents/claude/offers/files.js +51 -0
- package/dist/server/agents/claude/offers/initialize.js +59 -0
- package/dist/server/agents/claude/offers/models.js +20 -0
- package/dist/server/agents/claude/screen/choices.js +189 -0
- package/dist/server/agents/claude/screen/compacting.js +20 -0
- package/dist/server/agents/claude/screen/live.js +63 -0
- package/dist/server/agents/claude/screen/panel.js +140 -0
- package/dist/server/agents/claude/screen/prompt.js +23 -0
- package/dist/server/agents/claude/screen/screen.js +79 -0
- package/dist/server/agents/claude/terminals.js +110 -0
- package/dist/server/agents/claude/transcript/chat.js +254 -0
- package/dist/server/agents/claude/transcript/transcriptFile.js +194 -0
- package/dist/server/agents/claude/transcript/transcripts.js +210 -0
- package/dist/server/agents/claude/transcript/turn.js +136 -0
- package/dist/server/agents/claude/transcript/watch.js +60 -0
- package/dist/server/agents/claude/usage.js +36 -0
- package/dist/server/agents/codex/agent.js +263 -0
- package/dist/server/agents/codex/appServer.js +38 -0
- package/dist/server/agents/codex/items.js +226 -0
- package/dist/server/agents/codex/notifications.js +192 -0
- package/dist/server/agents/codex/rpc.js +121 -0
- package/dist/server/agents/codex/screen.js +5 -0
- package/dist/server/agents/codex/threads.js +208 -0
- package/dist/server/agents/types.js +1 -0
- package/dist/server/cli.js +49 -0
- package/dist/server/commands/account.js +61 -0
- package/dist/server/commands/attach.js +27 -0
- package/dist/server/commands/common.js +114 -0
- package/dist/server/commands/doctor.js +43 -0
- package/dist/server/commands/install.js +169 -0
- package/dist/server/commands/open.js +31 -0
- package/dist/server/commands/qr.js +33 -0
- package/dist/server/commands/serve.js +45 -0
- package/dist/server/commands/service.js +87 -0
- package/dist/server/commands/setup.js +36 -0
- package/dist/server/commands/setupWeb.js +87 -0
- package/dist/server/commands/status.js +32 -0
- package/dist/server/commands/wizard/probe.js +19 -0
- package/dist/server/commands/wizard/server.js +162 -0
- package/dist/server/http/app.js +144 -0
- package/dist/server/http/auth/guard.js +78 -0
- package/dist/server/http/auth/lockout.js +41 -0
- package/dist/server/http/auth/passkey.js +151 -0
- package/dist/server/http/auth/password.js +23 -0
- package/dist/server/http/auth/sessions.js +46 -0
- package/dist/server/http/routes/appUpdate.js +97 -0
- package/dist/server/http/routes/auth.js +79 -0
- package/dist/server/http/routes/chat.js +216 -0
- package/dist/server/http/routes/claude.js +69 -0
- package/dist/server/http/routes/codex.js +58 -0
- package/dist/server/http/routes/context.js +1 -0
- package/dist/server/http/routes/health.js +56 -0
- package/dist/server/http/routes/journal.js +35 -0
- package/dist/server/http/routes/mac.js +39 -0
- package/dist/server/http/routes/media.js +83 -0
- package/dist/server/http/routes/push.js +78 -0
- package/dist/server/http/routes/sessions.js +104 -0
- package/dist/server/http/routes/web.js +28 -0
- package/dist/server/http/serve.js +129 -0
- package/dist/server/http/sessionSocket.js +198 -0
- package/dist/server/ios-update/appUpdate.js +95 -0
- package/dist/server/ios-update/bundlePatch.js +56 -0
- package/dist/server/ios-update/bundlePatch.worker.js +11 -0
- package/dist/server/journal/db.js +243 -0
- package/dist/server/journal/derive.js +50 -0
- package/dist/server/journal/index.js +256 -0
- package/dist/server/journal/ingest.js +151 -0
- package/dist/server/notify/apns.js +126 -0
- package/dist/server/notify/push.js +109 -0
- package/dist/server/ops/awake.js +10 -0
- package/dist/server/ops/build.js +40 -0
- package/dist/server/ops/doctor.js +110 -0
- package/dist/server/ops/lastOp.js +34 -0
- package/dist/server/ops/problems.js +54 -0
- package/dist/server/ops/release.js +206 -0
- package/dist/server/ops/report.js +40 -0
- package/dist/server/ops/service.js +39 -0
- package/dist/server/ops/shutdown.js +12 -0
- package/dist/server/sessions/control.js +97 -0
- package/dist/server/sessions/events.js +77 -0
- package/dist/server/sessions/machine/accounts.js +55 -0
- package/dist/server/sessions/machine/input.js +208 -0
- package/dist/server/sessions/machine/lifecycle.js +357 -0
- package/dist/server/sessions/machine/moves.js +71 -0
- package/dist/server/sessions/machine/state.js +71 -0
- package/dist/server/sessions/machine/status.js +257 -0
- package/dist/server/sessions/machine/types.js +2 -0
- package/dist/server/sessions/machine.js +300 -0
- package/dist/server/sessions/outbox.js +194 -0
- package/dist/server/sessions/panes.js +60 -0
- package/dist/server/sessions/sessionName.js +7 -0
- package/dist/server/sessions/terminalEnv.js +40 -0
- package/dist/server/system/config.js +71 -0
- package/dist/server/system/dirs.js +54 -0
- package/dist/server/system/errors.js +37 -0
- package/dist/server/system/log.js +40 -0
- package/dist/server/system/messages.js +378 -0
- package/dist/server/system/node.js +6 -0
- package/dist/server/system/paths.js +51 -0
- package/dist/server/system/store.js +43 -0
- package/dist/server/system/tmux.js +198 -0
- package/dist/server/system/version.js +16 -0
- package/dist/server/uploads/fileUpload.js +171 -0
- package/dist/server/uploads/media.js +139 -0
- package/dist/web/assets/Setup-DinzDcF1.js +1 -0
- package/dist/web/assets/main-C8GiLoJy.css +1 -0
- package/dist/web/assets/main-w7i8PvU7.js +132 -0
- package/dist/web/icon-180.png +0 -0
- package/dist/web/icon-192.png +0 -0
- package/dist/web/icon-512.png +0 -0
- package/dist/web/icon.svg +6 -0
- package/dist/web/index.html +25 -0
- package/dist/web/manifest.webmanifest +15 -0
- package/dist/web/sw.js +8 -0
- package/dist/web/theme.js +11 -0
- package/package.json +77 -4
- package/scripts/smoke.mjs +130 -0
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
|
2
|
+
<rect width="512" height="512" rx="112" fill="#0d1117"/>
|
|
3
|
+
<rect x="24" y="24" width="464" height="464" rx="92" fill="none" stroke="#2f81f7" stroke-opacity=".35" stroke-width="4"/>
|
|
4
|
+
<path d="M140 176l92 80-92 80" fill="none" stroke="#e6edf3" stroke-width="40" stroke-linecap="round" stroke-linejoin="round"/>
|
|
5
|
+
<rect x="268" y="316" width="112" height="40" rx="20" fill="#3fb950"/>
|
|
6
|
+
</svg>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content" />
|
|
6
|
+
<!-- The --surface colours: iOS paints the strip below a short home-screen view in it, under the key bar.
|
|
7
|
+
src/looks.ts sets both to the chosen theme's. -->
|
|
8
|
+
<meta name="theme-color" content="#161b22" media="(prefers-color-scheme: dark)" />
|
|
9
|
+
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" />
|
|
10
|
+
<!-- Not a module, so it runs before the first paint (the server's CSP allows no inline script). -->
|
|
11
|
+
<script src="/theme.js"></script>
|
|
12
|
+
<meta name="apple-mobile-web-app-capable" content="yes" />
|
|
13
|
+
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
|
14
|
+
<meta name="apple-mobile-web-app-title" content="WorkOffDesk" />
|
|
15
|
+
<link rel="manifest" href="/manifest.webmanifest" />
|
|
16
|
+
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
|
17
|
+
<link rel="apple-touch-icon" href="/icon-180.png" />
|
|
18
|
+
<title>WorkOffDesk</title>
|
|
19
|
+
<script type="module" crossorigin src="/assets/main-w7i8PvU7.js"></script>
|
|
20
|
+
<link rel="stylesheet" crossorigin href="/assets/main-C8GiLoJy.css">
|
|
21
|
+
</head>
|
|
22
|
+
<body>
|
|
23
|
+
<div id="root"></div>
|
|
24
|
+
</body>
|
|
25
|
+
</html>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "WorkOffDesk",
|
|
3
|
+
"short_name": "WorkOffDesk",
|
|
4
|
+
"description": "Your Mac's Claude Code and Codex sessions, on your phone: chat, answer prompts, pick up where the terminal left off. Self-hosted, no account.",
|
|
5
|
+
"start_url": "/",
|
|
6
|
+
"scope": "/",
|
|
7
|
+
"display": "standalone",
|
|
8
|
+
"background_color": "#0d1117",
|
|
9
|
+
"theme_color": "#0d1117",
|
|
10
|
+
"icons": [
|
|
11
|
+
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
|
|
12
|
+
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" },
|
|
13
|
+
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
|
14
|
+
]
|
|
15
|
+
}
|
package/dist/web/sw.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
(function(){"use strict";var l;const u={...{justNow:"just now",minutesAgo:e=>`${e} min ago`,hoursAgo:e=>`${e} h ago`,daysAgo:e=>`${e} d ago`,yesterday:"Yesterday",seconds:e=>`${e}s`,minutesSeconds:(e,o)=>`${e}m ${o}s`,status:{starting:"Starting",working:"Working",waiting:"Needs you",idle:"Idle",ended:"Ended",compacting:"Compacting"},previewStarting:"Starting…",previewWorking:"Working…",compactingContext:"Compacting context…",close:"Close",back:"Back",save:"Save",delete:"Delete",edit:"Edit",copy:"Copy",copied:"Copied",appError:"Something went wrong",reopen:"Reopen",loading:"Loading…",reading:"Loading…",refresh:"Refresh",open:"Open",showScreen:"Screen",session:"Session",settings:"Settings",sent:"Sent",sentMessage:"Sent message",sending:"Sending…",sendingMessage:"Message being sent",tryAgain:"Try again",device:"Device",serverErrors:{"dialog-open":"A menu is open above. Pick an option or press Esc first.","background-session":"This conversation is in a Claude background session and cannot be resumed here.","open-on-mac":"This conversation is still open in a terminal on the Mac.","mac-would-not-quit":"It did not quit on the Mac, so nothing was resumed.","open-elsewhere":"This conversation is open in another Codex app (an editor or the Codex app). Close it there first.","not-an-image":"Only images can be uploaded (PNG, JPEG, GIF, WebP, HEIC).","upload-incomplete":"The file did not all reach the Mac. Send it again.","upload-offset":"The Mac has a different part of this file. It is being sent again from where the Mac is.","not-managed":"This server was started by hand, not as the service, so it cannot be restarted from here.","op-busy":"A restart or rollback is already running.","rollback-moved":"The releases changed in the meantime. Look again before rolling back.","account-unusable":"That account cannot be used now.","account-none-picked":"Pick an account first.","folder-gone":"Its folder is not on the Mac any more.","terminal-busy":"Something is running in this session’s terminal on the Mac. Quit it there first, or start another conversation."},noteCompacted:"Earlier conversation compacted",noteInterrupted:"Interrupted",toolStopped:e=>`Not run: ${e} was stopped (Esc) before it ran this.`,noteFailed:"Something went wrong",noteLimit:"Usage limit reached",noteTask:(e,o)=>`${e==="completed"?"Background task done":e==="failed"?"Background task failed":"Background task"}: ${o}`,notePeer:e=>`Message from ${e||"another session"}`,builtinCommands:{},push:{waiting:"Needs your confirmation",idle:"Done with this turn"}},...{panelTitles:{Model:"Model",Effort:"Effort",Settings:"Settings",Usage:"Usage",Btw:"Side question"},panelNamed:e=>`the “${e}” panel`,aPanel:"a panel",agentOpened:(e,o)=>`${e} opened ${o}`,agentHasOpen:(e,o)=>`${e} has ${o} open`,heldWhilePanel:"Messages stay in the box until it is closed",fiveHourUnseen:"5-hour limit: not on screen",fiveHourUsed:e=>`5-hour limit ${e}% used`,weekUnseen:"Weekly limit: not on screen",weekUsed:e=>`Weekly limit ${e}% used`,effortHint:"Left is faster, right is smarter; this session only",effortLevels:{},dialogAbout:{Model:"Your pick becomes the default for new sessions"},closeEsc:"Close (Esc)",otherModels:"Other models…",btwAsked:"Side question (/btw)",showKeys:"Show keys",hideKeys:"Hide keys",scrollUp:"Up",scrollDown:"Down",agentAsks:e=>`${e} is asking`,plan:"Plan",writeOwnAnswer:"Write your own answer",writeBelow:"Write the answer in the box below and send it",noneTicked:"None",typedHint:"To write your own answer, tap “Screen” and answer with the key bar",nextQuestion:"Next",submit:"Submit",ticked:e=>`(${e})`,listSeparator:", ",optionsChanged:"The options changed; these are the current ones",allowTool:e=>`Allow ${e}?`,allowYes:"Yes",allowAlways:e=>`Yes, and from now on ${e}`,alwaysMode:e=>e==="acceptEdits"?"accept all edits":`switch to ${e} mode`,alwaysRules:"allow these",alwaysDirs:e=>`allow access to ${e}`,alwaysJoin:" and ",allowNo:"No",allowNoDetail:"It stops and waits for you to say what to do",askOver:"Already answered on the Mac",notPressed:"Did not go through, tap again",escCancel:"Esc to cancel",youChose:"You chose: ",continueWord:"Continue",moveAccount:"Go on with another account",moveAccountTitle:"Go on with another account?",moveAccountNote:"The open conversations go on there from where they stopped, and new ones start on that account too.",moveAccountHow:"Another Claude account would go on from here. In a terminal on the Mac, run CLAUDE_CONFIG_DIR=~/.claude-<name> claude and sign in with /login; it then shows here.",moveAccountThen:e=>`“${e}” waits, and goes once on the other account.`,movingAccount:e=>`Moving to ${e}; it goes on once there…`},...{sendPaused:"Sending paused",loadingEarlier:"Loading earlier conversation",noConversation:"No conversation yet",typeBelow:"Type below; the replies show up here",agentWriting:e=>`${e} is writing…`,chatUnreadable:"Cannot read the conversation, retrying…",endedNoChat:"This conversation was not kept on this device.",goneOnMac:"It is no longer there on the Mac.",claudeWorking:"Working",agentWaiting:e=>`${e} is waiting for you`,toBottom:"Back to bottom",changes:"Changes",newFile:"New file",atLine:e=>`Line ${e}`,diffCut:"Too long, only the start is shown",changedFiles:"Files changed this time",filesChanged:e=>`${e} file${e===1?"":"s"} changed`,newMark:"new",viewImage:"View image",image:"Image",imageCount:e=>`${e} image${e===1?"":"s"}`,fileCount:e=>`${e} file${e===1?"":"s"}`,unseenImages:e=>`${e} image${e===1?"":"s"} attached (pasted on the Mac, not shown here)`,collapse:"Collapse",expandAll:"Show all",allLines:e=>`Show all ${e} lines`,rewind:"Rewind",stopReading:"Stop reading",readAloud:"Read aloud",webImage:"Web image",seeImageAbove:"↑ See the image above",running:"Running",noOutput:"(no output)",noResultYet:"No result yet",toolsCalled:e=>`${e} tool call${e===1?"":"s"}`,toolsDone:e=>`✓ ${e} tool call${e===1?"":"s"}`,took:e=>`took ${e}`,toolErrors:e=>`${e} failed`,imageExpired:"Image expired",imageGone:"Image no longer there",imageNotLoaded:"The image did not load · Tap to try again",playVideo:e=>`Play ${e}`,videoNotLoaded:"The video did not open · Tap to try again",videoGone:"This video is no longer on the Mac",tasks:(e,o)=>`Tasks ${e}/${o}`,taskNow:e=>`Now: ${e}`,taskNotStarted:"Next one not started",backgroundCommands:"Background commands",backgroundRunning:e=>`${e} running`,backgroundOpen:"View / stop (opens /tasks)",queuedMessages:"Queued messages",queued:e=>`${e} queued`,sendNow:"Send now",moveUp:"Up",moveDown:"Down",compactingQueued:e=>`Your ${e} message${e===1?" is":"s are"} queued and will go out in order after compacting`,compactingWillQueue:"Messages sent now are queued and go out after compacting",alsoOnMac:e=>e===1?"Also open in a terminal on the Mac":`Also open in ${e} terminals on the Mac`,alsoOnMacSub:e=>`Two Claudes are writing this conversation, each seeing only its own turns. Quit Claude there; to go on at the Mac, run workoffdesk attach ${e}`,keys:"Keys",paste:"Paste",enter:"Enter",modes:{default:"Ask every step","accept edits":"Auto-edit files","plan mode":"Plan only","auto mode":"Auto","bypass permissions":"Allow everything"},permissions:"Permissions",permissionsIs:e=>`Permissions: ${e}`,unknown:"unknown",modelIs:e=>`Model: ${e}, tap to switch`,model:"Model",effortIs:e=>`Effort: ${e}, tap to change`,resetsInMinutes:e=>`resets in ${e} min`,resetsInHours:(e,o)=>`resets in ${e} h ${o} min`,resetsInDays:(e,o)=>`resets in ${e} d ${o} h`,context:"Context",fiveHourLimit:"5-hour limit",sevenDayLimit:"7-day limit",sessionCost:"This session cost",linesChanged:"Changed",lines:(e,o)=>`+${e} −${o} lines`,contextLabel:e=>`Context ${e}`,contextIs:e=>`${e} of context used, tap for usage`,fiveHourLabel:e=>`5-hour ${e}`,sevenDayLabel:e=>`7-day ${e}`,limitIs:(e,o)=>`${o} of the ${e} used, tap for usage`,copyUsage:"Copy usage",rename:"Rename",name:"Name",renameFailed:"Not renamed",renameNote:"Leave it empty to use the name Claude or Codex gives the conversation, or else the folder's. Every device shows this name.",newSession:"New session",pickProject:"Pick a project",resumeRecent:"Resume a recent conversation",openFailed:"Not opened",newInProject:"New Claude in a project",newInProjectSub:"Pick a folder and start a new Claude Code",newCodexInProject:"New Codex in a project",newCodexInProjectSub:"Pick a folder and start a new Codex",resumeRecentSub:"Pick up a conversation from this Mac",loadFailed:"Could not load",nameOptional:"Name (optional)",nameOptionalHint:"Name (optional, the folder name by default)",findProject:"Search, or type a path (~/…)",noFolders:"No folders here",lookInside:e=>`Show the folders in ${e}`,noProjects:"No projects have used Claude yet. Type a path above, such as ~/",alreadyOpen:"Already open · ",folderGoneShort:"Folder gone, cannot resume here · ",inBackground:"In a Claude background session, cannot resume here · ",openElsewhere:"Open in another Codex app, cannot resume here · ",searchTitlePath:"Search titles or paths",noMatch:"No matching conversations",noConversations:"No Claude conversations on this Mac yet",automated:e=>`Sessions of automated tasks (${e})`,screenTitle:"Screen on the Mac",screenUnread:"Could not read the screen, try refreshing",screenContent:"Screen content",screenEmpty:"(the screen is empty)"},...{uploadFailed:"The image did not upload",speechBlocked:"Speech recognition is not available here (a home-screen web app may not get it). Tap the box and use the keyboard’s microphone.",speechDenied:"Speech recognition is not allowed. Allow this site the microphone and speech recognition in Settings, then try again.",speechStopped:"Speech recognition stopped. Hold to try again.",speechErrors:{network:"Speech recognition needs the network. Check the connection and hold to try again.","audio-capture":"The microphone could not be used. Another app may have it.",interrupted:"Listening was interrupted, by a call or another app taking the microphone.",busy:"Speech recognition is busy. Hold to try again in a moment.","speech-timeout":"Nothing was heard for a while, so listening stopped."},notHeard:"Did not catch that",micJustOn:"The microphone just turned on, hold and speak again",speechUnavailable:"Speech recognition is unavailable",noLiveSpeech:"This browser has no live speech recognition",commands:"Commands",snippet:e=>`Snippet ${e}`,snippets:"Snippets",files:"Files",removeImage:"Remove image",uploading:"Uploading",input:"Message",placeholderSpeech:"Message, or hold to talk…",placeholder:"Message",stop:"Stop",send:"Send",holdSend:"Release to send · slide to ✕ to cancel",holdSendSlide:"Release to send · slide up to ✕ to cancel, or to edit as text",holdCancel:"Release to cancel",holdText:"Release to edit as text",cancel:"Cancel",toText:"To text",heardLabel:"Speech to text",recognizing:"Recognizing…",openingMic:"Opening the microphone",holdToTalk:"Hold to talk",photoLibrary:"Photo library",takePhoto:"Take photo",photosAndVideos:"Photos and videos",pickFile:"File",largeVideo:(e,o)=>e===1?`This video is ${o} MB`:`These ${e} videos are ${o} MB`,largeVideoSub:e=>`As ${e===1?"it is":"they are"}, it takes a while to send. At 720p ${e===1?"it is":"they are"} much smaller, and the stills Claude looks at are the same; you pick ${e===1?"it":"them"} again in Photos.`,sendAsIs:"Send as it is",sendSmaller:"Make smaller and send",attach:"Add a photo, video or file",removeFile:"Remove file",fileUploadFailed:e=>`${e} did not upload`,whatAndWhy:(e,o)=>`${e}: ${o}`,cameraDenied:"WorkOffDesk may not use the camera. Allow it in Settings.",speechDeniedApp:"WorkOffDesk may not use the microphone or speech recognition. Allow both in Settings, then try again.",speechOff:"Speech recognition is unavailable right now. Check that Dictation is on in Settings › General › Keyboard and that you are online.",speechLanguage:"Speech recognition does not support the languages this phone is set to.",openSettings:"Open Settings"},...{notConnectedSend:"Not connected to the Mac yet; send again once it is",modeNotSwitched:"Did not switch to that mode; Claude may not offer it",switchingMode:"Switching…",applying:"Setting…",clipboardUnreadable:"Cannot read the clipboard. Long-press the box to paste.",endFailed:"Not ended: cannot reach the Mac, tap again in a moment",notSentGiveBack:"Not sent; the text is back in the box",alreadySent:"This one has already been sent",takeBackFailed:"Could not take it back, try again",deleteFailed:"Not deleted, try again",notConnectedTap:"Not connected to the Mac yet; tap again once it is",notSentRetry:"Not sent, try again",notMoved:"Not moved, try again",notEnded:"Not ended, try again",notRun:"Not run, try again",queueWhenStarted:e=>`Goes out in order once ${e} has started`,queueWhenContinued:e=>`Goes out in order once ${e} is back on this conversation`,queueAfterCompact:"Goes out in order after compacting",queueAfterWaiting:e=>`${e} is waiting for you; goes out in order after that`,queueAfterTurn:e=>`${e} reads these after the step it is on`,inAgentsQueue:e=>`In ${e}’s queue`,queueNow:"Goes out in order right away",renameSession:e=>`Rename: ${e}`,ending:"Ending…",confirmEnd:"Confirm end",end:"End",shellSession:"This is a command-line session on the Mac",shellSessionSub:"The phone shows only Claude conversations; use this session on the Mac",syncing:"Syncing",reconnecting:"Reconnecting…",reconnectingSub:"It reconnects when the network is back; the session keeps running on the Mac",sessionEnded:"Session ended",updateReady:"New version",updateReadySub:"Downloaded. It starts the next time the app opens.",updateNow:"Update now",sessionEndedSub:"This session has exited on the Mac",startAnother:"Start a new one",agentQuit:e=>`${e} has quit`,agentQuitSub:"Continue picks up this conversation where it stopped; a new one starts afresh in this folder",endedGoOnSub:"Continue picks this conversation up in a new session; a new one starts afresh in this folder",continueConversation:"Continue this conversation",continueFailed:"Not continued",suggestion:"Suggested next message · tap to put it in the box",dismissSuggestion:"Dismiss this suggestion",macSession:"Session on the Mac",readOnly:"Read-only",cannotRead:"Cannot read this conversation",openAlreadyOnPhone:"Open (already open on the phone)",inBackgroundNoResume:"In a Claude background session, cannot resume here",openElsewhereNoResume:"Open in another Codex app, cannot resume here",continueOnPhone:"Continue on the phone",onMacIdle:"Open on the Mac",onMacBusy:"Working on the Mac",onMacWaiting:"Asking you on the Mac",openInTerminals:e=>`Open in ${e} terminals`,takeOverNow:"Take over to the phone",folderGoneNoResume:"Its folder is gone, so it cannot go on here",folderGoneSub:e=>`${e} is not on the Mac any more, as when a worktree is removed. Start a new conversation in another folder.`,takeOverWhenDone:"Take over when this step is done",takeOverInterrupt:"Take over now (stops this step)",takeOverDropsQuestion:"Its question is dropped: once here, ask it to redo that step.",takeOverNote:e=>`${e} on the Mac quits and goes on here. Back at the Mac: workoffdesk attach`,takingOverWait:"Waiting for this step on the Mac to finish…",takeOverCancel:"Cancel",takeOverMissed:"It was not taken over. Try again.",startInTmuxHint:"Start Claude or Codex on the Mac with workoffdesk claude or workoffdesk codex and there is nothing to take over.",continueOnMac:"Continue on the Mac",continueOnMacSub:"Run this in a terminal on the Mac:",renameAction:"Rename",busy:"Running",needsYou:"Needs you",unread:"Unread",sessions:"Sessions",chooseSession:"Choose a session on the left.",search:"Search",noSessions:"No sessions yet",noSessionsSub:"Tap ⊕ at the top right to open a Shell or Claude.",noSessionsPhone:"Tap + at the top right to start Claude in a project.",sessionMenu:"Session actions",endSession:"End session",notFound:e=>`Nothing found for “${e}”`,otherMacSessions:"Other sessions on the Mac",wrongPassword:"Wrong username or password",loginNotKept:"Signed in, but the login could not be saved on this phone. Try again.",loginFailedOther:"Login failed. Try again.",tooManyTries:e=>`Too many tries, try again in ${e} min`,loginFailed:e=>`Login failed (${e})`,noServer:"Cannot reach the server, check the network",noAnswer:"The server did not answer, so this may or may not have happened. Check before trying again",unknownPasskey:"This passkey is not recognized. Log in with the password, then add this device in Settings.",tagline:"Your Mac’s Claude, on your phone",faceId:"Log in with Face ID",orPassword:"or with a password",username:"Username",password:"Password",login:"Log in",serverAddress:"Server address",serverAddressHint:"Where you open WorkOffDesk in a browser, like https://workoffdesk.example.com",badServerAddress:"That is not a web address"},...{textSize:"Text size",wholeApp:"Text in the whole app",thisDeviceOnly:"This device only",smaller:"Smaller text",larger:"Larger text",textPreview:"Claude’s replies, your messages and the lists all show at this size.",appearance:"Appearance",theme:"Theme",followSystem:"System",themeLight:"Light",themeDark:"Dark",themeEye:"Eye care",language:"Language",languageRestarts:"The app reopens in the language you pick.",snippetsLines:"Snippets, one per line",snippetsPlaceholder:`One per line, like:
|
|
2
|
+
Continue
|
|
3
|
+
Run lint and the type check
|
|
4
|
+
Commit the changes`,snippetsNote:"They come first when you type / in the box; a tap puts the whole line in. Kept on this device only.",pushInsecure:"Push needs this site opened over https (your real domain, for example).",pushUnsupported:"This browser does not support push. On iPhone, first “Share → Add to Home Screen” in Safari, then open it from the home screen.",pushDenied:"Notifications were denied; allow them for this site in the system settings.",failed:"Did not work",passkeyNone:"No passkeys yet. Add one to log in with Face ID.",passkeyNoneOnPhone:"No passkeys yet. Add one in the web app's Settings to log in there with Face ID.",pushAppUnready:"This Mac cannot reach the iPhone app yet: its server needs an Apple push key (apns.json in ~/.workoffdesk).",pushAppDenied:"Notifications are off for WorkOffDesk. Turn them on in the iPhone Settings app → WorkOffDesk → Notifications.",addedAgo:e=>`Added ${e}`,lastUsedAgo:e=>` · last used ${e}`,passkeysTitle:"Passkeys",addPasskey:"Add a passkey for this device",loggedIn:"Logged-in devices",thisDevice:"This device",loginBy:(e,o)=>`${e?"Passkey":"Password"} login · last active ${o}`,revoke:"Revoke",confirmDelete:"Confirm delete",confirmRevoke:"Confirm revoke",pushTitle:"Push notifications",pushRemind:"Tell me when Claude needs me",pushRemindSub:"Pushed to this device when Claude needs you or finishes a turn",logOut:"Log out",addAccount:"Add account",addAccountTagline:"Sign in to another account or server",signInAgainTagline:"Its Mac no longer takes this sign-in; sign in again",removeAccount:"Remove",confirmRemoveAccount:"Remove from this phone",account:"Account",appRelease:e=>e?`This app runs ${e}`:"This app runs the version it was installed with",thisMac:"This Mac",thisMacSub:"Checks, version, restart",macAllGood:"Everything works",macProblemCount:e=>e===1?"1 problem":`${e} problems`,macChecks:"Checks",checkNames:{macos:"macOS",node:"Node.js",tmux:"tmux",claude:"Claude Code",codex:"Codex",setup:"Account",service:"Service",origin:"Address",sleep:"Staying awake",apns:"iPhone notifications"},macFixTap:"Tap to copy",macCheckPassed:"Passed",macCheckFailed:"Failed",macVersion:"Version",macBuiltFrom:(e,o)=>`Built from ${e}${o?" with uncommitted changes":""}`,macServingSince:e=>`Serving since ${e}`,macReleases:"Releases kept",macRunning:"Running",macActions:"Service",macRestart:"Restart the service",macRestartNote:"Sessions keep running; the page comes back by itself.",macRestartConfirm:"Restart now",macRestartShort:"Restart",macRollbackShort:"Roll back",macRollbackSure:"Sure?",macPassed:e=>`${e} more passed`,macAllPassed:e=>`All ${e} checks passed`,macRollback:e=>`Roll back to ${e}`,macNoRollback:"No earlier release is kept to go back to.",macNotManaged:"This server was started by hand (workoffdesk serve), not as the service, so it cannot be restarted from here.",macRestarting:"Restarting…",macRollingBack:"Rolling back…",macRestarted:"Restarted",macRolledBack:"Rolled back",macNoReturn:"The Mac has not come back within a minute. Look at the Mac, or run workoffdesk doctor there.",macProblems:"Recent warnings and errors",macNoProblems:"Nothing went wrong lately.",macLogNote:"The full log is in the logs folder of ~/.workoffdesk on the Mac.",macClaudeAccounts:"Claude account",macAccountNote:"New sessions start on the account checked. Open ones keep theirs unless moved.",macElsewhere:e=>e===1?"1 open session is on another account":`${e} open sessions are on another account`,macMoveTo:e=>`Move to ${e}; it goes on from where it stopped.`,macMoveButton:"Move",macMovingSessions:e=>e===1?"Moving 1 session":`Moving ${e} sessions`,macMovingTo:e=>`Moving to ${e}: idle ones at once, ones replying once their reply is done.`,accountSignedOut:"Signed out",accountNotReady:"Open Claude Code with it in a terminal once first",accountApart:"Its conversations are kept apart from this Mac’s",accountNotSeen:"Not seen in use yet",limitReset:"reset",claudeAccountLine:"Account"},...{setupTitle:"Set up WorkOffDesk",setupSub:"Once, on this Mac. Then the phone does the rest.",setupSteps:{checks:"This Mac",account:"Account",address:"Address",service:"Service",phone:"Phone"},setupChange:"Change",setupContinue:"Continue",setupCheckAgain:"Check again",setupChecksNote:"What WorkOffDesk needs on this Mac.",setupChecksFix:"Run the commands in Terminal, then check again.",setupChecksOk:"This Mac has everything it needs.",setupAccountNote:"The name and password the phone signs in with.",setupAccountHave:e=>`There is an account: ${e}`,setupKeep:"Keep it",setupReplace:"Replace it",setupReplaceNote:"A new account signs every device out.",setupPasswordRule:e=>`At least ${e} characters`,setupPasswordAgain:"Password again",setupPasswordsDiffer:"The two passwords are not the same",setupSaveAccount:"Save the account",setupAddressNote:"The HTTPS address the phone opens, wherever it is.",setupAddressNow:e=>`Now: ${e}`,setupTailscale:"Tailscale",setupTailscaleUse:e=>`https://${e}, reached only from your own devices`,setupTailscaleDown:"Tailscale is installed but not running. Open it and sign in, then check again.",setupCloudflare:"Cloudflare tunnel",setupCloudflareNote:"A hostname on your domain, through a tunnel you made.",setupTunnel:"Tunnel",setupHostname:"Hostname",setupOther:"Another address",setupOtherNote:e=>`Any HTTPS address that reaches port ${e} on this Mac.`,setupNoWayIn:"The simplest way in: Tailscale on this Mac and on the phone. Install it, then check again.",setupUseAddress:"Use this address",setupServiceNote:"WorkOffDesk runs as a service that starts when you log in to this Mac.",setupInstall:"Install and start",setupInstalling:"Starting it. This can take half a minute.",setupPhoneNote:e=>`Point the phone's camera at the code, then sign in as ${e}.`,setupFinish:"Finish",setupFinished:"All set. You can close this tab.",setupNoToken:"Open this page from the address that workoffdesk setup --web printed in Terminal. Running it again prints a new one.",setupGone:"Setup is no longer running. Run workoffdesk setup --web again."}},h={...{justNow:"刚刚",minutesAgo:e=>`${e} 分钟前`,hoursAgo:e=>`${e} 小时前`,daysAgo:e=>`${e} 天前`,yesterday:"昨天",seconds:e=>`${e} 秒`,minutesSeconds:(e,o)=>`${e} 分 ${o} 秒`,status:{starting:"启动中",working:"在干活",waiting:"等你确认",idle:"空闲",ended:"已结束",compacting:"压缩中"},previewStarting:"启动中…",previewWorking:"在干活…",compactingContext:"正在压缩上下文…",close:"关闭",back:"返回",save:"保存",delete:"删除",edit:"编辑",copy:"复制",copied:"已复制",appError:"出了点问题",reopen:"重新打开",loading:"加载中…",reading:"读取中…",refresh:"刷新",open:"打开",showScreen:"看屏幕",session:"会话",settings:"设置",sent:"已送出",sentMessage:"已送出的消息",sending:"发送中…",sendingMessage:"正在发送的消息",tryAgain:"再试一次",device:"设备",serverErrors:{"dialog-open":"上面的菜单还开着,先选一项或按 Esc","background-session":"这个对话在 Claude 的后台会话里,不能在这里续","open-on-mac":"这个对话还开在电脑的终端里","mac-would-not-quit":"电脑上那边没有退出,没有续上","open-elsewhere":"这个对话开在别的 Codex 应用里(编辑器或 Codex App),先在那边关掉","not-an-image":"只能上传图片(PNG、JPEG、GIF、WebP、HEIC)","upload-incomplete":"文件没有完整传到 Mac,请重新发送","upload-offset":"Mac 上这个文件的进度对不上,正从 Mac 那边的位置接着发","not-managed":"这个服务是手动起的,不是后台服务,这里不能重启它。","op-busy":"已经有一次重启或回退在进行。","rollback-moved":"版本刚刚变了,再看一眼再回退。","account-unusable":"这个账号现在用不了。","account-none-picked":"先选一个账号。","folder-gone":"它的文件夹已经不在这台 Mac 上了。","terminal-busy":"这个会话在电脑的终端里正跑着别的程序,先在那边退出它,或者开一个新对话"},noteCompacted:"更早的对话已压缩",noteInterrupted:"已中断",toolStopped:e=>`没有执行:这一步还没跑,${e} 就收到 Esc 停下了。`,noteFailed:"出错了",noteLimit:"额度用完了",noteTask:(e,o)=>`${e==="completed"?"后台任务完成":e==="failed"?"后台任务失败":"后台任务"}:${o}`,notePeer:e=>`收到 ${e||"另一个会话"} 的消息`,builtinCommands:{agents:"管理子 agent",clear:"清空对话,从头开始",compact:"压缩对话,腾出上下文",config:"打开设置",context:"看上下文用了多少",cost:"看这次会话花了多少",doctor:"检查 Claude Code 的安装",export:"导出对话",help:"帮助",hooks:"管理钩子",init:"为项目写一份 CLAUDE.md",mcp:"管理 MCP 服务",memory:"编辑记忆文件",model:"换模型",permissions:"管理权限",plugin:"管理插件",resume:"接着以前的对话",review:"审查一个 PR",rewind:"回到之前的某一步",status:"看版本、账号和连接",todos:"看待办",usage:"看额度用了多少"},push:{waiting:"需要你确认",idle:"这一轮做完了"}},...{panelTitles:{Model:"模型",Effort:"思考强度",Settings:"设置",Usage:"用量",Btw:"顺带一问"},panelNamed:e=>`「${e}」面板`,aPanel:"一个面板",agentOpened:(e,o)=>`${e} 打开了${o}`,agentHasOpen:(e,o)=>`${e} 开着${o}`,heldWhilePanel:"发的消息先留在框里,关掉再发",fiveHourUnseen:"5 小时额度:屏幕上没露出来",fiveHourUsed:e=>`5 小时额度已用 ${e}%`,weekUnseen:"本周额度:屏幕上没露出来",weekUsed:e=>`本周额度已用 ${e}%`,effortHint:"左边更快,右边更聪明;只改这个会话",effortLevels:{low:"低",medium:"中",high:"高",xhigh:"超高",max:"最高"},dialogAbout:{Model:"选中的会成为新会话的默认模型"},closeEsc:"关闭 (Esc)",otherModels:"其他模型…",btwAsked:"顺带一问 (/btw)",showKeys:"显示按键",hideKeys:"收起按键",scrollUp:"上翻",scrollDown:"下翻",agentAsks:e=>`${e} 在问你`,plan:"计划",writeOwnAnswer:"自己写一个回答",writeBelow:"在下面的输入框写回答,发送就行",noneTicked:"都不选",typedHint:"要自己写答案的话,点「看屏幕」用快捷键条作答",nextQuestion:"下一题",submit:"提交",ticked:e=>`(${e} 项)`,listSeparator:"、",optionsChanged:"选项已经变了,这是现在的",allowTool:e=>`允许使用 ${e} 吗?`,allowYes:"允许",allowAlways:e=>`允许,并且以后${e}`,alwaysMode:e=>e==="acceptEdits"?"所有修改都直接接受":`切换到 ${e} 模式`,alwaysRules:"这些不再问",alwaysDirs:e=>`可以访问 ${e}`,alwaysJoin:",",allowNo:"不允许",allowNoDetail:"停下来,等你说该怎么做",askOver:"已经在电脑上回答过了",notPressed:"没按到,再点一次试试",escCancel:"Esc 取消",youChose:"你选了:",continueWord:"继续",moveAccount:"换个账号接着跑",moveAccountTitle:"换个账号接着跑?",moveAccountNote:"开着的会话都会在那个账号上从停下的地方接着跑,之后新开的会话也用它。",moveAccountHow:"换一个 Claude 账号就能接着用:在 Mac 的终端里运行 CLAUDE_CONFIG_DIR=~/.claude-<名字> claude,用 /login 登录另一个账号,它就会出现在这里。",moveAccountThen:e=>`「${e}」先留着,换好账号就发出去。`,movingAccount:e=>`正在换到 ${e},换好就接着跑…`},...{sendPaused:"发送暂停",loadingEarlier:"正在加载更早的对话",noConversation:"还没有对话",typeBelow:"在下面输入,回复会出现在这里",agentWriting:e=>`${e} 正在写…`,chatUnreadable:"读不到对话,正在重试…",endedNoChat:"这台设备上没有这段对话的记录。",goneOnMac:"Mac 上已经没有它了。",claudeWorking:"在干活",agentWaiting:e=>`${e} 在等你确认`,toBottom:"回到底部",changes:"改动",newFile:"新文件",atLine:e=>`第 ${e} 行`,diffCut:"太长了,只显示前面一部分",changedFiles:"这次改动的文件",filesChanged:e=>`改了 ${e} 个文件`,newMark:"新",viewImage:"查看图片",image:"图片",imageCount:e=>`${e} 张图`,fileCount:e=>`${e} 个文件`,unseenImages:e=>`附了 ${e} 张图(在 Mac 上粘贴的,这里看不到)`,collapse:"收起",expandAll:"展开全部",allLines:e=>`查看全部 ${e} 行`,rewind:"回退",stopReading:"停止朗读",readAloud:"朗读",webImage:"外链图片",seeImageAbove:"↑ 看上面那张图",running:"运行中",noOutput:"(没有输出)",noResultYet:"还没有结果",toolsCalled:e=>`已调 ${e} 个工具`,toolsDone:e=>`✓ 调了 ${e} 个工具`,took:e=>`用时 ${e}`,toolErrors:e=>`${e} 个出错`,imageExpired:"图片已过期",imageGone:"图片已不在",imageNotLoaded:"图片没加载出来 · 点按重试",playVideo:e=>`播放 ${e}`,videoNotLoaded:"视频没打开 · 点按重试",videoGone:"Mac 上已经没有这个视频了",tasks:(e,o)=>`任务 ${e}/${o}`,taskNow:e=>`正在:${e}`,taskNotStarted:"还没开始下一项",backgroundCommands:"后台命令",backgroundRunning:e=>`${e} 个运行中`,backgroundOpen:"查看 / 停止(打开 /tasks)",queuedMessages:"排队的消息",queued:e=>`排队 ${e} 条`,sendNow:"立即发送",moveUp:"上移",moveDown:"下移",compactingQueued:e=>`你的 ${e} 条消息在排队,压缩完按顺序自动发出`,compactingWillQueue:"现在发的消息会先排队,压缩完自动发出",alsoOnMac:e=>e===1?"这个对话在 Mac 的终端里也开着":`这个对话在 Mac 的 ${e} 个终端里也开着`,alsoOnMacSub:e=>`两个 Claude 在同时写它,各自只看得见自己那几轮。退出那边的 Claude;要在 Mac 上接着用,跑 workoffdesk attach ${e}`,keys:"快捷键",paste:"粘贴",enter:"回车",modes:{default:"每步都问","accept edits":"自动改文件","plan mode":"只做计划","auto mode":"自动","bypass permissions":"全部放行"},permissions:"权限",permissionsIs:e=>`权限:${e}`,unknown:"未知",modelIs:e=>`模型:${e},点开换模型`,model:"模型",effortIs:e=>`思考强度:${e},点开调整`,resetsInMinutes:e=>`${e} 分钟后重置`,resetsInHours:(e,o)=>`${e} 小时 ${o} 分后重置`,resetsInDays:(e,o)=>`${e} 天 ${o} 小时后重置`,context:"上下文",fiveHourLimit:"5 小时额度",sevenDayLimit:"7 天额度",sessionCost:"这个会话花了",linesChanged:"改了",lines:(e,o)=>`+${e} −${o} 行`,contextLabel:e=>`上下文 ${e}`,contextIs:e=>`上下文用了 ${e},点开看用量`,fiveHourLabel:e=>`5 小时 ${e}`,sevenDayLabel:e=>`7 天 ${e}`,limitIs:(e,o)=>`${e}用了 ${o},点开看用量`,copyUsage:"复制用量",rename:"改名",name:"名字",renameFailed:"没改成",renameNote:"不填就用 Claude 或 Codex 给这个对话起的名字,还没有就用目录名。各设备上都显示这个名字。",newSession:"新建会话",pickProject:"选一个项目",resumeRecent:"续上最近的对话",openFailed:"没开成",newInProject:"在项目里新开 Claude",newInProjectSub:"选一个目录,开一个新的 Claude Code",newCodexInProject:"在项目里新开 Codex",newCodexInProjectSub:"选一个目录,开一个新的 Codex",resumeRecentSub:"接着这台 Mac 上之前的对话",loadFailed:"加载失败",nameOptional:"名字(可不填)",nameOptionalHint:"名字(可不填,默认用目录名)",findProject:"搜索,或输入路径(~/…)",noFolders:"这里没有文件夹",lookInside:e=>`看看 ${e} 里的文件夹`,noProjects:"还没有用过 Claude 的项目。在上面输入一个路径,比如 ~/",alreadyOpen:"已打开 · ",folderGoneShort:"文件夹已不在,这里不能续 · ",inBackground:"在 Claude 后台会话里,这里不能续 · ",openElsewhere:"开在别的 Codex 应用里,这里不能续 · ",searchTitlePath:"搜标题或路径",noMatch:"没有匹配的对话",noConversations:"这台 Mac 上还没有 Claude 对话记录",automated:e=>`自动任务的会话(${e})`,screenTitle:"Mac 上的屏幕",screenUnread:"没读到屏幕,再刷新一次试试",screenContent:"屏幕内容",screenEmpty:"(屏幕是空的)"},...{uploadFailed:"图片没传上去",speechBlocked:"这里用不了语音识别(主屏幕上的网页可能不让用)。点输入框,用键盘上的麦克风说话。",speechDenied:"不能用语音识别。在 iPhone 的设置里允许这个网站使用麦克风和语音识别后再试。",speechStopped:"语音识别停了,再按住试一次。",speechErrors:{network:"语音识别要联网。看看网络,再按住试一次。","audio-capture":"麦克风用不了,可能被别的 App 占着。",interrupted:"听写被打断了,来电或别的 App 占了麦克风。",busy:"语音识别正忙,稍后再按住试一次。","speech-timeout":"好一会没听到声音,停止了听写。"},notHeard:"没听清",micJustOn:"麦克风刚打开,再按住说一次",speechUnavailable:"不能用语音识别",noLiveSpeech:"这个浏览器不支持实时语音",commands:"命令",snippet:e=>`常用语 ${e}`,snippets:"常用语",files:"文件",removeImage:"移除图片",uploading:"正在上传",input:"输入",placeholderSpeech:"发消息或按住说话…",placeholder:"发消息",stop:"停止",send:"发送",holdSend:"松开发送 · 滑到 ✕ 取消",holdSendSlide:"松开发送 · 上滑到 ✕ 取消 · 上滑到「转文字」转文字",holdCancel:"松开取消",holdText:"松开转文字",cancel:"取消",toText:"转文字",heardLabel:"语音转文字",recognizing:"正在识别…",openingMic:"正在打开麦克风",holdToTalk:"按住说话",photoLibrary:"照片图库",takePhoto:"拍照",photosAndVideos:"照片和视频",pickFile:"文件",largeVideo:(e,o)=>e===1?`这个视频有 ${o} MB`:`这 ${e} 个视频共 ${o} MB`,largeVideoSub:()=>"原样发要传一阵。压缩成 720p 会小很多,Claude 看的截图不受影响,但要在相册里再选一次。",sendAsIs:"原样发",sendSmaller:"压缩后发",attach:"添加照片、视频或文件",removeFile:"移除文件",fileUploadFailed:e=>`${e} 没传上去`,whatAndWhy:(e,o)=>`${e}:${o}`,cameraDenied:"WorkOffDesk 不能用相机。在设置里允许后再试。",speechDeniedApp:"WorkOffDesk 不能用麦克风或语音识别。在设置里两项都打开后再试。",speechOff:"现在用不了语音识别。看看 设置 › 通用 › 键盘 里的「听写」开着没有,网络通不通。",speechLanguage:"语音识别不支持这台手机设置的语言。",openSettings:"打开设置"},...{notConnectedSend:"还没连上 Mac,连上后再发一次",modeNotSwitched:"没切到那个模式,Claude 可能没开放它",switchingMode:"正在切换…",applying:"正在设置…",clipboardUnreadable:"读不到剪贴板。可以长按输入框粘贴。",endFailed:"没结束掉,连不上 Mac,稍后再点一次",notSentGiveBack:"没发出去,文字放回输入框了",alreadySent:"这条已经发出去了",takeBackFailed:"没拿回来,再试一次",deleteFailed:"没删掉,再试一次",notConnectedTap:"还没连上 Mac,连上后再点一次",notSentRetry:"没发出去,再试一次",notMoved:"没移动,再试一次",notEnded:"没结束掉,再试一次",notRun:"没运行,再试一次",queueWhenStarted:e=>`${e} 启动好就按顺序发出`,queueWhenContinued:e=>`接着这个对话后,${e} 一启动好就按顺序发出`,queueAfterCompact:"压缩完按顺序自动发出",queueAfterWaiting:e=>`${e} 在等你确认,之后按顺序发出`,queueAfterTurn:e=>`${e} 做完手上这一步就会读到`,inAgentsQueue:e=>`${e} 排队中`,queueNow:"马上按顺序发出",renameSession:e=>`改名:${e}`,ending:"正在结束…",confirmEnd:"确认结束",end:"结束",shellSession:"这是 Mac 上的命令行会话",shellSessionSub:"手机上只看 Claude 的对话,这个会话在 Mac 上用",syncing:"同步中",reconnecting:"重连中…",reconnectingSub:"网络恢复后会自动接上,会话一直在 Mac 上跑着",sessionEnded:"会话已结束",updateReady:"有新版本",updateReadySub:"已下载好,下次打开 App 时生效",updateNow:"立即更新",sessionEndedSub:"这个会话在 Mac 上已经退出了",startAnother:"重新开一个",agentQuit:e=>`${e} 已退出`,agentQuitSub:"「接着这个对话」接上原来的上下文;「重新开一个」在这个文件夹里开一段全新的对话",endedGoOnSub:"「接着这个对话」在新会话里接上原来的上下文;「重新开一个」在这个文件夹里开一段全新的对话",continueConversation:"接着这个对话",continueFailed:"没接上",suggestion:"猜你接下来想发 · 点一下填进输入框",dismissSuggestion:"不要这条建议",macSession:"Mac 上的会话",readOnly:"只读",cannotRead:"读不到这个对话",openAlreadyOnPhone:"打开(已经在手机上开着)",inBackgroundNoResume:"在 Claude 后台会话里,这里不能续",openElsewhereNoResume:"开在别的 Codex 应用里,这里不能续",continueOnPhone:"在手机上接着聊",onMacIdle:"电脑上开着",onMacBusy:"电脑上在干活",onMacWaiting:"电脑上在等你确认",openInTerminals:e=>`开了 ${e} 个`,takeOverNow:"接管到手机",folderGoneNoResume:"它的文件夹已经不在了,这里接不上",folderGoneSub:e=>`${e} 已经不在这台 Mac 上了(比如 worktree 被删掉了)。换个文件夹开一个新对话吧。`,takeOverWhenDone:"等这一步做完再接管",takeOverInterrupt:"立即接管(打断这一步)",takeOverDropsQuestion:"这个确认会作废,接上后让它重做这一步。",takeOverNote:e=>`电脑上的 ${e} 会退出,在手机上接着跑。回电脑:workoffdesk attach`,takingOverWait:"等电脑上这一步做完…",takeOverCancel:"取消",takeOverMissed:"没接上,再点一次",startInTmuxHint:"在电脑上用 workoffdesk claude 或 workoffdesk codex 启动,就不用接管",continueOnMac:"在电脑上继续",continueOnMacSub:"在 Mac 的终端里运行:",renameAction:"改名",busy:"运行中",needsYou:"等你确认",unread:"未读",sessions:"会话",chooseSession:"在左边选一个会话。",search:"搜索",noSessions:"还没有会话",noSessionsSub:"点右上角 ⊕,开一个 Shell 或 Claude。",noSessionsPhone:"点右上角 +,在一个项目里开 Claude。",sessionMenu:"会话操作",endSession:"结束会话",notFound:e=>`没有找到「${e}」`,otherMacSessions:"Mac 上的其他会话",wrongPassword:"用户名或密码不对",loginNotKept:"登录成功了,但没能存到这台手机上,再试一次。",loginFailedOther:"登录没成功,再试一次。",tooManyTries:e=>`尝试太多次了,${e} 分钟后再试`,loginFailed:e=>`登录失败(${e})`,noServer:"连不上服务器,检查一下网络",noAnswer:"服务器没有回应,这一步可能做了,也可能没做。先看一眼再重试",unknownPasskey:"这个 Passkey 不认识。先用密码登录,再到「设置」里添加这台设备。",tagline:"在手机上接着用 Mac 上的 Claude",faceId:"用 Face ID 登录",orPassword:"或者用密码",username:"用户名",password:"密码",login:"登录",serverAddress:"服务器地址",serverAddressHint:"在浏览器里打开 WorkOffDesk 的地址,比如 https://workoffdesk.example.com",badServerAddress:"这不是一个网址"},...{textSize:"文字大小",wholeApp:"整个 App 的字",thisDeviceOnly:"只改这台设备",smaller:"文字减小",larger:"文字加大",textPreview:"Claude 的回复、你发的消息和列表,都按这个大小显示。",appearance:"外观",theme:"主题",followSystem:"跟随系统",themeLight:"浅色",themeDark:"深色",themeEye:"护眼",language:"语言",languageRestarts:"选好后 App 会重新打开,换成这个语言。",snippetsLines:"常用语,一行一条",snippetsPlaceholder:`一行一条,例如:
|
|
5
|
+
继续
|
|
6
|
+
跑一下 lint 和类型检查
|
|
7
|
+
把改动提交了`,snippetsNote:"输入框里打 / 时排在最前面,点一下整句放进去。只存在这台设备上。",pushInsecure:"推送需要通过 https 打开这个网站(例如你的正式域名)。",pushUnsupported:"这个浏览器不支持推送。iPhone 上要先在 Safari 里「分享 → 添加到主屏幕」,再从主屏幕打开。",pushDenied:"通知权限被拒绝了,要到系统设置里给这个网站打开通知。",failed:"没成功",passkeyNone:"还没有 Passkey。添加后可以用 Face ID 直接登录。",passkeyNoneOnPhone:"还没有 Passkey。在网页版的「设置」里添加后,就能在网页上用 Face ID 登录。",pushAppUnready:"这台 Mac 还推送不到 iPhone App:服务器需要一把 Apple 推送密钥(~/.workoffdesk 里的 apns.json)。",pushAppDenied:"WorkOffDesk 的通知被关掉了,要到 iPhone「设置」→ WorkOffDesk →「通知」里打开。",addedAgo:e=>`添加于 ${e}`,lastUsedAgo:e=>` · 上次使用 ${e}`,passkeysTitle:"通行密钥(Passkey)",addPasskey:"添加这台设备的 Passkey",loggedIn:"已登录的设备",thisDevice:"这台设备",loginBy:(e,o)=>`${e?"Passkey":"密码"}登录 · 最近活动 ${o}`,revoke:"吊销",confirmDelete:"确认删除",confirmRevoke:"确认吊销",pushTitle:"推送通知",pushRemind:"Claude 需要你时提醒我",pushRemindSub:"等你确认、或一轮做完时推送到这台设备",logOut:"退出登录",addAccount:"添加账号",addAccountTagline:"登录另一个账号或服务器",signInAgainTagline:"这个账号在它的 Mac 上已失效,请重新登录",removeAccount:"移除",confirmRemoveAccount:"从这台手机移除",account:"账号",appRelease:e=>e?`App 当前版本 ${e}`:"App 当前版本:随安装包",thisMac:"这台 Mac",thisMacSub:"检查、版本、重启",macAllGood:"一切正常",macProblemCount:e=>`有 ${e} 个问题`,macChecks:"检查",checkNames:{macos:"macOS",node:"Node.js",tmux:"tmux",claude:"Claude Code",codex:"Codex",setup:"账号",service:"服务",origin:"地址",sleep:"防休眠",apns:"iPhone 通知"},macFixTap:"点一下复制",macCheckPassed:"通过",macCheckFailed:"没通过",macVersion:"版本",macBuiltFrom:(e,o)=>`构建自 ${e}${o?"(含未提交的改动)":""}`,macServingSince:e=>`${e}开始上线`,macReleases:"保留的版本",macRunning:"运行中",macActions:"服务",macRestart:"重启服务",macRestartNote:"会话不会断;页面会自己连回来。",macRestartConfirm:"现在重启",macRestartShort:"重启",macRollbackShort:"回退",macRollbackSure:"确定?",macPassed:e=>`另外 ${e} 项正常`,macAllPassed:e=>`${e} 项检查全部通过`,macRollback:e=>`回退到 ${e}`,macNoRollback:"没有保留更早的版本可以回退。",macNotManaged:"这个服务是手动起的(workoffdesk serve),不是作为后台服务运行的,所以这里不能重启它。",macRestarting:"正在重启…",macRollingBack:"正在回退…",macRestarted:"已重启",macRolledBack:"已回退",macNoReturn:"一分钟了 Mac 还没回来。去 Mac 上看看,或者在那里运行 workoffdesk doctor。",macProblems:"最近的警告和错误",macNoProblems:"最近没出过问题。",macLogNote:"完整日志在 Mac 上 ~/.workoffdesk 的 logs 文件夹里。",macClaudeAccounts:"Claude 账号",macAccountNote:"新会话用打勾的账号开;开着的会话不变,除非搬过去。",macElsewhere:e=>`${e} 个开着的会话还在别的账号上`,macMoveTo:e=>`搬到 ${e},从停下的地方接着跑。`,macMoveButton:"搬过去",macMovingSessions:e=>`正在搬 ${e} 个会话`,macMovingTo:e=>`正在搬到 ${e}:空闲的马上搬,正在回复的等这轮完了再搬。`,accountSignedOut:"已退出登录",accountNotReady:"先在终端里用它打开一次 Claude Code",accountApart:"它的对话没有和这台 Mac 的共用",accountNotSeen:"还没看到它的用量",limitReset:"已重置",claudeAccountLine:"账号"},...{setupTitle:"设置 WorkOffDesk",setupSub:"在这台 Mac 上设一次,之后就用手机。",setupSteps:{checks:"这台 Mac",account:"账号",address:"地址",service:"服务",phone:"手机"},setupChange:"修改",setupContinue:"继续",setupCheckAgain:"再检查一次",setupChecksNote:"WorkOffDesk 在这台 Mac 上需要的东西。",setupChecksFix:"在终端里运行这些命令,然后再检查一次。",setupChecksOk:"这台 Mac 需要的都有了。",setupAccountNote:"手机登录用的用户名和密码。",setupAccountHave:e=>`已有账号:${e}`,setupKeep:"保留",setupReplace:"换一个",setupReplaceNote:"换账号会让所有设备退出登录。",setupPasswordRule:e=>`至少 ${e} 个字符`,setupPasswordAgain:"再输一次密码",setupPasswordsDiffer:"两次输入的密码不一样",setupSaveAccount:"保存账号",setupAddressNote:"手机打开的 HTTPS 地址,手机在哪儿都能连上。",setupAddressNow:e=>`当前:${e}`,setupTailscale:"Tailscale",setupTailscaleUse:e=>`https://${e},只有你自己的设备能访问`,setupTailscaleDown:"Tailscale 装了但没在运行。打开它并登录,然后再检查一次。",setupCloudflare:"Cloudflare 隧道",setupCloudflareNote:"用你域名下的一个主机名,走你建好的隧道。",setupTunnel:"隧道",setupHostname:"主机名",setupOther:"其他地址",setupOtherNote:e=>`任何能连到这台 Mac ${e} 端口的 HTTPS 地址。`,setupNoWayIn:"最省事的连法:这台 Mac 和手机都装上 Tailscale。装好后再检查一次。",setupUseAddress:"用这个地址",setupServiceNote:"WorkOffDesk 作为服务运行,登录这台 Mac 时自动启动。",setupInstall:"安装并启动",setupInstalling:"正在启动,可能要半分钟。",setupPhoneNote:e=>`用手机相机对准这个码,然后用 ${e} 登录。`,setupFinish:"完成",setupFinished:"都设好了。可以关掉这个页面了。",setupNoToken:"请从终端里 workoffdesk setup --web 打印的地址打开这个页面。再运行一次会打印新地址。",setupGone:"设置已经结束了。再运行一次 workoffdesk setup --web。"}};function p(e){for(const o of e){if(/^zh\b/i.test(o))return"zh";if(/^en\b/i.test(o))return"en"}return"en"}const r=((l=globalThis.navigator)==null?void 0:l.languages)??[Intl.DateTimeFormat().resolvedOptions().locale],s=globalThis.workoffdeskLanguage,c=s==="zh"||s==="en"?s:p(r);r[0];const d=c==="zh"?h:u,n=self;n.addEventListener("install",()=>void n.skipWaiting()),n.addEventListener("activate",e=>e.waitUntil(n.clients.claim())),n.addEventListener("push",e=>{var t,a;let o={};try{o=((t=e.data)==null?void 0:t.json())??{}}catch{o={body:(a=e.data)==null?void 0:a.text()}}e.waitUntil(n.registration.showNotification(o.title??"WorkOffDesk",{body:o.event==="waiting"||o.event==="idle"?g(o.event,o.said):o.body,tag:o.tag,icon:"/icon-192.png",badge:"/icon-192.png",data:{url:o.url??"/"}}))});const g=(e,o="")=>o?e==="waiting"?`${d.push.waiting}
|
|
8
|
+
${o}`:o:d.push[e];n.addEventListener("notificationclick",e=>{var t;e.notification.close();const o=((t=e.notification.data)==null?void 0:t.url)??"/";e.waitUntil((async()=>{const i=(await n.clients.matchAll({type:"window",includeUncontrolled:!0}))[0];return i?(i.postMessage({type:"open",url:o}),i.focus()):n.clients.openWindow(o)})())})})();
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// The colours chosen in Settings, set before the first paint; src/looks.ts keeps them after.
|
|
2
|
+
(function () {
|
|
3
|
+
var theme = null;
|
|
4
|
+
try {
|
|
5
|
+
theme = localStorage.getItem('workoffdesk.theme');
|
|
6
|
+
} catch {
|
|
7
|
+
// Storage can be blocked; following the device still works.
|
|
8
|
+
}
|
|
9
|
+
if (theme !== 'light' && theme !== 'dark' && theme !== 'eye') theme = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
|
10
|
+
document.documentElement.dataset.theme = theme;
|
|
11
|
+
})();
|
package/package.json
CHANGED
|
@@ -1,6 +1,79 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "workoffdesk",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Your Mac's Claude Code and Codex sessions, on your phone: chat, answer prompts, pick up where the terminal left off. Self-hosted, no account.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"claude-code",
|
|
8
|
+
"tmux",
|
|
9
|
+
"pwa",
|
|
10
|
+
"remote",
|
|
11
|
+
"remote-control",
|
|
12
|
+
"chat"
|
|
13
|
+
],
|
|
14
|
+
"homepage": "https://github.com/workoffdesk/workoffdesk#readme",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/workoffdesk/workoffdesk.git",
|
|
18
|
+
"directory": "apps/server"
|
|
19
|
+
},
|
|
20
|
+
"bugs": "https://github.com/workoffdesk/workoffdesk/issues",
|
|
21
|
+
"type": "module",
|
|
22
|
+
"bin": {
|
|
23
|
+
"workoffdesk": "dist/server/cli.js"
|
|
24
|
+
},
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"registry": "https://registry.npmjs.org/",
|
|
27
|
+
"access": "public"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"dist",
|
|
31
|
+
"scripts/smoke.mjs",
|
|
32
|
+
"README.md",
|
|
33
|
+
"LICENSE",
|
|
34
|
+
"SECURITY.md",
|
|
35
|
+
"CHANGELOG.md"
|
|
36
|
+
],
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=22.13"
|
|
39
|
+
},
|
|
40
|
+
"scripts": {
|
|
41
|
+
"prepack": "node scripts/check-build.mjs && node scripts/npm-docs.mjs ../.. . README.md LICENSE SECURITY.md",
|
|
42
|
+
"build": "rm -rf dist && pnpm --filter @workoffdesk/web build && tsc -p tsconfig.json && cp -R ../web/dist dist/web && node scripts/build-app-update.mjs && node scripts/stamp-build.mjs",
|
|
43
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
44
|
+
"lint": "eslint .",
|
|
45
|
+
"test": "vitest run",
|
|
46
|
+
"test:e2e": "playwright test",
|
|
47
|
+
"screens": "playwright test -c playwright.screens.config.ts",
|
|
48
|
+
"demo": "sh tests/e2e/demo/make.sh",
|
|
49
|
+
"start": "node dist/server/cli.js serve",
|
|
50
|
+
"test:pack": "node scripts/pack-test.mjs",
|
|
51
|
+
"check": "pnpm run lint && pnpm run typecheck && vitest run"
|
|
52
|
+
},
|
|
53
|
+
"dependencies": {
|
|
54
|
+
"@fastify/static": "^10.1.5",
|
|
55
|
+
"@fastify/websocket": "^11.3.1",
|
|
56
|
+
"@simplewebauthn/server": "^14.0.3",
|
|
57
|
+
"bsdiff-wasm": "^0.1.4",
|
|
58
|
+
"fastify": "^5.12.5",
|
|
59
|
+
"pino": "^10.3.1",
|
|
60
|
+
"qrcode-generator": "2.0.4",
|
|
61
|
+
"web-push": "^3.6.7"
|
|
62
|
+
},
|
|
63
|
+
"devDependencies": {
|
|
64
|
+
"@eslint/js": "^9.39.3",
|
|
65
|
+
"@playwright/test": "1.58.2",
|
|
66
|
+
"@types/node": "20.19.9",
|
|
67
|
+
"@types/web-push": "^3.6.4",
|
|
68
|
+
"@types/ws": "^8.18.1",
|
|
69
|
+
"eslint": "^9.39.3",
|
|
70
|
+
"eslint-import-resolver-typescript": "3.10.1",
|
|
71
|
+
"eslint-plugin-import": "2.32.0",
|
|
72
|
+
"globals": "^16.5.0",
|
|
73
|
+
"tsx": "4.23.13",
|
|
74
|
+
"typescript": "5.9.3",
|
|
75
|
+
"typescript-eslint": "^8.56.1",
|
|
76
|
+
"vitest": "3.2.4",
|
|
77
|
+
"ws": "8.19.0"
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Starts a build on its own and walks the path a person takes: sign in, open a shell, type, close it.
|
|
3
|
+
// APP is the build to try, PORT a free port, SMOKE_HOME an empty folder it may fill.
|
|
4
|
+
// Exits 0 only when every step worked. `workoffdesk deploy` runs it before switching to a new build.
|
|
5
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
6
|
+
import { createHash } from 'node:crypto';
|
|
7
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { createRequire } from 'node:module';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
|
|
11
|
+
const { APP, PORT, SMOKE_HOME } = process.env;
|
|
12
|
+
if (!APP || !PORT || !SMOKE_HOME) {
|
|
13
|
+
console.error('Usage: APP=<build folder> PORT=<free port> SMOKE_HOME=<empty folder> node scripts/smoke.mjs');
|
|
14
|
+
process.exit(2);
|
|
15
|
+
}
|
|
16
|
+
const cli = join(APP, 'dist/server/cli.js');
|
|
17
|
+
const origin = `http://localhost:${PORT}`;
|
|
18
|
+
const base = `http://127.0.0.1:${PORT}`;
|
|
19
|
+
const password = 'smoke test passphrase';
|
|
20
|
+
const { version } = createRequire(import.meta.url)(join(APP, 'package.json'));
|
|
21
|
+
const require = createRequire(join(APP, 'node_modules/'));
|
|
22
|
+
const WebSocket = require(require.resolve('ws', { paths: [join(APP, 'node_modules/.pnpm/node_modules'), join(APP, 'node_modules')] }));
|
|
23
|
+
const { envForHome, tmuxSocketFor } = await import(join(APP, 'dist/server/system/paths.js'));
|
|
24
|
+
const env = envForHome(process.env, SMOKE_HOME);
|
|
25
|
+
const { Tmux } = await import(join(APP, 'dist/server/system/tmux.js'));
|
|
26
|
+
|
|
27
|
+
let server;
|
|
28
|
+
let failed = false;
|
|
29
|
+
const check = (name, ok, detail = '') => {
|
|
30
|
+
console.log(`${ok ? 'OK ' : 'FAIL'} ${name}${detail ? ` ${detail}` : ''}`);
|
|
31
|
+
if (!ok) failed = true;
|
|
32
|
+
return ok;
|
|
33
|
+
};
|
|
34
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
35
|
+
|
|
36
|
+
async function finish() {
|
|
37
|
+
if (server && server.exitCode === null) {
|
|
38
|
+
server.kill('SIGTERM');
|
|
39
|
+
await new Promise((r) => server.once('exit', r));
|
|
40
|
+
}
|
|
41
|
+
await new Tmux({ socket: tmuxSocketFor(SMOKE_HOME), conf: '/dev/null' }).killServer().catch(() => {});
|
|
42
|
+
process.exit(failed ? 1 : 0);
|
|
43
|
+
}
|
|
44
|
+
setTimeout(() => {
|
|
45
|
+
check('finished within 60 seconds', false);
|
|
46
|
+
void finish();
|
|
47
|
+
}, 60_000).unref();
|
|
48
|
+
|
|
49
|
+
async function run() {
|
|
50
|
+
// Its own port, so it never reaches for the one the running service holds.
|
|
51
|
+
writeFileSync(join(SMOKE_HOME, 'config.json'), JSON.stringify({ origin, port: Number(PORT) }));
|
|
52
|
+
const setup = spawnSync(process.execPath, [cli, 'setup', '--username', 'smoke'], { env, input: `${password}\n`, encoding: 'utf8' });
|
|
53
|
+
if (!check('setup', setup.status === 0, setup.stderr.trim())) return;
|
|
54
|
+
|
|
55
|
+
server = spawn(process.execPath, [cli, 'serve'], { env, stdio: ['ignore', 'inherit', 'inherit'] });
|
|
56
|
+
let health;
|
|
57
|
+
for (let i = 0; i < 60 && server.exitCode === null; i++) {
|
|
58
|
+
health = await fetch(`${base}/healthz`).then((r) => r.json(), () => null);
|
|
59
|
+
if (health) break;
|
|
60
|
+
await sleep(250);
|
|
61
|
+
}
|
|
62
|
+
if (!check('serve is up and /healthz reports this version', health?.ok === true && health.version === version, JSON.stringify(health))) return;
|
|
63
|
+
|
|
64
|
+
check('home page', (await fetch(`${base}/`)).status === 200);
|
|
65
|
+
await appUpdate();
|
|
66
|
+
const login = await fetch(`${base}/api/auth/password`, {
|
|
67
|
+
method: 'POST',
|
|
68
|
+
headers: { origin, 'content-type': 'application/json' },
|
|
69
|
+
body: JSON.stringify({ username: 'smoke', password }),
|
|
70
|
+
});
|
|
71
|
+
if (!check('sign in', login.ok, String(login.status))) return;
|
|
72
|
+
const headers = { origin, cookie: login.headers.get('set-cookie').split(';')[0] };
|
|
73
|
+
|
|
74
|
+
const created = await fetch(`${base}/api/m/local/sessions`, {
|
|
75
|
+
method: 'POST',
|
|
76
|
+
headers: { ...headers, 'content-type': 'application/json' },
|
|
77
|
+
body: JSON.stringify({ kind: 'shell', cwd: SMOKE_HOME }),
|
|
78
|
+
});
|
|
79
|
+
const { id } = await created.json();
|
|
80
|
+
if (!check('create a session', created.ok && typeof id === 'string', String(created.status))) return;
|
|
81
|
+
|
|
82
|
+
const ws = new WebSocket(`ws://127.0.0.1:${PORT}/api/m/local/sessions/${id}/ws`, { headers });
|
|
83
|
+
await new Promise((resolve, reject) => {
|
|
84
|
+
ws.once('open', resolve);
|
|
85
|
+
ws.once('error', reject);
|
|
86
|
+
});
|
|
87
|
+
await sleep(1500);
|
|
88
|
+
ws.send(Buffer.from('echo "smoke=$((40+2))"\r'));
|
|
89
|
+
let typed = false;
|
|
90
|
+
for (let i = 0; i < 20 && !typed; i++) {
|
|
91
|
+
await sleep(250);
|
|
92
|
+
const screen = await (await fetch(`${base}/api/m/local/sessions/${id}/screen`, { headers })).json();
|
|
93
|
+
typed = /smoke=42/.test(screen.text);
|
|
94
|
+
}
|
|
95
|
+
check('typing shows up on the screen', typed);
|
|
96
|
+
ws.close();
|
|
97
|
+
|
|
98
|
+
const journal = await fetch(`${base}/api/m/local/sessions/${id}/journal?v=0`, { headers }).then((r) => r.json(), () => null);
|
|
99
|
+
check('a copy starting over is given the journal', typeof journal?.epoch === 'string' && journal.reset === true && Array.isArray(journal.rows), JSON.stringify(journal)?.slice(0, 200));
|
|
100
|
+
|
|
101
|
+
const killed = await fetch(`${base}/api/m/local/sessions/${id}`, { method: 'DELETE', headers });
|
|
102
|
+
check('delete the session', killed.ok, String(killed.status));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// What a phone asks: the update for the runtime this build has one for, each file whole.
|
|
106
|
+
async function appUpdate() {
|
|
107
|
+
const built = join(APP, 'dist/app-update/ios/update.json');
|
|
108
|
+
const runtime = existsSync(built) ? JSON.parse(readFileSync(built, 'utf8')).runtimeVersion : 'no-such-runtime';
|
|
109
|
+
const res = await fetch(`${base}/api/app-update/manifest`, {
|
|
110
|
+
headers: { 'expo-protocol-version': '1', 'expo-platform': 'ios', 'expo-runtime-version': runtime },
|
|
111
|
+
});
|
|
112
|
+
if (!existsSync(built)) {
|
|
113
|
+
check('no app update offered', res.status === 204, String(res.status));
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (!check('app update manifest', res.status === 200 && res.headers.get('expo-protocol-version') === '1', String(res.status))) return;
|
|
117
|
+
const manifest = await res.json();
|
|
118
|
+
const files = [manifest.launchAsset, ...manifest.assets];
|
|
119
|
+
const broken = [];
|
|
120
|
+
for (const a of files) {
|
|
121
|
+
const bytes = Buffer.from(await (await fetch(a.url)).arrayBuffer());
|
|
122
|
+
if (createHash('sha256').update(bytes).digest('base64url') !== a.hash) broken.push(a.key);
|
|
123
|
+
}
|
|
124
|
+
check(`app update files match their hashes (${files.length})`, broken.length === 0, broken.join(' '));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
run().then(finish, (e) => {
|
|
128
|
+
check('nothing threw', false, e?.stack ?? String(e));
|
|
129
|
+
return finish();
|
|
130
|
+
});
|