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
8 changes: 7 additions & 1 deletion apps/desktop/build/after-pack.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,13 @@ exports.default = async function afterPack(context) {
const resources = platform === 'darwin'
? path.join(context.appOutDir, `${context.packager.appInfo.productFilename}.app`, 'Contents', 'Resources')
: path.join(context.appOutDir, 'resources')
await stageTerminalArtifact({ platform, arch, localDirectory, allowLocal, output: path.join(resources, 'terminal') })
// `zn-cli`, not the `terminal` of 2.52.0 to 2.57.0: those releases shipped
// `terminal` as root-owned 0700 (#869), and dpkg and pacman keep an
// existing folder's mode on upgrade, so fixing the mode alone never
// reached installed copies. A new name is created fresh with the packaged
// mode, and the old folder is removed with the old package's files.
// cli-install.ts reads the same name.
await stageTerminalArtifact({ platform, arch, localDirectory, allowLocal, output: path.join(resources, 'zn-cli') })
}
if (platform !== 'linux') return
await rewriteAsFreshFiles(path.join(context.appOutDir, 'resources', 'arch-extras'))
Expand Down
27 changes: 25 additions & 2 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@zennotes/desktop",
"productName": "ZenNotes",
"version": "2.57.0",
"version": "2.58.0",
"description": "ZenNotes desktop shell",
"private": true,
"main": "./out/main/index.js",
Expand Down Expand Up @@ -292,14 +292,37 @@
},
"deb": {
"afterInstall": "build/after-install.sh",
"compression": "xz"
"compression": "xz",
"depends": [
"libgtk-3-0",
"libnotify4",
"libnss3",
"libxss1",
"libxtst6",
"xdg-utils",
"libatspi2.0-0",
"libuuid1",
"libsecret-1-0",
"libasound2t64 | libasound2",
"libgbm1"
]
},
"rpm": {
"afterInstall": "build/after-install.sh",
"compression": "xz"
},
"pacman": {
"compression": "xz",
"depends": [
"gtk3",
"nss",
"alsa-lib",
"mesa",
"libnotify",
"libsecret",
"xdg-utils",
"desktop-file-utils"
],
"fpm": [
"--provides",
"zennotes",
Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/src/main/cli-install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,8 +275,9 @@ async function locateWrapper(): Promise<WrapperLocation | null> {
let terminal
try {
terminal = await prepareTerminalRuntime({
// Named in build/after-pack.js, which says why it is not `terminal` (#869).
bundleDir: app.isPackaged
? path.join(process.resourcesPath, 'terminal')
? path.join(process.resourcesPath, 'zn-cli')
: path.resolve(here, '../../build/terminal', `${process.platform}-${process.arch}`),
userData: app.getPath('userData'),
platform: process.platform,
Expand Down
32 changes: 23 additions & 9 deletions apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1864,11 +1864,12 @@ async function migrateLegacyRemoteWorkspaceSecrets(): Promise<void> {
}

// After this write the target profile holds the only copy of the
// credential (the config's own copy is stripped right below). The config
// parser synthesizes the legacy workspace's profile with a fresh random
// id on every load and keeps remoteWorkspaceProfileId only when it
// matches a profile, so the common legacy case arrives here with a found
// profile and a null selection. Leaving the selection null strands the
// credential (the config's own copy is stripped right below). For a
// config from before saved profiles existed, the config parser
// synthesizes the legacy workspace's profile with a fresh random id on
// every load and keeps remoteWorkspaceProfileId only when it matches a
// profile, so the common legacy case arrives here with a found profile
// and a null selection. Leaving the selection null strands the
// credential: boot resolves the token through the selected profile and
// lands on the reconnect screen asking for a token the user already
// saved. Never steal an existing valid selection, though.
Expand Down Expand Up @@ -2628,10 +2629,23 @@ async function connectRemoteWorkspaceProfile(
throw new Error("That saved remote workspace no longer exists.");
}
const authToken = await getRemoteWorkspaceSecret(profile.id);
const result = await setRemoteWorkspace(profile.baseUrl, authToken, {
profileId: profile.id,
vaultPath: profile.vaultPath,
});
let result: Awaited<ReturnType<typeof setRemoteWorkspace>>;
try {
result = await setRemoteWorkspace(profile.baseUrl, authToken, {
profileId: profile.id,
vaultPath: profile.vaultPath,
});
} catch (err) {
// A profile with no saved token that the server turns away is missing a
// token, not holding a wrong one: name the profile and where to add it,
// instead of "check the auth token" for a token that was never sent (#870).
if (!authToken && err instanceof RemoteRequestError && err.status === 401) {
throw new Error(
`No auth token is saved for "${profile.name}", and ${profile.baseUrl} needs one. Choose Edit on it under Settings → Vault → Saved Remote Workspaces and add the server's token.`,
);
}
throw err;
}
const connectedAt = Date.now();
await updateConfig((current) => ({
...current,
Expand Down
20 changes: 19 additions & 1 deletion apps/desktop/src/main/terminal-runtime.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
chmod,
mkdtemp,
mkdir,
writeFile,
Expand All @@ -22,7 +23,7 @@ afterEach(async () => {
async function fixture(version = '1.0.0') {
const root = await mkdtemp(path.join(os.tmpdir(), "zn terminal ' "))
roots.push(root)
const bundleDir = path.join(root, 'resources', 'terminal')
const bundleDir = path.join(root, 'resources', 'zn-cli')
await mkdir(bundleDir, { recursive: true })
const binary = `#!/bin/sh\nif [ "$1" = --desktop-integration ]; then\n echo '{"protocol":1,"version":"${version}"}'\n exit\nfi\nprintf '%s\\n' "$ZENNOTES_WORKSPACE_SOURCE" "$@"\nexit 7\n`
await writeFile(path.join(bundleDir, 'zn'), binary, { mode: 0o755 })
Expand Down Expand Up @@ -105,5 +106,22 @@ describe.skipIf(process.platform === 'win32')(
await rm(options.bundleDir, { recursive: true })
expect(await prepareTerminalRuntime(options)).toBeNull()
})

// Root reads through any mode, so the denial cannot be staged as root.
it.skipIf(process.getuid?.() === 0)(
'reports an unreadable bundle as a permission problem, not an invalid manifest (#869)',
async () => {
const options = await fixture()
await chmod(options.bundleDir, 0o000)
try {
const failure = prepareTerminalRuntime(options)
await expect(failure).rejects.toThrow(/cannot be read \(permission denied\)/)
await expect(failure).rejects.toThrow(options.bundleDir)
await expect(failure).rejects.not.toThrow(/invalid/i)
} finally {
await chmod(options.bundleDir, 0o755)
}
},
)
},
)
11 changes: 10 additions & 1 deletion apps/desktop/src/main/terminal-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,16 @@ async function prepare(
await fs.readFile(path.join(options.bundleDir, 'manifest.json'), 'utf8'),
)
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null
const code = (error as NodeJS.ErrnoException).code
if (code === 'ENOENT') return null
// A package that installs the bundle without read access for this user
// (#869) is not a broken manifest; say what is wrong and how to fix it.
if (code === 'EACCES' || code === 'EPERM') {
throw new Error(
`The bundled terminal at ${options.bundleDir} cannot be read (permission denied). Make that folder readable (chmod 755) or reinstall ZenNotes.`,
{ cause: error },
)
}
throw new Error('The bundled terminal manifest is invalid.', {
cause: error,
})
Expand Down
58 changes: 58 additions & 0 deletions apps/desktop/src/main/vault-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,3 +255,61 @@ describe('config persistence', () => {
expect(raw).toBe('corrupt')
})
})

describe('remote workspace profiles from the live connection (#870)', () => {
const SERVER = 'https://notes.example.com'
const writeRaw = async (value: unknown): Promise<void> => {
await writeFile(configFile(), JSON.stringify(value), 'utf8')
}

it('gives a config from before saved profiles one profile for its connection', async () => {
await writeRaw({ workspaceMode: 'remote', remoteWorkspace: { baseUrl: SERVER, authToken: 'legacy-token' } })

const cfg = await loadConfig()
expect(cfg.remoteWorkspaceProfiles).toHaveLength(1)
expect(cfg.remoteWorkspaceProfiles[0]).toMatchObject({
name: 'ZenNotes Server',
baseUrl: SERVER,
authToken: 'legacy-token',
vaultPath: null
})
})

it('does not make up a profile for the live connection of a current config', async () => {
await writeRaw({
workspaceMode: 'remote',
remoteWorkspace: { baseUrl: SERVER },
remoteWorkspaceProfileId: null,
remoteWorkspaceProfiles: []
})

const cfg = await loadConfig()
expect(cfg.remoteWorkspace).toEqual({ baseUrl: SERVER, authToken: null })
expect(cfg.remoteWorkspaceProfiles).toEqual([])
})

it('leaves one saved profile after Quick Connect writes the connection and then the profile', async () => {
await writeRaw({ workspaceMode: 'local', remoteWorkspace: null, remoteWorkspaceProfileId: null, remoteWorkspaceProfiles: [] })

// The order Quick Connect writes in: setRemoteWorkspace persists the live
// connection, then saveRemoteWorkspaceProfile adds the profile.
await updateConfig((cfg) => ({
...cfg,
workspaceMode: 'remote',
remoteWorkspace: { baseUrl: SERVER },
remoteWorkspaceProfileId: null
}))
await updateConfig((cfg) => ({
...cfg,
remoteWorkspaceProfiles: [
...cfg.remoteWorkspaceProfiles,
{ id: 'quick-connect', name: 'workspace (notes.example.com)', baseUrl: SERVER, vaultPath: '/workspace', lastConnectedAt: null }
],
remoteWorkspaceProfileId: 'quick-connect'
}))

const cfg = await loadConfig()
expect(cfg.remoteWorkspaceProfiles.map((p) => p.name)).toEqual(['workspace (notes.example.com)'])
expect(cfg.remoteWorkspaceProfileId).toBe('quick-connect')
})
})
14 changes: 13 additions & 1 deletion apps/desktop/src/main/vault.ts
Original file line number Diff line number Diff line change
Expand Up @@ -418,7 +418,19 @@ function normalizePersistedConfig(value: unknown): PersistedConfig {
.map((entry) => normalizeProfile(entry))
.filter((entry): entry is PersistedRemoteWorkspaceProfile => !!entry)
: []
if (legacyRemoteWorkspace && !remoteWorkspaceProfiles.some((entry) => entry.baseUrl === legacyRemoteWorkspace.baseUrl)) {
// Only a config from before saved remote workspaces existed (no
// `remoteWorkspaceProfiles` key at all) gets a profile made up for its
// connection. In a current config `remoteWorkspace` is just the live
// connection, which Quick Connect writes a moment before it saves the real
// profile, so making one up here turned one Quick Connect into two saved
// workspaces: the real one and a tokenless "ZenNotes Server" that took a new
// random id on every load until some other write kept it (#870).
const isPreProfileConfig = !Array.isArray(candidate.remoteWorkspaceProfiles)
if (
isPreProfileConfig &&
legacyRemoteWorkspace &&
!remoteWorkspaceProfiles.some((entry) => entry.baseUrl === legacyRemoteWorkspace.baseUrl)
) {
remoteWorkspaceProfiles.unshift({
id: randomUUID(),
name: 'ZenNotes Server',
Expand Down
2 changes: 1 addition & 1 deletion apps/share-viewer/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@zennotes/share-viewer",
"private": true,
"version": "2.57.0",
"version": "2.58.0",
"type": "module",
"description": "Read-only renderer for publicly shared ZenNotes, embedded by the zennotes.org website",
"homepage": "https://zennotes.org",
Expand Down
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@zennotes/web",
"private": true,
"version": "2.57.0",
"version": "2.58.0",
"type": "module",
"description": "ZenNotes web client for self-hosted and hosted deployments",
"homepage": "https://zennotes.org",
Expand Down
18 changes: 9 additions & 9 deletions package-lock.json

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

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "zennotes-monorepo",
"private": true,
"version": "2.57.0",
"version": "2.58.0",
"description": "ZenNotes monorepo for desktop, web, and self-hosted server builds",
"packageManager": "npm@10.9.2",
"engines": {
Expand Down
2 changes: 1 addition & 1 deletion packages/app-core/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@zennotes/app-core",
"private": true,
"version": "2.57.0",
"version": "2.58.0",
"type": "module",
"exports": {
"./main": "./src/main.tsx",
Expand Down
Loading
Loading