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
6 changes: 3 additions & 3 deletions assets/ui/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -211,8 +211,8 @@ body.no-mascot .mascot-slot { display: none; }
.seg-item.active { background: var(--s3); color: var(--text); }
.doc-row { display: grid; grid-template-columns: 38px minmax(0, 2fr) minmax(0, 1.4fr) 110px auto; gap: 12px; align-items: center; padding: 9px 10px; border-bottom: 1px solid var(--border); font-size: 13px; }
.doc-row.head { font-family: var(--mono); font-size: 10.5px; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); border-bottom-color: var(--border2); }
.doc-row .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); }
.doc-row .folder { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--mono); font-size: 11.5px; color: var(--muted); }
.doc-row .name { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); }
.doc-row .folder { display: block; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--mono); font-size: 11.5px; color: var(--muted); }
.doc-row .err { grid-column: 2 / -1; font-size: 12px; color: var(--muted); margin-top: -4px; }
.doc-row .row-actions { display: flex; gap: 6px; justify-content: flex-end; }
.status { font-family: var(--mono); font-size: 11px; border-radius: 5px; padding: 2px 7px; border: 1px solid; white-space: nowrap; justify-self: start; }
Expand Down Expand Up @@ -300,7 +300,7 @@ dialog::backdrop { background: rgba(0,0,0,.55); }
.tag-editor { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin: 0 0 16px; }
.tag-editor input { background: var(--bg); color: var(--text); border: 1px solid var(--border2); border-radius: 99px; padding: 2px 10px; font: inherit; font-size: 12px; width: 150px; outline: none; }
.tag-editor input:focus { border-color: var(--lime); }
.doc-row .name-cell { min-width: 0; }
.doc-row .name-cell { display: block; min-width: 0; overflow: hidden; }
.doc-row .name-cell .tags { margin-top: 2px; }
.cite-row { display: flex; gap: 6px; flex-wrap: wrap; margin: 12px 0 0; }
.collect-dialog { width: min(720px, calc(100vw - 32px)); max-height: min(80vh, 760px); }
Expand Down
11 changes: 5 additions & 6 deletions assets/ui/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ const FEATURE_GROUPS = [
]
const KIND_LABELS = { document: 'Documents (PDF, Word, Pages, Markdown, HTML…)', spreadsheet: 'Spreadsheets', presentation: 'Presentations', ebook: 'E-books', email: 'Email', text: 'Text, CSV, JSON and subtitles', code: 'Source code', image: 'Images and SVG', archive: 'ZIP archives' }
const DESKTOP_PREFS = [
['openAtLogin', 'Open at login', 'Start Duct when you sign in to this computer, so quick search and watched folders are always ready.'],
['island', 'Notch companion', 'Duct at the top of the screen: progress, quick search and a drop zone.'],
['sounds', 'Sounds', 'Short sounds when a job finishes or needs you.'],
['shortcut', 'Quick search shortcut', 'Search from any app, without switching to Duct.'],
Expand Down Expand Up @@ -905,11 +906,9 @@ async function openDocument(path, page, highlightTerms = []) {
if (!(await desktop.openDocument(path, page, highlightTerms))) toast("Couldn't open " + fileName(path), true)
return
}
if (/\.pdf$/i.test(path)) {
window.open('/viewer?path=' + encodeURIComponent(path) + '&page=' + (page || 1) + '&terms=' + encodeURIComponent(JSON.stringify(highlightTerms)), '_blank', 'noopener')
return
}
window.open('/api/file/' + encodeURIComponent(fileName(path)) + '?path=' + encodeURIComponent(path), '_blank', 'noopener')
// The workspace with this one document: pages as they look, and text you can add to any notebook.
const q = new URLSearchParams({ left: path, layout: 'one', lpage: String(page || 1), lterms: JSON.stringify(highlightTerms || []) })
window.open('/workspace?' + q, '_blank', 'noopener')
}

const openResult = r => r && openDocument(r.chunk.documentPath, r.chunk.page, terms(r))
Expand Down Expand Up @@ -1029,7 +1028,7 @@ function renderDocuments() {
if (desktop && desktop.revealDocument && !link) actions.push('<button class="btn btn-sm" data-doc-act="reveal">Show</button>')
if (isAdmin()) actions.push('<button class="btn btn-sm btn-danger" data-doc-act="remove" title="Remove from Duct">✕</button>')
return '<div class="doc-row" role="row" data-path="' + esc(d.path) + '">' + badge(d.format) +
'<span class="name-cell"><span class="name" title="' + esc(d.path) + '">' + esc(d.displayName || fileName(d.path)) + '</span>' +
'<span class="name-cell"><span class="name" title="' + esc((d.displayName || fileName(d.path)) + '\n' + d.path) + '">' + esc(d.displayName || fileName(d.path)) + '</span>' +
((d.tags || []).length ? '<span class="tags">' + d.tags.map(t => '<span class="tag">' + esc(t) + '</span>').join('') + '</span>' : '') + '</span>' +
'<span class="folder">' + esc(link ? d.path : d.source === 'library' ? 'Duct Library' : folderOf(d.path)) + '</span>' +
statusOf(d) + '<span class="row-actions">' + actions.join('') + '</span>' +
Expand Down
71 changes: 41 additions & 30 deletions electron/main.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,32 @@ function shortcutStatus() {
}

// Desktop-only switches shown in the page's Settings > Features (the rest live in the index's settings).
// Open at login: started in the background (no window), so quick search, the island and watched folders are ready.
// macOS and Windows keep this in the system's login items; Linux uses an XDG autostart entry.
const HIDDEN_FLAG = '--hidden'
const autostartFile = () => path.join(app.getPath('home'), '.config', 'autostart', 'duct.desktop')
function openAtLoginEnabled() {
if (process.platform === 'linux') return fs.existsSync(autostartFile())
return app.getLoginItemSettings({ args: [HIDDEN_FLAG] }).openAtLogin
}
function setOpenAtLogin(on) {
if (process.platform === 'linux') {
if (!on) { fs.rmSync(autostartFile(), { force: true }); return }
const exec = process.env.APPIMAGE || process.execPath
fs.mkdirSync(path.dirname(autostartFile()), { recursive: true })
fs.writeFileSync(autostartFile(), `[Desktop Entry]\nType=Application\nName=Duct\nExec="${exec}" ${HIDDEN_FLAG}\nX-GNOME-Autostart-enabled=true\n`)
return
}
app.setLoginItemSettings({ openAtLogin: on, openAsHidden: true, args: [HIDDEN_FLAG] })
}
/**
* Started by the system at login, rather than by someone opening Duct: stay in the background. Windows and Linux pass
* --hidden; recent macOS doesn't say, so there the window opens, and closing it leaves Duct in the menu bar.
*/
const startedAtLogin = () => process.argv.includes(HIDDEN_FLAG) || (process.platform === 'darwin' && app.getLoginItemSettings().wasOpenedAtLogin)

const DESKTOP_PREFS = {
openAtLogin: { get: openAtLoginEnabled, set: on => setOpenAtLogin(on) },
island: { get: islandEnabled, set: on => setIslandEnabled(on) },
sounds: { get: soundsEnabled, set: on => setSoundsEnabled(on) },
shortcut: { get: shortcutEnabled, set: on => setShortcutEnabled(on) },
Expand Down Expand Up @@ -314,7 +339,7 @@ async function addFilesToLibrary(filePaths) {
return { added, duplicates, failed }
}

function createWindow() {
function createWindow({ background = false } = {}) {
mainWindow = new BrowserWindow({
width: 1280,
height: 860,
Expand Down Expand Up @@ -343,7 +368,7 @@ function createWindow() {
mainWindow.loadURL(serverUrl)

mainWindow.once('ready-to-show', () => {
mainWindow.show()
if (!background) mainWindow.show()
})

mainWindow.on('closed', () => {
Expand Down Expand Up @@ -555,53 +580,33 @@ ipcMain.handle('duct:watchDirectory', async () => {

// Only documents in the index can be opened or revealed; the page can't name arbitrary paths.
// PDFs open in Duct's viewer at the page, with the matched words highlighted; other files use their default app.
// "Open" shows the document in Duct's workspace on its own, where text can be selected and added to any notebook;
// "Open in app" there hands it to the app it belongs to.
async function openDocument(filePath, page, terms) {
const doc = indexedFile(filePath)
if (!doc) return false
if (path.extname(doc.path).toLowerCase() === '.pdf') {
const safeTerms = Array.isArray(terms) ? terms.filter(t => typeof t === 'string').slice(0, 12) : []
const pageNumber = Number.isInteger(page) && page > 0 ? page : 1
const viewer = new BrowserWindow({
width: 1000,
height: 1100,
title: doc.displayName || path.basename(doc.path),
backgroundColor: '#0C0C0B',
icon: path.join(__dirname, 'icon.png'),
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true },
})
viewer.webContents.on('will-navigate', (event, url) => { if (!url.startsWith(serverUrl)) event.preventDefault() })
viewer.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
await viewer.loadURL(`${serverUrl}/viewer?path=${encodeURIComponent(doc.path)}&page=${pageNumber}&terms=${encodeURIComponent(JSON.stringify(safeTerms))}`)
return true
}
if (!indexedFile(filePath)) return false
telemetry?.record('opens')
return (await shell.openPath(doc.path)) === ''
}

function indexedFile(filePath) {
const doc = typeof filePath === 'string' ? duct.getDocument(filePath) : undefined
if (!doc || doc.source === 'url' || !fs.existsSync(doc.path)) return null
return doc
return openWorkspace(filePath, '', page, terms, '', 'one')
}

ipcMain.handle('duct:openDocument', (_event, filePath, page, terms) => openDocument(filePath, page, terms))

// The workspace: two documents side by side with a notebook. Any indexed document can be shown, not only PDFs.
function openWorkspace(left, right, page, terms, notebook) {
function openWorkspace(left, right, page, terms, notebook, layout) {
const known = p => (typeof p === 'string' && duct.getDocument(p) ? duct.getDocument(p).path : '')
const query = new URLSearchParams()
if (known(left)) query.set('left', known(left))
if (known(right)) query.set('right', known(right))
if (Number.isInteger(page) && page > 0) query.set('lpage', String(page))
if (Array.isArray(terms) && terms.length) query.set('lterms', JSON.stringify(terms.filter(t => typeof t === 'string').slice(0, 12)))
if (typeof notebook === 'string' && /^[\w-]{1,64}$/.test(notebook)) query.set('notebook', notebook)
if (layout === 'one') query.set('layout', 'one')
const win = new BrowserWindow({
width: 1500,
height: 950,
title: 'Duct Workspace',
backgroundColor: '#0C0C0B',
icon: path.join(__dirname, 'icon.png'),
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true },
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true, preload: path.join(__dirname, 'workspace-preload.cjs') },
})
win.webContents.on('will-navigate', (event, url) => { if (!url.startsWith(serverUrl)) event.preventDefault() })
win.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
Expand All @@ -611,6 +616,12 @@ function openWorkspace(left, right, page, terms, notebook) {

ipcMain.handle('duct:openWorkspace', (_event, left, right, page, terms, notebook) => openWorkspace(left, right, page, terms, notebook))

ipcMain.handle('duct:openInApp', async (_event, filePath) => {
const doc = indexedFile(filePath)
if (!doc) return false
return (await shell.openPath(doc.path)) === ''
})

ipcMain.handle('duct:revealDocument', (_event, filePath) => {
const doc = indexedFile(filePath)
if (!doc) return false
Expand Down Expand Up @@ -647,7 +658,7 @@ app.whenReady().then(async () => {
console.log(` Server started at ${url}`)

createAppMenu()
createWindow()
createWindow({ background: startedAtLogin() })
createTray()
if (islandEnabled()) startIsland()
registerShortcut()
Expand Down
7 changes: 7 additions & 0 deletions electron/workspace-preload.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// The workspace window's only bridge to the desktop app: open the document it shows in the app it belongs to.
// The main process checks the path is an indexed document before opening anything.
const { contextBridge, ipcRenderer } = require('electron')

contextBridge.exposeInMainWorld('ductWorkspace', {
openInApp: path => ipcRenderer.invoke('duct:openInApp', path),
})
Loading
Loading