Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
b15f829
Pin GitHub Actions to commit SHAs and add Dependabot
ScaleLeanChris Oct 6, 2026
d1f27bc
Ignore dev env vars when packaged and flip Electron fuses
ScaleLeanChris Oct 6, 2026
e2010ec
Keep unsaved edits in Landing Studio, page builder and warehouse SQL
ScaleLeanChris Oct 6, 2026
868b7c2
Stop agent and template results landing on the wrong page
ScaleLeanChris Oct 6, 2026
6e3bcaf
Harden native widget-ID reservation
ScaleLeanChris Oct 6, 2026
81a2c46
Choose toolkit executables with native dialogs, not renderer paths
ScaleLeanChris Oct 6, 2026
146e1ad
Handle IPC failures, add sample preview keyboard access, share escape…
ScaleLeanChris Oct 6, 2026
15ef909
Let failed publishes retry or be abandoned instead of locking the page
ScaleLeanChris Oct 6, 2026
ccc4429
Stop toolkit children with the worker, reset restart budget, reuse pr…
ScaleLeanChris Oct 6, 2026
a488a2a
Validate every warehouse table reference, including parenthesized joins
ScaleLeanChris Oct 6, 2026
aee3303
Commit revision snapshots with draft saves and check the revision on …
ScaleLeanChris Oct 6, 2026
d7097b7
Fix warehouse output decoding, CSV numbers, keyword checks, and byte …
ScaleLeanChris Oct 6, 2026
e001f8d
Guard stored rows and toolkit output against unreadable JSON and fail…
ScaleLeanChris Oct 6, 2026
8da05be
Merge branch 'review/17-24-27-draft-state' into review/integration
ScaleLeanChris Oct 6, 2026
5c93457
Merge branch 'review/22-widget-id-reservation' into review/integration
ScaleLeanChris Oct 6, 2026
628caa5
Merge branch 'review/18-26-warehouse' into review/integration
ScaleLeanChris Oct 6, 2026
1253ed8
Merge branch 'review/20-21-25-main-process' into review/integration
ScaleLeanChris Oct 6, 2026
c71c889
Merge branch 'review/19-23-29-renderer' into review/integration
ScaleLeanChris Oct 6, 2026
04319c9
Merge branch 'review/28-release-hardening' into review/integration
ScaleLeanChris Oct 6, 2026
9569818
Track bq children, bound engine restarts, keep picked paths on save
ScaleLeanChris Oct 6, 2026
c7effc7
Guard startup session migration and ask for host only when needed
ScaleLeanChris Oct 6, 2026
a29d6b8
Confirm quit and workspace switch with unsaved edits; prune builder c…
ScaleLeanChris Oct 6, 2026
defa55d
Bound the quit check and ignore repeated quits while asking
ScaleLeanChris Oct 6, 2026
eb082ff
Close CTE/WINDOW dashed-path bypass, pick bq with a native dialog, sh…
ScaleLeanChris Oct 6, 2026
53c6d62
Merge branch 'review/17-24-27-draft-state' into review/integration
ScaleLeanChris Oct 6, 2026
17a076b
Merge branch 'review/18-26-warehouse' into review/integration
ScaleLeanChris Oct 6, 2026
9ae524a
Merge branch 'review/20-21-25-main-process' into review/integration
ScaleLeanChris Oct 6, 2026
a073260
Merge branch 'review/19-23-29-renderer' into review/integration
ScaleLeanChris Oct 6, 2026
b129d62
Build the UTF-8 test output in the child to stay under Linux's argume…
ScaleLeanChris Oct 7, 2026
d3764d2
Fix quit with a hung renderer and stale edits after a confirmed discard
ScaleLeanChris Oct 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
groups:
actions:
patterns: ['*']
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
open-pull-requests-limit: 5
groups:
npm-minor-patch:
update-types: [minor, patch]
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ jobs:
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: '1'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: '24'
cache: npm
Expand Down
10 changes: 5 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ jobs:
runs-on: ${{ matrix.runner }}
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: '24'
cache: npm
Expand All @@ -49,7 +49,7 @@ jobs:
run: npm run package
- name: Check packaged sample worker and SQLite persistence
run: npm run package:smoke
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: ultracart-studio-${{ matrix.platform }}-${{ matrix.arch }}
path: |
Expand All @@ -70,8 +70,8 @@ jobs:
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
pattern: ultracart-studio-*
path: release-assets
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,4 @@ coverage/
credentials.enc*
*.pem
*.key
downloads/
16 changes: 15 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"zod": "^4.3.6"
},
"devDependencies": {
"@electron/fuses": "^2.1.3",
"@electron/packager": "^18.3.6",
"@tailwindcss/vite": "^4.1.0",
"@types/node": "^24.0.0",
Expand All @@ -59,10 +60,10 @@
"@vitejs/plugin-react": "^5.0.0",
"electron": "44.5.1",
"esbuild": "^0.27.0",
"prettier": "^3.6.2",
"tailwindcss": "^4.1.0",
"tsx": "^4.20.0",
"typescript": "^5.9.0",
"vite": "^7.3.0",
"prettier": "^3.6.2"
"vite": "^7.3.0"
}
}
23 changes: 22 additions & 1 deletion scripts/package-smoke.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { spawnSync } from 'node:child_process';
import { getCurrentFuseWire, FuseV1Options, FuseState } from '@electron/fuses';
import { createRequire } from 'node:module';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
Expand All @@ -14,9 +16,28 @@ const executable =
process.platform === 'darwin'
? join(directory, 'UltraCart Studio.app/Contents/MacOS/UltraCart Studio')
: join(directory, process.platform === 'win32' ? 'UltraCart Studio.exe' : 'ultracart-studio');
const bundle = process.platform === 'darwin' ? join(directory, 'UltraCart Studio.app') : executable;
const fuses = await getCurrentFuseWire(bundle);
const expected = {
RunAsNode: false,
EnableNodeOptionsEnvironmentVariable: false,
EnableNodeCliInspectArguments: false,
EnableEmbeddedAsarIntegrityValidation: true,
OnlyLoadAppFromAsar: true,
};
for (const [name, enabled] of Object.entries(expected)) {
const state = fuses[FuseV1Options[name]];
if (state !== (enabled ? FuseState.ENABLE : FuseState.DISABLE))
throw new Error(`Electron fuse ${name} must be ${enabled ? 'enabled' : 'disabled'}.`);
}
console.log('Electron fuses verified on the packaged executable.');
// RunAsNode is off in the packaged binary, so run the worker checks with the matching unfused
// development Electron runtime against the packaged app.asar.
const runtime = createRequire(import.meta.url)('electron');
if (typeof runtime !== 'string') throw new Error('Could not resolve the development Electron runtime.');
const data = await mkdtemp(join(tmpdir(), 'studio-package-smoke-'));
try {
const result = spawnSync(executable, [join(root, 'scripts/package-smoke.cjs'), resources, data], {
const result = spawnSync(runtime, [join(root, 'scripts/package-smoke.cjs'), resources, data], {
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
stdio: 'inherit',
timeout: 60000,
Expand Down
15 changes: 15 additions & 0 deletions scripts/package.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import packager from '@electron/packager';
import { flipFuses, FuseV1Options, FuseVersion } from '@electron/fuses';
import { createHash } from 'node:crypto';
import { createReadStream, existsSync } from 'node:fs';
import { chmod, copyFile, readFile, rm, writeFile } from 'node:fs/promises';
Expand Down Expand Up @@ -57,6 +58,20 @@ function run(command, args, options = {}) {
if (result.status !== 0) throw new Error(`${command} exited with ${result.status ?? result.signal}.`);
}
for (const directory of paths) {
// The asar archive is mandatory (asar: true above), so asar-only loading and integrity are safe to enforce.
const executable =
platform === 'darwin'
? join(directory, 'UltraCart Studio.app')
: join(directory, platform === 'win32' ? 'UltraCart Studio.exe' : 'ultracart-studio');
await flipFuses(executable, {
version: FuseVersion.V1,
resetAdHocDarwinSignature: platform === 'darwin' && arch === 'arm64',
[FuseV1Options.RunAsNode]: false,
[FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false,
[FuseV1Options.EnableNodeCliInspectArguments]: false,
[FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true,
[FuseV1Options.OnlyLoadAppFromAsar]: true,
});
const resources =
platform === 'darwin'
? join(directory, 'UltraCart Studio.app/Contents/Resources')
Expand Down
3 changes: 2 additions & 1 deletion src/main/content-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { DraftScope } from '../shared/drafts';
import { parseContentMap, type ContentMap } from '../shared/content-map';
import { isSampleSelection } from '../shared/sample';
import type { ConnectionService } from './domain/connection-service';
import { parseJson } from './domain/draft-service';

export async function readContentMap(
connection: Pick<ConnectionService, 'verify' | 'run'>,
Expand Down Expand Up @@ -46,7 +47,7 @@ export async function readContentMap(
],
{ acceptedExitCodes: [0, 1] }
);
const result = parseContentMap(JSON.parse(raw), scope);
const result = parseContentMap(parseJson(raw, 'The toolkit returned an unreadable content map.'), scope);
await connection.verify(scope.selection);
return result;
}
46 changes: 35 additions & 11 deletions src/main/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,23 @@ import { join } from 'node:path';
import { randomUUID } from 'node:crypto';
import type { Activity, Session } from '../shared/types';

function parse<T>(text: string, message: string): T {
try {
return JSON.parse(text) as T;
} catch {
throw new Error(message);
}
}
function readable<T>(rows: Record<string, unknown>[], table: string): T[] {
return rows.flatMap((row) => {
try {
return [JSON.parse(row.value as string) as T];
} catch {
console.error(`Skipped an unreadable ${table} row.`);
return [];
}
});
}
export class Store {
readonly db: DatabaseSync;
constructor(readonly directory: string) {
Expand All @@ -29,7 +46,7 @@ export class Store {
get<T>(key: string, fallback: T): T {
const row = this.db.prepare('SELECT value FROM kv WHERE key = ?').get(key) as
{ value: string } | undefined;
return row ? (JSON.parse(row.value) as T) : fallback;
return row ? parse<T>(row.value, `Saved ${key.split(':')[0]} data is unreadable.`) : fallback;
}
set(key: string, value: unknown) {
this.db
Expand All @@ -38,11 +55,14 @@ export class Store {
)
.run(key, JSON.stringify(value));
}
delete(key: string) {
this.db.prepare('DELETE FROM kv WHERE key = ?').run(key);
}
session(id: string): Session {
const row = this.db.prepare('SELECT value FROM sessions WHERE id = ?').get(id) as
{ value: string } | undefined;
if (!row) throw new Error('This conversation no longer exists.');
return JSON.parse(row.value);
return parse(row.value, 'This conversation is unreadable.');
}
saveSession(session: Session) {
this.db
Expand All @@ -57,19 +77,23 @@ export class Store {
.prepare('SELECT value FROM sessions WHERE workspace_id = ? ORDER BY updated_at DESC LIMIT 100')
.all(workspaceId)
: this.db.prepare('SELECT value FROM sessions ORDER BY updated_at DESC LIMIT 100').all();
return rows.map((row) => JSON.parse(row.value as string));
return readable(rows, 'conversation');
}
pendingSessions(): Session[] {
return this.db
.prepare("SELECT value FROM sessions WHERE json_extract(value, '$.status') = ?")
.all('working')
.map((row) => JSON.parse(row.value as string));
return readable(
this.db
.prepare("SELECT value FROM sessions WHERE json_valid(value) AND json_extract(value, '$.status') = ?")
.all('working'),
'conversation'
);
}
activity(workspaceId: string): Activity[] {
return this.db
.prepare('SELECT value FROM activity WHERE workspace_id = ? ORDER BY at DESC LIMIT 40')
.all(workspaceId)
.map((row) => JSON.parse(row.value as string));
return readable(
this.db
.prepare('SELECT value FROM activity WHERE workspace_id = ? ORDER BY at DESC LIMIT 40')
.all(workspaceId),
'activity'
);
}
log(workspaceId: string, kind: Activity['kind'], text: string, detail = '') {
const item: Activity = {
Expand Down
29 changes: 23 additions & 6 deletions src/main/domain/connection-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,11 @@ export function safeFailure(text: string) {
return 'The toolkit could not complete the request. Check the selected profile, network, and installed toolkit, then retry.';
}

export type ChildObserver = (pid: number, state: 'started' | 'exited') => void;

export class ConnectionService {
/** Lets the worker report toolkit child pids so the main process can kill them if the worker dies. */
static childObserver: ChildObserver | null = null;
private children = new Set<ChildProcessWithoutNullStreams>();
private login: Login | null = null;
private loginChild: ChildProcessWithoutNullStreams | null = null;
Expand Down Expand Up @@ -197,15 +201,15 @@ export class ConnectionService {
...invocation.options,
detached: process.platform !== 'win32',
});
this.children.add(child);
this.track(child);
let stdout = '',
stderr = '',
finished = false;
const finish = (error?: Error) => {
if (finished) return;
finished = true;
clearTimeout(timer);
this.children.delete(child);
this.untrack(child);
if (error) reject(error);
else resolve(stdout.trim());
};
Expand Down Expand Up @@ -352,14 +356,14 @@ export class ConnectionService {
if (this.disposed || this.login.phase !== 'starting') throw new Error('Sign-in cancelled.');
const child = spawn(invocation.command, invocation.args, invocation.options);
this.loginChild = child;
this.children.add(child);
this.track(child);
child.stdin.end();
let stderr = '',
stdout = '';
const finish = (phase: Login['phase'], message: string) => {
if (this.loginTimer) clearTimeout(this.loginTimer);
this.loginTimer = null;
this.children.delete(child);
this.untrack(child);
if (this.loginChild === child) this.loginChild = null;
if (this.login?.id === id && ['starting', 'waiting'].includes(this.login.phase))
this.login = { id, profile, phase, message, url: null, code: null };
Expand Down Expand Up @@ -441,11 +445,24 @@ export class ConnectionService {
dispose() {
this.disposed = true;
if (this.loginTimer) clearTimeout(this.loginTimer);
for (const child of this.children) this.stopChild(child);
this.children.clear();
for (const child of this.children) {
this.stopChild(child);
this.untrack(child);
}
this.login = null;
this.loginChild = null;
}
private track(child: ChildProcessWithoutNullStreams) {
this.children.add(child);
if (child.pid) ConnectionService.childObserver?.(child.pid, 'started');
// The pid stays reported until the process has really exited, even after we stop waiting for it.
child.once('exit', () => {
if (child.pid) ConnectionService.childObserver?.(child.pid, 'exited');
});
}
private untrack(child: ChildProcessWithoutNullStreams) {
this.children.delete(child);
}
private stopChild(child: ChildProcessWithoutNullStreams) {
if (process.platform !== 'win32' && child !== this.loginChild && child.pid) {
try {
Expand Down
Loading
Loading