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
1 change: 1 addition & 0 deletions app/cloud-account.js
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,7 @@ class CloudAccount {

listVersions() { return this.storage.listVersions(); }
listDeletions() { return this.storage.listDeletions(); }
listSettings() { return this.storage.listSettings(); }
listParts() { return this.storage.listParts(); }
upload(file) { return this.storage.upload(file); }
download(id, options) { return this.storage.download(id, options); }
Expand Down
5 changes: 5 additions & 0 deletions app/google-drive.js
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,11 @@ class GoogleDrive {
return this.listAppData("trashed = false and appProperties has { key='stepforge' and value='deletion-v1' }");
}

// Global placeholders, branding and language (see core/shared-settings.js).
async listSettings() {
return this.listAppData("trashed = false and appProperties has { key='stepforge' and value='settings-v1' }");
}

// Files shared between versions (see core/cloud-parts.js).
async listParts() {
return this.listAppData("trashed = false and appProperties has { key='stepforge' and value='part-v1' }");
Expand Down
22 changes: 20 additions & 2 deletions app/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const { GitHubPages } = require('./github-pages');
const { EXPIRY_DAYS, titleFromHtml } = require('../core/pages-site');
const { TransferMeter } = require('../core/transfer-meter');
const { CloudSync } = require('../core/cloud-sync');
const { SharedSettings } = require('../core/shared-settings');
const { createCredentialVault } = require('./credential-vault');
const { SearchIndex } = require('../core/search');
const { findMatches, applyReplacements, isMatchArg } = require('../core/find-replace');
Expand Down Expand Up @@ -119,6 +120,8 @@ let cloudSync;
let cloudConnecting = false;
let cloudTesting = false;
let cloudEditorDirty = false;
// Synced settings wait while Settings is open, so saving it can't undo them.
let settingsOpen = false;

// The sync services, in the order Settings → Accounts lists them.
const CLOUD_LABELS = { google: 'Google Drive', onedrive: 'OneDrive', dropbox: 'Dropbox', webdav: 'Nextcloud' };
Expand Down Expand Up @@ -299,6 +302,8 @@ function createWindow() {
// away from it and every popup is denied, so no other document can run
// with this window's preload bridge.
security.installWindowSecurity(mainWindow, 'main');
// A reload closes Settings without saying so.
mainWindow.webContents.on('did-start-loading', () => { settingsOpen = false; });
mainWindow.webContents.on('before-input-event', (event, input) => {
// Electron reports both the key-down and key-up as separate
// before-input-event calls; only act on the down edge or every tap
Expand Down Expand Up @@ -787,6 +792,13 @@ function setupIpc() {
if (trustedSender(event)) cloudEditorDirty = Boolean(dirty);
});

// Whatever was changed in Settings syncs as soon as it closes.
ipcMain.on('cloud:settings-open', (event, open) => {
if (!trustedSender(event)) return;
settingsOpen = Boolean(open);
if (!settingsOpen) cloudSync?.markSharedChanged();
});

const IMAGE_BUDGET = 256 * 1024 * 1024; // channels that carry base64 PNGs
const h = (channel, fn, opts = {}) => {
const { maxChars = 2 * 1024 * 1024, validate = null } = opts;
Expand Down Expand Up @@ -1384,7 +1396,7 @@ function setupIpc() {
settings.set('library.trashEmptyingSince', Date.now());
}
if (keyPath === 'appearance') applyTheme();
if (keyPath.startsWith('capture.hotkey')) registerHotkeys();
if (keyPath === 'capture' || keyPath.startsWith('capture.hotkey')) registerHotkeys();
return settings.data;
}, { validate: (a) => c.settingsKeyPath(a.keyPath) });
h('ai:test', async ({ enabled = null, ollama = null } = {}) => {
Expand Down Expand Up @@ -1941,6 +1953,7 @@ if (!gotLock) {
const dataDir = libraryLocation.applyPending();
store = new GuideStore(dataDir);
settings = new Settings(store.settingsDir);
branding = new BrandingStore(store.settingsDir);
// Dev builds keep their sign-in to their own data folder instead of
// sharing (and possibly clearing) the release build's OS backup.
const cloudVault = (slot) => (devBuild ? null : createCredentialVault({ slot }));
Expand Down Expand Up @@ -2008,6 +2021,12 @@ if (!gotLock) {
sendToRenderer('cloud:library-changed', { guideId });
},
onStatus: () => sendToRenderer('cloud:status', cloudStatus()),
shared: new SharedSettings({ settings, branding }),
sharedReady: () => !settingsOpen,
onSharedChange: (changes) => {
if (changes.includes('shortcuts')) registerHotkeys();
sendToRenderer('cloud:settings-changed', { changes });
},
});
searchIndex = new SearchIndex(store.indexDir);
// Rebuild/reconcile the index against the library at startup so a missing,
Expand All @@ -2027,7 +2046,6 @@ if (!gotLock) {
setInterval(emptyOldTrash, TRASH_CHECK_MS);
setTimeout(convertOldSnapshots, 60 * 1000);
templates = new TemplateManager(store.templatesDir);
branding = new BrandingStore(store.settingsDir);
textIntel = new TextIntelService({
store,
settings,
Expand Down
3 changes: 3 additions & 0 deletions app/preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ const api = {
},
cloud: {
setEditorDirty: (dirty) => ipcRenderer.send('cloud:editor-dirty', Boolean(dirty)),
setSettingsOpen: (open) => ipcRenderer.send('cloud:settings-open', Boolean(open)),
status: invoke('cloud:status'),
// Only the service and, for WebDAV, the server and login cross IPC. App
// registrations stay in the main process.
Expand All @@ -102,6 +103,8 @@ const api = {
test: invoke('cloud:test'),
onStatus: (fn) => { const listener = (e, payload) => fn(payload); ipcRenderer.on('cloud:status', listener); return () => ipcRenderer.removeListener('cloud:status', listener); },
onLibraryChanged: (fn) => ipcRenderer.on('cloud:library-changed', (e, payload) => fn(payload)),
// Global placeholders, branding or language arrived from another computer.
onSettingsChanged: (fn) => ipcRenderer.on('cloud:settings-changed', (e, payload) => fn(payload)),
},
github: {
status: invoke('github:status'),
Expand Down
17 changes: 17 additions & 0 deletions app/renderer/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ class StepForgeApp {
this.cloudStatus.addEventListener('click', () => this.openSettings(!this.cloudProvider || this.cloudProvider === 'google' ? 'drive' : this.cloudProvider));
api.cloud.onStatus((status) => this.renderCloudStatus(status));
api.cloud.onLibraryChanged(() => this.refreshLibrary().catch(console.error));
api.cloud.onSettingsChanged((payload) => this.onSettingsSynced(payload?.changes).catch(console.error));
api.cloud.status().then((status) => this.renderCloudStatus(status)).catch(console.error);
this.libraryRenderToken = 0;

Expand Down Expand Up @@ -132,6 +133,22 @@ class StepForgeApp {
api.branding.onChangedByAgent(() => this.onBrandingChangedByAgent());
}

/**
* Global placeholders, branding or language arrived from another
* computer: pick up the active brand profile, and draw the PNG copies any
* new SVG or JPG images need.
*/
async onSettingsSynced(changes = []) {
this.state.settings = await api.settings.all();
this.editor.setSettings(this.state.settings);
await ensurePendingBrandRasters(api);
// When language switching is available, apply a synced choice through
// its handler, saving open edits before it reloads the window.
if (changes.includes('language') && api.app?.setLanguage && await this.flushEditorBeforeNavigation()) {
await api.app?.setLanguage?.({ language: this.state.settings.language });
}
}

/**
* An AI agent changed brand profiles: pick up the active profile (its
* annotation colors), and draw the PNG copies new SVG or JPG images need.
Expand Down
5 changes: 4 additions & 1 deletion app/renderer/dialogs.js
Original file line number Diff line number Diff line change
Expand Up @@ -636,7 +636,10 @@ function showSettingsDialog({
flushEdits = null,
section: initialSection = 'general',
} = {}) {
return new Promise((resolve) => {
return new Promise((done) => {
// Synced settings wait while this is open, so Save can't undo them.
api.cloud?.setSettingsOpen?.(true);
const resolve = (saved) => { api.cloud?.setSettingsOpen?.(false); done(saved); };
const form = el('form', { className: 'settings-form' });
// A service's id ("drive", "onedrive", "dropbox", "webdav", "github")
// opens Accounts with that service's panel showing.
Expand Down
85 changes: 59 additions & 26 deletions core/branding.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const fs = require('node:fs');
const path = require('node:path');
const crypto = require('node:crypto');
const zlib = require('node:zlib');
const { isDeepStrictEqual } = require('node:util');
const { newId, nowIso, writeJsonSync, readJsonIfExists, atomicWriteFileSync } = require('./util');
const { zipSync, unzipSync } = require('./zip');
const { decodePng } = require('./png');
Expand Down Expand Up @@ -256,6 +257,37 @@ function familyFromFileName(fileName) {
return cleanFontName(base.split(/[-_]/)[0] || base) || 'Brand font';
}

/** Validate and normalize an image or font without writing to the library. */
function prepareAsset(bytes, { kind, fileName = '' }) {
if (!Buffer.isBuffer(bytes) || !bytes.length) throw new Error('That file is empty.');
let data = bytes;
let meta;
if (kind === 'image') {
checkFileSize('image', bytes.length);
const type = imageType(bytes);
if (!type) throw new Error('Choose a PNG, JPG or SVG image.');
let size;
if (type === 'svg') {
data = Buffer.from(sanitizeSvg(bytes), 'utf8');
size = svgSize(data.toString('utf8'));
} else {
size = type === 'png' ? pngSize(bytes) : jpegSize(bytes);
}
if (!(size.width > 0 && size.height > 0) && type !== 'svg') throw new Error('StepForge couldn’t read the size of that image.');
meta = { kind, ...IMAGE_TYPES[type], width: size.width || 0, height: size.height || 0 };
} else if (kind === 'font') {
checkFileSize('font', bytes.length);
const type = fontType(bytes);
if (!type) throw new Error('Choose a WOFF2, WOFF, TTF or OTF font file.');
meta = { kind, ...FONT_TYPES[type], family: fontFamilyName(bytes) || familyFromFileName(fileName) };
} else {
throw new Error('Unknown asset kind.');
}
const id = crypto.createHash('sha256').update(data).digest('hex').slice(0, 32);
meta = { id, ...meta, name: cleanText(path.basename(String(fileName)), 160), addedAt: nowIso() };
return { data, meta };
}

// ---- store ---------------------------------------------------------------------

class BrandingStore {
Expand Down Expand Up @@ -308,6 +340,28 @@ class BrandingStore {
this.collectGarbage();
}

/**
* Make the saved profiles exactly `profiles` (cloud sync). Unlike save(),
* each keeps its own timestamps, so the same profiles synced back aren't a
* change. Asset references that don't exist are dropped.
*/
replaceProfiles(profiles) {
const keep = new Set();
for (const raw of profiles) {
const profile = normalizeProfile(raw, { id: raw.id });
const file = this.profileFile(profile.id);
const exists = (asset) => (asset && this.assetMeta(asset) ? asset : null);
profile.logo = exists(profile.logo);
profile.banner = exists(profile.banner);
profile.fonts.heading.asset = exists(profile.fonts.heading.asset);
profile.fonts.body.asset = exists(profile.fonts.body.asset);
if (!isDeepStrictEqual(readJsonIfExists(file, null), profile)) writeJsonSync(file, profile);
keep.add(profile.id);
}
for (const { id } of this.list()) if (!keep.has(id)) fs.rmSync(this.profileFile(id), { force: true });
this.collectGarbage();
}

duplicate(id) {
const source = this.get(id);
if (!source) throw new Error('That brand profile no longer exists.');
Expand Down Expand Up @@ -338,32 +392,9 @@ class BrandingStore {
* `needsRaster` means the renderer should draw it to a PNG (setRaster).
*/
addAsset(bytes, { kind, fileName = '' }) {
if (!Buffer.isBuffer(bytes) || !bytes.length) throw new Error('That file is empty.');
let data = bytes;
let meta;
if (kind === 'image') {
checkFileSize('image', bytes.length);
const type = imageType(bytes);
if (!type) throw new Error('Choose a PNG, JPG or SVG image.');
let size;
if (type === 'svg') {
data = Buffer.from(sanitizeSvg(bytes), 'utf8');
size = svgSize(data.toString('utf8'));
} else {
size = type === 'png' ? pngSize(bytes) : jpegSize(bytes);
}
if (!(size.width > 0 && size.height > 0) && type !== 'svg') throw new Error('StepForge couldn’t read the size of that image.');
meta = { kind, ...IMAGE_TYPES[type], width: size.width || 0, height: size.height || 0 };
} else if (kind === 'font') {
checkFileSize('font', bytes.length);
const type = fontType(bytes);
if (!type) throw new Error('Choose a WOFF2, WOFF, TTF or OTF font file.');
meta = { kind, ...FONT_TYPES[type], family: fontFamilyName(bytes) || familyFromFileName(fileName) };
} else {
throw new Error('Unknown asset kind.');
}
const id = crypto.createHash('sha256').update(data).digest('hex').slice(0, 32);
meta = { id, ...meta, name: cleanText(path.basename(String(fileName)), 160), addedAt: nowIso() };
const { data, meta: prepared } = prepareAsset(bytes, { kind, fileName });
let meta = prepared;
const { id } = meta;
fs.mkdirSync(this.assetsDir, { recursive: true });
const file = this.assetFile(meta);
if (!fs.existsSync(file)) atomicWriteFileSync(file, data);
Expand Down Expand Up @@ -626,6 +657,8 @@ function brandTemplateDefaults(format, profile) {
module.exports = {
BrandingStore,
normalizeProfile,
profileAssetIds,
prepareAsset,
loadBranding,
brandTemplateDefaults,
checkFileSize,
Expand Down
Loading
Loading