codebay 0.0.1 → 0.2.1
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/.mochi/import-css/index-0nwv3654.css +18 -0
- package/.mochi/import-css/index-xndx4980.css +54 -0
- package/.mochi/manifest.json +1612 -0
- package/.mochi/server-island.js +1 -0
- package/.mochi/svelte-client/HydratableIsland-wrq79y0w.js +1 -0
- package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-7xgbmxxg.js +2 -0
- package/.mochi/svelte-client/_hydrate-AvatarsView_257s9yq1wwr4f-3cnbjpz2.js +13 -0
- package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-2ntmdm3e.js +1 -0
- package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-t08spym1.js +23 -0
- package/.mochi/svelte-client/chunk-04qv4w6w.js +2 -0
- package/.mochi/svelte-client/chunk-5jnepvzx.js +2 -0
- package/.mochi/svelte-client/chunk-hcztrd75.js +1 -0
- package/.mochi/svelte-client/chunk-qxwt25hn.js +4 -0
- package/.mochi/svelte-client/chunk-sjmcgh7f.js +2 -0
- package/.mochi/svelte-client/chunk-tr67wtwn.js +2 -0
- package/.mochi/svelte-compile/App-f0q5n4rk.server.js +1168 -0
- package/.mochi/svelte-compile/Avatars-p8gfef7x.server.js +332 -0
- package/.mochi/svelte-compile/ClientStats-z3tn9ad6.server.js +99 -0
- package/.mochi/svelte-compile/DefaultError-wh77vebf.server.js +52 -0
- package/.mochi/svelte-compile/Instance-atjwsczx.server.js +356 -0
- package/.mochi/svelte-compile/Settings-q88xw6h8.server.js +1173 -0
- package/.mochi/svelte-compile/chunk-4kwy539a.js +14 -0
- package/.mochi/svelte-compile/chunk-9bjk7yx6.js +81 -0
- package/.mochi/svelte-compile/chunk-btmcxhqy.js +58 -0
- package/.mochi/svelte-compile/chunk-sxqys3nw.js +1668 -0
- package/.mochi/svelte-compile/chunk-v6796ga4.js +3295 -0
- package/.mochi/svelte-compile/chunk-we0ggtws.js +34 -0
- package/.mochi/svelte-css/AppBar-3bxinv4pabv8l.css +1 -0
- package/.mochi/svelte-css/AppShell-36mpg5smnbmuw.css +1 -0
- package/.mochi/svelte-css/Avatar-24th63awm9mna.css +1 -0
- package/.mochi/svelte-css/AvatarEditor-1ryrs1md5g36y.css +1 -0
- package/.mochi/svelte-css/AvatarsView-3hq37u4dgtljn.css +1 -0
- package/.mochi/svelte-css/BackLink-2wlu0eovz9j86.css +1 -0
- package/.mochi/svelte-css/BranchBox-1yqu1q2mgnjwz.css +1 -0
- package/.mochi/svelte-css/Brand-1la7zw5uhcbl4.css +1 -0
- package/.mochi/svelte-css/Button-2vtbrzb5kgu25.css +1 -0
- package/.mochi/svelte-css/CheckmarkIcon-3qlazj2oc50jc.css +1 -0
- package/.mochi/svelte-css/ClientStats-19qzckq40p2q9.css +1 -0
- package/.mochi/svelte-css/CoinButton-1rej3sh3tkjud.css +1 -0
- package/.mochi/svelte-css/CredMenu-9hwgeptp7crb.css +1 -0
- package/.mochi/svelte-css/DashboardView-1cy4xpqhja9sx.css +1 -0
- package/.mochi/svelte-css/DefaultError-2b0daamydxgb2.css +1 -0
- package/.mochi/svelte-css/ErrorIcon-2x0tzsqxskw28.css +1 -0
- package/.mochi/svelte-css/FolderBrowser-1x428zb3f2kti.css +1 -0
- package/.mochi/svelte-css/HealthBox-er2her0jukwl.css +1 -0
- package/.mochi/svelte-css/IdeBar-2qoqi2ya9zdn3.css +1 -0
- package/.mochi/svelte-css/IdeLoader-26rlko8e00j3g.css +1 -0
- package/.mochi/svelte-css/InstanceCard-16f77aysm7lgp.css +1 -0
- package/.mochi/svelte-css/InstanceView-wq85bxtusont.css +1 -0
- package/.mochi/svelte-css/LoaderIcon-soq5wt3p05ye.css +1 -0
- package/.mochi/svelte-css/PortsBox-1t25b5yg7jva3.css +1 -0
- package/.mochi/svelte-css/SettingsCog-jftyadvo64qo.css +1 -0
- package/.mochi/svelte-css/SettingsView-1nfdwnm16cpp2.css +1 -0
- package/.mochi/svelte-css/Skeleton-8k69usiju43h.css +1 -0
- package/.mochi/svelte-css/StatusBadge-3mq0j7z6aq4j.css +1 -0
- package/.mochi/svelte-css/ThemePicker-1ir8jyocm8mvi.css +1 -0
- package/.mochi/svelte-css/ToastBar-3havgkmtofntq.css +1 -0
- package/.mochi/svelte-css/ToastIcon-2z5wrkc95khg0.css +1 -0
- package/.mochi/svelte-css/ToastMessage-2jfwb2og2lfa8.css +1 -0
- package/.mochi/svelte-css/ToastWrapper-1fbnym10rm2nv.css +1 -0
- package/.mochi/svelte-css/Toaster-kk60j193854n.css +1 -0
- package/.mochi/svelte-css/TopBar-2q87li6yt8ids.css +1 -0
- package/LICENSE +21 -0
- package/README.md +34 -3
- package/bin/codebay.ts +22 -0
- package/migrations/0001_init.sql +20 -0
- package/migrations/0002_port_forwards.sql +9 -0
- package/migrations/0003_options.sql +4 -0
- package/migrations/0004_instance_image.sql +1 -0
- package/package.json +87 -9
- package/public/sounds/done.wav +0 -0
- package/public/sounds/waiting.wav +0 -0
- package/src/__fixtures__/HelloWorld.svelte +4 -0
- package/src/additional-svelte-typings.d.ts +1 -0
- package/src/api.ts +61 -0
- package/src/avatars/anchor.ts +17 -0
- package/src/avatars/bear.ts +17 -0
- package/src/avatars/bee.ts +17 -0
- package/src/avatars/bob-marley.ts +17 -0
- package/src/avatars/car.ts +17 -0
- package/src/avatars/carpathians.ts +17 -0
- package/src/avatars/cat-body.ts +17 -0
- package/src/avatars/cat-sits.ts +17 -0
- package/src/avatars/cat.ts +17 -0
- package/src/avatars/celtic-cross.ts +17 -0
- package/src/avatars/cherry.ts +17 -0
- package/src/avatars/cotton.ts +17 -0
- package/src/avatars/crab.ts +17 -0
- package/src/avatars/crown.ts +17 -0
- package/src/avatars/diamond.ts +17 -0
- package/src/avatars/dog.ts +17 -0
- package/src/avatars/elephant.ts +17 -0
- package/src/avatars/fish.ts +17 -0
- package/src/avatars/fleur-de-lis.ts +17 -0
- package/src/avatars/flower.ts +17 -0
- package/src/avatars/fox.ts +17 -0
- package/src/avatars/frog.ts +17 -0
- package/src/avatars/ghost.ts +17 -0
- package/src/avatars/giraffe.ts +17 -0
- package/src/avatars/glasses.ts +17 -0
- package/src/avatars/heart.ts +17 -0
- package/src/avatars/index.ts +128 -0
- package/src/avatars/invader.ts +17 -0
- package/src/avatars/kangaroo.ts +17 -0
- package/src/avatars/key.ts +17 -0
- package/src/avatars/lightning.ts +17 -0
- package/src/avatars/mushroom.ts +17 -0
- package/src/avatars/name-prompts.ts +30 -0
- package/src/avatars/octopus.ts +17 -0
- package/src/avatars/owl.ts +17 -0
- package/src/avatars/penguin.ts +17 -0
- package/src/avatars/planet.ts +17 -0
- package/src/avatars/rabbit.ts +17 -0
- package/src/avatars/rat.ts +17 -0
- package/src/avatars/robot.ts +17 -0
- package/src/avatars/rocket.ts +17 -0
- package/src/avatars/sea.ts +17 -0
- package/src/avatars/serialize.ts +65 -0
- package/src/avatars/skull.ts +17 -0
- package/src/avatars/snail.ts +17 -0
- package/src/avatars/snake.ts +17 -0
- package/src/avatars/snakegame.ts +17 -0
- package/src/avatars/star.ts +17 -0
- package/src/avatars/svelte.ts +17 -0
- package/src/avatars/target.ts +17 -0
- package/src/avatars/tree.ts +17 -0
- package/src/avatars/types.ts +24 -0
- package/src/avatars/ukraine.ts +17 -0
- package/src/avatars/whale.ts +17 -0
- package/src/avatars/wheat.ts +17 -0
- package/src/avatars/yinyan.ts +17 -0
- package/src/components/AppBar.svelte +35 -0
- package/src/components/AppShell.svelte +302 -0
- package/src/components/Avatar.svelte +235 -0
- package/src/components/AvatarEditor.svelte +297 -0
- package/src/components/AvatarsView.svelte +115 -0
- package/src/components/BranchBox.svelte +32 -0
- package/src/components/Brand.svelte +41 -0
- package/src/components/Button.svelte +161 -0
- package/src/components/CoinButton.svelte +121 -0
- package/src/components/CredMenu.svelte +171 -0
- package/src/components/DashboardView.svelte +198 -0
- package/src/components/FolderBrowser.svelte +635 -0
- package/src/components/HealthBox.svelte +161 -0
- package/src/components/IdeBar.svelte +186 -0
- package/src/components/IdeLoader.svelte +135 -0
- package/src/components/InstanceCard.svelte +220 -0
- package/src/components/InstanceView.svelte +567 -0
- package/src/components/PortsBox.svelte +74 -0
- package/src/components/SettingsCog.svelte +22 -0
- package/src/components/SettingsView.svelte +1494 -0
- package/src/components/Skeleton.svelte +43 -0
- package/src/components/StatusBadge.svelte +54 -0
- package/src/components/ThemePicker.svelte +77 -0
- package/src/components/TopBar.svelte +64 -0
- package/src/components/UiShowcase.svelte +578 -0
- package/src/components/ui-showcase/ComponentDemo.svelte +85 -0
- package/src/container-injections/attention-hooks.ts +117 -0
- package/src/container-injections/claude-aliases.ts +43 -0
- package/src/container-injections/claude-code-credentials.ts +145 -0
- package/src/container-injections/claude-code-custom.ts +125 -0
- package/src/container-injections/claude-no-coauthor.ts +35 -0
- package/src/container-injections/claude-skip-permissions.ts +38 -0
- package/src/container-injections/claude-statusline.ts +109 -0
- package/src/container-injections/git-identity.ts +63 -0
- package/src/container-injections/git-safe-directory.ts +18 -0
- package/src/container-injections/github-credentials.ts +162 -0
- package/src/container-injections/host-env-vars.ts +108 -0
- package/src/container-injections/tmux.ts +67 -0
- package/src/fontsource.d.ts +3 -0
- package/src/index.ts +81 -0
- package/src/lib/auth.server.ts +95 -0
- package/src/lib/bridge.server.ts +34 -0
- package/src/lib/config.server.ts +140 -0
- package/src/lib/crypto.server.ts +12 -0
- package/src/lib/db.server.ts +196 -0
- package/src/lib/devcontainer.server.ts +511 -0
- package/src/lib/docker-client.server.ts +61 -0
- package/src/lib/docker.server.ts +139 -0
- package/src/lib/exec.server.ts +92 -0
- package/src/lib/git.server.ts +68 -0
- package/src/lib/health.server.ts +105 -0
- package/src/lib/injections.server.ts +72 -0
- package/src/lib/instances.server.ts +552 -0
- package/src/lib/links.ts +9 -0
- package/src/lib/picker.server.ts +46 -0
- package/src/lib/ports.server.ts +9 -0
- package/src/lib/proxy.server.ts +170 -0
- package/src/lib/repo-url.ts +60 -0
- package/src/lib/spawn.server.ts +14 -0
- package/src/lib/theme.server.ts +22 -0
- package/src/live.ts +50 -0
- package/src/pages/App.svelte +14 -0
- package/src/pages/Avatars.svelte +9 -0
- package/src/pages/Instance.svelte +9 -0
- package/src/pages/Settings.svelte +76 -0
- package/src/pages/UI.svelte +10 -0
- package/src/routes.ts +444 -0
- package/src/settings.ts +14 -0
- package/src/shell.html +100 -0
- package/src/sound.ts +69 -0
- package/src/theme.ts +24 -0
- package/src/types.ts +79 -0
- package/svelte.config.js +5 -0
package/src/routes.ts
ADDED
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Mochi,
|
|
3
|
+
apiError,
|
|
4
|
+
error,
|
|
5
|
+
fail,
|
|
6
|
+
json,
|
|
7
|
+
success,
|
|
8
|
+
type MochiApiEvent,
|
|
9
|
+
type MochiRouteValue
|
|
10
|
+
} from 'mochi-framework';
|
|
11
|
+
import { dockerArch, dockerAvailable, pruneBuildCache } from './lib/docker.server.ts';
|
|
12
|
+
import { devcontainerCliAvailable } from './lib/devcontainer.server.ts';
|
|
13
|
+
import { resolveInjections } from './lib/injections.server.ts';
|
|
14
|
+
import {
|
|
15
|
+
DEFAULT_HAIKU_MODEL,
|
|
16
|
+
DEFAULT_MODEL,
|
|
17
|
+
DEFAULT_OPUS_MODEL,
|
|
18
|
+
DEFAULT_SMALL_FAST_MODEL,
|
|
19
|
+
DEFAULT_SONNET_MODEL
|
|
20
|
+
} from './container-injections/claude-code-custom.ts';
|
|
21
|
+
import { hostEnvVarPresence, parseHostEnvVarNames } from './container-injections/host-env-vars.ts';
|
|
22
|
+
import { browse } from './lib/picker.server.ts';
|
|
23
|
+
import { pickNamePrompt } from './avatars/name-prompts.ts';
|
|
24
|
+
import {
|
|
25
|
+
addForwardedPort,
|
|
26
|
+
createInstance,
|
|
27
|
+
deleteAllInstances,
|
|
28
|
+
deleteDatabaseAndShutdown,
|
|
29
|
+
deleteInstance,
|
|
30
|
+
listInstances,
|
|
31
|
+
rebuildInstance,
|
|
32
|
+
rebuildRunningInstancesNoCache,
|
|
33
|
+
removeForwardedPort,
|
|
34
|
+
renameInstance,
|
|
35
|
+
sanitizeInstance,
|
|
36
|
+
startInstance,
|
|
37
|
+
stopInstance,
|
|
38
|
+
streamClose,
|
|
39
|
+
streamOpen,
|
|
40
|
+
subscribeLogs
|
|
41
|
+
} from './lib/instances.server.ts';
|
|
42
|
+
import {
|
|
43
|
+
deleteFolderHistory,
|
|
44
|
+
getInstance,
|
|
45
|
+
getOption,
|
|
46
|
+
listFolderHistory,
|
|
47
|
+
setOption
|
|
48
|
+
} from './lib/db.server.ts';
|
|
49
|
+
import { APP_VERSION, DEFAULT_COPY_IGNORE, DEFAULT_IMAGE } from './lib/config.server.ts';
|
|
50
|
+
import { wsUpgradeAllowed } from './lib/auth.server.ts';
|
|
51
|
+
import { clearAttention, setAttention } from './lib/bridge.server.ts';
|
|
52
|
+
import { timingSafeEqualStr } from './lib/crypto.server.ts';
|
|
53
|
+
import { proxyRoutes } from './lib/proxy.server.ts';
|
|
54
|
+
|
|
55
|
+
async function preflight() {
|
|
56
|
+
const [docker, cli, auth] = await Promise.all([
|
|
57
|
+
dockerAvailable(),
|
|
58
|
+
devcontainerCliAvailable(),
|
|
59
|
+
// Driven off the registry, so adding an injection extends the setup UI for free.
|
|
60
|
+
Promise.all(
|
|
61
|
+
resolveInjections()
|
|
62
|
+
.filter((i) => i.auth)
|
|
63
|
+
.map(async (i) => {
|
|
64
|
+
const status = await i.auth!.status();
|
|
65
|
+
return {
|
|
66
|
+
id: i.id,
|
|
67
|
+
label: i.label,
|
|
68
|
+
available: status.available,
|
|
69
|
+
source: status.source,
|
|
70
|
+
hint: i.auth!.hint
|
|
71
|
+
};
|
|
72
|
+
})
|
|
73
|
+
)
|
|
74
|
+
]);
|
|
75
|
+
return { docker, cli, auth };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Lets a route handler just `throw` for both validation and business-logic failures. */
|
|
79
|
+
async function mutate(fn: () => Promise<unknown> | unknown): Promise<Response> {
|
|
80
|
+
try {
|
|
81
|
+
return json(await fn());
|
|
82
|
+
} catch (err) {
|
|
83
|
+
return apiError(400, (err as Error).message);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Always answers 200 on success, so the one route needing a 201 is written out by hand. */
|
|
88
|
+
function mutationRoute(
|
|
89
|
+
method: 'POST' | 'DELETE',
|
|
90
|
+
handler: (event: MochiApiEvent) => Promise<unknown> | unknown
|
|
91
|
+
) {
|
|
92
|
+
return Mochi.api((event) => {
|
|
93
|
+
if (event.method !== method) return apiError(405, 'Method Not Allowed');
|
|
94
|
+
return mutate(() => handler(event));
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function str(formData: FormData, key: string): string {
|
|
99
|
+
return String(formData.get(key) ?? '').trim();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** An unchecked box is omitted from the form entirely, so presence is the only signal. */
|
|
103
|
+
function onChecked(formData: FormData, key: string): boolean {
|
|
104
|
+
return formData.get(key) === 'on';
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export const routes: Record<string, MochiRouteValue> = {
|
|
108
|
+
// Dashboard and IDE share one shell so navigating between them keeps the iframes mounted;
|
|
109
|
+
// `snapshot` seeds the live list so neither renders a loading flash first.
|
|
110
|
+
'/': Mochi.page('./src/pages/App.svelte', {
|
|
111
|
+
serverProps: async () => ({
|
|
112
|
+
preflight: await preflight(),
|
|
113
|
+
initialPath: '/',
|
|
114
|
+
snapshot: await listInstances()
|
|
115
|
+
})
|
|
116
|
+
}),
|
|
117
|
+
|
|
118
|
+
'/ide/:id': Mochi.page('./src/pages/App.svelte', {
|
|
119
|
+
serverProps: async (_req, params) => {
|
|
120
|
+
if (!params.id || !getInstance(params.id)) error(404, 'Instance not found');
|
|
121
|
+
return {
|
|
122
|
+
preflight: await preflight(),
|
|
123
|
+
initialPath: `/ide/${params.id}`,
|
|
124
|
+
snapshot: await listInstances()
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
}),
|
|
128
|
+
|
|
129
|
+
'/instances/:id': Mochi.page('./src/pages/Instance.svelte', {
|
|
130
|
+
serverProps: (_req, params) => {
|
|
131
|
+
// Only validates existence — the view hydrates its data from the stream.
|
|
132
|
+
if (!params.id || !getInstance(params.id)) error(404, 'Instance not found');
|
|
133
|
+
// Lets the health panel's skeleton render one row per real check before the first snapshot.
|
|
134
|
+
return { id: params.id, injectionChecks: resolveInjections().filter((i) => i.check).length };
|
|
135
|
+
},
|
|
136
|
+
actions: {
|
|
137
|
+
restart: ({ params }) => {
|
|
138
|
+
try {
|
|
139
|
+
rebuildInstance(params.id!);
|
|
140
|
+
} catch (err) {
|
|
141
|
+
return fail(400, { error: (err as Error).message });
|
|
142
|
+
}
|
|
143
|
+
return success({});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}),
|
|
147
|
+
|
|
148
|
+
// Reached from the settings page's coin. The name hint is rolled here rather than in
|
|
149
|
+
// the editor so hydration can't re-roll it and swap the word out mid-load.
|
|
150
|
+
'/avatars': Mochi.page('./src/pages/Avatars.svelte', {
|
|
151
|
+
serverProps: () => ({ namePlaceholder: pickNamePrompt() })
|
|
152
|
+
}),
|
|
153
|
+
|
|
154
|
+
// Settings mutations are Form actions rather than JSON routes, so each control
|
|
155
|
+
// still works without JS and `enhance` only upgrades it.
|
|
156
|
+
'/settings': Mochi.page('./src/pages/Settings.svelte', {
|
|
157
|
+
serverProps: async () => {
|
|
158
|
+
// Only names ever reach the client; presence is sent separately, values never.
|
|
159
|
+
const hostEnvVarNames = parseHostEnvVarNames(getOption('host_env_var_names'));
|
|
160
|
+
return {
|
|
161
|
+
defaultImage: getOption('default_image') ?? DEFAULT_IMAGE,
|
|
162
|
+
builtinImage: DEFAULT_IMAGE,
|
|
163
|
+
disableBuildCache: getOption('disable_build_cache') === '1',
|
|
164
|
+
// An explicit empty string (as opposed to unset) means "copy everything".
|
|
165
|
+
copyIgnorePatterns: getOption('copy_ignore_patterns') ?? DEFAULT_COPY_IGNORE,
|
|
166
|
+
builtinCopyIgnore: DEFAULT_COPY_IGNORE,
|
|
167
|
+
dockerArch: await dockerArch(),
|
|
168
|
+
// Only whether each token is set — the page renders a placeholder from that.
|
|
169
|
+
manualTokensEnabled: getOption('manual_tokens_enabled') === '1',
|
|
170
|
+
githubTokenSet: !!getOption('manual_github_token'),
|
|
171
|
+
claudeTokenSet: !!getOption('manual_claude_code_token'),
|
|
172
|
+
// Custom endpoint (LiteLLM / Bedrock): toggle state, base URL (non-secret),
|
|
173
|
+
// whether the token is set (never the secret value), and model IDs prefilled
|
|
174
|
+
// from the module defaults when not yet customised.
|
|
175
|
+
customEndpointEnabled: getOption('custom_endpoint_enabled') === '1',
|
|
176
|
+
customEndpointBaseUrl: getOption('custom_endpoint_base_url') ?? '',
|
|
177
|
+
customEndpointTokenSet: !!getOption('custom_endpoint_token')?.trim(),
|
|
178
|
+
customEndpointOpusModel: getOption('custom_endpoint_opus_model') ?? DEFAULT_OPUS_MODEL,
|
|
179
|
+
customEndpointSonnetModel:
|
|
180
|
+
getOption('custom_endpoint_sonnet_model') ?? DEFAULT_SONNET_MODEL,
|
|
181
|
+
customEndpointHaikuModel: getOption('custom_endpoint_haiku_model') ?? DEFAULT_HAIKU_MODEL,
|
|
182
|
+
customEndpointSmallFastModel:
|
|
183
|
+
getOption('custom_endpoint_small_fast_model') ?? DEFAULT_SMALL_FAST_MODEL,
|
|
184
|
+
customEndpointModel: getOption('custom_endpoint_model') ?? DEFAULT_MODEL,
|
|
185
|
+
hostEnvVarsEnabled: getOption('host_env_vars_enabled') === '1',
|
|
186
|
+
hostEnvVarNames,
|
|
187
|
+
hostEnvVarPresence: hostEnvVarPresence(hostEnvVarNames),
|
|
188
|
+
version: APP_VERSION
|
|
189
|
+
};
|
|
190
|
+
},
|
|
191
|
+
actions: {
|
|
192
|
+
// Persist the default container image used when a source folder ships no devcontainer.json.
|
|
193
|
+
defaultImage: ({ formData }) => {
|
|
194
|
+
const image = str(formData, 'image');
|
|
195
|
+
if (!image) return fail(400, { error: 'image is required' });
|
|
196
|
+
setOption('default_image', image);
|
|
197
|
+
return success({ image });
|
|
198
|
+
},
|
|
199
|
+
|
|
200
|
+
disableBuildCache: ({ formData }) => {
|
|
201
|
+
const enabled = onChecked(formData, 'enabled');
|
|
202
|
+
setOption('disable_build_cache', enabled ? '1' : '0');
|
|
203
|
+
return success({ enabled });
|
|
204
|
+
},
|
|
205
|
+
|
|
206
|
+
// Unlike defaultImage, an empty value is valid — it means "copy everything".
|
|
207
|
+
copyIgnorePatterns: ({ formData }) => {
|
|
208
|
+
const patterns = str(formData, 'patterns');
|
|
209
|
+
setOption('copy_ignore_patterns', patterns);
|
|
210
|
+
return success({ patterns });
|
|
211
|
+
},
|
|
212
|
+
|
|
213
|
+
// Tokens are stored plaintext in the options table and never sent back to the client.
|
|
214
|
+
manualTokensToggle: ({ formData }) => {
|
|
215
|
+
const enabled = onChecked(formData, 'enabled');
|
|
216
|
+
setOption('manual_tokens_enabled', enabled ? '1' : '0');
|
|
217
|
+
return success({ enabled });
|
|
218
|
+
},
|
|
219
|
+
githubToken: ({ formData }) => {
|
|
220
|
+
const value = str(formData, 'githubToken');
|
|
221
|
+
setOption('manual_github_token', value);
|
|
222
|
+
return success({ set: value.length > 0 });
|
|
223
|
+
},
|
|
224
|
+
claudeToken: ({ formData }) => {
|
|
225
|
+
const value = str(formData, 'claudeToken');
|
|
226
|
+
setOption('manual_claude_code_token', value);
|
|
227
|
+
return success({ set: value.length > 0 });
|
|
228
|
+
},
|
|
229
|
+
|
|
230
|
+
customEndpointToggle: ({ formData }) => {
|
|
231
|
+
const enabled = onChecked(formData, 'enabled');
|
|
232
|
+
setOption('custom_endpoint_enabled', enabled ? '1' : '0');
|
|
233
|
+
return success({ enabled });
|
|
234
|
+
},
|
|
235
|
+
customBaseUrl: ({ formData }) => {
|
|
236
|
+
const value = str(formData, 'baseUrl');
|
|
237
|
+
setOption('custom_endpoint_base_url', value);
|
|
238
|
+
return success({ set: value.length > 0 });
|
|
239
|
+
},
|
|
240
|
+
customToken: ({ formData }) => {
|
|
241
|
+
const value = str(formData, 'token');
|
|
242
|
+
setOption('custom_endpoint_token', value);
|
|
243
|
+
return success({ set: value.length > 0 });
|
|
244
|
+
},
|
|
245
|
+
customModels: ({ formData }) => {
|
|
246
|
+
setOption('custom_endpoint_opus_model', str(formData, 'opusModel'));
|
|
247
|
+
setOption('custom_endpoint_sonnet_model', str(formData, 'sonnetModel'));
|
|
248
|
+
setOption('custom_endpoint_haiku_model', str(formData, 'haikuModel'));
|
|
249
|
+
setOption('custom_endpoint_small_fast_model', str(formData, 'smallFastModel'));
|
|
250
|
+
setOption('custom_endpoint_model', str(formData, 'defaultModel'));
|
|
251
|
+
return success({});
|
|
252
|
+
},
|
|
253
|
+
|
|
254
|
+
// Only names are stored; a value is never persisted nor accepted from the client.
|
|
255
|
+
hostEnvVarsToggle: ({ formData }) => {
|
|
256
|
+
const enabled = onChecked(formData, 'enabled');
|
|
257
|
+
setOption('host_env_vars_enabled', enabled ? '1' : '0');
|
|
258
|
+
const names = parseHostEnvVarNames(getOption('host_env_var_names'));
|
|
259
|
+
return success({ enabled, presence: hostEnvVarPresence(names) });
|
|
260
|
+
},
|
|
261
|
+
// Replaces the whole list, so both the add and remove forms post every name they want kept.
|
|
262
|
+
hostEnvVarNames: ({ formData }) => {
|
|
263
|
+
const raw = formData.getAll('names').map(String);
|
|
264
|
+
const names = [...new Set(raw.map((n) => n.trim()))].filter(Boolean);
|
|
265
|
+
for (const name of names) {
|
|
266
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
|
|
267
|
+
return fail(400, { error: `invalid variable name: ${name}` });
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
setOption('host_env_var_names', JSON.stringify(names));
|
|
271
|
+
const saved = parseHostEnvVarNames(getOption('host_env_var_names'));
|
|
272
|
+
return success({ presence: hostEnvVarPresence(saved) });
|
|
273
|
+
},
|
|
274
|
+
|
|
275
|
+
clearBuildCache: async () => {
|
|
276
|
+
const { spaceReclaimed } = await pruneBuildCache();
|
|
277
|
+
return success({ spaceReclaimed: spaceReclaimed ?? 0 });
|
|
278
|
+
},
|
|
279
|
+
|
|
280
|
+
rebuildAllNoCache: () => success({ count: rebuildRunningInstancesNoCache() }),
|
|
281
|
+
|
|
282
|
+
// The process exits mid-flight, so the client may see success or a dropped
|
|
283
|
+
// connection; SettingsView treats both as "shutting down".
|
|
284
|
+
shutdown: async () => {
|
|
285
|
+
await deleteDatabaseAndShutdown();
|
|
286
|
+
return success({});
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}),
|
|
290
|
+
|
|
291
|
+
'/api/browse': Mochi.api(async ({ url }) => {
|
|
292
|
+
try {
|
|
293
|
+
return json(await browse(url.searchParams.get('path') ?? undefined));
|
|
294
|
+
} catch (err) {
|
|
295
|
+
return apiError(400, (err as Error).message);
|
|
296
|
+
}
|
|
297
|
+
}),
|
|
298
|
+
|
|
299
|
+
'/api/stream': Mochi.ws({
|
|
300
|
+
// Mochi.ws routes bypass the global basicAuth handle, so enforce origin + auth here.
|
|
301
|
+
upgrade: (req) => (wsUpgradeAllowed(req) ? {} : false),
|
|
302
|
+
open: streamOpen,
|
|
303
|
+
message: () => {},
|
|
304
|
+
close: streamClose
|
|
305
|
+
}),
|
|
306
|
+
|
|
307
|
+
'/api/instances': Mochi.api(async ({ method, request }) => {
|
|
308
|
+
if (method === 'GET') return json({ instances: await listInstances() });
|
|
309
|
+
if (method === 'POST') {
|
|
310
|
+
const body = (await request.json().catch(() => null)) as {
|
|
311
|
+
sourcePath?: string;
|
|
312
|
+
name?: string;
|
|
313
|
+
branch?: string;
|
|
314
|
+
} | null;
|
|
315
|
+
if (!body?.sourcePath) return apiError(400, 'sourcePath is required');
|
|
316
|
+
try {
|
|
317
|
+
const instance = await createInstance(body.sourcePath, body.name, { branch: body.branch });
|
|
318
|
+
return json({ instance: sanitizeInstance(instance) }, { status: 201 });
|
|
319
|
+
} catch (err) {
|
|
320
|
+
return apiError(400, (err as Error).message);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return apiError(405, 'Method Not Allowed');
|
|
324
|
+
}),
|
|
325
|
+
|
|
326
|
+
'/api/history': Mochi.api(async ({ method, request }) => {
|
|
327
|
+
if (method === 'GET') return json({ history: listFolderHistory() });
|
|
328
|
+
if (method === 'DELETE') {
|
|
329
|
+
return mutate(async () => {
|
|
330
|
+
const body = (await request.json().catch(() => null)) as { sourcePath?: string } | null;
|
|
331
|
+
if (!body?.sourcePath) throw new Error('sourcePath is required');
|
|
332
|
+
deleteFolderHistory(body.sourcePath);
|
|
333
|
+
return { ok: true };
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
return apiError(405, 'Method Not Allowed');
|
|
337
|
+
}),
|
|
338
|
+
|
|
339
|
+
'/api/instances/delete-all': mutationRoute('POST', async () => {
|
|
340
|
+
await deleteAllInstances();
|
|
341
|
+
return { ok: true };
|
|
342
|
+
}),
|
|
343
|
+
|
|
344
|
+
'/api/instances/:id/rename': mutationRoute('POST', async ({ params, request }) => {
|
|
345
|
+
const body = (await request.json().catch(() => null)) as { name?: string } | null;
|
|
346
|
+
if (!body?.name) throw new Error('name is required');
|
|
347
|
+
return { instance: sanitizeInstance(renameInstance(params.id!, body.name)) };
|
|
348
|
+
}),
|
|
349
|
+
|
|
350
|
+
// Both port routes only mutate the persisted set; /rebuild is what applies it.
|
|
351
|
+
'/api/instances/:id/ports': mutationRoute('POST', async ({ params, request }) => {
|
|
352
|
+
const body = (await request.json().catch(() => null)) as { port?: number } | null;
|
|
353
|
+
if (typeof body?.port !== 'number') throw new Error('port (number) is required');
|
|
354
|
+
return { instance: sanitizeInstance(await addForwardedPort(params.id!, body.port)) };
|
|
355
|
+
}),
|
|
356
|
+
|
|
357
|
+
'/api/instances/:id/ports/:port': mutationRoute('DELETE', ({ params }) => {
|
|
358
|
+
const port = Number.parseInt(params.port!, 10);
|
|
359
|
+
if (!Number.isInteger(port)) throw new Error('Invalid port');
|
|
360
|
+
return { instance: sanitizeInstance(removeForwardedPort(params.id!, port)) };
|
|
361
|
+
}),
|
|
362
|
+
|
|
363
|
+
'/api/instances/:id/rebuild': mutationRoute('POST', ({ params }) => ({
|
|
364
|
+
instance: sanitizeInstance(rebuildInstance(params.id!))
|
|
365
|
+
})),
|
|
366
|
+
|
|
367
|
+
'/api/instances/:id/start': mutationRoute('POST', async ({ params }) => ({
|
|
368
|
+
instance: sanitizeInstance(await startInstance(params.id!))
|
|
369
|
+
})),
|
|
370
|
+
|
|
371
|
+
'/api/instances/:id/stop': mutationRoute('POST', async ({ params }) => ({
|
|
372
|
+
instance: sanitizeInstance(await stopInstance(params.id!))
|
|
373
|
+
})),
|
|
374
|
+
|
|
375
|
+
'/api/instances/:id/delete': mutationRoute('POST', async ({ params }) => {
|
|
376
|
+
await deleteInstance(params.id!);
|
|
377
|
+
return { ok: true };
|
|
378
|
+
}),
|
|
379
|
+
|
|
380
|
+
'/api/instances/:id/attention/clear': mutationRoute('POST', ({ params }) => {
|
|
381
|
+
clearAttention(params.id!);
|
|
382
|
+
return { ok: true };
|
|
383
|
+
}),
|
|
384
|
+
|
|
385
|
+
// Token-authed instead of Basic Auth (see auth.server.ts); the token rides in a
|
|
386
|
+
// header rather than the query string so it stays out of request logs.
|
|
387
|
+
'/api/bridge/attention': Mochi.api(async ({ method, url, request }) => {
|
|
388
|
+
if (method !== 'POST') return apiError(405, 'Method Not Allowed');
|
|
389
|
+
const id = url.searchParams.get('id');
|
|
390
|
+
const token = request.headers.get('X-Bridge-Token');
|
|
391
|
+
const state = url.searchParams.get('state');
|
|
392
|
+
console.log(
|
|
393
|
+
`[bridge] attention POST id=${id ?? '(none)'} state=${state ?? '(none)'} hasToken=${!!token}`
|
|
394
|
+
);
|
|
395
|
+
if (!id || !token) {
|
|
396
|
+
console.warn('[bridge] rejected: missing id or token');
|
|
397
|
+
return apiError(400, 'id and token are required');
|
|
398
|
+
}
|
|
399
|
+
const row = getInstance(id);
|
|
400
|
+
// One uniform 403, constant-time — otherwise an attacker could enumerate instance ids.
|
|
401
|
+
if (!row || !row.bridge_token || !timingSafeEqualStr(token, row.bridge_token)) {
|
|
402
|
+
console.warn(
|
|
403
|
+
`[bridge] rejected id=${id}: ${!row ? 'no such instance' : !row.bridge_token ? 'instance has no bridge_token' : 'token mismatch'}`
|
|
404
|
+
);
|
|
405
|
+
return apiError(403, 'Forbidden');
|
|
406
|
+
}
|
|
407
|
+
if (state === 'done') setAttention(id, 'done');
|
|
408
|
+
else if (state === 'waiting') setAttention(id, 'waiting');
|
|
409
|
+
else clearAttention(id); // 'busy' / anything else → Claude resumed, dismiss the pulse
|
|
410
|
+
console.log(
|
|
411
|
+
`[bridge] accepted id=${id} → attention=${state === 'done' || state === 'waiting' ? state : 'cleared'}`
|
|
412
|
+
);
|
|
413
|
+
return json({ ok: true });
|
|
414
|
+
}),
|
|
415
|
+
|
|
416
|
+
'/api/instances/:id/logs': Mochi.ws<{ id: string; unsub?: () => void }>({
|
|
417
|
+
upgrade: (req, params) =>
|
|
418
|
+
wsUpgradeAllowed(req) && params.id && getInstance(params.id) ? { id: params.id } : false,
|
|
419
|
+
open(ws) {
|
|
420
|
+
ws.data.user.unsub = subscribeLogs(ws.data.user.id, (chunk) => {
|
|
421
|
+
try {
|
|
422
|
+
ws.send(chunk);
|
|
423
|
+
} catch {
|
|
424
|
+
/* socket closed */
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
},
|
|
428
|
+
message: () => {},
|
|
429
|
+
close(ws) {
|
|
430
|
+
ws.data.user.unsub?.();
|
|
431
|
+
}
|
|
432
|
+
}),
|
|
433
|
+
|
|
434
|
+
...proxyRoutes,
|
|
435
|
+
|
|
436
|
+
...(process.env.MODE === 'development'
|
|
437
|
+
? {
|
|
438
|
+
'/debug': Mochi.page('./src/pages/UI.svelte', {
|
|
439
|
+
// Feed the showcase the real auth providers so CredMenu mirrors `/`.
|
|
440
|
+
serverProps: async () => ({ preflight: await preflight() })
|
|
441
|
+
})
|
|
442
|
+
}
|
|
443
|
+
: {})
|
|
444
|
+
};
|
package/src/settings.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// SSR-safe: every function no-ops when localStorage is absent.
|
|
2
|
+
|
|
3
|
+
const SOUND_KEY = 'codebay.sound';
|
|
4
|
+
|
|
5
|
+
/** Defaults to on — only an explicit 'off' disables it. */
|
|
6
|
+
export function soundEnabled(): boolean {
|
|
7
|
+
if (typeof localStorage === 'undefined') return true;
|
|
8
|
+
return localStorage.getItem(SOUND_KEY) !== 'off';
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function setSoundEnabled(on: boolean): void {
|
|
12
|
+
if (typeof localStorage === 'undefined') return;
|
|
13
|
+
localStorage.setItem(SOUND_KEY, on ? 'on' : 'off');
|
|
14
|
+
}
|
package/src/shell.html
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
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" />
|
|
6
|
+
<title>Codebay</title>
|
|
7
|
+
<style>
|
|
8
|
+
/* Each themed variable is defined once as light-dark(light, dark). With no
|
|
9
|
+
data-theme attribute (the "auto" mode) `color-scheme: light dark` lets the
|
|
10
|
+
browser preference pick the value; an explicit choice pins color-scheme
|
|
11
|
+
below, which forces every light-dark() to that side. */
|
|
12
|
+
:root {
|
|
13
|
+
color-scheme: light dark;
|
|
14
|
+
--bg: light-dark(#d7d8d2, #1b1c15);
|
|
15
|
+
--bg-card: light-dark(#e7e8e2, #24261c);
|
|
16
|
+
--ink: light-dark(#14150f, #e8e9df);
|
|
17
|
+
--ink-soft: light-dark(#45463e, #b7b8a9);
|
|
18
|
+
--ink-faint: light-dark(#7d7e74, #85867a);
|
|
19
|
+
--rule: light-dark(#20211a, #46473a);
|
|
20
|
+
--rule-soft: light-dark(#b9bab2, #34352a);
|
|
21
|
+
--warn-bg: light-dark(#e4d98f, #4a3d12);
|
|
22
|
+
--warn-line: light-dark(#9c8729, #c9a83a);
|
|
23
|
+
--warn-ink: light-dark(#463b08, #f3e3a0);
|
|
24
|
+
--ok-bg: light-dark(#8fbf6e, #23421a);
|
|
25
|
+
--ok-line: light-dark(#4c7a2e, #6fa84a);
|
|
26
|
+
--ok-ink: light-dark(#1c3110, #cdeab8);
|
|
27
|
+
--danger: light-dark(#b23a2e, #e2564a);
|
|
28
|
+
--danger-soft: light-dark(#e3c6c1, #4a231e);
|
|
29
|
+
--screen-bg: #0d0e0a;
|
|
30
|
+
--screen-ink: #d8d9cf;
|
|
31
|
+
--screen-line: rgba(255, 255, 255, 0.04);
|
|
32
|
+
--attn-done: #22c55e;
|
|
33
|
+
--attn-waiting: #f59e0b;
|
|
34
|
+
--info: #388bfd;
|
|
35
|
+
--grid-line: light-dark(rgba(0, 0, 0, 0.035), rgba(255, 255, 255, 0.035));
|
|
36
|
+
/* --attn-done at reduced opacity — reads correctly on both themes' track color. */
|
|
37
|
+
--switch-on-bg: rgba(34, 197, 94, 0.32);
|
|
38
|
+
--font-display: 'Doto Variable', 'JetBrains Mono Variable', monospace;
|
|
39
|
+
--font-mono:
|
|
40
|
+
'JetBrains Mono Variable', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
41
|
+
--font-serif: var(--font-display);
|
|
42
|
+
--font-sans: var(--font-mono);
|
|
43
|
+
}
|
|
44
|
+
:root[data-theme='light'] {
|
|
45
|
+
color-scheme: light;
|
|
46
|
+
}
|
|
47
|
+
:root[data-theme='dark'] {
|
|
48
|
+
color-scheme: dark;
|
|
49
|
+
}
|
|
50
|
+
* {
|
|
51
|
+
box-sizing: border-box;
|
|
52
|
+
}
|
|
53
|
+
/* The whole theme is square — enforce it globally (covers third-party
|
|
54
|
+
widgets like the toaster too). Intentional, not a workaround.
|
|
55
|
+
Exclude Mochi's dev-only debug bar so it keeps its own styling. */
|
|
56
|
+
*:not(#mochi-dev-toolbar):not(#mochi-dev-toolbar *) {
|
|
57
|
+
border-radius: 0 !important;
|
|
58
|
+
}
|
|
59
|
+
/* Shared "panel" chrome — the bordered, drop-shadowed card look used
|
|
60
|
+
throughout the app (health box, ports/log boxes, instance cards,
|
|
61
|
+
dropdowns, modals, the UI showcase). A component can still override
|
|
62
|
+
`box-shadow`/`border` locally (e.g. the folder-picker modal's bigger
|
|
63
|
+
8px shadow) — Svelte's auto-scoping class gives any same-component
|
|
64
|
+
rule higher specificity than this global one, so local overrides
|
|
65
|
+
always win regardless of source order. */
|
|
66
|
+
.panel {
|
|
67
|
+
border: 1px solid var(--ink);
|
|
68
|
+
box-shadow: 4px 4px 0 var(--ink);
|
|
69
|
+
}
|
|
70
|
+
/* Shared dark header bar — the inverted label strip at the top of a panel. */
|
|
71
|
+
.panel-bar {
|
|
72
|
+
padding: 9px 14px;
|
|
73
|
+
background: var(--ink);
|
|
74
|
+
color: var(--bg);
|
|
75
|
+
font-family: var(--font-display);
|
|
76
|
+
font-weight: 700;
|
|
77
|
+
font-size: 12px;
|
|
78
|
+
text-transform: uppercase;
|
|
79
|
+
letter-spacing: 0.12em;
|
|
80
|
+
}
|
|
81
|
+
body {
|
|
82
|
+
margin: 0;
|
|
83
|
+
font-family: var(--font-sans);
|
|
84
|
+
/* Pale LCD panel with a faint pixel-grid matrix overlay. */
|
|
85
|
+
background:
|
|
86
|
+
repeating-linear-gradient(0deg, var(--grid-line) 0 1px, transparent 1px 3px),
|
|
87
|
+
repeating-linear-gradient(90deg, var(--grid-line) 0 1px, transparent 1px 3px), var(--bg);
|
|
88
|
+
color: var(--ink);
|
|
89
|
+
font-size: 14px;
|
|
90
|
+
line-height: 1.55;
|
|
91
|
+
min-height: 100vh;
|
|
92
|
+
-webkit-font-smoothing: antialiased;
|
|
93
|
+
}
|
|
94
|
+
</style>
|
|
95
|
+
{{mochi.head}} {{mochi.css}}
|
|
96
|
+
</head>
|
|
97
|
+
<body>
|
|
98
|
+
{{mochi.body}} {{mochi.script}}
|
|
99
|
+
</body>
|
|
100
|
+
</html>
|
package/src/sound.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { soundEnabled } from './settings.ts';
|
|
2
|
+
|
|
3
|
+
/** Regenerate these WAVs with `bun run gen:chimes`. */
|
|
4
|
+
const SOUNDS = {
|
|
5
|
+
done: '/sounds/done.wav', // brighter ascending pair
|
|
6
|
+
waiting: '/sounds/waiting.wav' // lower, more insistent
|
|
7
|
+
} as const;
|
|
8
|
+
|
|
9
|
+
type Chime = keyof typeof SOUNDS;
|
|
10
|
+
|
|
11
|
+
const elements: Partial<Record<Chime, HTMLAudioElement>> = {};
|
|
12
|
+
let unlocked = false;
|
|
13
|
+
|
|
14
|
+
function element(name: Chime): HTMLAudioElement | null {
|
|
15
|
+
if (typeof window === 'undefined') return null;
|
|
16
|
+
let el = elements[name];
|
|
17
|
+
if (!el) {
|
|
18
|
+
el = new Audio(SOUNDS[name]);
|
|
19
|
+
el.preload = 'auto';
|
|
20
|
+
elements[name] = el;
|
|
21
|
+
}
|
|
22
|
+
return el;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Must be called from a user-gesture handler — a silent play/pause is what satisfies the browser. */
|
|
26
|
+
export function unlockAudio(): void {
|
|
27
|
+
if (unlocked || typeof window === 'undefined') return;
|
|
28
|
+
unlocked = true;
|
|
29
|
+
for (const name of Object.keys(SOUNDS) as Chime[]) {
|
|
30
|
+
const el = element(name);
|
|
31
|
+
if (!el) continue;
|
|
32
|
+
const { volume } = el;
|
|
33
|
+
el.volume = 0;
|
|
34
|
+
void el
|
|
35
|
+
.play()
|
|
36
|
+
.then(() => {
|
|
37
|
+
el.pause();
|
|
38
|
+
el.currentTime = 0;
|
|
39
|
+
el.volume = volume;
|
|
40
|
+
})
|
|
41
|
+
.catch(() => {
|
|
42
|
+
el.volume = volume;
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function playChime(state: Chime): void {
|
|
48
|
+
if (!soundEnabled()) {
|
|
49
|
+
console.log(`[chime] playChime(${state}) — skipped (sound disabled in settings)`);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const el = element(state);
|
|
53
|
+
if (!el) {
|
|
54
|
+
console.log(`[chime] playChime(${state}) — no audio element (SSR/no window?)`);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (!unlocked) {
|
|
58
|
+
console.warn(
|
|
59
|
+
`[chime] playChime(${state}) — audio not yet unlocked; browser may block until the page is interacted with`
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
el.currentTime = 0;
|
|
63
|
+
void el
|
|
64
|
+
.play()
|
|
65
|
+
.then(() => console.log(`[chime] playChime(${state}) — playing`))
|
|
66
|
+
.catch((err) =>
|
|
67
|
+
console.warn(`[chime] playChime(${state}) — play rejected:`, err?.message ?? err)
|
|
68
|
+
);
|
|
69
|
+
}
|
package/src/theme.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Importable only from the Svelte build graph — `cookies` resolves through Mochi's
|
|
3
|
+
* isomorphic virtual module. Server entry files use `getRequestContext().cookies` instead.
|
|
4
|
+
*/
|
|
5
|
+
import { cookies } from 'mochi-framework';
|
|
6
|
+
|
|
7
|
+
export type Theme = 'light' | 'dark' | 'auto';
|
|
8
|
+
|
|
9
|
+
const THEME_KEY = 'theme';
|
|
10
|
+
|
|
11
|
+
export function getTheme(): Theme {
|
|
12
|
+
const value = cookies.get(THEME_KEY);
|
|
13
|
+
return value === 'dark' || value === 'light' ? value : 'auto';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function setTheme(theme: Theme): void {
|
|
17
|
+
cookies.set(THEME_KEY, theme, { expires: 400, path: '/' });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Auto removes the attribute entirely, letting `light-dark()` follow the browser preference. */
|
|
21
|
+
export function applyTheme(theme: Theme): void {
|
|
22
|
+
if (theme === 'auto') delete document.documentElement.dataset.theme;
|
|
23
|
+
else document.documentElement.dataset.theme = theme;
|
|
24
|
+
}
|