From af85ca95e9d239410f201b4cee698cc7c88fca75 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Tue, 10 Oct 2023 15:30:56 +0800 Subject: [PATCH] [ENG-927, ENG-735, ENG-766] Fix Updater & Tauri 1.5 (#1361) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * custom updater with toasts * new state management + updated router route * tauri-specific update route * ref * update in prod only * change 'Install' to 'Update' * fix tsconfig * desktop tauri * remove tauri patch * tauri 1.5 * tauri 1.5 * use tauri script * native-deps * Rework preprep and tauri script to better support tauri 1.5 * Update to tauri 1.5.1 - Update workspace and apps/desktop dependencies - Fix mustache import, @types/mustache is not compatible with ES imports - Replace arm64 with aarch64 in machineID, they should be treated the same and this simplyfies the code * Fix tauri updater not building due to missing key - Fix dmg background not being found - Generate an adhoc key for tauri updater with it is enabled and the user is doing a prod build * Fix ctrl+c/ctrl+v typo * Normalie @tanstack/react-query version through workspace - Use undici in scripts instead of global fetch - Fix typecheck * Fix linux prod and dev builds - Improve error handling in tauri.mjs * Normalize dev deps in workspace - Improve linux shared libs setup * Fix CI and server docker * Fix windows - Remove superfluous envvar * Attempt to fix server, mobile, deb and release updater * Attempt to fix deb and mobile again - Fix type on deb dependency - Enable release deb for aarch64-unknown-linux-gnu * Github doesn't have arm runners - Fix typo in server Dockerfile * Publish deb and updater artifacts * remove version from asset name * update commands * log release * Some logs on updater errors * show updater errors on frontend * fix desktop ui caching --------- Co-authored-by: VĂ­tor Vasconcellos Co-authored-by: Ericson Fogo Soares --- .cargo/config.toml.mustache | 26 +- .github/actions/publish-artifacts/action.yaml | 36 +-- .github/actions/setup-system/action.yml | 4 +- .github/workflows/release.yml | 6 +- .github/workflows/server.yml | 1 - .gitignore | 2 +- Cargo.lock | Bin 236895 -> 237222 bytes apps/desktop/package.json | 38 ++- apps/desktop/src-tauri/Cargo.toml | 8 +- apps/desktop/src-tauri/scripts/spawn.js | 30 -- apps/desktop/src-tauri/scripts/tauri.js | 193 ------------ apps/desktop/src-tauri/src/main.rs | 9 +- apps/desktop/src-tauri/src/updater.rs | 95 ++++++ apps/desktop/src-tauri/tauri.conf.json | 22 +- apps/desktop/src/App.tsx | 8 +- apps/desktop/src/commands.ts | 9 + apps/desktop/src/index.html | 23 +- apps/desktop/src/updater.tsx | 87 ++++++ apps/mobile/modules/sd-core/ios/build-rust.sh | 47 +-- apps/mobile/package.json | 10 +- apps/server/docker/Dockerfile | 5 +- apps/storybook/package.json | 12 +- apps/web/package.json | 20 +- crates/sync/example/web/package.json | 8 +- .../app/$libraryId/Layout/Sidebar/Footer.tsx | 17 ++ interface/app/index.tsx | 6 +- interface/package.json | 34 +-- interface/tsconfig.json | 15 +- interface/util/Platform.tsx | 14 + package.json | 48 +-- packages/client/package.json | 9 +- packages/client/tsconfig.json | 3 +- packages/config/package.json | 14 +- packages/ui/package.json | 17 +- pnpm-lock.yaml | Bin 895374 -> 924072 bytes pnpm-workspace.yaml | 1 + scripts/.eslintrc.cjs | 70 +++++ scripts/.gitignore | 2 + scripts/deps.mjs | 197 ------------- scripts/git.mjs | 86 ------ scripts/machineId.mjs | 60 ---- scripts/package.json | 42 +++ scripts/preprep.mjs | 275 +++++++----------- scripts/setup.sh | 8 +- scripts/tauri.mjs | 139 +++++++++ scripts/tsconfig.json | 33 +++ scripts/{suffix.mjs => utils/consts.mjs} | 73 ++--- scripts/utils/deps.mjs | 198 +++++++++++++ scripts/utils/git.mjs | 87 ++++++ scripts/{ => utils}/github.mjs | 215 +++++++------- scripts/utils/machineId.mjs | 68 +++++ scripts/utils/patchTauri.mjs | 142 +++++++++ scripts/utils/shared.mjs | 200 +++++++++++++ scripts/utils/spawn.mjs | 33 +++ scripts/utils/which.mjs | 41 +++ scripts/which.mjs | 41 --- turbo.json | 2 +- 57 files changed, 1734 insertions(+), 1155 deletions(-) delete mode 100644 apps/desktop/src-tauri/scripts/spawn.js delete mode 100644 apps/desktop/src-tauri/scripts/tauri.js create mode 100644 apps/desktop/src-tauri/src/updater.rs create mode 100644 apps/desktop/src/updater.tsx create mode 100644 scripts/.eslintrc.cjs create mode 100644 scripts/.gitignore delete mode 100644 scripts/deps.mjs delete mode 100644 scripts/git.mjs delete mode 100644 scripts/machineId.mjs create mode 100644 scripts/package.json create mode 100644 scripts/tauri.mjs create mode 100644 scripts/tsconfig.json rename scripts/{suffix.mjs => utils/consts.mjs} (58%) create mode 100644 scripts/utils/deps.mjs create mode 100644 scripts/utils/git.mjs rename scripts/{ => utils}/github.mjs (67%) create mode 100644 scripts/utils/machineId.mjs create mode 100644 scripts/utils/patchTauri.mjs create mode 100644 scripts/utils/shared.mjs create mode 100644 scripts/utils/spawn.mjs create mode 100644 scripts/utils/which.mjs delete mode 100644 scripts/which.mjs diff --git a/.cargo/config.toml.mustache b/.cargo/config.toml.mustache index 0ff5b5d11..42bb1b1b8 100644 --- a/.cargo/config.toml.mustache +++ b/.cargo/config.toml.mustache @@ -2,48 +2,48 @@ {{#protoc}} PROTOC = "{{{protoc}}}" {{/protoc}} -{{#ffmpeg}} -FFMPEG_DIR = "{{{ffmpeg}}}" -{{/ffmpeg}} +{{^isLinux}} +FFMPEG_DIR = "{{{nativeDeps}}}" +{{/isLinux}} {{#isMacOS}} [target.x86_64-apple-darwin] -rustflags = ["-L", "{{{projectRoot}}}/target/Frameworks/lib"] +rustflags = ["-L", "{{{nativeDeps}}}/lib"] [target.x86_64-apple-darwin.heif] -rustc-link-search = ["{{{projectRoot}}}/target/Frameworks/lib"] +rustc-link-search = ["{{{nativeDeps}}}/lib"] rustc-link-lib = ["heif"] [target.aarch64-apple-darwin] -rustflags = ["-L", "{{{projectRoot}}}/target/Frameworks/lib"] +rustflags = ["-L", "{{{nativeDeps}}}/lib"] [target.aarch64-apple-darwin.heif] -rustc-link-search = ["{{{projectRoot}}}/target/Frameworks/lib"] +rustc-link-search = ["{{{nativeDeps}}}/lib"] rustc-link-lib = ["heif"] {{/isMacOS}} {{#isWin}} [target.x86_64-pc-windows-msvc] -rustflags = ["-L", "{{{projectRoot}}}\\target\\Frameworks\\lib"] +rustflags = ["-L", "{{{nativeDeps}}}\\lib"] [target.x86_64-pc-windows-msvc.heif] -rustc-link-search = ["{{{projectRoot}}}\\target\\Frameworks\\lib"] +rustc-link-search = ["{{{nativeDeps}}}\\lib"] rustc-link-lib = ["heif"] {{/isWin}} {{#isLinux}} [target.x86_64-unknown-linux-gnu] -rustflags = ["-L", "{{{projectRoot}}}/target/Frameworks/lib", "-C", "link-arg=-Wl,-rpath=${ORIGIN}/../lib/spacedrive"] +rustflags = ["-L", "{{{nativeDeps}}}/lib", "-C", "link-arg=-Wl,-rpath=${ORIGIN}/../lib/spacedrive"] [target.x86_64-unknown-linux-gnu.heif] -rustc-link-search = ["{{{projectRoot}}}/target/Frameworks/lib"] +rustc-link-search = ["{{{nativeDeps}}}/lib"] rustc-link-lib = ["heif"] [target.aarch64-unknown-linux-gnu] -rustflags = ["-L", "{{{projectRoot}}}/target/Frameworks/lib", "-C", "link-arg=-Wl,-rpath=${ORIGIN}/../lib/spacedrive"] +rustflags = ["-L", "{{{nativeDeps}}}/lib", "-C", "link-arg=-Wl,-rpath=${ORIGIN}/../lib/spacedrive"] [target.aarch64-unknown-linux-gnu.heif] -rustc-link-search = ["{{{projectRoot}}}/target/Frameworks/lib"] +rustc-link-search = ["{{{nativeDeps}}}/lib"] rustc-link-lib = ["heif"] {{/isLinux}} diff --git a/.github/actions/publish-artifacts/action.yaml b/.github/actions/publish-artifacts/action.yaml index e6443a6e0..76a0ffeb2 100644 --- a/.github/actions/publish-artifacts/action.yaml +++ b/.github/actions/publish-artifacts/action.yaml @@ -23,14 +23,14 @@ runs: if-no-files-found: error retention-days: 1 - # - name: Publish artifacts (Debian - deb) - # if: ${{ matrix.settings.host == 'ubuntu-20.04' }} - # uses: actions/upload-artifact@v3 - # with: - # name: Spacedrive-deb-${{ inputs.target }}-${{ env.GITHUB_SHA_SHORT }} - # path: target/${{ inputs.target }}/${{ inputs.profile }}/bundle/deb/*.deb - # if-no-files-found: error - # retention-days: 1 + - name: Publish artifacts (Debian - deb) + if: ${{ matrix.settings.host == 'ubuntu-20.04' }} + uses: actions/upload-artifact@v3 + with: + name: Spacedrive-deb-${{ inputs.target }}-${{ env.GITHUB_SHA_SHORT }} + path: target/${{ inputs.target }}/${{ inputs.profile }}/bundle/deb/*.deb + if-no-files-found: error + retention-days: 1 - name: Publish artifacts (Windows - msi) if: ${{ matrix.settings.host == 'windows-latest' }} @@ -50,13 +50,13 @@ runs: if-no-files-found: error retention-days: 1 - # - name: Publish updater binaries - # uses: actions/upload-artifact@v3 - # with: - # name: Spacedrive-Updaters-${{ inputs.target }}-${{ env.GITHUB_SHA_SHORT }} - # path: | - # target/${{ inputs.target }}/${{ inputs.profile }}/bundle/**/*.tar.gz* - # target/${{ inputs.target }}/${{ inputs.profile }}/bundle/**/*.zip* - # !target/**/deb/**/*.tar.gz - # if-no-files-found: error - # retention-days: 1 + - name: Publish updater binaries + uses: actions/upload-artifact@v3 + with: + name: Spacedrive-Updater-${{ inputs.target }}-${{ env.GITHUB_SHA_SHORT }} + path: | + target/${{ inputs.target }}/${{ inputs.profile }}/bundle/**/*.tar.gz* + target/${{ inputs.target }}/${{ inputs.profile }}/bundle/**/*.zip* + !target/**/deb/**/*.tar.gz + if-no-files-found: error + retention-days: 1 diff --git a/.github/actions/setup-system/action.yml b/.github/actions/setup-system/action.yml index 806762a92..678fe3b12 100644 --- a/.github/actions/setup-system/action.yml +++ b/.github/actions/setup-system/action.yml @@ -64,7 +64,7 @@ runs: TARGET_TRIPLE: ${{ inputs.target }} GITHUB_TOKEN: ${{ inputs.token }} run: | - pushd .. - npm i archive-wasm mustache + pushd scripts + npm i --production popd node scripts/preprep.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f81a9415a..0f7670a89 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,11 +24,12 @@ jobs: # target: aarch64-pc-windows-msvc - host: ubuntu-20.04 target: x86_64-unknown-linux-gnu - bundles: appimage + bundles: appimage,deb # - host: ubuntu-20.04 # target: x86_64-unknown-linux-musl # - host: ubuntu-20.04 # target: aarch64-unknown-linux-gnu + # bundles: deb # no appimage for now unfortunetly # - host: ubuntu-20.04 # target: aarch64-unknown-linux-musl # - host: ubuntu-20.04 @@ -95,7 +96,7 @@ jobs: - name: Build run: | - pnpm tauri build --ci -v --target ${{ matrix.settings.target }} --bundles ${{ matrix.settings.bundles }} + pnpm tauri build --ci -v --target ${{ matrix.settings.target }} --bundles ${{ matrix.settings.bundles }},updater env: TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} @@ -107,7 +108,6 @@ jobs: APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - NODE_OPTIONS: --max-old-space-size=4096 - name: Publish Artifacts uses: ./.github/actions/publish-artifacts diff --git a/.github/workflows/server.yml b/.github/workflows/server.yml index 3172ded67..e25eb4d03 100644 --- a/.github/workflows/server.yml +++ b/.github/workflows/server.yml @@ -61,7 +61,6 @@ jobs: build-args: | REPO=${{ steps.image_info.outputs.repo }} REPO_REF=${{ steps.image_info.outputs.repo_ref }} - NODE_OPTIONS: "--max-old-space-size=4096" containerfiles: | ./apps/server/docker/Dockerfile diff --git a/.gitignore b/.gitignore index ab416e845..9641838ee 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,7 @@ packages/*/node_modules packages/*/data apps/*/data apps/*/stats.html +apps/.deps apps/releases/.vscode apps/desktop/src-tauri/tauri.conf.patch.json apps/desktop/src-tauri/*.dll @@ -78,7 +79,6 @@ dev.db-journal sd_init.json spacedrive -scripts/.tmp .cargo/config .cargo/config.toml .github/scripts/deps diff --git a/Cargo.lock b/Cargo.lock index cbe9c1191b36b777ceff1691808e5a1f260f34a4..3f85c7d709ad9b9f4717ab926bf24f01681c6465 100644 GIT binary patch delta 607 zcmYk3J8M)?6oonGs>vi0m0+fjh?ydUVB*>5eX0}^Y@9(ten8Gslu3k)7Qx0Bij|Kk zb|VrH#YPYjE(jL(`2%5yL9Hz;?Yxl2W|!;x*4l?(rrv#>`gkz^aC+g%^a81;7wGGG zyiUjz>Z&?}-+EL`_`&AV;P!U*oAvJ=eYr^4^6>t%89aDETlMY^eZMxg)V~#dKjZEn z>Q{>O03o?3ge@s}=8CZve2}0NIKh3AJWHmEhQuIS$#n498;H#KJe>PHbL!?`X|OZs zT%5Sj>E8$upPwgf+~`&`df^;5& z4lzq7l;b=r!GlSb0~Dd1bzVB=jkApb&Q%TL-di$>>l@@OZguI2`tLsN5Zs-hlht-f zNDk>DUK!EVR%3Tmx_&&O>&LNoj5G$O*z6ieW-PlRfdh{K%1D>3Nh&7pUCtp!U!ph0 zgENg(&M_&SB!ZxAymXh2;nN@V@F>%Qgk*TrLaV|VvkstP6cwEH+9t?Cf(T8P)H2&* zRSrTM5dt`G9nRe$?fUdD`e^gNN8IJDaeQ@-wD5Abn#EC%c81-pc0IgS?Opu`uUNVE delta 473 zcmX|-y=oLu7(ki37|bF=3dvdo3{y-I74DsH?q8Gj0bE1&0fg`Vd@97Ogwcp#v8bfd zX2EM0D-o-t8W8jaw6T<3S%QU)t(}E~g)eY8=e+%xFF)ib-^=q&Gr7|=_kEgf^xRO8 zC(fN$L85b&b&{-(;FUnjoeJO)MMXeJ#_CWbL{MCNE;)k4*)*)DwN-X8-<#YnuJ$*J zeQfSNjA^sUiZndTR{9SP_Ts*ugc#CjqgOm#lt+s)YZoOuE@vxO^7MMdWcswr^7hw= zJ=uEom|be8kJ#zB-TA_n7t+g}Y&;Bglbvk{<2D3N6`>XiB|JDH;w12*_B4ZTNNkj8V9nC@Qag04|AY=%(`s9KG|BP;^vVVeO;ll)k>p*4t}8*z { - if (typeof command !== 'string' || command.length === 0) - throw new Error('Command must be a string and not empty'); - - if (args == null) args = []; - else if (!Array.isArray(args) || args.some((arg) => typeof arg !== 'string')) - throw new Error('Args must be an array of strings'); - - return new Promise((resolve, reject) => { - const child = spawn(command, args, { shell: true, stdio: 'inherit' }); - process.on('SIGTERM', () => child.kill('SIGTERM')); - process.on('SIGINT', () => child.kill('SIGINT')); - process.on('SIGBREAK', () => child.kill('SIGBREAK')); - process.on('SIGHUP', () => child.kill('SIGHUP')); - child.on('error', (error) => { - console.error(error); - reject(1); - }); - child.on('exit', (code, signal) => { - if (code === null) code = signal === 'SIGINT' ? 0 : 1; - if (code === 0) { - resolve(); - } else { - reject(code); - } - }); - }); -}; diff --git a/apps/desktop/src-tauri/scripts/tauri.js b/apps/desktop/src-tauri/scripts/tauri.js deleted file mode 100644 index 8ead83e76..000000000 --- a/apps/desktop/src-tauri/scripts/tauri.js +++ /dev/null @@ -1,193 +0,0 @@ -const fs = require('node:fs'); -const path = require('node:path'); - -const toml = require('@iarna/toml'); -const semver = require('semver'); - -const { spawn } = require('./spawn.js'); - -const workspace = path.resolve(__dirname, '../../../../'); -const cargoConfig = toml.parse( - fs.readFileSync(path.resolve(workspace, '.cargo/config.toml'), { encoding: 'binary' }) -); -if (cargoConfig.env && typeof cargoConfig.env === 'object') - for (const [name, value] of Object.entries(cargoConfig.env)) - if (!process.env[name]) process.env[name] = value; - -const toRemove = []; -const [_, __, ...args] = process.argv; - -if (args.length === 0) args.push('build'); - -const tauriConf = JSON.parse( - fs.readFileSync(path.resolve(__dirname, '..', 'tauri.conf.json'), 'utf-8') -); - -const framework = path.join(workspace, 'target/Frameworks'); - -switch (args[0]) { - case 'dev': { - if (process.platform === 'win32') setupSharedLibs('dll', path.join(framework, 'bin'), true); - break; - } - case 'build': { - if ( - !process.env.NODE_OPTIONS || - !process.env.NODE_OPTIONS.includes('--max_old_space_size') - ) { - process.env.NODE_OPTIONS = `--max_old_space_size=4096 ${ - process.env.NODE_OPTIONS ?? '' - }`; - } - - if (args.findIndex((e) => e === '-c' || e === '--config') !== -1) { - throw new Error('Custom tauri build config is not supported.'); - } - - const targets = args - .filter((_, index, args) => { - if (index === 0) return false; - const previous = args[index - 1]; - return previous === '-t' || previous === '--target'; - }) - .flatMap((target) => target.split(',')); - - const tauriPatch = { - tauri: { bundle: { macOS: {}, resources: [] } } - }; - - switch (process.platform) { - case 'darwin': { - // ARM64 support was added in macOS 11, but we need at least 11.2 due to our ffmpeg build - let macOSMinimumVersion = tauriConf?.tauri?.bundle?.macOS?.minimumSystemVersion; - let macOSArm64MinimumVersion = '11.2'; - if ( - (targets.includes('aarch64-apple-darwin') || - (targets.length === 0 && process.arch === 'arm64')) && - (macOSMinimumVersion == null || - semver.lt( - semver.coerce(macOSMinimumVersion), - semver.coerce(macOSArm64MinimumVersion) - )) - ) { - macOSMinimumVersion = macOSArm64MinimumVersion; - console.log( - `aarch64-apple-darwin target detected, setting minimum system version to ${macOSMinimumVersion}` - ); - } - - if (macOSMinimumVersion) { - process.env.MACOSX_DEPLOYMENT_TARGET = macOSMinimumVersion; - tauriPatch.tauri.bundle.macOS.minimumSystemVersion = macOSMinimumVersion; - } - - // Point tauri to our ffmpeg framework - tauriPatch.tauri.bundle.macOS.frameworks = [ - path.join(workspace, 'target/Frameworks/FFMpeg.framework') - ]; - - // Configure DMG background - process.env.BACKGROUND_FILE = path.resolve(__dirname, '..', 'dmg-background.png'); - process.env.BACKGROUND_FILE_NAME = path.basename(process.env.BACKGROUND_FILE); - process.env.BACKGROUND_CLAUSE = `set background picture of opts to file ".background:${process.env.BACKGROUND_FILE_NAME}"`; - - if (!fs.existsSync(process.env.BACKGROUND_FILE)) - console.warn( - `WARNING: DMG background file not found at ${process.env.BACKGROUND_FILE}` - ); - - break; - } - case 'linux': - fs.rmSync(path.join(workspace, 'target/release/bundle/appimage'), { - recursive: true, - force: true - }); - // Point tauri to the ffmpeg DLLs - tauriPatch.tauri.bundle.resources.push( - ...setupSharedLibs('so', path.join(framework, 'lib')) - ); - break; - case 'win32': - // Point tauri to the ffmpeg DLLs - tauriPatch.tauri.bundle.resources.push( - ...setupSharedLibs('dll', path.join(framework, 'bin')) - ); - break; - } - - toRemove.push( - ...tauriPatch.tauri.bundle.resources.map((file) => - path.join(workspace, 'apps/desktop/src-tauri', file) - ) - ); - - const tauriPatchConf = path.resolve(__dirname, '..', 'tauri.conf.patch.json'); - fs.writeFileSync(tauriPatchConf, JSON.stringify(tauriPatch, null, 2)); - - toRemove.push(tauriPatchConf); - args.splice(1, 0, '-c', tauriPatchConf); - } -} - -process.on('SIGINT', () => { - for (const file of toRemove) - try { - fs.unlinkSync(file); - } catch (e) {} -}); - -let code = 0; -spawn('pnpm', ['exec', 'tauri', ...args]) - .catch((exitCode) => { - if (args[0] === 'build' || process.platform === 'linux') { - // Work around appimage buindling not working sometimes - appimageDir = path.join(workspace, 'target/release/bundle/appimage'); - appDir = path.join(appimageDir, 'spacedrive.AppDir'); - if ( - fs.existsSync(path.join(appimageDir, 'build_appimage.sh')) && - fs.existsSync(appDir) && - !fs.readdirSync(appimageDir).filter((file) => file.endsWith('.AppImage')).length - ) { - process.chdir(appimageDir); - fs.rmSync(appDir, { recursive: true, force: true }); - return spawn('bash', ['build_appimage.sh']).catch((exitCode) => { - code = exitCode; - console.error(`tauri ${args[0]} failed with exit code ${exitCode}`); - }); - } - } - - code = exitCode; - console.error(`tauri ${args[0]} failed with exit code ${exitCode}`); - console.error( - `If you got an error related to FFMpeg or Protoc/Protobuf you may need to re-run \`pnpm i\`` - ); - }) - .finally(() => { - for (const file of toRemove) - try { - fs.unlinkSync(file); - } catch (e) {} - - process.exit(code); - }); - -function setupSharedLibs(sufix, binDir, dev = false) { - const sharedLibs = fs - .readdirSync(binDir) - .filter((file) => file.endsWith(`.${sufix}`) || file.includes(`.${sufix}.`)); - - let targetDir = path.join(workspace, 'apps/desktop/src-tauri'); - if (dev) { - targetDir = path.join(workspace, 'target/debug'); - // Ensure the target/debug directory exists - fs.mkdirSync(targetDir, { recursive: true }); - } - - // Copy all shared libs to targetDir - for (const dll of sharedLibs) - fs.copyFileSync(path.join(binDir, dll), path.join(targetDir, dll)); - - return sharedLibs; -} diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index df11de836..d4ccbbb42 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -18,6 +18,7 @@ mod theme; mod file; mod menu; +mod updater; #[tauri::command(async)] #[specta::specta] @@ -133,9 +134,6 @@ async fn main() -> tauri::Result<()> { let app = app .setup(|app| { - #[cfg(feature = "updater")] - tauri::updater::builder(app.handle()).should_install(|_current, _latest| true); - let app = app.handle(); app.windows().iter().for_each(|(_, window)| { @@ -178,6 +176,7 @@ async fn main() -> tauri::Result<()> { }) .on_menu_event(menu::handle_menu_event) .menu(menu::get_menu()) + .manage(updater::State::default()) .invoke_handler(tauri_handlers![ app_ready, reset_spacedrive, @@ -189,7 +188,9 @@ async fn main() -> tauri::Result<()> { file::open_file_path_with, file::open_ephemeral_file_with, file::reveal_items, - theme::lock_app_theme + theme::lock_app_theme, + updater::check_for_update, + updater::install_update ]) .build(tauri::generate_context!())?; diff --git a/apps/desktop/src-tauri/src/updater.rs b/apps/desktop/src-tauri/src/updater.rs new file mode 100644 index 000000000..720cfeb06 --- /dev/null +++ b/apps/desktop/src-tauri/src/updater.rs @@ -0,0 +1,95 @@ +use tauri::Manager; +use tokio::sync::Mutex; +use tracing::{error, warn}; + +#[derive(Debug, Clone, specta::Type, serde::Serialize)] +pub struct Update { + pub version: String, + pub body: Option, +} + +impl Update { + fn new(update: &tauri::updater::UpdateResponse) -> Self { + Self { + version: update.latest_version().to_string(), + body: update.body().map(|b| b.to_string()), + } + } +} + +#[derive(Default)] +pub struct State { + install_lock: Mutex<()>, +} + +async fn get_update( + app: tauri::AppHandle, +) -> Result, String> { + tauri::updater::builder(app) + .header("X-Spacedrive-Version", "stable") + .map_err(|e| e.to_string())? + .check() + .await + .map_err(|e| e.to_string()) +} + +#[derive(Clone, serde::Serialize, specta::Type)] +#[serde(rename_all = "camelCase", tag = "status")] +pub enum UpdateEvent { + Loading, + Error(String), + UpdateAvailable { update: Update }, + NoUpdateAvailable, + Installing, +} + +#[tauri::command] +#[specta::specta] +pub async fn check_for_update(app: tauri::AppHandle) -> Result, String> { + app.emit_all("updater", UpdateEvent::Loading).ok(); + + let update = match get_update(app.clone()).await { + Ok(update) => update, + Err(e) => { + app.emit_all("updater", UpdateEvent::Error(e.clone())).ok(); + return Err(e); + } + }; + + let update = update.is_update_available().then(|| Update::new(&update)); + + app.emit_all( + "updater", + update + .clone() + .map(|update| UpdateEvent::UpdateAvailable { update }) + .unwrap_or(UpdateEvent::NoUpdateAvailable), + ) + .ok(); + + Ok(update) +} + +#[tauri::command] +#[specta::specta] +pub async fn install_update( + app: tauri::AppHandle, + state: tauri::State<'_, State>, +) -> Result<(), String> { + let lock = match state.install_lock.try_lock() { + Ok(lock) => lock, + Err(_) => return Err("Update already installing".into()), + }; + + app.emit_all("updater", UpdateEvent::Installing).ok(); + + get_update(app.clone()) + .await? + .download_and_install() + .await + .map_err(|e| e.to_string())?; + + drop(lock); + + Ok(()) +} diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 52ae00ae0..fb792d00e 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -1,13 +1,12 @@ { "package": { - "productName": "Spacedrive", - "version": "0.1.0" + "productName": "Spacedrive" }, "build": { "distDir": "../dist", "devPath": "http://localhost:8001", "beforeDevCommand": "pnpm dev", - "beforeBuildCommand": "pnpm turbo run build --filter @sd/desktop" + "beforeBuildCommand": "pnpm turbo run build --filter=@sd/desktop..." }, "tauri": { "macOSPrivateApi": true, @@ -31,10 +30,16 @@ "shortDescription": "The universal file manager.", "longDescription": "A cross-platform universal file explorer, powered by an open-source virtual distributed filesystem.", "deb": { - "depends": [] + "depends": [ + "ffmpeg", + "gstreamer1.0-plugins-bad", + "gstreamer1.0-plugins-ugly", + "gstreamer1.0-gtk3", + "gstreamer1.0-libav" + ] }, "macOS": { - "frameworks": [], + "frameworks": ["../../.deps/FFMpeg.framework"], "minimumSystemVersion": "10.15", "exceptionDomain": "", "entitlements": null @@ -50,9 +55,12 @@ } }, "updater": { - "active": false, + "active": true, + "dialog": false, "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEZBMURCMkU5NEU3NDAyOEMKUldTTUFuUk82YklkK296dlkxUGkrTXhCT3ZMNFFVOWROcXNaS0RqWU1kMUdRV2tDdFdIS0Y3YUsK", - "endpoints": ["https://spacedrive.com/api/releases/alpha/{{target}}/{{arch}}"] + "endpoints": [ + "https://spacedrive-landing-git-eng-927-fix-updater-spacedrive.vercel.app/api/releases/tauri/{{target}}/{{arch}}" + ] }, "allowlist": { "all": false, diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index b85c572fa..59025553d 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -22,6 +22,7 @@ import { getSpacedropState } from '@sd/interface/hooks/useSpacedropState'; import '@sd/ui/style'; import * as commands from './commands'; +import { updater, useUpdater } from './updater'; // TODO: Bring this back once upstream is fixed up. // const client = hooks.createClient({ @@ -57,7 +58,7 @@ if (customUriServerUrl && !customUriServerUrl?.endsWith('/')) { } const queryParams = customUriAuthToken ? `?token=${encodeURIComponent(customUriAuthToken)}` : ''; -const platform: Platform = { +const platform = { platform: 'tauri', getThumbnailUrlByThumbKey: (keyParts) => `${customUriServerUrl}thumbnail/${keyParts @@ -75,13 +76,14 @@ const platform: Platform = { showDevtools: () => invoke('show_devtools'), confirm: (msg, cb) => confirm(msg).then(cb), userHomeDir: homeDir, + updater, auth: { start(url) { open(url); } }, ...commands -}; +} satisfies Platform; const queryClient = new QueryClient({ defaultOptions: { @@ -119,6 +121,8 @@ export default function App() { }; }, []); + useUpdater(); + return ( diff --git a/apps/desktop/src/commands.ts b/apps/desktop/src/commands.ts index 11563f250..70485e77c 100644 --- a/apps/desktop/src/commands.ts +++ b/apps/desktop/src/commands.ts @@ -54,6 +54,15 @@ export function lockAppTheme(themeType: AppThemeType) { return invoke()("lock_app_theme", { themeType }) } +export function checkForUpdate() { + return invoke()("check_for_update") +} + +export function installUpdate() { + return invoke()("install_update") +} + +export type Update = { version: string; body: string | null } export type OpenWithApplication = { url: string; name: string } export type AppThemeType = "Auto" | "Light" | "Dark" export type EphemeralFileOpenResult = { t: "Ok"; c: string } | { t: "Err"; c: string } diff --git a/apps/desktop/src/index.html b/apps/desktop/src/index.html index 011a80c58..9e256ff06 100644 --- a/apps/desktop/src/index.html +++ b/apps/desktop/src/index.html @@ -1,13 +1,16 @@ - - - - - Spacedrive - - -
- - + + + + + + Spacedrive + + + +
+ + + diff --git a/apps/desktop/src/updater.tsx b/apps/desktop/src/updater.tsx new file mode 100644 index 000000000..800a99e58 --- /dev/null +++ b/apps/desktop/src/updater.tsx @@ -0,0 +1,87 @@ +import { listen } from '@tauri-apps/api/event'; +import { useEffect, useRef } from 'react'; +import { proxy, useSnapshot } from 'valtio'; +import { UpdateStore } from '@sd/interface'; +import { toast, ToastId } from '@sd/ui'; + +import * as commands from './commands'; + +export const updateStore = proxy({ + status: 'idle' +}); + +listen('updater', (e) => { + Object.assign(updateStore, e.payload); + console.log(updateStore); +}); + +const onInstallCallbacks = new Set<() => void>(); + +export const updater = { + useSnapshot: () => useSnapshot(updateStore), + checkForUpdate: commands.checkForUpdate, + installUpdate: () => { + for (const cb of onInstallCallbacks) { + cb(); + } + + const promise = commands.installUpdate(); + + toast.promise(promise, { + loading: 'Downloading Update', + success: 'Update Downloaded. Restart Spacedrive to install', + error: (e: any) => ( + <> +

Failed to download update

+

Error: {e.toString()}

+ + ) + }); + + return promise; + } +}; + +async function checkForUpdate() { + const update = await updater.checkForUpdate(); + + if (!update) return; + + let id: ToastId | null = null; + + const cb = () => { + if (id !== null) toast.dismiss(id); + }; + + onInstallCallbacks.add(cb); + + toast.info( + (_id) => { + id = _id; + + return { + title: 'New Update Available', + body: `Version ${update.version}` + }; + }, + { + onClose() { + onInstallCallbacks.delete(cb); + }, + duration: 10 * 1000, + action: { + label: 'Update', + onClick: () => updater.installUpdate() + } + } + ); +} + +export function useUpdater() { + const alreadyChecked = useRef(false); + + useEffect(() => { + if (!alreadyChecked.current && import.meta.env.PROD) checkForUpdate(); + alreadyChecked.current = true; + }, []); +} diff --git a/apps/mobile/modules/sd-core/ios/build-rust.sh b/apps/mobile/modules/sd-core/ios/build-rust.sh index 2e57e09b2..ae697c85a 100755 --- a/apps/mobile/modules/sd-core/ios/build-rust.sh +++ b/apps/mobile/modules/sd-core/ios/build-rust.sh @@ -1,40 +1,49 @@ -#!/usr/bin/env zsh +#!/usr/bin/env sh -set -e +set -eu -echo "Building \'sd-mobile-ios\' library..." +if [ "${CI:-}" = "true" ]; then + set -x +fi + +if [ -z "${HOME:-}" ]; then + HOME="$(CDPATH='' cd -- "$(osascript -e 'set output to (POSIX path of (path to home folder))')" && pwd)" + export HOME +fi + +echo "Building 'sd-mobile-ios' library..." __dirname="$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)" -TARGET_DIRECTORY="$(CDPATH='' cd -- "${__dirname}/../../../../../target" && pwd)" -if [[ $CONFIGURATION != "Debug" ]]; then +# Ensure target dir exists +TARGET_DIRECTORY="${__dirname}/../../../../../target" +mkdir -p "$TARGET_DIRECTORY" +TARGET_DIRECTORY="$(CDPATH='' cd -- "$TARGET_DIRECTORY" && pwd)" + +if [ "${CONFIGURATION:-}" != "Debug" ]; then CARGO_FLAGS=--release export CARGO_FLAGS fi -export PROTOC="${TARGET_DIRECTORY}/Frameworks/bin/protoc" - # TODO: Also do this for non-Apple Silicon Macs -if [[ $SPACEDRIVE_CI == "1" ]]; then +if [ "${SPACEDRIVE_CI:-}" = "1" ]; then # Required for CI - export PATH="$HOME/.cargo/bin:$PATH" + export PATH="${CARGO_HOME:-"${HOME}/.cargo"}/bin:$PATH" cargo build -p sd-mobile-ios --target x86_64-apple-ios - if [[ $PLATFORM_NAME = "iphonesimulator" ]] - then - lipo -create -output $TARGET_DIRECTORY/libsd_mobile_iossim.a $TARGET_DIRECTORY/x86_64-apple-ios/debug/libsd_mobile_ios.a + if [ "${PLATFORM_NAME:-}" = "iphonesimulator" ]; then + lipo -create -output "$TARGET_DIRECTORY"/libsd_mobile_iossim.a "$TARGET_DIRECTORY"/x86_64-apple-ios/debug/libsd_mobile_ios.a else - lipo -create -output $TARGET_DIRECTORY/libsd_mobile_ios.a $TARGET_DIRECTORY/x86_64-apple-ios/debug/libsd_mobile_ios.a + lipo -create -output "$TARGET_DIRECTORY"/libsd_mobile_ios.a "$TARGET_DIRECTORY"/x86_64-apple-ios/debug/libsd_mobile_ios.a fi exit 0 fi -if [[ $PLATFORM_NAME = "iphonesimulator" ]] -then - cargo build -p sd-mobile-ios --target aarch64-apple-ios-sim - lipo -create -output $TARGET_DIRECTORY/libsd_mobile_iossim.a $TARGET_DIRECTORY/aarch64-apple-ios-sim/debug/libsd_mobile_ios.a +if [ "${PLATFORM_NAME:-}" = "iphonesimulator" ]; then + cargo build -p sd-mobile-ios --target aarch64-apple-ios-sim + lipo -create -output "$TARGET_DIRECTORY"/libsd_mobile_iossim.a "$TARGET_DIRECTORY"/aarch64-apple-ios-sim/debug/libsd_mobile_ios.a else - cargo build -p sd-mobile-ios --target aarch64-apple-ios - lipo -create -output $TARGET_DIRECTORY/libsd_mobile_ios.a $TARGET_DIRECTORY/aarch64-apple-ios/debug/libsd_mobile_ios.a + cargo build -p sd-mobile-ios --target aarch64-apple-ios + lipo -create -output "$TARGET_DIRECTORY"/libsd_mobile_ios.a "$TARGET_DIRECTORY"/aarch64-apple-ios/debug/libsd_mobile_ios.a fi diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 18ab0e7c9..1314251b8 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -28,7 +28,7 @@ "@sd/assets": "workspace:*", "@sd/client": "workspace:*", "@shopify/flash-list": "1.5.0", - "@tanstack/react-query": "^4.29.1", + "@tanstack/react-query": "^4.35", "class-variance-authority": "^0.5.3", "dayjs": "^1.11.8", "event-target-polyfill": "^0.0.3", @@ -42,7 +42,7 @@ "lottie-react-native": "6.2.0", "moti": "^0.26.0", "phosphor-react-native": "^1.1.2", - "react": "18.2.0", + "react": "^18.2.0", "react-hook-form": "~7.45.2", "react-native": "0.72.4", "react-native-document-picker": "^9.0.1", @@ -61,13 +61,13 @@ "zod": "~3.22.2" }, "devDependencies": { - "@babel/core": "^7.22.11", + "@babel/core": "~7", "@rnx-kit/metro-config": "^1.3.8", "@sd/config": "workspace:*", - "@types/react": "~18.0.38", + "@types/react": "^18.2.0", "babel-plugin-module-resolver": "^5.0.0", "eslint-plugin-react-native": "^4.0.0", "react-native-svg-transformer": "^1.1.0", - "typescript": "^5.1.3" + "typescript": "^5.2" } } diff --git a/apps/server/docker/Dockerfile b/apps/server/docker/Dockerfile index 7e7b97290..9baaba88d 100644 --- a/apps/server/docker/Dockerfile +++ b/apps/server/docker/Dockerfile @@ -67,6 +67,8 @@ ENV PATH="/root/.cargo/bin:$PATH" RUN --mount=type=cache,target=/var/cache/apt --mount=type=cache,target=/var/lib/apt \ env CI=true ./scripts/setup.sh +RUN cd ./scripts; npm i --production + RUN --mount=type=cache,target=/root/.cache/prisma/binaries/cli/ \ pnpm prep @@ -89,11 +91,12 @@ ENV TZ=UTC \ # Note: This needs to happen before the apt call to avoid locking issues with the previous step COPY --from=server /srv/spacedrive/target/release/sd-server /usr/bin/ +COPY --from=server /srv/spacedrive/apps/.deps/lib /usr/lib/spacedrive RUN --mount=type=cache,target=/var/cache/apt --mount=type=cache,target=/var/lib/apt \ apt-get install \ libavdevice59 libpostproc56 libswscale6 libswresample4 libavformat59 libavutil57 libavfilter8 \ - libavcodec59 libheif1 + libavcodec59 COPY --chmod=755 entrypoint.sh /usr/bin/ diff --git a/apps/storybook/package.json b/apps/storybook/package.json index bebfde3da..827fe29c2 100644 --- a/apps/storybook/package.json +++ b/apps/storybook/package.json @@ -21,15 +21,15 @@ "devDependencies": { "@sd/config": "workspace:*", "@sd/ui": "workspace:*", - "@types/react": "^18.0.28", - "@types/react-dom": "^18.0.11", - "@vitejs/plugin-react": "^3.1.0", + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "@vitejs/plugin-react": "^4.1", "autoprefixer": "^10.4.12", - "postcss": "^8.4.17", + "postcss": "^8.4", "prop-types": "^15.8.1", "storybook": "^7.0.5", "tailwindcss": "^3.3.2", - "typescript": "^5.0.4", - "vite": "^4.2.0" + "typescript": "^5.2", + "vite": "^4.4" } } diff --git a/apps/web/package.json b/apps/web/package.json index ac786e8f0..51ab39003 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -14,7 +14,7 @@ "@rspc/client": "=0.0.0-main-799eec5d", "@sd/client": "workspace:*", "@sd/interface": "workspace:*", - "@tanstack/react-query": "^4.12.0", + "@tanstack/react-query": "^4.35", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "6.9.0" @@ -23,16 +23,16 @@ "@playwright/test": "^1.30.0", "@sd/config": "workspace:*", "@sd/ui": "workspace:*", - "@types/react": "^18.0.21", - "@types/react-dom": "^18.0.6", - "@vitejs/plugin-react": "^2.1.0", + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "@vitejs/plugin-react": "^4.1", "autoprefixer": "^10.4.12", - "postcss": "^8.4.17", + "postcss": "^8.4", "rollup-plugin-visualizer": "^5.9.0", - "typescript": "^5.0.4", - "vite": "^4.0.4", - "vite-plugin-html": "^3.2.0", - "vite-plugin-svgr": "^2.2.1", - "vite-tsconfig-paths": "^4.0.3" + "typescript": "^5.2", + "vite": "^4.4", + "vite-plugin-html": "^3.2", + "vite-plugin-svgr": "^3.3", + "vite-tsconfig-paths": "^4.2" } } diff --git a/crates/sync/example/web/package.json b/crates/sync/example/web/package.json index e01ebc072..e219e9a5f 100644 --- a/crates/sync/example/web/package.json +++ b/crates/sync/example/web/package.json @@ -10,10 +10,10 @@ }, "license": "MIT", "devDependencies": { - "@tanstack/react-query": "^4.10.1", - "@vitejs/plugin-react": "^2.1.0", - "typescript": "^4.8.2", - "vite": "^4.0.4" + "@tanstack/react-query": "^4.35", + "@vitejs/plugin-react": "^4.1", + "typescript": "^5.2", + "vite": "^4.4" }, "dependencies": { "clsx": "^1.2.1", diff --git a/interface/app/$libraryId/Layout/Sidebar/Footer.tsx b/interface/app/$libraryId/Layout/Sidebar/Footer.tsx index 8ac8ffcd1..c04caa0cb 100644 --- a/interface/app/$libraryId/Layout/Sidebar/Footer.tsx +++ b/interface/app/$libraryId/Layout/Sidebar/Footer.tsx @@ -4,6 +4,7 @@ import { useKeys } from 'rooks'; import { JobManagerContextProvider, useClientContext, useDebugState } from '@sd/client'; import { Button, ButtonLink, dialogManager, modifierSymbols, Popover, Tooltip } from '@sd/ui'; import { useKeyMatcher } from '~/hooks'; +import { usePlatform } from '~/util/Platform'; import DebugPopover from './DebugPopover'; import FeedbackDialog from './FeedbackDialog'; @@ -20,8 +21,24 @@ export default () => { navigate('settings/client/general'); }); + const updater = usePlatform().updater; + const updaterState = updater?.useSnapshot(); + return (
+ {updater && updaterState && ( + <> + {updaterState.status === 'updateAvailable' && ( + + )} + + )}
{ const libraries = useCachedLibraries(); diff --git a/interface/package.json b/interface/package.json index dff5a5094..e290e9515 100644 --- a/interface/package.json +++ b/interface/package.json @@ -1,16 +1,8 @@ { "name": "@sd/interface", - "version": "1.0.0", - "license": "GPL-3.0-only", "private": true, "main": "index.tsx", "types": "index.tsx", - "exports": { - ".": "./index.tsx", - "./assets/*": "./assets/*", - "./components/*": "./components/*", - "./hooks/*": "./hooks/*" - }, "scripts": { "lint": "eslint . --cache", "typecheck": "tsc -b" @@ -19,6 +11,7 @@ "@fontsource/inter": "^4.5.13", "@headlessui/react": "^1.7.3", "@icons-pack/react-simple-icons": "^7.2.0", + "@phosphor-icons/react": "^2.0.10", "@radix-ui/react-progress": "^1.0.1", "@radix-ui/react-slider": "^1.1.0", "@radix-ui/react-toast": "^1.1.2", @@ -31,13 +24,10 @@ "@splinetool/react-spline": "^2.2.3", "@splinetool/runtime": "^0.9.128", "@tailwindcss/forms": "^0.5.3", - "@tanstack/react-query": "^4.12.0", - "@tanstack/react-query-devtools": "^4.22.0", + "@tanstack/react-query": "^4.35", + "@tanstack/react-query-devtools": "^4.35", "@tanstack/react-table": "^8.8.5", "@tanstack/react-virtual": "3.0.0-beta.61", - "@types/react-scroll-sync": "^0.8.4", - "@types/uuid": "^9.0.2", - "@vitejs/plugin-react": "^2.1.0", "autoprefixer": "^10.4.12", "class-variance-authority": "^0.5.3", "clsx": "^1.2.1", @@ -45,7 +35,6 @@ "dayjs": "^1.11.8", "dragselect": "^2.7.4", "framer-motion": "^10.11.5", - "@phosphor-icons/react": "^2.0.10", "prismjs": "^1.29.0", "react": "^18.2.0", "react-colorful": "^5.6.1", @@ -76,15 +65,16 @@ }, "devDependencies": { "@sd/config": "workspace:*", - "@types/babel__core": "^7.20.1", + "@types/babel__core": "^7.20", "@types/loadable__component": "^5.13.4", - "@types/node": "^18.11.9", - "@types/react": "^18.0.21", - "@types/react-dom": "^18.0.6", + "@types/node": "^18.17", + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", "@types/react-router-dom": "^5.3.3", - "@vitejs/plugin-react": "^1.3.1", - "typescript": "5.0.4", - "vite": "^4.0.4", - "vite-plugin-svgr": "^2.2.1" + "@types/uuid": "^9.0.2", + "@vitejs/plugin-react": "^4.1", + "typescript": "^5.2", + "vite": "^4.4", + "vite-plugin-svgr": "^3.3" } } diff --git a/interface/tsconfig.json b/interface/tsconfig.json index 3ad612626..30a4e12ed 100644 --- a/interface/tsconfig.json +++ b/interface/tsconfig.json @@ -1,20 +1,13 @@ { "extends": "../packages/config/base.tsconfig.json", "compilerOptions": { - "declarationDir": "dist", "paths": { "~/*": ["./*"] }, - "types": ["vite-plugin-svgr/client", "vite/client", "node"] + "types": ["vite-plugin-svgr/client", "vite/client", "node"], + "declarationDir": "dist" }, - "include": ["./**/*"], + "include": ["**/*"], "exclude": ["dist"], - "references": [ - { - "path": "../packages/ui" - }, - { - "path": "../packages/client" - } - ] + "references": [{ "path": "../packages/ui" }, { "path": "../packages/client" }] } diff --git a/interface/util/Platform.tsx b/interface/util/Platform.tsx index c12b6ccb1..35814d5d7 100644 --- a/interface/util/Platform.tsx +++ b/interface/util/Platform.tsx @@ -37,9 +37,23 @@ export type Platform = { openFilePathWith?(library: string, fileIdsAndAppUrls: [number, string][]): Promise; openEphemeralFileWith?(pathsAndUrls: [string, string][]): Promise; lockAppTheme?(themeType: 'Auto' | 'Light' | 'Dark'): any; + updater?: { + useSnapshot: () => UpdateStore; + checkForUpdate(): Promise; + installUpdate(): Promise; + }; auth: auth.ProviderConfig; }; +export type Update = { version: string; body: string | null }; +export type UpdateStore = + | { status: 'idle' } + | { status: 'loading' } + | { status: 'error' } + | { status: 'updateAvailable'; update: Update } + | { status: 'noUpdateAvailable' } + | { status: 'installing' }; + // Keep this private and use through helpers below const context = createContext(undefined!); diff --git a/package.json b/package.json index 913707efe..e94c9906c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "private": true, "scripts": { - "preprep": "pnpm exec node scripts/preprep.mjs", + "preprep": "pnpm --filter @sd/scripts -- prep", "prep": "pnpm gen:prisma", "postprep": "pnpm codegen", "build": "turbo run build", @@ -27,7 +27,7 @@ "typecheck": "pnpm -r typecheck", "lint": "turbo run lint", "lint:fix": "turbo run lint -- --fix", - "clean": "rimraf -g \"node_modules/\" \"**/node_modules/\" \"target/\" \"**/.build/\" \"**/.next/\" \"**/dist/!(.gitignore)**\" \"**/tsconfig.tsbuildinfo\"" + "clean": "git clean -qfX ." }, "pnpm": { "overrides": { @@ -35,25 +35,22 @@ } }, "devDependencies": { - "@babel/plugin-syntax-import-assertions": "^7.22.5", - "@cspell/dict-rust": "^2.0.1", - "@cspell/dict-typescript": "^2.0.2", + "@babel/plugin-syntax-import-assertions": "~7", + "@cspell/dict-rust": "^4.0.1", + "@cspell/dict-typescript": "^3.1.2", "@ianvs/prettier-plugin-sort-imports": "^4.1.0", - "@storybook/react-vite": "^7.0.20", - "archive-wasm": "^1.5.3", - "cspell": "^6.31.1", - "mustache": "^4.2.0", + "@storybook/react-vite": "^7.4.6", + "cspell": "^7.3.7", "prettier": "^3.0.3", - "prettier-plugin-tailwindcss": "^0.5.3", - "rimraf": "^4.4.1", - "turbo": "^1.10.2", - "turbo-ignore": "^0.3.0", - "typescript": "^5.0.4", - "vite": "^4.3.9" + "prettier-plugin-tailwindcss": "^0.5.5", + "turbo": "^1.10.14", + "turbo-ignore": "^1.10.14", + "typescript": "^5.2", + "vite": "^4.4" }, "overrides": { "vite-plugin-svgr": "https://github.com/spacedriveapp/vite-plugin-svgr#cb4195b69849429cdb18d1f12381676bf9196a84", - "@types/node": "^18.0.0" + "@types/node": "^18.17" }, "engines": { "pnpm": ">=8.0.0", @@ -62,23 +59,6 @@ "node": ">=18.17 <19 || >=20.1" }, "eslintConfig": { - "root": true, - "overrides": [ - { - "files": [ - "*.mjs" - ], - "env": { - "node": true, - "es2022": true, - "browser": false, - "commonjs": false, - "shared-node-browser": false - }, - "parserOptions": { - "sourceType": "module" - } - } - ] + "root": true } } diff --git a/packages/client/package.json b/packages/client/package.json index ca98cbcb2..a5bf246a7 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,11 +1,8 @@ { "name": "@sd/client", - "version": "0.0.0", "private": true, "main": "./src/index.ts", - "files": [ - "dist/**" - ], + "types": "./src/index.ts", "scripts": { "test": "jest", "lint": "eslint src --cache", @@ -16,7 +13,7 @@ "@rspc/client": "=0.0.0-main-799eec5d", "@rspc/react": "=0.0.0-main-799eec5d", "@sd/config": "workspace:*", - "@tanstack/react-query": "^4.12.0", + "@tanstack/react-query": "^4.35", "@zxcvbn-ts/core": "^2.1.0", "@zxcvbn-ts/language-common": "^2.0.1", "@zxcvbn-ts/language-en": "^2.1.0", @@ -29,7 +26,7 @@ "@types/react": "^18.0.21", "scripts": "*", "tsconfig": "*", - "typescript": "^5.0.4" + "typescript": "^5.2" }, "peerDependencies": { "react": "^18.2.0" diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json index 8e080d56b..41d393a37 100644 --- a/packages/client/tsconfig.json +++ b/packages/client/tsconfig.json @@ -2,7 +2,8 @@ "extends": "../config/base.tsconfig.json", "compilerOptions": { "rootDir": "src", - "declarationDir": "dist" + "outDir": "./dist", + "emitDeclarationOnly": false }, "include": ["src"] } diff --git a/packages/config/package.json b/packages/config/package.json index a1f9a78d2..2438f6ec6 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -11,19 +11,19 @@ "lint": "eslint . --cache" }, "devDependencies": { - "@typescript-eslint/eslint-plugin": "^5.59.6", - "@typescript-eslint/parser": "^5.59.6", - "eslint": "^8.41.0", + "@typescript-eslint/eslint-plugin": "^6.7", + "@typescript-eslint/parser": "^6.7", + "eslint": "^8.50", "eslint-config-next": "13.3.0", - "eslint-config-prettier": "^8.8.0", + "eslint-config-prettier": "^9.0", "eslint-config-turbo": "^1.9.8", - "eslint-plugin-prettier": "^4.2.1", + "eslint-plugin-prettier": "^5.0", "eslint-plugin-react": "^7.32.2", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-tailwindcss": "^3.12.0", "eslint-utils": "^3.0.0", "regexpp": "^3.2.0", - "vite-plugin-html": "^3.2.0", - "vite-plugin-svgr": "^2.2.1" + "vite-plugin-html": "^3.2", + "vite-plugin-svgr": "^3.3" } } diff --git a/packages/ui/package.json b/packages/ui/package.json index 4466a2193..8955b308d 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -36,7 +36,6 @@ "class-variance-authority": "^0.5.3", "clsx": "^1.2.1", "@phosphor-icons/react": "^2.0.10", - "postcss": "^8.4.17", "react": "^18.2.0", "react-dom": "^18.2.0", "react-loading-icons": "^1.1.0", @@ -48,20 +47,18 @@ "zod": "~3.22.2" }, "devDependencies": { - "@babel/core": "^7.22.11", + "@babel/core": "~7", "@sd/config": "workspace:*", "@storybook/types": "^7.0.24", "@tailwindcss/typography": "^0.5.7", - "@types/node": "^18.15.1", - "@types/react": "^18.0.21", - "@types/react-dom": "^18.0.6", + "@types/node": "^18.17", + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", "autoprefixer": "^10.4.12", - "babel-loader": "^8.2.5", - "sass": "^1.55.0", - "sass-loader": "^13.0.2", - "style-loader": "^3.3.1", + "sass": "^1.68", + "postcss": "^8.4", "tailwindcss": "^3.3.2", "tailwindcss-animate": "^1.0.5", - "typescript": "5.0.4" + "typescript": "^5.2" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 869883e4648a1741c9bec4cbee8f000843e36361..86fc67bdaa29d13f981611d8cb801aa0659284f6 100644 GIT binary patch delta 44454 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 delta 30678 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 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0656b5b95..15e02b506 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,3 +5,4 @@ packages: - 'interface' - 'docs' - 'crates/sync/example/web' + - 'scripts' diff --git a/scripts/.eslintrc.cjs b/scripts/.eslintrc.cjs new file mode 100644 index 000000000..f567f53f3 --- /dev/null +++ b/scripts/.eslintrc.cjs @@ -0,0 +1,70 @@ +module.exports = { + root: true, + env: { + node: true, + es2022: true, + browser: false, + commonjs: false, + 'shared-node-browser': false, + }, + rules: { + 'no-void': [ + 'error', + { + allowAsStatement: true, + }, + ], + 'no-proto': 'error', + 'valid-jsdoc': 'off', + 'import/order': [ + 'error', + { + alphabetize: { + order: 'asc', + }, + 'newlines-between': 'always', + }, + ], + 'no-unused-vars': [ + 'error', + { argsIgnorePattern: '^_', destructuredArrayIgnorePattern: '^_' }, + ], + 'jsdoc/require-returns-check': 'off', + 'jsdoc/require-param-description': 'off', + 'jsdoc/require-returns-description': 'off', + 'standard/no-callback-literal': 'off', + }, + parser: '@babel/eslint-parser', + plugins: ['@babel'], + extends: [ + 'eslint:recommended', + 'standard', + 'plugin:import/recommended', + 'plugin:prettier/recommended', + 'plugin:jsdoc/recommended-typescript-flavor', + ], + settings: { + jsdoc: { + mode: 'typescript', + tagNamePreference: { + typicalname: 'typicalname', + }, + }, + }, + parserOptions: { + project: './tsconfig.json', + sourceType: 'module', + babelOptions: { + presets: [ + [ + '@babel/preset-env', + { + shippedProposals: true, + }, + ], + ], + }, + tsconfigRootDir: __dirname, + requireConfigFile: false, + }, +} diff --git a/scripts/.gitignore b/scripts/.gitignore new file mode 100644 index 000000000..b152df746 --- /dev/null +++ b/scripts/.gitignore @@ -0,0 +1,2 @@ +.tmp +node_modules diff --git a/scripts/deps.mjs b/scripts/deps.mjs deleted file mode 100644 index fb8b9f8de..000000000 --- a/scripts/deps.mjs +++ /dev/null @@ -1,197 +0,0 @@ -import * as fs from 'node:fs/promises'; -import * as os from 'node:os'; -import * as path from 'node:path'; -import { env } from 'node:process'; -import { extractTo } from 'archive-wasm/src/fs.mjs'; - -import { - getGh, - getGhArtifactContent, - getGhReleasesAssets, - getGhWorkflowRunArtifacts -} from './github.mjs'; -import { - FFMPEG_SUFFFIX, - FFMPEG_WORKFLOW, - getConst, - getSuffix, - LIBHEIF_SUFFIX, - LIBHEIF_WORKFLOW, - PDFIUM_SUFFIX, - PROTOC_SUFFIX -} from './suffix.mjs'; -import { which } from './which.mjs'; - -const noop = () => {}; - -const __debug = env.NODE_ENV === 'debug'; -const __osType = os.type(); - -// Github repos -const PDFIUM_REPO = 'bblanchon/pdfium-binaries'; -const PROTOBUF_REPO = 'protocolbuffers/protobuf'; -const SPACEDRIVE_REPO = 'spacedriveapp/spacedrive'; - -/** - * Download and extract protobuff compiler binary - * @param {string[]} machineId - * @param {string} framework - */ -export async function downloadProtc(machineId, framework) { - if (await which('protoc')) return; - - console.log('Downloading protoc...'); - - const protocSuffix = getSuffix(PROTOC_SUFFIX, machineId); - if (protocSuffix == null) throw new Error('NO_PROTOC'); - - let found = false; - for await (const release of getGhReleasesAssets(PROTOBUF_REPO)) { - if (!protocSuffix.test(release.name)) continue; - try { - await extractTo(await getGh(release.downloadUrl), framework, { - chmod: 0o600, - overwrite: true - }); - found = true; - break; - } catch (error) { - console.warn('Failed to download protoc, re-trying...'); - if (__debug) console.error(error); - } - } - - if (!found) throw new Error('NO_PROTOC'); - - // cleanup - await fs.unlink(path.join(framework, 'readme.txt')).catch(__debug ? console.error : noop); -} - -/** - * Download and extract pdfium library for generating PDFs thumbnails - * @param {string[]} machineId - * @param {string} framework - */ -export async function downloadPDFium(machineId, framework) { - console.log('Downloading pdfium...'); - - const pdfiumSuffix = getSuffix(PDFIUM_SUFFIX, machineId); - if (pdfiumSuffix == null) throw new Error('NO_PDFIUM'); - - let found = false; - for await (const release of getGhReleasesAssets(PDFIUM_REPO)) { - if (!pdfiumSuffix.test(release.name)) continue; - try { - await extractTo(await getGh(release.downloadUrl), framework, { - chmod: 0o600, - overwrite: true - }); - found = true; - break; - } catch (error) { - console.warn('Failed to download pdfium, re-trying...'); - if (__debug) console.error(error); - } - } - - if (!found) throw new Error('NO_PDFIUM'); - - // cleanup - const cleanup = [ - fs.rename(path.join(framework, 'LICENSE'), path.join(framework, 'LICENSE.pdfium')), - ...['args.gn', 'PDFiumConfig.cmake', 'VERSION'].map((file) => - fs.unlink(path.join(framework, file)).catch(__debug ? console.error : noop) - ) - ]; - - switch (__osType) { - case 'Linux': - cleanup.push(fs.chmod(path.join(framework, 'lib', 'libpdfium.so'), 0o750)); - break; - case 'Darwin': - cleanup.push(fs.chmod(path.join(framework, 'lib', 'libpdfium.dylib'), 0o750)); - break; - } - - await Promise.all(cleanup); -} - -/** - * Download and extract ffmpeg libs for video thumbnails - * @param {string[]} machineId - * @param {string} framework - * @param {string[]} branches - */ -export async function downloadFFMpeg(machineId, framework, branches) { - const workflow = getConst(FFMPEG_WORKFLOW, machineId); - if (workflow == null) { - console.log('Checking FFMPeg...'); - if (await which('ffmpeg')) { - // TODO: check ffmpeg version match what we need - return; - } else { - throw new Error('NO_FFMPEG'); - } - } - - console.log('Downloading FFMPeg...'); - - const ffmpegSuffix = getSuffix(FFMPEG_SUFFFIX, machineId); - if (ffmpegSuffix == null) throw new Error('NO_FFMPEG'); - - let found = false; - for await (const artifact of getGhWorkflowRunArtifacts(SPACEDRIVE_REPO, workflow, branches)) { - if (!ffmpegSuffix.test(artifact.name)) continue; - try { - const data = await getGhArtifactContent(SPACEDRIVE_REPO, artifact.id); - await extractTo(data, framework, { - chmod: 0o600, - recursive: true, - overwrite: true - }); - found = true; - break; - } catch (error) { - console.warn('Failed to download FFMpeg, re-trying...'); - if (__debug) console.error(error); - } - } - - if (!found) throw new Error('NO_FFMPEG'); -} - -/** - * Download and extract libheif libs for heif thumbnails - * @param {string[]} machineId - * @param {string} framework - * @param {string[]} branches - */ -export async function downloadLibHeif(machineId, framework, branches) { - const workflow = getConst(LIBHEIF_WORKFLOW, machineId); - if (workflow == null) return; - - console.log('Downloading LibHeif...'); - - const libHeifSuffix = getSuffix(LIBHEIF_SUFFIX, machineId); - if (libHeifSuffix == null) throw new Error('NO_LIBHEIF'); - - let found = false; - for await (const artifact of getGhWorkflowRunArtifacts(SPACEDRIVE_REPO, workflow, branches)) { - if (!libHeifSuffix.test(artifact.name)) continue; - try { - const data = await getGhArtifactContent(SPACEDRIVE_REPO, artifact.id); - await extractTo(data, framework, { - chmod: 0o600, - recursive: true, - overwrite: true - }); - found = true; - break; - } catch (error) { - console.warn('Failed to download LibHeif, re-trying...'); - if (__debug) console.error(error); - } - } - - if (!found) throw new Error('NO_LIBHEIF'); -} diff --git a/scripts/git.mjs b/scripts/git.mjs deleted file mode 100644 index 9e33b28ee..000000000 --- a/scripts/git.mjs +++ /dev/null @@ -1,86 +0,0 @@ -import { exec as execCb } from 'node:child_process'; -import * as fs from 'node:fs/promises'; -import * as path from 'node:path'; -import { env } from 'node:process'; -import { promisify } from 'node:util'; - -const __debug = env.NODE_ENV === 'debug'; - -const exec = promisify(execCb); - -/** - * @param {string} repoPath - * @returns {string?} - */ -async function getRemoteBranchName(repoPath) { - let branchName; - try { - branchName = (await exec('git symbolic-ref --short HEAD', { cwd: repoPath })).stdout.trim(); - if (!branchName) throw 'Empty local branch name'; - } catch (error) { - if (__debug) { - console.warn(`Failed to read git local branch name`); - console.error(error); - } - return null; - } - - let remoteBranchName; - try { - remoteBranchName = ( - await exec(`git for-each-ref --format="%(upstream:short)" refs/heads/${branchName}`, { - cwd: repoPath - }) - ).stdout.trim(); - const [remote, branch] = remoteBranchName.split('/'); - if (!branch) throw 'Empty remote branch name'; - remoteBranchName = branch; - } catch (error) { - if (__debug) { - console.warn(`Failed to read git remote branch name`); - console.error(error); - } - return null; - } - - return remoteBranchName; -} - -// https://stackoverflow.com/q/3651860#answer-67151923 -const REF_REGEX = /ref:\s+refs\/heads\/(?[^\s\x00-\x1F\:\?\[\\\^\~]+)/; -const GITHUB_REF_REGEX = /^refs\/heads\//; - -/** - * @param {string} repoPath - * @returns {Promise} - */ -export async function getGitBranches(repoPath) { - const branches = ['main', 'master']; - - if (env.GITHUB_HEAD_REF) { - branches.unshift(env.GITHUB_HEAD_REF); - } else if (env.GITHUB_REF) { - branches.unshift(env.GITHUB_REF.replace(GITHUB_REF_REGEX, '')); - } - - const remoteBranchName = await getRemoteBranchName(repoPath); - if (remoteBranchName) { - branches.unshift(remoteBranchName); - } else { - let head; - try { - head = await fs.readFile(path.join(repoPath, '.git', 'HEAD'), { encoding: 'utf8' }); - } catch (error) { - if (__debug) { - console.warn(`Failed to read git HEAD file`); - console.error(error); - } - return branches; - } - - const match = REF_REGEX.exec(head); - if (match?.groups?.branch) branches.unshift(match.groups.branch); - } - - return branches; -} diff --git a/scripts/machineId.mjs b/scripts/machineId.mjs deleted file mode 100644 index eb8ad56bd..000000000 --- a/scripts/machineId.mjs +++ /dev/null @@ -1,60 +0,0 @@ -import { exec as execCb } from 'node:child_process'; -import * as os from 'node:os'; -import { env } from 'node:process'; -import { promisify } from 'node:util'; - -const __debug = env.NODE_ENV === 'debug'; - -let libc = 'glibc'; -if (os.type() === 'Linux') { - try { - const exec = promisify(execCb); - if ((await exec('ldd /bin/ls')).stdout.includes('musl')) { - libc = 'musl'; - } - } catch (error) { - if (__debug) { - console.warn(`Failed to check libc type`); - console.error(error); - } - } -} - -const OS_TYPE = { - darwin: 'Darwin', - windows: 'Windows_NT', - linux: 'Linux' -}; - -export function getMachineId() { - let machineId; - - /** - * Possible TARGET_TRIPLE: - * x86_64-apple-darwin - * aarch64-apple-darwin - * x86_64-pc-windows-msvc - * aarch64-pc-windows-msvc - * x86_64-unknown-linux-gnu - * x86_64-unknown-linux-musl - * aarch64-unknown-linux-gnu - * aarch64-unknown-linux-musl - * armv7-unknown-linux-gnueabihf - */ - if (env.TARGET_TRIPLE) { - const target = env.TARGET_TRIPLE.split('-'); - const osType = OS_TYPE[target[2]]; - - if (!osType) throw new Error(`Unknown OS type: ${target[2]}`); - if (!target[0]) throw new Error(`Unknown machine type: ${target[0]}`); - - machineId = [osType, target[0]]; - if (machineId[0] === 'Linux') machineId.push(target[3].includes('musl') ? 'musl' : 'glibc'); - } else { - // Current machine identifiers - machineId = [os.type(), os.machine()]; - if (machineId[0] === 'Linux') machineId.push(libc); - } - - return machineId; -} diff --git a/scripts/package.json b/scripts/package.json new file mode 100644 index 000000000..82651d6ea --- /dev/null +++ b/scripts/package.json @@ -0,0 +1,42 @@ +{ + "name": "@sd/scripts", + "private": true, + "main": "./preprep.mjs", + "type": "module", + "scripts": { + "prep": "node preprep.mjs", + "tauri": "node tauri.mjs", + "lint": "eslint --cache", + "typecheck": "tsc" + }, + "prettier": { + "semi": false, + "endOfLine": "lf", + "printWidth": 99, + "singleQuote": true, + "arrowParens": "avoid", + "trailingComma": "es5" + }, + "dependencies": { + "@iarna/toml": "^2.2.5", + "archive-wasm": "^1.5.3", + "mustache": "^4.2.0", + "semver": "^7.5.0", + "undici": "^5.25.4" + }, + "devDependencies": { + "@babel/core": "~7", + "@babel/eslint-parser": "~7", + "@babel/eslint-plugin": "~7", + "@types/mustache": "^4.2.3", + "@types/node": "^18.17", + "@typescript-eslint/eslint-plugin": "^6.7", + "@typescript-eslint/parser": "^6.7", + "eslint": "^8.50", + "eslint-config-prettier": "^9.0", + "eslint-config-standard": "^17.1", + "eslint-plugin-jsdoc": "^46.8", + "eslint-plugin-prettier": "^5.0", + "typescript": "^5.2" + } +} diff --git a/scripts/preprep.mjs b/scripts/preprep.mjs index 58495ec89..269c9b1ee 100644 --- a/scripts/preprep.mjs +++ b/scripts/preprep.mjs @@ -1,229 +1,156 @@ -import { exec as _exec } from 'node:child_process'; -import * as fs from 'node:fs/promises'; -import * as os from 'node:os'; -import * as path from 'node:path'; -import { env, umask } from 'node:process'; -import { fileURLToPath } from 'node:url'; -import { promisify } from 'node:util'; -import mustache from 'mustache'; +import * as fs from 'node:fs/promises' +import * as path from 'node:path' +import { env, exit, umask } from 'node:process' +import { fileURLToPath } from 'node:url' -import { downloadFFMpeg, downloadLibHeif, downloadPDFium, downloadProtc } from './deps.mjs'; -import { getGitBranches } from './git.mjs'; -import { getMachineId } from './machineId.mjs'; -import { which } from './which.mjs'; +import * as _mustache from 'mustache' -umask(0o026); +import { downloadFFMpeg, downloadLibHeif, downloadPDFium, downloadProtc } from './utils/deps.mjs' +import { getGitBranches } from './utils/git.mjs' +import { getMachineId } from './utils/machineId.mjs' +import { + setupMacOsFramework, + symlinkSharedLibsMacOS, + symlinkSharedLibsLinux, +} from './utils/shared.mjs' +import { which } from './utils/which.mjs' if (/^(msys|mingw|cygwin)$/i.test(env.OSTYPE ?? '')) { - console.error('Bash for windows is not supported, please execute this from Powershell or CMD'); - process.exit(255); + console.error( + 'Bash for windows is not supported, please interact with this repo from Powershell or CMD' + ) + exit(255) } -const exec = promisify(_exec); +// @ts-expect-error +const mustache = /** @type {import("mustache")} */ (_mustache.default) -const __debug = env.NODE_ENV === 'debug'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); +// Limit file permissions +umask(0o026) + +const __debug = env.NODE_ENV === 'debug' +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) // NOTE: Must point to package root path -const __root = path.resolve(path.join(__dirname, '..')); +const __root = path.resolve(path.join(__dirname, '..')) + +const bugWarn = + 'This is probably a bug, please open a issue with you system info at: ' + + 'https://github.com/spacedriveapp/spacedrive/issues/new/choose' // Current machine identifiers -const machineId = getMachineId(); +const machineId = getMachineId() // Basic dependeny check -if ( - (await Promise.all([which('cargo'), which('rustc'), which('pnpm'), which('node')])).some( - (found) => !found - ) -) { +if ((await Promise.all([which('cargo'), which('rustc'), which('pnpm')])).some(found => !found)) { console.error(`Basic dependencies missing. -Make sure you have rust, node.js and pnpm installed: +Make sure you have rust and pnpm installed: https://rustup.rs -https://nodejs.org/en/download https://pnpm.io/installation Also that you have run the setup script: packages/scripts/${machineId[0] === 'Windows_NT' ? 'setup.ps1' : 'setup.sh'} -`); +`) } -// Accepted git branches for querying for artifacts (current, main, master) -const branches = await getGitBranches(__root); - -// Create the basic target directory hierarchy -const framework = path.join(__root, 'target', 'Frameworks'); -await fs.rm(framework, { force: true, recursive: true }); +// Directory where the native deps will be downloaded +const nativeDeps = path.join(__root, 'apps', '.deps') +await fs.rm(nativeDeps, { force: true, recursive: true }) await Promise.all( - ['bin', 'lib', 'include'].map((dir) => - fs.mkdir(path.join(framework, dir), { mode: 0o750, recursive: true }) + ['bin', 'lib', 'include'].map(dir => + fs.mkdir(path.join(nativeDeps, dir), { mode: 0o750, recursive: true }) ) -); +) + +// Accepted git branches for querying for artifacts (current, main, master) +const branches = await getGitBranches(__root) // Download all necessary external dependencies await Promise.all([ - downloadProtc(machineId, framework).catch((e) => { + downloadProtc(machineId, nativeDeps).catch(e => { console.error( - 'Failed to download protoc, this is required for Spacedrive to compile. ' + + 'Failed to download protobuf compiler, this is required to build Spacedrive. ' + 'Please install it with your system package manager' - ); - throw e; + ) + throw e }), - downloadPDFium(machineId, framework).catch((e) => { + downloadPDFium(machineId, nativeDeps).catch(e => { console.warn( 'Failed to download pdfium lib. ' + - "This is optional, but if one isn't configured Spacedrive won't be able to generate thumbnails for PDF files" - ); - if (__debug) console.error(e); + "This is optional, but if one isn't present Spacedrive won't be able to generate thumbnails for PDF files" + ) + if (__debug) console.error(e) }), - downloadFFMpeg(machineId, framework, branches).catch((e) => { - console.error( - 'Failed to download ffmpeg. This is probably a bug, please open a issue with you system info at: ' + - 'https://github.com/spacedriveapp/spacedrive/issues/new/choose' - ); - throw e; + downloadFFMpeg(machineId, nativeDeps, branches).catch(e => { + console.error(`Failed to download ffmpeg. ${bugWarn}`) + throw e }), - downloadLibHeif(machineId, framework, branches).catch((e) => { - console.error( - 'Failed to download libheif. This is probably a bug, please open a issue with you system info at: ' + - 'https://github.com/spacedriveapp/spacedrive/issues/new/choose' - ); - throw e; - }) -]).catch((e) => { - if (__debug) console.error(e); - process.exit(1); -}); + downloadLibHeif(machineId, nativeDeps, branches).catch(e => { + console.error(`Failed to download libheif. ${bugWarn}`) + throw e + }), +]).catch(e => { + if (__debug) console.error(e) + exit(1) +}) + +// Extra OS specific setup +try { + if (machineId[0] === 'Linux') { + console.log(`Symlink shared libs...`) + symlinkSharedLibsLinux(__root, nativeDeps).catch(e => { + console.error(`Failed to symlink shared libs. ${bugWarn}`) + throw e + }) + } else if (machineId[0] === 'Darwin') { + console.log(`Setup Framework...`) + await setupMacOsFramework(nativeDeps).catch(e => { + console.error(`Failed to setup Framework. ${bugWarn}`) + throw e + }) + // This is still required due to how ffmpeg-sys-next builds script works + console.log(`Symlink shared libs...`) + await symlinkSharedLibsMacOS(nativeDeps).catch(e => { + console.error(`Failed to symlink shared libs. ${bugWarn}`) + throw e + }) + } +} catch (error) { + if (__debug) console.error(error) + exit(1) +} // Generate .cargo/config.toml -console.log('Generating cargo config...'); +console.log('Generating cargo config...') try { await fs.writeFile( path.join(__root, '.cargo', 'config.toml'), mustache .render( await fs.readFile(path.join(__root, '.cargo', 'config.toml.mustache'), { - encoding: 'utf8' + encoding: 'utf8', }), { - ffmpeg: machineId[0] === 'Linux' ? false : framework.replaceAll('\\', '\\\\'), + isWin: machineId[0] === 'Windows_NT', + isMacOS: machineId[0] === 'Darwin', + isLinux: machineId[0] === 'Linux', + // Escape windows path separator to be compatible with TOML parsing protoc: path .join( - framework, + nativeDeps, 'bin', machineId[0] === 'Windows_NT' ? 'protoc.exe' : 'protoc' ) .replaceAll('\\', '\\\\'), - projectRoot: __root.replaceAll('\\', '\\\\'), - isWin: machineId[0] === 'Windows_NT', - isMacOS: machineId[0] === 'Darwin', - isLinux: machineId[0] === 'Linux' + nativeDeps: nativeDeps.replaceAll('\\', '\\\\'), } ) .replace(/\n\n+/g, '\n'), { mode: 0o751, flag: 'w+' } - ); + ) } catch (error) { - console.error( - 'Failed to generate .cargo/config.toml, please open an issue on: ' + - 'https://github.com/spacedriveapp/spacedrive/issues/new/choose' - ); - if (__debug) console.error(error); - process.exit(1); -} - -if (machineId[0] === 'Linux') { - // Setup Linux libraries - const libDir = path.join(__root, 'target', 'lib'); - await fs.rm(libDir, { force: true, recursive: true }); - await fs.mkdir(libDir, { recursive: true, mode: 0o751 }); - await fs.symlink(path.join(framework, 'lib'), path.join(__root, 'target', 'lib', 'spacedrive')); -} else if (machineId[0] === 'Darwin') { - // Setup macOS Frameworks - try { - console.log('Setup Frameworks & Sign libraries...'); - const ffmpegFramework = path.join(framework, 'FFMpeg.framework'); - // Move pdfium License to FFMpeg.framework - await fs.rename( - path.join(framework, 'LICENSE.pdfium'), - path.join( - ffmpegFramework, - 'Resources', - 'English.lproj', - 'Documentation', - 'LICENSE.pdfium' - ) - ); - // Move include files to FFMpeg.framework - const include = path.join(framework, 'include'); - const headers = path.join(ffmpegFramework, 'Headers'); - const includeFiles = await fs.readdir(include, { recursive: true, withFileTypes: true }); - const moveIncludes = includeFiles - .filter( - (entry) => - (entry.isFile() || entry.isSymbolicLink()) && !entry.name.endsWith('.proto') - ) - .map(async (entry) => { - const file = path.join(entry.path, entry.name); - const newFile = path.resolve(headers, path.relative(include, file)); - await fs.mkdir(path.dirname(newFile), { mode: 0o751, recursive: true }); - await fs.rename(file, newFile); - }); - // Move libs to FFMpeg.framework - const lib = path.join(framework, 'lib'); - const libraries = path.join(ffmpegFramework, 'Libraries'); - const libFiles = await fs.readdir(lib, { recursive: true, withFileTypes: true }); - const moveLibs = libFiles - .filter( - (entry) => - (entry.isFile() || entry.isSymbolicLink()) && entry.name.endsWith('.dylib') - ) - .map(async (entry) => { - const file = path.join(entry.path, entry.name); - const newFile = path.resolve(libraries, path.relative(lib, file)); - await fs.mkdir(path.dirname(newFile), { mode: 0o751, recursive: true }); - await fs.rename(file, newFile); - }); - - await Promise.all([...moveIncludes, ...moveLibs]); - - // Symlink headers - const headerFiles = await fs.readdir(headers, { recursive: true, withFileTypes: true }); - const linkHeaders = headerFiles - .filter((entry) => entry.isFile() || entry.isSymbolicLink()) - .map(async (entry) => { - const file = path.join(entry.path, entry.name); - const link = path.resolve(include, path.relative(headers, file)); - const linkDir = path.dirname(link); - await fs.mkdir(linkDir, { mode: 0o751, recursive: true }); - await fs.symlink(path.relative(linkDir, file), link); - }); - // Symlink libraries - const libraryFiles = await fs.readdir(libraries, { recursive: true, withFileTypes: true }); - const linkLibs = libraryFiles - .filter( - (entry) => - (entry.isFile() || entry.isSymbolicLink()) && entry.name.endsWith('.dylib') - ) - .map(async (entry) => { - const file = path.join(entry.path, entry.name); - const link = path.resolve(lib, path.relative(libraries, file)); - const linkDir = path.dirname(link); - await fs.mkdir(linkDir, { mode: 0o751, recursive: true }); - await fs.symlink(path.relative(linkDir, file), link); - if (entry.isFile()) { - // Sign the lib with the local machine certificate (Required for it to work on macOS 13+) - await exec(`codesign -s "${env.APPLE_SIGNING_IDENTITY || '-'}" -f "${file}"`); - } - }); - - await Promise.all([...linkHeaders, ...linkLibs]); - } catch (error) { - console.error( - 'Failed to configure required Frameworks.This is probably a bug, please open a issue with you system info at: ' + - 'https://github.com/spacedriveapp/spacedrive/issues/new/choose' - ); - if (__debug) console.error(error); - process.exit(1); - } + console.error(`Failed to generate .cargo/config.toml. ${bugWarn}`) + if (__debug) console.error(error) + exit(1) } diff --git a/scripts/setup.sh b/scripts/setup.sh index de5d8fefb..d9ff58e2d 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -32,6 +32,12 @@ script_failure() { trap 'script_failure ${LINENO:-}' ERR +case "${OSTYPE:-}" in + 'msys' | 'mingw' | 'cygwin') + err 'Bash for windows is not supported, please interact with this repo from Powershell or CMD' + ;; +esac + if [ "${CI:-}" != "true" ]; then echo 'Spacedrive Development Environment Setup' echo 'To set up your machine for Spacedrive development, this script will install some required dependencies with your system package manager' @@ -106,7 +112,7 @@ case "$(uname)" in echo fi ;; - "Linux") # https://github.com/tauri-apps/tauri-docs/blob/dev/docs/guides/getting-started/prerequisites.md + "Linux") # https://github.com/tauri-apps/tauri-docs/blob/dev/docs/guides/getting-started/prerequisites.md#setting-up-linux if has apt-get; then echo "Detected apt!" echo "Installing dependencies with apt..." diff --git a/scripts/tauri.mjs b/scripts/tauri.mjs new file mode 100644 index 000000000..08b8cbc16 --- /dev/null +++ b/scripts/tauri.mjs @@ -0,0 +1,139 @@ +import * as fs from 'node:fs/promises' +import * as path from 'node:path' +import { env, exit, umask, platform } from 'node:process' +import { fileURLToPath } from 'node:url' + +import * as toml from '@iarna/toml' + +import { patchTauri } from './utils/patchTauri.mjs' +import spawn from './utils/spawn.mjs' + +if (/^(msys|mingw|cygwin)$/i.test(env.OSTYPE ?? '')) { + console.error( + 'Bash for windows is not supported, please interact with this repo from Powershell or CMD' + ) + exit(255) +} + +// Limit file permissions +umask(0o026) + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const [_, __, ...args] = process.argv + +// NOTE: Must point to package root path +const __root = path.resolve(path.join(__dirname, '..')) + +// Location for desktop app +const desktopApp = path.join(__root, 'apps', 'desktop') + +// Location of the native dependencies +const nativeDeps = path.join(__root, 'apps', '.deps') + +// Files to be removed when script finish executing +const __cleanup = /** @type {string[]} */ ([]) +const cleanUp = () => Promise.all(__cleanup.map(file => fs.unlink(file).catch(() => {}))) +process.on('SIGINT', cleanUp) + +// Check if file/dir exists +const exists = (/** @type {string} */ path) => + fs + .access(path, fs.constants.R_OK) + .then(() => true) + .catch(() => false) + +// Export environment variables defined in cargo.toml +const cargoConfig = await fs + .readFile(path.resolve(__root, '.cargo', 'config.toml'), { encoding: 'binary' }) + .then(toml.parse) +if (cargoConfig.env && typeof cargoConfig.env === 'object') + for (const [name, value] of Object.entries(cargoConfig.env)) if (!env[name]) env[name] = value + +// Default command +if (args.length === 0) args.push('build') + +let code = 0 +try { + switch (args[0]) { + case 'dev': { + __cleanup.push(...(await patchTauri(__root, nativeDeps, args))) + break + } + case 'build': { + if (!env.NODE_OPTIONS || !env.NODE_OPTIONS.includes('--max_old_space_size')) { + env.NODE_OPTIONS = `--max_old_space_size=4096 ${env.NODE_OPTIONS ?? ''}` + } + + __cleanup.push(...(await patchTauri(__root, nativeDeps, args))) + + switch (process.platform) { + case 'darwin': { + // Configure DMG background + env.BACKGROUND_FILE = path.resolve( + desktopApp, + 'src-tauri', + 'dmg-background.png' + ) + env.BACKGROUND_FILE_NAME = path.basename(env.BACKGROUND_FILE) + env.BACKGROUND_CLAUSE = `set background picture of opts to file ".background:${env.BACKGROUND_FILE_NAME}"` + + if (!(await exists(env.BACKGROUND_FILE))) + console.warn( + `WARNING: DMG background file not found at ${env.BACKGROUND_FILE}` + ) + + break + } + case 'linux': + // Cleanup appimage bundle to avoid build_appimage.sh failing + await fs.rm(path.join(__root, 'target', 'release', 'bundle', 'appimage'), { + recursive: true, + force: true, + }) + break + } + } + } + + await spawn('pnpm', ['exec', 'tauri', ...args], desktopApp).catch(async error => { + if (args[0] === 'build' || platform === 'linux') { + // Work around appimage buindling not working sometimes + const appimageDir = path.join(__root, 'target', 'release', 'bundle', 'appimage') + if ( + (await exists(path.join(appimageDir, 'build_appimage.sh'))) && + (await fs.readdir(appimageDir).then(f => f.every(f => !f.endsWith('.AppImage')))) + ) { + // Remove AppDir to allow build_appimage to rebuild it + await fs.rm(path.join(appimageDir, 'spacedrive.AppDir'), { + recursive: true, + force: true, + }) + return spawn('bash', ['build_appimage.sh'], appimageDir).catch(exitCode => { + code = exitCode + console.error(`tauri ${args[0]} failed with exit code ${exitCode}`) + }) + } + } + + console.error( + `tauri ${args[0]} failed with exit code ${typeof error === 'number' ? error : 1}` + ) + + console.warn( + `If you got an error related to FFMpeg or Protoc/Protobuf you may need to re-run \`pnpm prep\`` + ) + + throw error + }) +} catch (error) { + if (typeof error === 'number') { + code = error + } else { + if (error instanceof Error) console.error(error) + code = 1 + } +} finally { + cleanUp() + exit(code) +} diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json new file mode 100644 index 000000000..353936000 --- /dev/null +++ b/scripts/tsconfig.json @@ -0,0 +1,33 @@ +{ + "compilerOptions": { + "lib": ["esnext"], + "noEmit": true, + "outDir": "src", + "strict": true, + "checkJs": true, + "allowJs": true, + "module": "esnext", + "target": "esnext", + "declaration": true, + "incremental": true, + "skipLibCheck": true, + "removeComments": false, + "noUnusedLocals": true, + "isolatedModules": true, + "esModuleInterop": false, + "disableSizeLimit": true, + "moduleResolution": "node", + "noImplicitReturns": true, + "resolveJsonModule": true, + "noUnusedParameters": true, + "experimentalDecorators": true, + "useDefineForClassFields": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "forceConsistentCasingInFileNames": true, + "noPropertyAccessFromIndexSignature": false + }, + "include": ["./**/*.mjs"], + "exclude": ["node_modules"], + "$schema": "https://json.schemastore.org/tsconfig" +} diff --git a/scripts/suffix.mjs b/scripts/utils/consts.mjs similarity index 58% rename from scripts/suffix.mjs rename to scripts/utils/consts.mjs index 69f6f71d0..44255b4aa 100644 --- a/scripts/suffix.mjs +++ b/scripts/utils/consts.mjs @@ -4,78 +4,69 @@ export const PROTOC_SUFFIX = { i386: 'linux-x86_32', i686: 'linux-x86_32', x86_64: 'linux-x86_64', - arm64: 'linux-aarch_64', - aarch64: 'linux-aarch_64' + aarch64: 'linux-aarch_64', }, Darwin: { x86_64: 'osx-x86_64', - arm64: 'osx-aarch_64', - aarch64: 'osx-aarch_64' + + aarch64: 'osx-aarch_64', }, Windows_NT: { i386: 'win32', i686: 'win32', - x86_64: 'win64' - } -}; + x86_64: 'win64', + }, +} export const PDFIUM_SUFFIX = { Linux: { x86_64: { musl: 'linux-musl-x64', - glibc: 'linux-x64' + glibc: 'linux-x64', }, - arm64: 'linux-arm64', - aarch64: 'linux-arm64' + aarch64: 'linux-arm64', }, Darwin: { x86_64: 'mac-x64', - arm64: 'mac-arm64', - aarch64: 'mac-arm64' + aarch64: 'mac-arm64', }, Windows_NT: { x86_64: 'win-x64', - arm64: 'win-arm64', - aarch64: 'win-arm64' - } -}; + aarch64: 'win-arm64', + }, +} export const FFMPEG_SUFFFIX = { Darwin: { x86_64: 'x86_64', - arm64: 'arm64', - aarch64: 'arm64' + aarch64: 'arm64', }, Windows_NT: { - x86_64: 'x86_64' - } -}; + x86_64: 'x86_64', + }, +} export const FFMPEG_WORKFLOW = { Darwin: 'ffmpeg-macos.yml', - Windows_NT: 'ffmpeg-windows.yml' -}; + Windows_NT: 'ffmpeg-windows.yml', +} export const LIBHEIF_SUFFIX = { Linux: { x86_64: { musl: 'x86_64-linux-musl', - glibc: 'x86_64-linux-gnu' - }, - arm64: { - musl: 'aarch64-linux-musl', - glibc: 'aarch64-linux-gnu' + glibc: 'x86_64-linux-gnu', }, aarch64: { musl: 'aarch64-linux-musl', - glibc: 'aarch64-linux-gnu' - } - } -}; + glibc: 'aarch64-linux-gnu', + }, + }, +} export const LIBHEIF_WORKFLOW = { - Linux: 'libheif-linux.yml' -}; + Linux: 'libheif-linux.yml', +} /** * @param {Record} constants @@ -84,15 +75,15 @@ export const LIBHEIF_WORKFLOW = { */ export function getConst(constants, identifiers) { /** @type {string | Record} */ - let constant = constants; + let constant = constants for (const id of identifiers) { - constant = /** @type {string | Record} */ (constant[id]); - if (!constant) return null; - if (typeof constant !== 'object') break; + constant = /** @type {string | Record} */ (constant[id]) + if (!constant) return null + if (typeof constant !== 'object') break } - return typeof constant === 'string' ? constant : null; + return typeof constant === 'string' ? constant : null } /** @@ -101,6 +92,6 @@ export function getConst(constants, identifiers) { * @returns {RegExp?} */ export function getSuffix(suffixes, identifiers) { - const suffix = getConst(suffixes, identifiers); - return suffix ? new RegExp(`${suffix}(\\.[^\\.]+)*$`) : null; + const suffix = getConst(suffixes, identifiers) + return suffix ? new RegExp(`${suffix}(\\.[^\\.]+)*$`) : null } diff --git a/scripts/utils/deps.mjs b/scripts/utils/deps.mjs new file mode 100644 index 000000000..e10db4bfc --- /dev/null +++ b/scripts/utils/deps.mjs @@ -0,0 +1,198 @@ +import * as fs from 'node:fs/promises' +import * as os from 'node:os' +import * as path from 'node:path' +import { env } from 'node:process' + +import { extractTo } from 'archive-wasm/src/fs.mjs' + +import { + FFMPEG_SUFFFIX, + FFMPEG_WORKFLOW, + getConst, + getSuffix, + LIBHEIF_SUFFIX, + LIBHEIF_WORKFLOW, + PDFIUM_SUFFIX, + PROTOC_SUFFIX, +} from './consts.mjs' +import { + getGh, + getGhArtifactContent, + getGhReleasesAssets, + getGhWorkflowRunArtifacts, +} from './github.mjs' +import { which } from './which.mjs' + +const noop = () => {} + +const __debug = env.NODE_ENV === 'debug' +const __osType = os.type() + +// Github repos +const PDFIUM_REPO = 'bblanchon/pdfium-binaries' +const PROTOBUF_REPO = 'protocolbuffers/protobuf' +const SPACEDRIVE_REPO = 'spacedriveapp/spacedrive' + +/** + * Download and extract protobuff compiler binary + * @param {string[]} machineId + * @param {string} nativeDeps + */ +export async function downloadProtc(machineId, nativeDeps) { + if (await which('protoc')) return + + console.log('Downloading protoc...') + + const protocSuffix = getSuffix(PROTOC_SUFFIX, machineId) + if (protocSuffix == null) throw new Error('NO_PROTOC') + + let found = false + for await (const release of getGhReleasesAssets(PROTOBUF_REPO)) { + if (!protocSuffix.test(release.name)) continue + try { + await extractTo(await getGh(release.downloadUrl), nativeDeps, { + chmod: 0o600, + overwrite: true, + }) + found = true + break + } catch (error) { + console.warn('Failed to download protoc, re-trying...') + if (__debug) console.error(error) + } + } + + if (!found) throw new Error('NO_PROTOC') + + // cleanup + await fs.unlink(path.join(nativeDeps, 'readme.txt')).catch(__debug ? console.error : noop) +} + +/** + * Download and extract pdfium library for generating PDFs thumbnails + * @param {string[]} machineId + * @param {string} nativeDeps + */ +export async function downloadPDFium(machineId, nativeDeps) { + console.log('Downloading pdfium...') + + const pdfiumSuffix = getSuffix(PDFIUM_SUFFIX, machineId) + if (pdfiumSuffix == null) throw new Error('NO_PDFIUM') + + let found = false + for await (const release of getGhReleasesAssets(PDFIUM_REPO)) { + if (!pdfiumSuffix.test(release.name)) continue + try { + await extractTo(await getGh(release.downloadUrl), nativeDeps, { + chmod: 0o600, + overwrite: true, + }) + found = true + break + } catch (error) { + console.warn('Failed to download pdfium, re-trying...') + if (__debug) console.error(error) + } + } + + if (!found) throw new Error('NO_PDFIUM') + + // cleanup + const cleanup = [ + fs.rename(path.join(nativeDeps, 'LICENSE'), path.join(nativeDeps, 'LICENSE.pdfium')), + ...['args.gn', 'PDFiumConfig.cmake', 'VERSION'].map(file => + fs.unlink(path.join(nativeDeps, file)).catch(__debug ? console.error : noop) + ), + ] + + switch (__osType) { + case 'Linux': + cleanup.push(fs.chmod(path.join(nativeDeps, 'lib', 'libpdfium.so'), 0o750)) + break + case 'Darwin': + cleanup.push(fs.chmod(path.join(nativeDeps, 'lib', 'libpdfium.dylib'), 0o750)) + break + } + + await Promise.all(cleanup) +} + +/** + * Download and extract ffmpeg libs for video thumbnails + * @param {string[]} machineId + * @param {string} nativeDeps + * @param {string[]} branches + */ +export async function downloadFFMpeg(machineId, nativeDeps, branches) { + const workflow = getConst(FFMPEG_WORKFLOW, machineId) + if (workflow == null) { + console.log('Checking FFMPeg...') + if (await which('ffmpeg')) { + // TODO: check ffmpeg version match what we need + return + } else { + throw new Error('NO_FFMPEG') + } + } + + console.log('Downloading FFMPeg...') + + const ffmpegSuffix = getSuffix(FFMPEG_SUFFFIX, machineId) + if (ffmpegSuffix == null) throw new Error('NO_FFMPEG') + + let found = false + for await (const artifact of getGhWorkflowRunArtifacts(SPACEDRIVE_REPO, workflow, branches)) { + if (!ffmpegSuffix.test(artifact.name)) continue + try { + const data = await getGhArtifactContent(SPACEDRIVE_REPO, artifact.id) + await extractTo(data, nativeDeps, { + chmod: 0o600, + recursive: true, + overwrite: true, + }) + found = true + break + } catch (error) { + console.warn('Failed to download FFMpeg, re-trying...') + if (__debug) console.error(error) + } + } + + if (!found) throw new Error('NO_FFMPEG') +} + +/** + * Download and extract libheif libs for heif thumbnails + * @param {string[]} machineId + * @param {string} nativeDeps + * @param {string[]} branches + */ +export async function downloadLibHeif(machineId, nativeDeps, branches) { + const workflow = getConst(LIBHEIF_WORKFLOW, machineId) + if (workflow == null) return + + console.log('Downloading LibHeif...') + + const libHeifSuffix = getSuffix(LIBHEIF_SUFFIX, machineId) + if (libHeifSuffix == null) throw new Error('NO_LIBHEIF') + + let found = false + for await (const artifact of getGhWorkflowRunArtifacts(SPACEDRIVE_REPO, workflow, branches)) { + if (!libHeifSuffix.test(artifact.name)) continue + try { + const data = await getGhArtifactContent(SPACEDRIVE_REPO, artifact.id) + await extractTo(data, nativeDeps, { + chmod: 0o600, + recursive: true, + overwrite: true, + }) + found = true + break + } catch (error) { + console.warn('Failed to download LibHeif, re-trying...') + if (__debug) console.error(error) + } + } + + if (!found) throw new Error('NO_LIBHEIF') +} diff --git a/scripts/utils/git.mjs b/scripts/utils/git.mjs new file mode 100644 index 000000000..35750da21 --- /dev/null +++ b/scripts/utils/git.mjs @@ -0,0 +1,87 @@ +import { exec as execCb } from 'node:child_process' +import * as fs from 'node:fs/promises' +import * as path from 'node:path' +import { env } from 'node:process' +import { promisify } from 'node:util' + +const __debug = env.NODE_ENV === 'debug' + +const exec = promisify(execCb) + +/** + * @param {string} repoPath + * @returns {Promise} + */ +async function getRemoteBranchName(repoPath) { + let branchName + try { + branchName = (await exec('git symbolic-ref --short HEAD', { cwd: repoPath })).stdout.trim() + if (!branchName) throw new Error('Empty local branch name') + } catch (error) { + if (__debug) { + console.warn(`Failed to read git local branch name`) + console.error(error) + } + return null + } + + let remoteBranchName + try { + remoteBranchName = ( + await exec(`git for-each-ref --format="%(upstream:short)" refs/heads/${branchName}`, { + cwd: repoPath, + }) + ).stdout.trim() + const [_, branch] = remoteBranchName.split('/') + if (!branch) throw new Error('Empty remote branch name') + remoteBranchName = branch + } catch (error) { + if (__debug) { + console.warn(`Failed to read git remote branch name`) + console.error(error) + } + return null + } + + return remoteBranchName +} + +// https://stackoverflow.com/q/3651860#answer-67151923 +// eslint-disable-next-line no-control-regex +const REF_REGEX = /ref:\s+refs\/heads\/(?[^\s\x00-\x1F:?[\\^~]+)/ +const GITHUB_REF_REGEX = /^refs\/heads\// + +/** + * @param {string} repoPath + * @returns {Promise} + */ +export async function getGitBranches(repoPath) { + const branches = ['main', 'master'] + + if (env.GITHUB_HEAD_REF) { + branches.unshift(env.GITHUB_HEAD_REF) + } else if (env.GITHUB_REF) { + branches.unshift(env.GITHUB_REF.replace(GITHUB_REF_REGEX, '')) + } + + const remoteBranchName = await getRemoteBranchName(repoPath) + if (remoteBranchName) { + branches.unshift(remoteBranchName) + } else { + let head + try { + head = await fs.readFile(path.join(repoPath, '.git', 'HEAD'), { encoding: 'utf8' }) + } catch (error) { + if (__debug) { + console.warn(`Failed to read git HEAD file`) + console.error(error) + } + return branches + } + + const match = REF_REGEX.exec(head) + if (match?.groups?.branch) branches.unshift(match.groups.branch) + } + + return branches +} diff --git a/scripts/github.mjs b/scripts/utils/github.mjs similarity index 67% rename from scripts/github.mjs rename to scripts/utils/github.mjs index db7664a9c..7d38bcf2f 100644 --- a/scripts/github.mjs +++ b/scripts/utils/github.mjs @@ -1,35 +1,36 @@ -import * as fs from 'node:fs/promises'; -import { dirname, join as joinPath, posix as path } from 'node:path'; -import { env } from 'node:process'; -import { setTimeout } from 'node:timers/promises'; -import { fileURLToPath } from 'node:url'; -import { extract } from 'archive-wasm'; +import * as fs from 'node:fs/promises' +import { dirname, join as joinPath, posix as path } from 'node:path' +import { env } from 'node:process' +import { setTimeout } from 'node:timers/promises' +import { fileURLToPath } from 'node:url' -const __debug = env.NODE_ENV === 'debug'; -const __offline = env.OFFLINE === 'true'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); -const cacheDir = joinPath(__dirname, '.tmp'); -await fs.mkdir(cacheDir, { recursive: true, mode: 0o751 }); +import { fetch, Headers } from 'undici' + +const __debug = env.NODE_ENV === 'debug' +const __offline = env.OFFLINE === 'true' +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) +const cacheDir = joinPath(__dirname, '.tmp') +await fs.mkdir(cacheDir, { recursive: true, mode: 0o751 }) // Note: Trailing slashs are important to correctly append paths -const GH = 'https://api.github.com/repos/'; -const NIGTHLY = 'https://nightly.link/'; +const GH = 'https://api.github.com/repos/' +const NIGTHLY = 'https://nightly.link/' // Github routes -const RELEASES = 'releases'; -const WORKFLOWS = 'actions/workflows'; -const ARTIFACTS = 'actions/artifacts'; +const RELEASES = 'releases' +const WORKFLOWS = 'actions/workflows' +const ARTIFACTS = 'actions/artifacts' // Default GH headers const GH_HEADERS = new Headers({ - 'Accept': 'application/vnd.github+json', - 'X-GitHub-Api-Version': '2022-11-28' -}); + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', +}) // Load github auth token if available if ('GITHUB_TOKEN' in env && env.GITHUB_TOKEN) - GH_HEADERS.append('Authorization', `Bearer ${env.GITHUB_TOKEN}`); + GH_HEADERS.append('Authorization', `Bearer ${env.GITHUB_TOKEN}`) /** * @param {string} resource @@ -38,69 +39,69 @@ if ('GITHUB_TOKEN' in env && env.GITHUB_TOKEN) */ async function getCache(resource, headers) { /** @type {Buffer | undefined} */ - let data; + let data /** @type {[string, string] | undefined} */ - let header; + let header // Don't cache in CI - if (env.CI === 'true') return null; + if (env.CI === 'true') return null if (headers) resource += Array.from(headers.entries()) .filter(([name]) => name !== 'If-None-Match' && name !== 'If-Modified-Since') .flat() - .join(':'); + .join(':') try { const cache = JSON.parse( await fs.readFile(joinPath(cacheDir, Buffer.from(resource).toString('base64url')), { - encoding: 'utf8' + encoding: 'utf8', }) - ); + ) if (cache && typeof cache === 'object') { if (cache.etag && typeof cache.etag === 'string') { - header = ['If-None-Match', cache.etag]; + header = ['If-None-Match', cache.etag] } else if (cache.modifiedSince && typeof cache.modifiedSince === 'string') { - header = ['If-Modified-Since', cache.modifiedSince]; + header = ['If-Modified-Since', cache.modifiedSince] } if (cache.data && typeof cache.data === 'string') - data = Buffer.from(cache.data, 'base64'); + data = Buffer.from(cache.data, 'base64') } } catch (error) { if (__debug) { - console.warn(`CACHE MISS: ${resource}`); - console.error(error); + console.warn(`CACHE MISS: ${resource}`) + console.error(error) } } - return data ? { data, header } : null; + return data ? { data, header } : null } /** - * @param {Response} response + * @param {import('undici').Response} response * @param {string} resource * @param {Buffer} [cachedData] * @param {Headers} [headers] * @returns {Promise} */ async function setCache(response, resource, cachedData, headers) { - const data = Buffer.from(await response.arrayBuffer()); + const data = Buffer.from(await response.arrayBuffer()) // Don't cache in CI - if (env.CI === 'true') return data; + if (env.CI === 'true') return data - const etag = response.headers.get('ETag') || undefined; - const modifiedSince = response.headers.get('Last-Modified') || undefined; + const etag = response.headers.get('ETag') || undefined + const modifiedSince = response.headers.get('Last-Modified') || undefined if (headers) resource += Array.from(headers.entries()) .filter(([name]) => name !== 'If-None-Match' && name !== 'If-Modified-Since') .flat() - .join(':'); + .join(':') if (response.status === 304 || (response.ok && data.length === 0)) { // Cache hit - if (!cachedData) throw new Error('Empty cache hit ????'); - return cachedData; + if (!cachedData) throw new Error('Empty cache hit ????') + return cachedData } try { @@ -109,18 +110,18 @@ async function setCache(response, resource, cachedData, headers) { JSON.stringify({ etag, modifiedSince, - data: data.toString('base64') + data: data.toString('base64'), }), { mode: 0o640, flag: 'w+' } - ); + ) } catch (error) { if (__debug) { - console.warn(`CACHE WRITE FAIL: ${resource}`); - console.error(error); + console.warn(`CACHE WRITE FAIL: ${resource}`) + console.error(error) } } - return data; + return data } /** @@ -130,30 +131,30 @@ async function setCache(response, resource, cachedData, headers) { * @returns {Promise} */ export async function get(resource, headers, preferCache) { - if (headers == null) headers = new Headers(); - if (resource instanceof URL) resource = resource.toString(); + if (headers == null) headers = new Headers() + if (resource instanceof URL) resource = resource.toString() - const cache = await getCache(resource, headers); + const cache = await getCache(resource, headers) if (__offline) { if (cache?.data == null) - throw new Error(`OFFLINE MODE: Cache for request ${resource} doesn't exist`); - return cache.data; + throw new Error(`OFFLINE MODE: Cache for request ${resource} doesn't exist`) + return cache.data } - if (preferCache && cache?.data != null) return cache.data; + if (preferCache && cache?.data != null) return cache.data - if (cache?.header) headers.append(...cache.header); + if (cache?.header) headers.append(...cache.header) - const response = await fetch(resource, { headers }); + const response = await fetch(resource, { headers }) if (!response.ok) { if (cache?.data) { - if (__debug) console.warn(`CACHE HIT due to fail: ${resource} ${response.statusText}`); - return cache.data; + if (__debug) console.warn(`CACHE HIT due to fail: ${resource} ${response.statusText}`) + return cache.data } - throw new Error(response.statusText); + throw new Error(response.statusText) } - return await setCache(response, resource, cache?.data, headers); + return await setCache(response, resource, cache?.data, headers) } // Header name Description @@ -163,8 +164,8 @@ export async function get(resource, headers, preferCache) { // x-ratelimit-reset The time at which the current rate limit window resets in UTC epoch seconds. const RATE_LIMIT = { reset: 0, - remaining: Infinity -}; + remaining: Infinity, +} /** * Get resource from a Github route with some pre-defined parameters @@ -172,52 +173,52 @@ const RATE_LIMIT = { * @returns {Promise} */ export async function getGh(route) { - route = new URL(route, GH).toString(); + route = new URL(route, GH).toString() - const cache = await getCache(route); + const cache = await getCache(route) if (__offline) { if (cache?.data == null) - throw new Error(`OFFLINE MODE: Cache for request ${route} doesn't exist`); - return cache?.data; + throw new Error(`OFFLINE MODE: Cache for request ${route} doesn't exist`) + return cache?.data } if (RATE_LIMIT.remaining === 0) { - if (cache?.data) return cache.data; + if (cache?.data) return cache.data console.warn( `RATE LIMIT: Waiting ${RATE_LIMIT.reset} seconds before contacting Github again... [CTRL+C to cancel]` - ); - await setTimeout(RATE_LIMIT.reset * 1000); + ) + await setTimeout(RATE_LIMIT.reset * 1000) } - const headers = new Headers(GH_HEADERS); - if (cache?.header) headers.append(...cache.header); + const headers = new Headers(GH_HEADERS) + if (cache?.header) headers.append(...cache.header) - const response = await fetch(route, { method: 'GET', headers }); + const response = await fetch(route, { method: 'GET', headers }) - const rateReset = Number.parseInt(response.headers.get('x-ratelimit-reset') ?? ''); - const rateRemaining = Number.parseInt(response.headers.get('x-ratelimit-remaining') ?? ''); + const rateReset = Number.parseInt(response.headers.get('x-ratelimit-reset') ?? '') + const rateRemaining = Number.parseInt(response.headers.get('x-ratelimit-remaining') ?? '') if (!(Number.isNaN(rateReset) || Number.isNaN(rateRemaining))) { - const reset = rateReset - Date.now() / 1000; - if (reset > RATE_LIMIT.reset) RATE_LIMIT.reset = reset; + const reset = rateReset - Date.now() / 1000 + if (reset > RATE_LIMIT.reset) RATE_LIMIT.reset = reset if (rateRemaining < RATE_LIMIT.remaining) { - RATE_LIMIT.remaining = rateRemaining; + RATE_LIMIT.remaining = rateRemaining if (__debug) { - console.warn(`Github remaining requests: ${RATE_LIMIT.remaining}`); - await setTimeout(5000); + console.warn(`Github remaining requests: ${RATE_LIMIT.remaining}`) + await setTimeout(5000) } } } if (!response.ok) { if (cache?.data) { - if (__debug) console.warn(`CACHE HIT due to fail: ${route} ${response.statusText}`); - return cache.data; + if (__debug) console.warn(`CACHE HIT due to fail: ${route} ${response.statusText}`) + return cache.data } - if (response.status === 403 && RATE_LIMIT.remaining === 0) return await getGh(route); - throw new Error(response.statusText); + if (response.status === 403 && RATE_LIMIT.remaining === 0) return await getGh(route) + throw new Error(response.statusText) } - return await setCache(response, route, cache?.data); + return await setCache(response, route, cache?.data) } /** @@ -225,17 +226,17 @@ export async function getGh(route) { * @yields {{name: string, downloadUrl: string}} */ export async function* getGhReleasesAssets(repo) { - let page = 0; + let page = 0 while (true) { // "${_gh_url}/protocolbuffers/protobuf/releases?page=${_page}&per_page=100" const releases = JSON.parse( (await getGh(path.join(repo, `${RELEASES}?page=${page++}&per_page=100`))).toString( 'utf8' ) - ); + ) - if (!Array.isArray(releases)) throw new Error(`Error: ${JSON.stringify(releases)}`); - if (releases.length === 0) return; + if (!Array.isArray(releases)) throw new Error(`Error: ${JSON.stringify(releases)}`) + if (releases.length === 0) return for (const release of /** @type {unknown[]} */ (releases)) { if ( @@ -246,9 +247,9 @@ export async function* getGhReleasesAssets(repo) { Array.isArray(release.assets) ) ) - throw new Error(`Invalid release: ${release}`); + throw new Error(`Invalid release: ${release}`) - if ('prerelease' in release && release.prerelease) continue; + if ('prerelease' in release && release.prerelease) continue for (const asset of /** @type {unknown[]} */ (release.assets)) { if ( @@ -261,9 +262,9 @@ export async function* getGhReleasesAssets(repo) { typeof asset.browser_download_url === 'string' ) ) - throw new Error(`Invalid release.asset: ${asset}`); + throw new Error(`Invalid release.asset: ${asset}`) - yield { name: asset.name, downloadUrl: asset.browser_download_url }; + yield { name: asset.name, downloadUrl: asset.browser_download_url } } } } @@ -276,11 +277,11 @@ export async function* getGhReleasesAssets(repo) { * @yields {{ id: number, name: string }} */ export async function* getGhWorkflowRunArtifacts(repo, yaml, branch) { - if (!branch) branch = 'main'; - if (typeof branch === 'string') branch = [branch]; - if (!(branch instanceof Set)) branch = new Set(branch); + if (!branch) branch = 'main' + if (typeof branch === 'string') branch = [branch] + if (!(branch instanceof Set)) branch = new Set(branch) - let page = 0; + let page = 0 while (true) { const workflow = /** @type {unknown} */ ( JSON.parse( @@ -295,7 +296,7 @@ export async function* getGhWorkflowRunArtifacts(repo, yaml, branch) { ) ).toString('utf8') ) - ); + ) if ( !( workflow && @@ -304,9 +305,9 @@ export async function* getGhWorkflowRunArtifacts(repo, yaml, branch) { Array.isArray(workflow.workflow_runs) ) ) - throw new Error(`Error: ${JSON.stringify(workflow)}`); + throw new Error(`Error: ${JSON.stringify(workflow)}`) - if (workflow.workflow_runs.length === 0) return; + if (workflow.workflow_runs.length === 0) return for (const run of /** @type {unknown[]} */ (workflow.workflow_runs)) { if ( @@ -319,13 +320,13 @@ export async function* getGhWorkflowRunArtifacts(repo, yaml, branch) { typeof run.artifacts_url === 'string' ) ) - throw new Error(`Invalid Workflow run: ${run}`); + throw new Error(`Invalid Workflow run: ${run}`) - if (!branch.has(run.head_branch)) continue; + if (!branch.has(run.head_branch)) continue const response = /** @type {unknown} */ ( JSON.parse((await getGh(run.artifacts_url)).toString('utf8')) - ); + ) if ( !( @@ -335,7 +336,7 @@ export async function* getGhWorkflowRunArtifacts(repo, yaml, branch) { Array.isArray(response.artifacts) ) ) - throw new Error(`Error: ${JSON.stringify(response)}`); + throw new Error(`Error: ${JSON.stringify(response)}`) for (const artifact of /** @type {unknown[]} */ (response.artifacts)) { if ( @@ -348,9 +349,9 @@ export async function* getGhWorkflowRunArtifacts(repo, yaml, branch) { typeof artifact.name === 'string' ) ) - throw new Error(`Invalid artifact: ${artifact}`); + throw new Error(`Invalid artifact: ${artifact}`) - yield { id: artifact.id, name: artifact.name }; + yield { id: artifact.id, name: artifact.name } } } } @@ -366,11 +367,11 @@ export async function getGhArtifactContent(repo, id) { if (GH_HEADERS.has('Authorization')) { try { // "${_gh_url}/${_sd_gh_path}/actions/artifacts/${_artifact_id}/zip" - return await getGh(path.join(repo, ARTIFACTS, id.toString(), 'zip')); + return await getGh(path.join(repo, ARTIFACTS, id.toString(), 'zip')) } catch (error) { if (__debug) { - console.warn('Failed to download artifact from github, fallback to nightly.link'); - console.error(error); + console.warn('Failed to download artifact from github, fallback to nightly.link') + console.error(error) } } } @@ -381,5 +382,5 @@ export async function getGhArtifactContent(repo, id) { * Use it when running in evironments that are not authenticated with github * "https://nightly.link/${_sd_gh_path}/actions/artifacts/${_artifact_id}.zip" */ - return await get(new URL(path.join(repo, ARTIFACTS, `${id}.zip`), NIGTHLY), null, true); + return await get(new URL(path.join(repo, ARTIFACTS, `${id}.zip`), NIGTHLY), null, true) } diff --git a/scripts/utils/machineId.mjs b/scripts/utils/machineId.mjs new file mode 100644 index 000000000..0351dc420 --- /dev/null +++ b/scripts/utils/machineId.mjs @@ -0,0 +1,68 @@ +import { exec as execCb } from 'node:child_process' +import * as os from 'node:os' +import { env } from 'node:process' +import { promisify } from 'node:util' + +const __debug = env.NODE_ENV === 'debug' + +/** @type {'musl' | 'glibc'} */ +let libc = 'glibc' +if (os.type() === 'Linux') { + try { + const exec = promisify(execCb) + if ((await exec('ldd /bin/ls')).stdout.includes('musl')) { + libc = 'musl' + } + } catch (error) { + if (__debug) { + console.warn(`Failed to check libc type`) + console.error(error) + } + } +} + +/** @type {Record} */ +const OS_TYPE = { + darwin: 'Darwin', + windows: 'Windows_NT', + linux: 'Linux', +} + +/** @returns {['Darwin' | 'Windows_NT', 'x86_64' | 'aarch64'] | ['Linux', 'x86_64' | 'aarch64', 'musl' | 'glibc']} */ +export function getMachineId() { + let _os, _arch + let _libc = libc + + /** + * Supported TARGET_TRIPLE: + * x86_64-apple-darwin + * aarch64-apple-darwin + * x86_64-pc-windows-msvc + * aarch64-pc-windows-msvc + * x86_64-unknown-linux-gnu + * x86_64-unknown-linux-musl + * aarch64-unknown-linux-gnu + * aarch64-unknown-linux-musl + */ + if (env.TARGET_TRIPLE) { + const target = env.TARGET_TRIPLE.split('-') + _os = OS_TYPE[target[2] ?? ''] + _arch = target[0] + if (_os === 'Linux') _libc = target[3]?.includes('musl') ? 'musl' : 'glibc' + } else { + // Current machine identifiers + _os = os.type() + _arch = os.machine() + if (_arch === 'arm64') _arch = 'aarch64' + } + + if (_arch !== 'x86_64' && _arch !== 'aarch64') throw new Error(`Unsuported architecture`) + + if (_os === 'Linux') { + return [_os, _arch, _libc] + } else if (_os !== 'Darwin' && _os !== 'Windows_NT') { + throw new Error(`Unsuported OS`) + } + + return [_os, _arch] +} diff --git a/scripts/utils/patchTauri.mjs b/scripts/utils/patchTauri.mjs new file mode 100644 index 000000000..efb3a1802 --- /dev/null +++ b/scripts/utils/patchTauri.mjs @@ -0,0 +1,142 @@ +import { exec as _exec } from 'node:child_process' +import * as fs from 'node:fs/promises' +import * as os from 'node:os' +import * as path from 'node:path' +import { env } from 'node:process' +import { promisify } from 'node:util' + +import * as semver from 'semver' + +import { copyLinuxLibs, copyWindowsDLLs } from './shared.mjs' + +const exec = promisify(_exec) +const __debug = env.NODE_ENV === 'debug' + +/** + * @param {string} nativeDeps + * @returns {Promise} + */ +export async function tauriUpdaterKey(nativeDeps) { + if (env.TAURI_PRIVATE_KEY) return null + + // pnpm exec tauri signer generate -w + const privateKeyPath = path.join(nativeDeps, 'tauri.key') + const publicKeyPath = path.join(nativeDeps, 'tauri.key.pub') + const readKeys = () => + Promise.all([ + fs.readFile(publicKeyPath, { encoding: 'utf-8' }), + fs.readFile(privateKeyPath, { encoding: 'utf-8' }), + ]) + + let privateKey, publicKey + try { + ;[publicKey, privateKey] = await readKeys() + if (!(publicKey && privateKey)) throw new Error('Empty keys') + } catch (err) { + if (__debug) { + console.warn('Failed to read tauri updater keys') + console.error(err) + } + + const quote = os.type() === 'Windows_NT' ? '"' : "'" + await exec(`pnpm exec tauri signer generate --ci -w ${quote}${privateKeyPath}${quote}`) + ;[publicKey, privateKey] = await readKeys() + if (!(publicKey && privateKey)) throw new Error('Empty keys') + } + + env.TAURI_PRIVATE_KEY = privateKey + return publicKey +} + +/** + * @param {string} root + * @param {string} nativeDeps + * @param {string[]} args + * @returns {Promise} + */ +export async function patchTauri(root, nativeDeps, args) { + if (args.findIndex(e => e === '-c' || e === '--config') !== -1) { + throw new Error('Custom tauri build config is not supported.') + } + + // Location for desktop app tauri code + const tauriRoot = path.join(root, 'apps', 'desktop', 'src-tauri') + + const osType = os.type() + const resources = + osType === 'Linux' + ? await copyLinuxLibs(root, nativeDeps) + : osType === 'Windows_NT' + ? await copyWindowsDLLs(root, nativeDeps) + : { files: [], toClean: [] } + const tauriPatch = { + tauri: { + bundle: { + macOS: { + minimumSystemVersion: '', + }, + resources: resources.files, + }, + updater: /** @type {{ pubkey?: string }} */ ({}), + }, + } + + const tauriConfig = await fs + .readFile(path.join(tauriRoot, 'tauri.conf.json'), 'utf-8') + .then(JSON.parse) + + if (args[0] === 'build') { + if (tauriConfig?.tauri?.updater?.active) { + const pubKey = await tauriUpdaterKey(nativeDeps) + if (pubKey != null) tauriPatch.tauri.updater.pubkey = pubKey + } + } + + if (osType === 'Darwin') { + // ARM64 support was added in macOS 11, but we need at least 11.2 due to our ffmpeg build + const macOSArm64MinimumVersion = '11.2' + + let macOSMinimumVersion = tauriConfig?.tauri?.bundle?.macOS?.minimumSystemVersion + + const targets = args + .filter((_, index, args) => { + if (index === 0) return false + const previous = args[index - 1] + return previous === '-t' || previous === '--target' + }) + .flatMap(target => target.split(',')) + + if ( + (targets.includes('aarch64-apple-darwin') || + (targets.length === 0 && process.arch === 'arm64')) && + (macOSMinimumVersion == null || + semver.lt( + /** @type {import('semver').SemVer} */ (semver.coerce(macOSMinimumVersion)), + /** @type {import('semver').SemVer} */ ( + semver.coerce(macOSArm64MinimumVersion) + ) + )) + ) { + macOSMinimumVersion = macOSArm64MinimumVersion + console.log( + `aarch64-apple-darwin target detected, setting minimum system version to ${macOSMinimumVersion}` + ) + } + + if (macOSMinimumVersion) { + env.MACOSX_DEPLOYMENT_TARGET = macOSMinimumVersion + tauriPatch.tauri.bundle.macOS.minimumSystemVersion = macOSMinimumVersion + } else { + throw new Error('No minimum macOS version detected, please review tauri.conf.json') + } + } + + const tauriPatchConf = path.join(tauriRoot, 'tauri.conf.patch.json') + await fs.writeFile(tauriPatchConf, JSON.stringify(tauriPatch, null, 2)) + + // Modify args to load patched tauri config + args.splice(1, 0, '-c', tauriPatchConf) + + // Files to be removed + return [tauriPatchConf, ...resources.toClean] +} diff --git a/scripts/utils/shared.mjs b/scripts/utils/shared.mjs new file mode 100644 index 000000000..c50dd00b6 --- /dev/null +++ b/scripts/utils/shared.mjs @@ -0,0 +1,200 @@ +import { exec as execCb } from 'node:child_process' +import * as fs from 'node:fs/promises' +import * as path from 'node:path' +import { env } from 'node:process' +import { promisify } from 'node:util' + +const exec = promisify(execCb) +const signId = env.APPLE_SIGNING_IDENTITY || '-' + +/** + * @param {string} origin + * @param {string} target + * @param {boolean} [rename] + */ +async function link(origin, target, rename) { + const parent = path.dirname(target) + await fs.mkdir(parent, { recursive: true, mode: 0o751 }) + await (rename ? fs.rename(origin, target) : fs.symlink(path.relative(parent, origin), target)) +} + +/** + * Move headers and dylibs of external deps to our framework + * @param {string} nativeDeps + */ +export async function setupMacOsFramework(nativeDeps) { + // External deps + const lib = path.join(nativeDeps, 'lib') + const include = path.join(nativeDeps, 'include') + + // Framework + const framework = path.join(nativeDeps, 'FFMpeg.framework') + const headers = path.join(framework, 'Headers') + const libraries = path.join(framework, 'Libraries') + const documentation = path.join(framework, 'Resources', 'English.lproj', 'Documentation') + + // Move files + await Promise.all([ + // Move pdfium license to framework + fs.rename( + path.join(nativeDeps, 'LICENSE.pdfium'), + path.join(documentation, 'LICENSE.pdfium') + ), + // Move dylibs to framework + fs.readdir(lib, { recursive: true, withFileTypes: true }).then(file => + file + .filter( + entry => + (entry.isFile() || entry.isSymbolicLink()) && entry.name.endsWith('.dylib') + ) + .map(entry => { + const file = path.join(entry.path, entry.name) + const newFile = path.resolve(libraries, path.relative(lib, file)) + return link(file, newFile, true) + }) + ), + // Move headers to framework + fs.readdir(include, { recursive: true, withFileTypes: true }).then(file => + file + .filter( + entry => + (entry.isFile() || entry.isSymbolicLink()) && + !entry.name.endsWith('.proto') + ) + .map(entry => { + const file = path.join(entry.path, entry.name) + const newFile = path.resolve(headers, path.relative(include, file)) + return link(file, newFile, true) + }) + ), + ]) +} + +/** + * Symlink shared libs paths for Linux + * @param {string} root + * @param {string} nativeDeps + * @returns {Promise} + */ +export async function symlinkSharedLibsLinux(root, nativeDeps) { + // rpath=${ORIGIN}/../lib/spacedrive + const targetLib = path.join(root, 'target', 'lib') + const targetRPath = path.join(targetLib, 'spacedrive') + await fs.unlink(targetRPath).catch(() => {}) + await fs.mkdir(targetLib, { recursive: true }) + await link(path.join(nativeDeps, 'lib'), targetRPath) +} + +/** + * Symlink shared libs paths for macOS + * @param {string} nativeDeps + */ +export async function symlinkSharedLibsMacOS(nativeDeps) { + // External deps + const lib = path.join(nativeDeps, 'lib') + const include = path.join(nativeDeps, 'include') + + // Framework + const framework = path.join(nativeDeps, 'FFMpeg.framework') + const headers = path.join(framework, 'Headers') + const libraries = path.join(framework, 'Libraries') + + // Link files + await Promise.all([ + // Link header files + fs.readdir(headers, { recursive: true, withFileTypes: true }).then(files => + Promise.all( + files + .filter(entry => entry.isFile() || entry.isSymbolicLink()) + .map(entry => { + const file = path.join(entry.path, entry.name) + return link(file, path.resolve(include, path.relative(headers, file))) + }) + ) + ), + // Link dylibs + fs.readdir(libraries, { recursive: true, withFileTypes: true }).then(files => + Promise.all( + files + .filter( + entry => + (entry.isFile() || entry.isSymbolicLink()) && + entry.name.endsWith('.dylib') + ) + .map(entry => { + const file = path.join(entry.path, entry.name) + /** @type {Promise[]} */ + const actions = [ + link(file, path.resolve(lib, path.relative(libraries, file))), + ] + + // Sign dylib (Required for it to work on macOS 13+) + if (entry.isFile()) + actions.push(exec(`codesign -s "${signId}" -f "${file}"`)) + + return actions.length > 1 ? Promise.all(actions) : actions[0] + }) + ) + ), + ]) +} + +/** + * Copy Windows DLLs for tauri build + * @param {string} root + * @param {string} nativeDeps + * @returns {Promise<{files: string[], toClean: string[]}>} + */ +export async function copyWindowsDLLs(root, nativeDeps) { + const tauriSrc = path.join(root, 'apps', 'desktop', 'src-tauri') + const files = await Promise.all( + await fs.readdir(path.join(nativeDeps, 'bin'), { withFileTypes: true }).then(files => + files + .filter(entry => entry.isFile() && entry.name.endsWith(`.dll`)) + .map(async entry => { + await fs.copyFile( + path.join(entry.path, entry.name), + path.join(tauriSrc, entry.name) + ) + return entry.name + }) + ) + ) + + return { files, toClean: files.map(file => path.join(tauriSrc, file)) } +} + +/** + * Symlink shared libs paths for Linux + * @param {string} root + * @param {string} nativeDeps + * @returns {Promise<{files: string[], toClean: string[]}>} + */ +export async function copyLinuxLibs(root, nativeDeps) { + // rpath=${ORIGIN}/../lib/spacedrive + const tauriSrc = path.join(root, 'apps', 'desktop', 'src-tauri') + const files = await fs + .readdir(path.join(nativeDeps, 'lib'), { withFileTypes: true }) + .then(files => + Promise.all( + files + .filter( + entry => + (entry.isFile() || entry.isSymbolicLink()) && + (entry.name.endsWith('.so') || entry.name.includes('.so.')) + ) + .map(async entry => { + await fs.copyFile( + path.join(entry.path, entry.name), + path.join(tauriSrc, entry.name) + ) + return entry.name + }) + ) + ) + + return { + files, + toClean: files.map(file => path.join(tauriSrc, file)), + } +} diff --git a/scripts/utils/spawn.mjs b/scripts/utils/spawn.mjs new file mode 100644 index 000000000..590dd6f5b --- /dev/null +++ b/scripts/utils/spawn.mjs @@ -0,0 +1,33 @@ +import { spawn } from 'node:child_process' + +/** + * @param {string} command + * @param {string[]} args + * @param {string} [cwd] + * @returns {Promise} + */ +export default function (command, args, cwd) { + if (typeof command !== 'string' || command.length === 0) + throw new Error('Command must be a string and not empty') + + if (args == null) args = [] + else if (!Array.isArray(args) || args.some(arg => typeof arg !== 'string')) + throw new Error('Args must be an array of strings') + + return new Promise((resolve, reject) => { + const child = spawn(command, args, { cwd, shell: true, stdio: 'inherit' }) + process.on('SIGTERM', () => child.kill('SIGTERM')) + process.on('SIGINT', () => child.kill('SIGINT')) + process.on('SIGBREAK', () => child.kill('SIGBREAK')) + process.on('SIGHUP', () => child.kill('SIGHUP')) + child.on('error', reject) + child.on('exit', (code, signal) => { + if (code === null) code = signal === 'SIGINT' ? 0 : 1 + if (code === 0) { + resolve() + } else { + reject(code) + } + }) + }) +} diff --git a/scripts/utils/which.mjs b/scripts/utils/which.mjs new file mode 100644 index 000000000..7fa316384 --- /dev/null +++ b/scripts/utils/which.mjs @@ -0,0 +1,41 @@ +import { exec as execCb } from 'node:child_process' +import * as fs from 'node:fs/promises' +import * as os from 'node:os' +import * as path from 'node:path' +import { env } from 'node:process' +import { promisify } from 'node:util' + +const exec = promisify(execCb) + +/** + * @param {string} progName + * @returns {Promise} + */ +async function where(progName) { + // Reject paths + if (/[\\]/.test(progName)) return false + try { + await exec(`where "${progName}"`) + } catch { + return false + } + + return true +} + +/** + * @param {string} progName + * @returns {Promise} + */ +export async function which(progName) { + return os.type() === 'Windows_NT' + ? where(progName) + : Promise.any( + Array.from(new Set(env.PATH?.split(':'))).map(dir => + fs.access(path.join(dir, progName), fs.constants.X_OK) + ) + ).then( + () => true, + () => false + ) +} diff --git a/scripts/which.mjs b/scripts/which.mjs deleted file mode 100644 index 8bcd1ccab..000000000 --- a/scripts/which.mjs +++ /dev/null @@ -1,41 +0,0 @@ -import { exec as execCb } from 'node:child_process'; -import * as fs from 'node:fs/promises'; -import * as os from 'node:os'; -import * as path from 'node:path'; -import { env } from 'node:process'; -import { promisify } from 'node:util'; - -const exec = promisify(execCb); - -/** - * @param {string} progName - * @returns {Promise} - */ -async function where(progName) { - // Reject paths - if (/[\\]/.test(progName)) return false; - try { - await exec(`where "${progName}"`); - } catch { - return false; - } - - return true; -} - -/** - * @param {string} progName - * @returns {Promise} - */ -export async function which(progName) { - return os.type() === 'Windows_NT' - ? where(progName) - : Promise.any( - Array.from(new Set(env.PATH?.split(':'))).map((dir) => - fs.access(path.join(dir, progName), fs.constants.X_OK) - ) - ).then( - () => true, - () => false - ); -} diff --git a/turbo.json b/turbo.json index 0d26e706a..0afb03a45 100644 --- a/turbo.json +++ b/turbo.json @@ -2,7 +2,7 @@ "$schema": "https://turborepo.org/schema.json", "pipeline": { "build": { - "inputs": ["!src-tauri/**"], + "inputs": ["**/*.ts", "!src-tauri/**", "!node_modules/**"], "dependsOn": ["^build"], "outputs": ["dist/**"] },