Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
31 changes: 13 additions & 18 deletions apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { watchProject } from "./watcher.js";

const here = fileURLToPath(import.meta.url);

function createWindow(projectRoot: string): BrowserWindow {
function createWindow(projectRoot: string | null): BrowserWindow {
const window = new BrowserWindow({
width: 1180,
height: 820,
Expand Down Expand Up @@ -66,13 +66,16 @@ function createWindow(projectRoot: string): BrowserWindow {
ipcMain.handle(channel, (_event, ...args: unknown[]) => dispatch(api, channel, args));
}

// 8.10 — whoever wrote it, the screen follows.
const watcher = watchProject(projectRoot, (change) => {
if (!window.isDestroyed()) window.webContents.send(CHANNELS.changed, change);
});
// 8.10 — whoever wrote it, the screen follows. A launcher window has no
// project to watch.
const watcher = projectRoot
? watchProject(projectRoot, (change) => {
if (!window.isDestroyed()) window.webContents.send(CHANNELS.changed, change);
})
: null;

window.on("closed", () => {
void watcher.close();
void watcher?.close();
session?.dispose();
for (const channel of Object.values(CHANNELS)) ipcMain.removeHandler(channel);
});
Expand Down Expand Up @@ -116,18 +119,10 @@ function createWindow(projectRoot: string): BrowserWindow {
}

void app.whenReady().then(() => {
const projectRoot = resolveProject({ argv: process.argv, cwd: process.cwd() });
if (!projectRoot) {
// 8.4's launcher is what belongs here. Until it exists, saying so beats
// opening the user's home directory as though it were a wiki.
process.stderr.write(
"open-wiki: no project here. Run `ow` inside a project directory, " +
"or `ow init` to create one.\n",
);
app.quit();
return;
}
createWindow(projectRoot);
// 8.4 — `ow` outside a project opens the launcher rather than guessing at
// one. A window with no project answers `null` to `project()`, and the
// renderer shows the list of known projects instead of a wiki.
createWindow(resolveProject({ argv: process.argv, cwd: process.cwd() }));
});

app.on("window-all-closed", () => app.quit());
130 changes: 109 additions & 21 deletions apps/desktop/src/main/ipc.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { join } from "node:path";
import { recordingId, type Operation } from "@open-wiki/access";
import { recordingId, type Language, type Operation } from "@open-wiki/access";
import type { Finding, SourceState } from "@open-wiki/access/read";
import type { PageView, ProjectInfo, WikiIndex } from "./api.js";
import { projectInfo, readPage, sources, wikiIndex } from "./api.js";
Expand All @@ -18,6 +18,21 @@ import {
type SaveResult,
} from "./edit.js";
import { ingestDrop, type DropOutcome } from "./ingest.js";
import {
createProject,
credentialState,
parseCredentialInput,
currentLanguage,
forgetProject,
knownProjects,
saveCredential,
setLanguage,
type CredentialCheck,
type CredentialState,
type KnownProject,
type SaveCredentialInput,
} from "./settings.js";
import { runTranscription, type TranscribeOutcome } from "./transcribe-run.js";
import type { RecorderSession, RecorderStatus } from "./recorder.js";
import {
findings,
Expand Down Expand Up @@ -65,6 +80,17 @@ export const CHANNELS = {
locate: "sources:locate",
drop: "sources:drop",

// The credential (8.3), the launcher (8.4), the content language (8.12) and
// the run 6.3 starts.
credential: "settings:credential",
saveCredential: "settings:save-credential",
language: "settings:language",
setLanguage: "settings:set-language",
knownProjects: "launcher:projects",
createProject: "launcher:create",
forgetProject: "launcher:forget",
transcribe: "sources:transcribe",

/** Main → renderer, for 8.10. */
changed: "project:changed",
} as const;
Expand All @@ -88,7 +114,12 @@ export interface RecorderControl {
}

export interface Deps {
projectRoot: string;
/**
* Null in a launcher window (plan 8.4). `ow` outside a project opens one,
* and every channel that needs a project refuses by saying there is none —
* which is a better answer than a window wired to a directory nobody chose.
*/
projectRoot: string | null;
recorder?: RecorderControl;
/** Injected so a test does not depend on today's date. */
now?: () => Date;
Expand All @@ -110,7 +141,8 @@ export interface StartedRecording {
}

export interface DesktopApi {
project(): ProjectInfo;
/** Null in a launcher window — the renderer shows the launcher instead. */
project(): ProjectInfo | null;
index(): WikiIndex;
page(slug: string): PageView;
sources(): SourceState[];
Expand Down Expand Up @@ -143,9 +175,30 @@ export interface DesktopApi {
findings(): Finding[];
locate(id: string, fragment: string): SourceLocation;
drop(paths: readonly string[]): Promise<DropOutcome[]>;

credential(): CredentialState;
saveCredential(input: SaveCredentialInput): Promise<CredentialCheck>;
language(): Language;
setLanguage(language: Language): Language;
knownProjects(): KnownProject[];
createProject(name: string, directory: string, language: Language): KnownProject;
forgetProject(name: string): void;
transcribe(id: string, restart?: boolean): Promise<TranscribeOutcome>;
}

export class NoProjectError extends Error {
constructor() {
super("this window has no project open");
this.name = "NoProjectError";
}
}

export function createApi(deps: Deps): DesktopApi {
/** The project, or a refusal. Every channel but the launcher's needs one. */
const root = (): string => {
if (!deps.projectRoot) throw new NoProjectError();
return deps.projectRoot;
};
const ensure = (): RecorderSession => {
if (!deps.recorder) throw new Error("recording is not available in this window");
return deps.recorder.ensure();
Expand All @@ -158,15 +211,15 @@ export function createApi(deps: Deps): DesktopApi {
const now = deps.now ?? ((): Date => new Date());

return {
project: () => projectInfo(deps.projectRoot),
index: () => wikiIndex(deps.projectRoot),
page: (slug) => readPage(deps.projectRoot, slug),
sources: () => sources(deps.projectRoot),
project: () => (deps.projectRoot ? projectInfo(deps.projectRoot) : null),
index: () => wikiIndex(root()),
page: (slug) => readPage(root(), slug),
sources: () => sources(root()),

async recordStart(occasion) {
if (!deps.recorder) throw new Error("recording is not available in this window");
const id = recordingId(deps.projectRoot, { occasion, at: now() });
const dir = join(deps.projectRoot, "raw", id);
const id = recordingId(root(), { occasion, at: now() });
const dir = join(root(), "raw", id);
await ensure().start(occasion, dir);
return { id, dir };
},
Expand All @@ -190,19 +243,28 @@ export function createApi(deps: Deps): DesktopApi {
return session ? session.status() : IDLE_STATUS;
},

save: (input) => savePage(deps.projectRoot, input, savePageToday),
create: (input) => createPage(deps.projectRoot, input, savePageToday),
rename: (from, to) => renamePage(deps.projectRoot, from, to),
remove: (slug) => deletePage(deps.projectRoot, slug),
history: () => history(deps.projectRoot),
undo: (id) => undoOperation(deps.projectRoot, id),
save: (input) => savePage(root(), input, savePageToday),
create: (input) => createPage(root(), input, savePageToday),
rename: (from, to) => renamePage(root(), from, to),
remove: (slug) => deletePage(root(), slug),
history: () => history(root()),
undo: (id) => undoOperation(root(), id),

sourceDetail: (id) => sourceDetail(deps.projectRoot, id),
sourcesOfPage: (slug) => sourcesOfPage(deps.projectRoot, slug),
retitle: (id, title) => retitleSource(deps.projectRoot, id, title),
findings: () => findings(deps.projectRoot),
locate: (id, fragment) => locateCitation(deps.projectRoot, id, fragment),
drop: (paths) => ingestDrop(deps.projectRoot, paths),
sourceDetail: (id) => sourceDetail(root(), id),
sourcesOfPage: (slug) => sourcesOfPage(root(), slug),
retitle: (id, title) => retitleSource(root(), id, title),
findings: () => findings(root()),
locate: (id, fragment) => locateCitation(root(), id, fragment),
drop: (paths) => ingestDrop(root(), paths),

credential: () => credentialState(root()),
saveCredential: (input) => saveCredential(root(), input),
language: () => currentLanguage(root()),
setLanguage: (language) => setLanguage(root(), language),
knownProjects: () => knownProjects(),
createProject: (name, directory, language) => createProject(name, directory, language),
forgetProject: (name) => forgetProject(name),
transcribe: (id, restart) => runTranscription(root(), id, { restart }),
};
}

Expand Down Expand Up @@ -259,6 +321,32 @@ export async function dispatch(
return api.findings();
case CHANNELS.locate:
return api.locate(String(args[0] ?? ""), String(args[1] ?? ""));
case CHANNELS.credential:
return api.credential();
case CHANNELS.saveCredential: {
// Parsed, not cast. Every other channel coerces its arguments; this one
// carries the shape that decides what is written into the secrets file.
const input = parseCredentialInput(args[0]);
if (!input) throw new Error("that is not a transcription provider");
return api.saveCredential(input);
}
case CHANNELS.language:
return api.language();
case CHANNELS.setLanguage:
return api.setLanguage(String(args[0] ?? "") as Language);
case CHANNELS.knownProjects:
return api.knownProjects();
case CHANNELS.createProject:
return api.createProject(
String(args[0] ?? ""),
String(args[1] ?? ""),
String(args[2] ?? "en") as Language,
);
case CHANNELS.forgetProject:
return api.forgetProject(String(args[0] ?? ""));
case CHANNELS.transcribe:
return api.transcribe(String(args[0] ?? ""), args[1] === true);

case CHANNELS.drop:
// The renderer hands over paths Chromium gave it for a drop. Anything
// that is not a string is not a path.
Expand Down
11 changes: 11 additions & 0 deletions apps/desktop/src/main/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,17 @@ const api = {
locate: (id: string, fragment: string) => ipcRenderer.invoke(CHANNELS.locate, id, fragment),
drop: (paths: readonly string[]) => ipcRenderer.invoke(CHANNELS.drop, paths),

credential: () => ipcRenderer.invoke(CHANNELS.credential),
saveCredential: (input: unknown) => ipcRenderer.invoke(CHANNELS.saveCredential, input),
language: () => ipcRenderer.invoke(CHANNELS.language),
setLanguage: (language: string) => ipcRenderer.invoke(CHANNELS.setLanguage, language),
knownProjects: () => ipcRenderer.invoke(CHANNELS.knownProjects),
createProject: (name: string, directory: string, language: string) =>
ipcRenderer.invoke(CHANNELS.createProject, name, directory, language),
forgetProject: (name: string) => ipcRenderer.invoke(CHANNELS.forgetProject, name),
transcribe: (id: string, restart?: boolean) =>
ipcRenderer.invoke(CHANNELS.transcribe, id, restart === true),

/**
* The path of a dropped file (plan 3.5).
*
Expand Down
Loading
Loading