From e83b0f6c2350c2e122fd69ebaf326513ea331f62 Mon Sep 17 00:00:00 2001 From: "trop[bot]" <37223003+trop[bot]@users.noreply.github.com> Date: Wed, 6 Aug 2025 19:35:15 +0200 Subject: [PATCH] feat: add `app.getRecentDocuments()` (#47923) feat: add app.getRecentDocuments() Co-authored-by: trop[bot] <37223003+trop[bot]@users.noreply.github.com> Co-authored-by: Shelley Vohr --- docs/api/app.md | 16 ++++++++ docs/tutorial/recent-documents.md | 6 +++ shell/browser/api/electron_api_app.cc | 2 + shell/browser/browser.h | 3 ++ shell/browser/browser_linux.cc | 4 ++ shell/browser/browser_mac.mm | 24 +++++++++--- shell/browser/browser_win.cc | 53 +++++++++++++++++++++++++-- spec/api-app-spec.ts | 45 ++++++++++++++++++++++- 8 files changed, 142 insertions(+), 11 deletions(-) diff --git a/docs/api/app.md b/docs/api/app.md index 119c867abef9..0ff66e518e1f 100644 --- a/docs/api/app.md +++ b/docs/api/app.md @@ -775,6 +775,22 @@ bar, and on macOS, you can visit it from dock menu. Clears the recent documents list. +### `app.getRecentDocuments()` _macOS_ _Windows_ + +Returns `string[]` - An array containing documents in the most recent documents list. + +```js +const { app } = require('electron') + +const path = require('node:path') + +const file = path.join(app.getPath('desktop'), 'foo.txt') +app.addRecentDocument(file) + +const recents = app.getRecentDocuments() +console.log(recents) // ['/path/to/desktop/foo.txt'} +``` + ### `app.setAsDefaultProtocolClient(protocol[, path, args])` * `protocol` string - The name of your protocol, without `://`. For example, diff --git a/docs/tutorial/recent-documents.md b/docs/tutorial/recent-documents.md index 8bbe27aad952..072d8a12e170 100644 --- a/docs/tutorial/recent-documents.md +++ b/docs/tutorial/recent-documents.md @@ -77,6 +77,11 @@ To clear the list of recent documents, use the In this guide, the list of documents is cleared once all windows have been closed. +#### Accessing the list of recent documents + +To access the list of recent documents, use the +[app.getRecentDocuments][getrecentdocuments] API. + ## Additional information ### Windows Notes @@ -138,5 +143,6 @@ of `app` module will be emitted for it. [dock-menu-image]: https://cloud.githubusercontent.com/assets/639601/5069610/2aa80758-6e97-11e4-8cfb-c1a414a10774.png [addrecentdocument]: ../api/app.md#appaddrecentdocumentpath-macos-windows [clearrecentdocuments]: ../api/app.md#appclearrecentdocuments-macos-windows +[getrecentdocuments]: ../api/app.md#appgetrecentdocuments-macos-windows [app-registration]: https://learn.microsoft.com/en-us/windows/win32/shell/app-registration [menu-item-image]: https://user-images.githubusercontent.com/3168941/33003655-ea601c3a-cd70-11e7-97fa-7c062149cfb1.png diff --git a/shell/browser/api/electron_api_app.cc b/shell/browser/api/electron_api_app.cc index 7ff15cacc5b9..f2cb65aeeb4e 100644 --- a/shell/browser/api/electron_api_app.cc +++ b/shell/browser/api/electron_api_app.cc @@ -1719,6 +1719,8 @@ gin::ObjectTemplateBuilder App::GetObjectTemplateBuilder(v8::Isolate* isolate) { base::BindRepeating(&Browser::AddRecentDocument, browser)) .SetMethod("clearRecentDocuments", base::BindRepeating(&Browser::ClearRecentDocuments, browser)) + .SetMethod("getRecentDocuments", + base::BindRepeating(&Browser::GetRecentDocuments, browser)) #if BUILDFLAG(IS_WIN) .SetMethod("setAppUserModelId", base::BindRepeating(&Browser::SetAppUserModelID, browser)) diff --git a/shell/browser/browser.h b/shell/browser/browser.h index aa5c8207c56b..f4edd1d7d77a 100644 --- a/shell/browser/browser.h +++ b/shell/browser/browser.h @@ -125,6 +125,9 @@ class Browser : private WindowListObserver { // Clear the recent documents list. void ClearRecentDocuments(); + // Return the recent documents list. + std::vector GetRecentDocuments(); + #if BUILDFLAG(IS_WIN) // Set the application user model ID. void SetAppUserModelID(const std::wstring& name); diff --git a/shell/browser/browser_linux.cc b/shell/browser/browser_linux.cc index 55c1762480ce..03e1a9521c11 100644 --- a/shell/browser/browser_linux.cc +++ b/shell/browser/browser_linux.cc @@ -95,6 +95,10 @@ bool SetDefaultWebClient(const std::string& protocol) { void Browser::AddRecentDocument(const base::FilePath& path) {} +std::vector Browser::GetRecentDocuments() { + return std::vector(); +} + void Browser::ClearRecentDocuments() {} bool Browser::SetAsDefaultProtocolClient(const std::string& protocol, diff --git a/shell/browser/browser_mac.mm b/shell/browser/browser_mac.mm index 1a78da0a650d..c4b67ed5cc14 100644 --- a/shell/browser/browser_mac.mm +++ b/shell/browser/browser_mac.mm @@ -162,19 +162,31 @@ void Browser::Show() { } void Browser::AddRecentDocument(const base::FilePath& path) { - NSString* path_string = base::apple::FilePathToNSString(path); - if (!path_string) + NSURL* url = base::apple::FilePathToNSURL(path); + if (!url) { + LOG(WARNING) << "Failed to convert file path " << path.value() + << " to NSURL"; return; - NSURL* u = [NSURL fileURLWithPath:path_string]; - if (!u) - return; - [[NSDocumentController sharedDocumentController] noteNewRecentDocumentURL:u]; + } + + [[NSDocumentController sharedDocumentController] + noteNewRecentDocumentURL:url]; } void Browser::ClearRecentDocuments() { [[NSDocumentController sharedDocumentController] clearRecentDocuments:nil]; } +std::vector Browser::GetRecentDocuments() { + NSArray* recentURLs = + [[NSDocumentController sharedDocumentController] recentDocumentURLs]; + std::vector documents; + documents.reserve([recentURLs count]); + for (NSURL* url in recentURLs) + documents.push_back(std::string([url.path UTF8String])); + return documents; +} + bool Browser::RemoveAsDefaultProtocolClient(const std::string& protocol, gin::Arguments* args) { NSString* identifier = [base::apple::MainBundle() bundleIdentifier]; diff --git a/shell/browser/browser_win.cc b/shell/browser/browser_win.cc index fb75895404eb..3b08b32cf6ad 100644 --- a/shell/browser/browser_win.cc +++ b/shell/browser/browser_win.cc @@ -17,6 +17,7 @@ #include "base/base_paths.h" #include "base/command_line.h" #include "base/file_version_info.h" +#include "base/files/file_enumerator.h" #include "base/files/file_path.h" #include "base/logging.h" #include "base/path_service.h" @@ -314,14 +315,33 @@ void GetApplicationInfoForProtocolUsingAssocQuery( app_display_name, std::move(promise)); } +std::string ResolveShortcut(const base::FilePath& lnk_path) { + std::string target_path; + + CComPtr shell_link; + if (SUCCEEDED(CoCreateInstance(CLSID_ShellLink, nullptr, CLSCTX_INPROC_SERVER, + IID_PPV_ARGS(&shell_link)))) { + CComPtr persist_file; + if (SUCCEEDED(shell_link->QueryInterface(IID_PPV_ARGS(&persist_file)))) { + if (SUCCEEDED(persist_file->Load(lnk_path.value().c_str(), STGM_READ))) { + WCHAR resolved_path[MAX_PATH]; + if (SUCCEEDED( + shell_link->GetPath(resolved_path, MAX_PATH, nullptr, 0))) { + target_path = base::FilePath(resolved_path).MaybeAsASCII(); + } + } + } + } + + return target_path; +} + void Browser::AddRecentDocument(const base::FilePath& path) { CComPtr item; HRESULT hr = SHCreateItemFromParsingName(path.value().c_str(), nullptr, IID_PPV_ARGS(&item)); if (SUCCEEDED(hr)) { - SHARDAPPIDINFO info; - info.psi = item; - info.pszAppID = GetAppUserModelID(); + SHARDAPPIDINFO info = {item, GetAppUserModelID()}; SHAddToRecentDocs(SHARD_APPIDINFO, &info); } } @@ -330,6 +350,33 @@ void Browser::ClearRecentDocuments() { SHAddToRecentDocs(SHARD_APPIDINFO, nullptr); } +std::vector Browser::GetRecentDocuments() { + ScopedAllowBlockingForElectron allow_blocking; + std::vector docs; + + PWSTR recent_path_ptr = nullptr; + HRESULT hr = + SHGetKnownFolderPath(FOLDERID_Recent, 0, nullptr, &recent_path_ptr); + if (SUCCEEDED(hr) && recent_path_ptr) { + base::FilePath recent_folder(recent_path_ptr); + CoTaskMemFree(recent_path_ptr); + + base::FileEnumerator enumerator(recent_folder, /*recursive=*/false, + base::FileEnumerator::FILES, + FILE_PATH_LITERAL("*.lnk")); + + for (base::FilePath file = enumerator.Next(); !file.empty(); + file = enumerator.Next()) { + std::string resolved_path = ResolveShortcut(file); + if (!resolved_path.empty()) { + docs.push_back(resolved_path); + } + } + } + + return docs; +} + void Browser::SetAppUserModelID(const std::wstring& name) { electron::SetAppUserModelID(name); } diff --git a/spec/api-app-spec.ts b/spec/api-app-spec.ts index 1aa4daae04e0..e6f21f34850a 100644 --- a/spec/api-app-spec.ts +++ b/spec/api-app-spec.ts @@ -11,6 +11,7 @@ import * as http from 'node:http'; import * as https from 'node:https'; import * as net from 'node:net'; import * as path from 'node:path'; +import { setTimeout } from 'node:timers/promises'; import { promisify } from 'node:util'; import { collectStreamBody, getResponse } from './lib/net-helpers'; @@ -19,6 +20,8 @@ import { closeWindow, closeAllWindows } from './lib/window-helpers'; const fixturesPath = path.resolve(__dirname, 'fixtures'); +const isMacOSx64 = process.platform === 'darwin' && process.arch === 'x64'; + describe('electron module', () => { it('does not expose internal modules to require', () => { expect(() => { @@ -356,6 +359,44 @@ describe('app module', () => { }); }); + // GitHub Actions macOS-13 runners used for x64 seem to have a problem with this test. + ifdescribe(process.platform !== 'linux' && !isMacOSx64)('app.{add|get|clear}RecentDocument(s)', () => { + const tempFiles = [ + path.join(fixturesPath, 'foo.txt'), + path.join(fixturesPath, 'bar.txt'), + path.join(fixturesPath, 'baz.txt') + ]; + + afterEach(() => { + app.clearRecentDocuments(); + for (const file of tempFiles) { + fs.unlinkSync(file); + } + }); + + beforeEach(() => { + for (const file of tempFiles) { + fs.writeFileSync(file, 'Lorem Ipsum'); + } + }); + + it('can add a recent document', async () => { + app.addRecentDocument(tempFiles[0]); + await setTimeout(2000); + expect(app.getRecentDocuments()).to.include.members([tempFiles[0]]); + }); + + it('can clear recent documents', async () => { + app.addRecentDocument(tempFiles[1]); + app.addRecentDocument(tempFiles[2]); + await setTimeout(2000); + expect(app.getRecentDocuments()).to.include.members([tempFiles[1], tempFiles[2]]); + app.clearRecentDocuments(); + await setTimeout(2000); + expect(app.getRecentDocuments()).to.deep.equal([]); + }); + }); + describe('app.relaunch', () => { let server: net.Server | null = null; const socketPath = process.platform === 'win32' ? '\\\\.\\pipe\\electron-app-relaunch' : '/tmp/electron-app-relaunch'; @@ -553,8 +594,8 @@ describe('app module', () => { describe('app.badgeCount', () => { const platformIsNotSupported = - (process.platform === 'win32') || - (process.platform === 'linux' && !app.isUnityRunning()); + (process.platform === 'win32') || + (process.platform === 'linux' && !app.isUnityRunning()); const expectedBadgeCount = 42;